using System.Windows.Media; using ytLive.Helpers; using ytLive.Services; namespace ytLive.Models; public enum SourceType { DisplayCapture, WindowCapture, Background, Image, TextOverlay, Countdown, WebSource, ChatBox, /// The session scoreboard strip: full-width, state not events. Scoreboard, MediaSource, /// The designated alert zone: the six monetization events play their /// unique celebration animations here (OBS alert-box equivalent, native). AlertBox, /// A privacy blur: blurs the frame beneath it inside a rect or ellipse. Live scene /// only, always below the brand logo, several allowed (strongest wins on overlap). Blur, /// A scene-local feed of recent alert events. Ticker } /// Distinguishes how a media source presents its content (TASK 21). public enum MediaSourceType { Video, Audio } /// Playback state of a media source (TASK 21). public enum MediaPlaybackState { Stopped, Playing, Paused } public enum TextLayerAlignment { Left, Center, Right } public enum TextLayerVerticalAlignment { Top, Center, Bottom } public enum ClipShape { Traditional, Round } /// How an alert box presents its announcement strip (TASK 47). The strip is /// the full-width pill naming the event ("Viewer — Super Chat · $12.34"). public enum AlertDisplayMethod { /// Marquee: the pill scrolls across the top edge, paced so the text is /// read times per alert. TickerScroll, /// Pulse: the static pill blinks on and off for the whole alert. Flash, /// Static: the pill sits centred and still for the whole alert. Solid } /// /// 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 the singleton-vs-multi-instance /// discriminator in ai.md. /// public class Source : SceneElement { private readonly Ticker _ticker = new(); public Source() { _ticker.PropertyChanged += (_, e) => { if (e.PropertyName == nameof(Ticker.IsEnabled)) Raise(nameof(AlertShowTicker)); if (e.PropertyName == nameof(Ticker.DisplayMethod)) Raise(nameof(AlertDisplayMethod)); }; } public Ticker Ticker => _ticker; 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)); Raise(nameof(IsTextOverlay)); Raise(nameof(IsScoreboard)); Raise(nameof(IsTicker)); Raise(nameof(ShowsChromaKey)); } } } /// Blur is draggable like a layer: it has no pixels of its own, but the creator places /// and sizes it in the preview, so it needs the same selection box, grab and resize handle /// (creator ruling 2026-11-01). public override bool IsDraggable => Type is SourceType.Image or SourceType.WebSource or SourceType.ChatBox or SourceType.TextOverlay or SourceType.AlertBox or SourceType.Blur or SourceType.Ticker or SourceType.MediaSource; /// Live captures, chat, web sources and media are dynamic; static art is fixed. public override ElementKind Kind => Type is SourceType.DisplayCapture or SourceType.WindowCapture or SourceType.ChatBox or SourceType.WebSource or SourceType.MediaSource or SourceType.AlertBox or SourceType.Blur or SourceType.Ticker ? ElementKind.Dynamic : ElementKind.Static; public override bool IsWebSource => Type == SourceType.WebSource; public override bool IsTextOverlay => Type == SourceType.TextOverlay; public override bool IsScoreboard => Type == SourceType.Scoreboard; public bool IsTicker => Type == SourceType.Ticker; /// The permanent brand logo layer (one per scene, directly above the background). /// Locked against removal; its image comes from the App Settings brand logo. public bool IsBrandLogo { get; set; } /// The permanent scene background (live desktop/game capture). Never removable/reorderable. 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); } /// /// Identifies what this live-capture source points at: "monitor:<index>" or /// "window:<hwnd>". Persisted so a re-designated background survives a reload. /// private string? _captureKey; public string? CaptureKey { get => _captureKey; set => Set(ref _captureKey, value); } public override 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; /// Types whose frame is produced at runtime rather than stored as an asset. The /// scoreboard joined this list 2026-10-30 — without it the strip was rendered, published to /// VideoImageSource, and never displayed (the same shape as the invisible-alert defect). private bool IsFrameSource => Type is SourceType.TextOverlay or SourceType.ChatBox or SourceType.WebSource or SourceType.MediaSource or SourceType.AlertBox or SourceType.Scoreboard or SourceType.Ticker; public override ImageSource? DisplaySource => IsLiveCapture ? (VideoImageSource ?? _imageSource) : (IsFrameSource ? VideoImageSource : _imageSource); public override bool IsImageSource => Type == SourceType.Image; // Countdown config private int _timerMinutes = 15; // Privacy blur (Live scene only): 0–1, the same scale and default as the webcam's background // blur so "half strength" means the same blur in both places. private double _blurAmount = 0.5; public double BlurAmount { get => _blurAmount; set => Set(ref _blurAmount, Math.Clamp(value, 0, 1)); } public bool IsBlur => Type == SourceType.Blur; /// True for a round blur (circle) — the shape rides the existing clip surface, so the /// box/circle choice is the same switch every other layer uses rather than a second concept. public bool IsRoundBlur => IsBlur && ClipShape == ClipShape.Round; 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)); Raise(nameof(MediaFileDisplay)); } } } /// The chosen file's name for the properties panel (path stays in the model). public string MediaFileDisplay => string.IsNullOrWhiteSpace(_mediaPath) ? "No video chosen" : System.IO.Path.GetFileName(_mediaPath); private bool _mediaIsLooping; public bool MediaIsLooping { get => _mediaIsLooping; set { if (Set(ref _mediaIsLooping, value)) Raise(nameof(CanPlayMedia)); } } private double _mediaVolume = 1.0; public double MediaVolume { get => _mediaVolume; set => Set(ref _mediaVolume, Math.Clamp(value, 0.0, 1.0)); } private bool _mediaMuted; /// Silences this layer's soundtrack (the picture keeps playing). One decoder is /// shared per file, so the level/mute are the file's — the layer is the control. public bool MediaMuted { get => _mediaMuted; set => Set(ref _mediaMuted, value); } private bool _isMediaRunning; /// True while the clip is decoding. False means it has played out and is holding its /// last frame — the panel then offers "Play again" instead of leaving a frozen picture with no /// explanation (the 2026-11-05 "clicking Loop did nothing" report). public bool IsMediaRunning { get => _isMediaRunning; set { if (Set(ref _isMediaRunning, value)) Raise(nameof(IsMediaFinished)); } } public bool IsMediaFinished => !IsMediaRunning; /// Play is a manual, one-shot action: while Loop is on the clip restarts itself, so the /// button would be a no-op (creator ruling 2026-11-05 — the button is beside the Video File label). public bool CanPlayMedia => !MediaIsLooping; private MediaPlaybackState _mediaPlaybackState = MediaPlaybackState.Stopped; public MediaPlaybackState MediaPlaybackState { get => _mediaPlaybackState; set => Set(ref _mediaPlaybackState, value); } public override bool IsMediaSource => Type == SourceType.MediaSource; // ── Green/Blue Screen keying (TASK 64) ──────────────────────────────────────────── // Settings live on the LAYER. The decoder is keyed by (path + these settings), so two // layers with the same look share one decode and two different looks get two decoders. private bool _chromaKeyEnabled; public bool ChromaKeyEnabled { get => _chromaKeyEnabled; set { if (Set(ref _chromaKeyEnabled, value)) { Raise(nameof(ChromaKey)); Raise(nameof(HasChromaSuggestion)); } } } private string _chromaKeyColor = "#00B140"; /// The screen colour as it was shot (the detector's median), not a canonical green. public string ChromaKeyColor { get => _chromaKeyColor; set { if (Set(ref _chromaKeyColor, value)) Raise(nameof(ChromaKey)); } } private double _chromaKeySimilarity = 0.12; /// Creator-facing "Amount": how far from the key colour still counts as screen. public double ChromaKeySimilarity { get => _chromaKeySimilarity; set { if (Set(ref _chromaKeySimilarity, Math.Clamp(value, 0, 0.5))) Raise(nameof(ChromaKey)); } } private double _chromaKeySoftness = 0.08; /// Creator-facing "Softness": the width of the fade at the keyed edge. public double ChromaKeySoftness { get => _chromaKeySoftness; set { if (Set(ref _chromaKeySoftness, Math.Clamp(value, 0, 0.5))) Raise(nameof(ChromaKey)); } } private bool _chromaKeySpill = true; /// Creator-facing "Fringe": clean the coloured rim the screen leaves on hair and edges. public bool ChromaKeySpill { get => _chromaKeySpill; set { if (Set(ref _chromaKeySpill, value)) Raise(nameof(ChromaKey)); } } private ChromaScreen _chromaScreen = ChromaScreen.Green; public ChromaScreen ChromaScreen { get => _chromaScreen; set { if (Set(ref _chromaScreen, value)) Raise(nameof(ChromaKey)); } } /// The look this layer asks its decoder for — the one place the settings become an /// ffmpeg chain and a session signature. public ChromaKeyOptions ChromaKey => new(ChromaKeyEnabled, ChromaKeyColor, ChromaKeySimilarity, ChromaKeySoftness, ChromaKeySpill, ChromaScreen); private ChromaKeySuggestion _chromaSuggestion; /// The detector's offer for this clip. Transient by design — never persisted, because /// it is a hint about a file, not a setting the creator made. public ChromaKeySuggestion ChromaSuggestion { get => _chromaSuggestion; set { _chromaSuggestion = value; Raise(nameof(ChromaSuggestion)); Raise(nameof(HasChromaSuggestion)); Raise(nameof(ChromaSuggestionText)); Raise(nameof(HasChromaSuggestionColor)); } } /// True while there is an offer the creator has not accepted. public bool HasChromaSuggestion => !ChromaKeyEnabled && ChromaSuggestion.Screen != ChromaScreen.None; private bool _chromaKeyAutoApplied; /// True when the app applied the key on its own (the "Auto Green/Blue Screen" /// setting). The panel says so, so an automatic change is never silent. public bool ChromaKeyAutoApplied { get => _chromaKeyAutoApplied; set { if (Set(ref _chromaKeyAutoApplied, value)) Raise(nameof(HasAutoChromaKey)); } } public bool HasAutoChromaKey => ChromaKeyAutoApplied && ChromaKeyEnabled; /// Which layers get the Green/Blue Screen block: a video layer, or the alert box's /// own clip (TASK 64 — a clip is a clip). public bool ShowsChromaKey => IsMediaSource || (Type == SourceType.AlertBox && !AlertUseDefaultVideo && !string.IsNullOrWhiteSpace(AlertVideoPath)); public string ChromaSuggestionText => ChromaSuggestion.Screen switch { ChromaScreen.Green => "Looks like a green screen.", ChromaScreen.Blue => "Looks like a blue screen.", _ => string.Empty, }; /// The detected colour, for the swatch next to the offer. public string ChromaSuggestionColor => ChromaSuggestion.ColorHex ?? "#00B140"; public bool HasChromaSuggestionColor => ChromaSuggestion.ColorHex != null; // Alert video config (TASK 47): the shipped default clip lives in the Asset // table (seeded like image assets); the creator's replacement is a FILE PATH // reference only — never into the DB (creator ruling, 2026-09-26). Runtime // order: replacement path exists → DB default → six celebration animations. private string? _alertVideoAssetId; public string? AlertVideoAssetId { get => _alertVideoAssetId; set => Set(ref _alertVideoAssetId, value); } private string? _alertVideoPath; public string? AlertVideoPath { get => _alertVideoPath; set { if (Set(ref _alertVideoPath, value)) Raise(nameof(ShowsChromaKey)); } } private bool _alertUseDefaultVideo = true; public bool AlertUseDefaultVideo { get => _alertUseDefaultVideo; set { if (Set(ref _alertUseDefaultVideo, value)) Raise(nameof(ShowsChromaKey)); } } public bool AlertShowTicker { get => _ticker.IsEnabled; set => _ticker.IsEnabled = value; } private double _alertVideoVolume = 1.0; public double AlertVideoVolume { get => _alertVideoVolume; set => Set(ref _alertVideoVolume, Math.Clamp(value, 0.0, 1.0)); } // Sound byte that plays with the alert clip (creator ruling 2026-11-01). Referenced from disk // like the clip; the video always plays once to completion, the sound may loop under it. private string? _alertAudioPath; public string? AlertAudioPath { get => _alertAudioPath; set => Set(ref _alertAudioPath, value); } private bool _alertAudioLoop; public bool AlertAudioLoop { get => _alertAudioLoop; set => Set(ref _alertAudioLoop, value); } public string AlertAudioFileDisplay => string.IsNullOrWhiteSpace(_alertAudioPath) ? "No sound byte" : System.IO.Path.GetFileName(_alertAudioPath); /// True once a file is chosen — gates the loop switch and the clear button. public bool HasAlertAudio => !string.IsNullOrWhiteSpace(_alertAudioPath); public AlertDisplayMethod AlertDisplayMethod { get => _ticker.DisplayMethod; set => _ticker.DisplayMethod = value; } public override bool IsAlertBox => Type == SourceType.AlertBox; // 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)); } } /// Alpha channel of ChatBackgroundColor as 0.0–1.0. Slider writes back the full hex string. 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; private string _textContent = "Text"; public string TextContent { get => _textContent; set => Set(ref _textContent, value ?? string.Empty); } private string _textFontFamily = "Segoe UI"; public string TextFontFamily { get => _textFontFamily; set => Set(ref _textFontFamily, string.IsNullOrWhiteSpace(value) ? "Segoe UI" : value.Trim()); } private double _textFontSize = 48; public double TextFontSize { get => _textFontSize; set => Set(ref _textFontSize, double.IsFinite(value) ? Math.Clamp(value, 8, 300) : 48); } private string _textColor = "#FFFFFF"; public string TextColor { get => _textColor; set { if (!Set(ref _textColor, IsValidTextColor(value) ? value.ToUpperInvariant() : "#FFFFFF")) return; Raise(nameof(TextColorEntry)); Raise(nameof(TextColorOptions)); } } public NamedColor TextColorEntry { get => BorderColorPalette.EntryFor(TextColor); set => TextColor = value.Hex; } public IReadOnlyList TextColorOptions => BorderColorPalette.OptionsFor(TextColor); private bool _textBold; public bool TextBold { get => _textBold; set => Set(ref _textBold, value); } private bool _textItalic; public bool TextItalic { get => _textItalic; set => Set(ref _textItalic, value); } private bool _textUnderline; public bool TextUnderline { get => _textUnderline; set => Set(ref _textUnderline, value); } private TextLayerAlignment _textAlignment; public TextLayerAlignment TextAlignment { get => _textAlignment; set => Set(ref _textAlignment, Enum.IsDefined(value) ? value : TextLayerAlignment.Left); } private TextLayerVerticalAlignment _textVerticalAlignment; public TextLayerVerticalAlignment TextVerticalAlignment { get => _textVerticalAlignment; set => Set(ref _textVerticalAlignment, Enum.IsDefined(value) ? value : TextLayerVerticalAlignment.Top); } private static bool IsValidTextColor(string? value) { if (value is not { Length: 7 or 9 } || value[0] != '#') return false; for (var i = 1; i < value.Length; i++) if (!Uri.IsHexDigit(value[i])) return false; return true; } public override bool IsCountdown => Type == SourceType.Countdown; private bool _chatPreviewEnabled = true; public bool ChatPreviewEnabled { get => _chatPreviewEnabled; set => Set(ref _chatPreviewEnabled, value); } }