d4aa588da0
Replace the external StreamElements feed with NATIVE YouTube events per the creator's 2026-09-23 question. Chat parity half: - YouTubeChatService decodes ALL SIX liveChat/messages event types into ChatMessage.Kind (superChat/superSticker/newSponsor/membershipGifting/ giftMembershipReceived/memberMilestoneChat) — the four formerly-empty overlay rows are real now — and re-arms its one-shot poll on the server's pollingIntervalMillis (streamList connection semantics; clamp 1000-6000ms, maxResults=2000). ParsePage internal static seam + ChatPage record for deterministic tests; optional HttpClient ctor seam kept; InjectSimulatedMessage (TASK 41) preserved. Alerts half (creator rulings: one celebration zone, six UNIQUE animations, no menus/polls, sub mention = chat row only, NO viewer count): - New SourceType.AlertBox 'Stream Alerts' (one per layout, CanAddAlerts gate mirroring chat; idle = transparent). - AlertRenderer: six distinct branded animations (SuperChat slide-up/shine/ count-up, SuperSticker scale-pop, NewMember drop-in/flash, MemberGift slide-left/ chip-fan, GiftReceived confetti, MemberMilestone rise/growth-bar), every card drawing the 'made with LlamaCasty!' brand line. - AlertOverlayLayer: true component (Commit-G pattern) — queue (cap 10, drop tail never stall), 33ms UI ticker, cache-first RenderFrame + UpdatePreview, Advance(double) as the deterministic test clock; ChatEventKind.None rows never enqueue. - Wired: resolver RenderAlertBox, _alertLayer ctor + dispose, LoadLayout previews, Add menu item (Controls/LeftPanel.xaml), TestSessionViewModel sims tagged (member->NewMember, superchat->SuperChat). Good Dog ONE integration test: AlertLayerTests (RealApp STA, real WPF raster) — ParsePage classifies all six kinds + cadence fields; None rows enqueue nothing; six events play pairwise-distinct moving frames then drain to null. Gate: clean build 0 warnings; full suite 316/317, the one failure (LayerReorderPersistenceTests.RealMouseDrag) repros on the clean tree — the known environmental class (real-mouse-drag no-ops with a game/fullscreen window focused). References (OBS/overlay ecosystem): - streamList semantics: https://developers.google.com/youtube/v3/live/docs/liveChatMessages/streamList - OBS alert-box pattern (designated celebration zone, idle transparent): creator-chosen model
263 lines
8.2 KiB
C#
263 lines
8.2 KiB
C#
using System.Windows.Media;
|
||
using ytLive.Helpers;
|
||
|
||
namespace ytLive.Models;
|
||
|
||
public enum SourceType
|
||
{
|
||
DisplayCapture,
|
||
WindowCapture,
|
||
Background,
|
||
Image,
|
||
TextOverlay,
|
||
Countdown,
|
||
WebSource,
|
||
ChatBox,
|
||
MediaSource,
|
||
/// <summary>The designated alert zone: the six monetization events play their
|
||
/// unique celebration animations here (OBS alert-box equivalent, native).</summary>
|
||
AlertBox
|
||
}
|
||
|
||
/// <summary>Distinguishes how a media source presents its content (TASK 21).</summary>
|
||
public enum MediaSourceType
|
||
{
|
||
Video,
|
||
Audio
|
||
}
|
||
|
||
/// <summary>Playback state of a media source (TASK 21).</summary>
|
||
public enum MediaPlaybackState
|
||
{
|
||
Stopped,
|
||
Playing,
|
||
Paused
|
||
}
|
||
|
||
public enum ClipShape
|
||
{
|
||
Traditional,
|
||
Round
|
||
}
|
||
|
||
/// <summary>
|
||
/// A multi-instance scene object: image/background/text (screen/window later).
|
||
/// The webcam is NOT a Source — it's a singleton resource whose per-scene usage
|
||
/// is a <see cref="WebcamSceneConfig"/>. See the singleton-vs-multi-instance
|
||
/// discriminator in ai.md.
|
||
/// </summary>
|
||
public class Source : SceneElement
|
||
{
|
||
private SourceType _type;
|
||
public SourceType Type
|
||
{
|
||
get => _type;
|
||
set
|
||
{
|
||
if (Set(ref _type, value))
|
||
{
|
||
IsBackground = value == SourceType.Background;
|
||
Raise(nameof(IsLiveCapture));
|
||
Raise(nameof(DisplaySource));
|
||
Raise(nameof(IsWebSource));
|
||
}
|
||
}
|
||
}
|
||
|
||
public override bool IsDraggable => Type is SourceType.Image or SourceType.WebSource or SourceType.ChatBox or SourceType.AlertBox;
|
||
|
||
/// <summary>Live captures, chat, web sources and media are dynamic; static art is fixed.</summary>
|
||
public override ElementKind Kind => Type is SourceType.DisplayCapture or SourceType.WindowCapture
|
||
or SourceType.ChatBox or SourceType.WebSource or SourceType.MediaSource or SourceType.AlertBox
|
||
? ElementKind.Dynamic : ElementKind.Static;
|
||
|
||
public override bool IsWebSource => Type == SourceType.WebSource;
|
||
|
||
/// <summary>The permanent scene background (live desktop/game capture). Never removable/reorderable.</summary>
|
||
private bool _isBackground;
|
||
public override bool IsBackground { get => _isBackground; set => Set(ref _isBackground, value); }
|
||
|
||
private bool _showDesktop;
|
||
public bool ShowDesktop { get => _showDesktop; set => Set(ref _showDesktop, value); }
|
||
|
||
private bool _isEnabled = true;
|
||
public bool IsEnabled { get => _isEnabled; set => Set(ref _isEnabled, value); }
|
||
|
||
// Display/Window capture
|
||
private int? _monitorIndex;
|
||
public int? MonitorIndex { get => _monitorIndex; set => Set(ref _monitorIndex, value); }
|
||
|
||
private IntPtr? _windowHandle;
|
||
public IntPtr? WindowHandle { get => _windowHandle; set => Set(ref _windowHandle, value); }
|
||
|
||
/// <summary>
|
||
/// Identifies what this live-capture source points at: "monitor:<index>" or
|
||
/// "window:<hwnd>". Persisted so a re-designated background survives a reload.
|
||
/// </summary>
|
||
private string? _captureKey;
|
||
public string? CaptureKey { get => _captureKey; set => Set(ref _captureKey, value); }
|
||
|
||
public bool IsLiveCapture => Type is SourceType.DisplayCapture or SourceType.WindowCapture;
|
||
|
||
// Image (asset stored in the layout database)
|
||
private string? _assetId;
|
||
private ImageSource? _imageSource;
|
||
|
||
public string? AssetId
|
||
{
|
||
get => _assetId;
|
||
set
|
||
{
|
||
if (Set(ref _assetId, value))
|
||
{
|
||
_imageSource = ImageCache.Get(value ?? string.Empty);
|
||
Raise(nameof(ImageSource));
|
||
Raise(nameof(DisplaySource));
|
||
}
|
||
}
|
||
}
|
||
|
||
public ImageSource? ImageSource => _imageSource;
|
||
|
||
public override ImageSource? DisplaySource => IsLiveCapture
|
||
? (VideoImageSource ?? _imageSource)
|
||
: (Type is SourceType.ChatBox or SourceType.WebSource or SourceType.MediaSource or SourceType.AlertBox ? VideoImageSource : _imageSource);
|
||
|
||
public override bool IsImageSource => Type == SourceType.Image;
|
||
|
||
// Countdown config
|
||
private int _timerMinutes = 15;
|
||
public int TimerMinutes
|
||
{
|
||
get => _timerMinutes;
|
||
set => Set(ref _timerMinutes, Math.Clamp(value, 1, 60));
|
||
}
|
||
|
||
// Web source config
|
||
private string? _webUri;
|
||
public string? WebUri { get => _webUri; set => Set(ref _webUri, value); }
|
||
|
||
// Media source config (TASK 21: video/audio file playback)
|
||
private MediaSourceType _mediaType = MediaSourceType.Video;
|
||
public MediaSourceType MediaType
|
||
{
|
||
get => _mediaType;
|
||
set => Set(ref _mediaType, value);
|
||
}
|
||
|
||
private string? _mediaPath;
|
||
public string? MediaPath
|
||
{
|
||
get => _mediaPath;
|
||
set
|
||
{
|
||
if (Set(ref _mediaPath, value))
|
||
Raise(nameof(IsMediaSource));
|
||
}
|
||
}
|
||
|
||
private bool _mediaIsLooping;
|
||
public bool MediaIsLooping
|
||
{
|
||
get => _mediaIsLooping;
|
||
set => Set(ref _mediaIsLooping, value);
|
||
}
|
||
|
||
private double _mediaVolume = 1.0;
|
||
public double MediaVolume
|
||
{
|
||
get => _mediaVolume;
|
||
set => Set(ref _mediaVolume, Math.Clamp(value, 0.0, 1.0));
|
||
}
|
||
|
||
private MediaPlaybackState _mediaPlaybackState = MediaPlaybackState.Stopped;
|
||
public MediaPlaybackState MediaPlaybackState
|
||
{
|
||
get => _mediaPlaybackState;
|
||
set => Set(ref _mediaPlaybackState, value);
|
||
}
|
||
|
||
public bool IsMediaSource => Type == SourceType.MediaSource;
|
||
|
||
// Chat box config (TASK 3.18)
|
||
private double _chatFontSize = 16;
|
||
public double ChatFontSize
|
||
{
|
||
get => _chatFontSize;
|
||
set => Set(ref _chatFontSize, Math.Clamp(value, 8, 72));
|
||
}
|
||
|
||
private string _chatFontColor = "#FFFFFF";
|
||
public string ChatFontColor { get => _chatFontColor; set => Set(ref _chatFontColor, value); }
|
||
|
||
private string _chatBackgroundColor = "#80000000";
|
||
public string ChatBackgroundColor
|
||
{
|
||
get => _chatBackgroundColor;
|
||
set
|
||
{
|
||
if (Set(ref _chatBackgroundColor, value))
|
||
Raise(nameof(ChatBackgroundOpacity));
|
||
}
|
||
}
|
||
|
||
/// <summary>Alpha channel of ChatBackgroundColor as 0.0–1.0. Slider writes back the full hex string.</summary>
|
||
public double ChatBackgroundOpacity
|
||
{
|
||
get
|
||
{
|
||
var hex = _chatBackgroundColor.Trim().TrimStart('#');
|
||
return hex.Length == 8
|
||
? byte.Parse(hex.AsSpan(0, 2), System.Globalization.NumberStyles.HexNumber) / 255.0
|
||
: 1.0;
|
||
}
|
||
set
|
||
{
|
||
var alpha = (byte)Math.Clamp(value * 255, 0, 255);
|
||
var hex = _chatBackgroundColor.Trim().TrimStart('#');
|
||
if (hex.Length == 8) hex = hex[2..];
|
||
if (hex.Length == 6) ChatBackgroundColor = $"#{alpha:X2}{hex}";
|
||
}
|
||
}
|
||
|
||
private int _chatMaxMessages = 5;
|
||
public int ChatMaxMessages
|
||
{
|
||
get => _chatMaxMessages;
|
||
set => Set(ref _chatMaxMessages, Math.Clamp(value, 1, 20));
|
||
}
|
||
|
||
private bool _chatShowTimestamps = true;
|
||
public bool ChatShowTimestamps { get => _chatShowTimestamps; set => Set(ref _chatShowTimestamps, value); }
|
||
|
||
private bool _chatShowBadges = true;
|
||
public bool ChatShowBadges { get => _chatShowBadges; set => Set(ref _chatShowBadges, value); }
|
||
|
||
private string _chatMemberColor = "#e94560";
|
||
public string ChatMemberColor { get => _chatMemberColor; set => Set(ref _chatMemberColor, value); }
|
||
|
||
private string _chatSuperChatColor = "#eab308";
|
||
public string ChatSuperChatColor { get => _chatSuperChatColor; set => Set(ref _chatSuperChatColor, value); }
|
||
|
||
private string _chatFontFamily = "Segoe UI";
|
||
public string ChatFontFamily { get => _chatFontFamily; set => Set(ref _chatFontFamily, value); }
|
||
|
||
private int _chatFadeSeconds;
|
||
public int ChatFadeSeconds
|
||
{
|
||
get => _chatFadeSeconds;
|
||
set => Set(ref _chatFadeSeconds, Math.Clamp(value, 0, 60));
|
||
}
|
||
|
||
public override bool IsChatBox => Type == SourceType.ChatBox;
|
||
|
||
public override bool IsCountdown => Type == SourceType.Countdown;
|
||
|
||
private bool _chatPreviewEnabled = true;
|
||
public bool ChatPreviewEnabled
|
||
{
|
||
get => _chatPreviewEnabled;
|
||
set => Set(ref _chatPreviewEnabled, value);
|
||
}
|
||
}
|