using System.Collections.ObjectModel; using System.IO; using System.Windows; using System.Windows.Input; using System.Windows.Media; using System.Windows.Media.Imaging; using ytLive.Helpers; using ytLive.Models; using ytLive.Services; namespace ytLive.ViewModels; public partial class MainViewModel { // Screen background: a permanent live capture (desktop/game) that every scene // shows at the bottom layer. One shared capture session per key — the // ScreenCaptureManager refcounts by key, mirroring CameraManager. private readonly IFullScreenDetector _fullScreenDetector; private readonly ScreenCaptureManager _screenCaptureManager; private readonly ScreenCaptureSourceFactory _screenCaptureFactory; private ImageSource? _activeBackgroundImage; private ImageSource? _backgroundImage; private bool _backgroundSizeMismatch; private string _backgroundSizeMismatchText = ""; private HashSet _liveCaptureKeys = new(); public ObservableCollection Displays { get; } = new(); public ImageSource? ActiveBackgroundImage { get => _activeBackgroundImage; private set => SetProperty(ref _activeBackgroundImage, value); } /// The active scene's live-capture background frame (rendered below the /// static background). Set from the shared capture bitmap as it arrives. public ImageSource? BackgroundImage { get => _backgroundImage; private set { if (SetProperty(ref _backgroundImage, value)) { OnPropertyChanged(nameof(ShowPreviewPlaceholder)); OnPropertyChanged(nameof(BackgroundVisible)); } } } public bool BackgroundVisible => StagedScene?.Elements.OfType().FirstOrDefault(s => s.IsBackground)?.IsVisible == true; public bool BackgroundSizeMismatch { get => _backgroundSizeMismatch; private set => SetProperty(ref _backgroundSizeMismatch, value); } public string BackgroundSizeMismatchText { get => _backgroundSizeMismatchText; private set => SetProperty(ref _backgroundSizeMismatchText, value); } public ICommand ChangeCaptureCommand { get; } public ICommand RefreshCaptureCommand { get; } public ICommand SetBackgroundDisplayCommand { get; } public ICommand ToggleShowDesktopCommand { get; } public ICommand BrowseBackgroundCommand { get; } private void UpdateActiveBackground() { // Static-art rows only. Capture-flavored rows (Live) must NEVER feed // ActiveBackgroundImage — it renders above BackgroundImage, so it would // permanently cover the desktop/game capture. Live's canvas shows // DisplaySource instead (capture, falling back to live-background.jpg). var background = StagedScene?.Elements.OfType().FirstOrDefault(s => s.Type == SourceType.Background); ActiveBackgroundImage = background != null && !string.IsNullOrWhiteSpace(background.AssetId) ? ImageCache.Get(background.AssetId) : null; OnPropertyChanged(nameof(ShowPreviewPlaceholder)); } private void UpdateBackgroundImage() { var scene = StagedScene; var background = scene?.Elements.OfType().FirstOrDefault(s => s.IsBackground); BackgroundImage = background?.DisplaySource; if (background != null && !background.UseDefaultBackground && !string.IsNullOrWhiteSpace(background.CustomBackgroundPath) && System.IO.File.Exists(background.CustomBackgroundPath)) { try { var bmi = new System.Windows.Media.Imaging.BitmapImage(); bmi.BeginInit(); bmi.UriSource = new System.Uri(background.CustomBackgroundPath); bmi.CacheOption = System.Windows.Media.Imaging.BitmapCacheOption.OnLoad; bmi.EndInit(); bmi.Freeze(); double expectedRatio = 1920.0 / 1080.0; double imageRatio = (double)bmi.PixelWidth / bmi.PixelHeight; double ratioDiff = Math.Abs(imageRatio - expectedRatio) / expectedRatio; if (ratioDiff > 0.05) { BackgroundSizeMismatch = true; BackgroundSizeMismatchText = $"Image is {bmi.PixelWidth}\u00d7{bmi.PixelHeight} \u2014 stream expects 1920\u00d71080 (16:9)"; } else { BackgroundSizeMismatch = false; BackgroundSizeMismatchText = ""; } } catch { BackgroundSizeMismatch = false; BackgroundSizeMismatchText = ""; } } else { BackgroundSizeMismatch = false; BackgroundSizeMismatchText = ""; } } private void RefreshSnapshotsAsync() { foreach (var scene in Scenes) { // Minis never render live captures. While the Live screen is staged // (being previewed) its mini shows the green placeholder; unstaged, // it shows the stored default art. Real-time surface = center only. var stagedLive = ReferenceEquals(scene, StagedScene) && SceneCatalog.Is(scene.Name, SceneCatalog.Live); var img = stagedLive ? null : LoadBackgroundImage(scene); if (img != null) scene.Snapshot = img; else if (scene.Snapshot == null || stagedLive) scene.Snapshot = CreatePlaceholderSnapshot(scene.Name); } } internal static System.Windows.Media.Imaging.BitmapSource? LoadBackgroundImage(Scene scene) { var bg = scene.Elements.OfType().FirstOrDefault(s => s.IsBackground); if (bg != null && !bg.UseDefaultBackground && !string.IsNullOrWhiteSpace(bg.CustomBackgroundPath) && System.IO.File.Exists(bg.CustomBackgroundPath)) { try { var bitmap = new System.Windows.Media.Imaging.BitmapImage(); bitmap.BeginInit(); bitmap.UriSource = new Uri(bg.CustomBackgroundPath); bitmap.CacheOption = System.Windows.Media.Imaging.BitmapCacheOption.OnLoad; bitmap.EndInit(); bitmap.Freeze(); return bitmap; } catch { /* fall through to AssetId */ } } if (bg != null && !string.IsNullOrWhiteSpace(bg.AssetId)) return ImageCache.Get(bg.AssetId); return null; } private static BitmapSource CreatePlaceholderSnapshot(string sceneName) { var colors = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Starting"] = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(233, 69, 96)), ["Live"] = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(34, 197, 94)), ["BRB"] = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(234, 179, 8)), ["Chat"] = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(59, 130, 246)), ["Ending"] = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(168, 85, 247)), }; var brush = colors.TryGetValue(sceneName, out var c) ? c : new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(60, 60, 80)); var dv = new System.Windows.Media.DrawingVisual(); using (var ctx = dv.RenderOpen()) { ctx.DrawRectangle(brush, null, new System.Windows.Rect(0, 0, 320, 180)); var ft = new System.Windows.Media.FormattedText( sceneName, System.Globalization.CultureInfo.InvariantCulture, System.Windows.FlowDirection.LeftToRight, new System.Windows.Media.Typeface("Segoe UI"), 18, System.Windows.Media.Brushes.White, 96); ctx.DrawText(ft, new System.Windows.Point(160 - ft.Width / 2, 90 - ft.Height / 2)); } var rtb = new System.Windows.Media.Imaging.RenderTargetBitmap( 320, 180, 96, 96, System.Windows.Media.PixelFormats.Pbgra32); rtb.Render(dv); rtb.Freeze(); return rtb; } // ─── Screen background capture (live desktop/game) ─── private const string MonitorKeyPrefix = "monitor:"; /// Every background-enabled scene has exactly one background, kept at index 0 (bottom layer). /// Returns null for a scene with = false. internal static Source? EnsureBackground(Scene scene) { if (!scene.HasBackground) return null; var background = scene.Elements.OfType().FirstOrDefault(s => s.IsBackground); if (background != null) return background; background = CreateBackground(scene.Name); scene.Elements.Insert(0, background); return background; } /// The one Background row for a scene. Live gets the capture flavor /// (Show Desktop / monitor switch / fullscreen-game detect) falling back to /// live-background.jpg; every other screen is a static default-art layer. /// Both are named "Background" — the user never sees capture jargon here. internal static Source CreateBackground(string sceneName) { var isLive = SceneCatalog.Is(sceneName, SceneCatalog.Live); return new Source { Name = "Background", Type = isLive ? SourceType.DisplayCapture : SourceType.Background, IsBackground = true, IsEnabled = true, X = 0, Y = 0, Width = MasterFrameWidth, Height = MasterFrameHeight, }; } /// Heals any loaded layout to the locked Background model: exactly /// one Background per canonical scene, correct flavor for the scene (static /// art everywhere, DisplayCapture on Live only), at index 0, named /// "Background". Duplicates and stale rows are dropped; missing ones seeded; /// non-canonical scenes get HasBackground=false and lose any backgrounds. internal static void NormalizeBackgrounds(IEnumerable scenes) { foreach (var scene in scenes) { if (!SceneCatalog.HasBackground(scene.Name)) { scene.HasBackground = false; foreach (var stale in scene.Elements.OfType().Where(s => s.IsBackground).ToList()) scene.Elements.Remove(stale); continue; } scene.HasBackground = true; var isLive = SceneCatalog.Is(scene.Name, SceneCatalog.Live); var wantedType = isLive ? SourceType.DisplayCapture : SourceType.Background; var candidates = scene.Elements.OfType().Where(s => s.IsBackground).ToList(); var keep = candidates.FirstOrDefault(b => b.Type == wantedType) ?? candidates.FirstOrDefault(); foreach (var extra in candidates.Where(b => !ReferenceEquals(b, keep))) scene.Elements.Remove(extra); if (keep == null) { keep = CreateBackground(scene.Name); scene.Elements.Insert(0, keep); } // Source.Type's setter derives IsBackground from the enum, so a // flavor correction must re-assert the flag afterwards. if (keep.Type != wantedType) { keep.Type = wantedType; keep.IsBackground = true; } keep.Name = "Background"; keep.IsEnabled = true; var index = scene.Elements.IndexOf(keep); if (index > 0) scene.Elements.Move(index, 0); } } // Only the Live screen's background is a real-time surface. Every other // lookup below (auto-capture, focus hooks, refresh) must ignore the static // default-art rows so they can never start a desktop capture session. private IEnumerable AllBackgrounds() => Scenes.Where(s => SceneCatalog.Is(s.Name, SceneCatalog.Live)) .SelectMany(s => s.Elements.OfType().Where(x => x.IsBackground)); // Full-screen game on its monitor, else null (static bg fallback — no // infinity mirror). Called at launch (from ReacquireScreenCaptures, before // the window steals focus) and on focus regain (RefreshBackgroundAutoCapture). // When a background has ShowDesktop=true, returns the primary monitor instead // of null so the desktop is always visible. private string? ResolveAutoCaptureKey() { var gameMonitor = _fullScreenDetector.GetForegroundFullScreenMonitorIndex(); if (gameMonitor != null) return $"{MonitorKeyPrefix}{gameMonitor}"; if (AllBackgrounds().Any(b => b.ShowDesktop)) return $"{MonitorKeyPrefix}{_fullScreenDetector.PrimaryMonitorIndex()}"; return null; } // After a layout load / file open: make sure every background-enabled scene // has a background, give any background without a persisted key the auto-detected // one, then acquire one capture per unique background key. ScreenCaptureManager // refcounts by key, so every scene pointing at the same monitor shares one // session. Captures no longer referenced by any scene are released. private void ReacquireScreenCaptures() { HealBackgrounds(); var auto = ResolveAutoCaptureKey(); if (auto == null) { foreach (var background in AllBackgrounds()) background.CaptureKey = null; } else { foreach (var background in AllBackgrounds()) if (string.IsNullOrWhiteSpace(background.CaptureKey)) background.CaptureKey = auto; } var keys = AllBackgrounds() .Select(b => b.CaptureKey!) .Where(k => !string.IsNullOrWhiteSpace(k)) .ToHashSet(); // A layout reload replaces the scenes; stop captures that are no longer // referenced (mirrors ReacquireWebcam's device swap). foreach (var stale in _liveCaptureKeys.Except(keys)) _ = _screenCaptureManager.ReleaseAllAsync(stale); _liveCaptureKeys = keys; foreach (var key in keys) _ = _screenCaptureManager.AcquireAsync(key); _fullScreenDetector.StartWatching(); } // ScreenCaptureManager creates the shared WriteableBitmap on the UI thread; // every background pointing at that key picks it up. private void OnScreenPreviewBitmapChanged(string key, WriteableBitmap bitmap) { var activeBackground = StagedScene?.Elements.OfType().FirstOrDefault(s => s.IsBackground); if (activeBackground?.CaptureKey == key) BackgroundImage = bitmap; foreach (var background in AllBackgrounds().Where(b => b.CaptureKey == key)) background.VideoImageSource = bitmap; } // EVENT_SYSTEM_FOREGROUND fires whenever the foreground window changes. // When a full-screen game appears on a different monitor than the current // background, auto-redesignate. When ShowDesktop is enabled and no fullscreen // game is detected, fall back to the primary monitor (desktop view). private void OnFullscreenMonitorChanged(int? monitor) { if (monitor != null) { var newKey = $"{MonitorKeyPrefix}{monitor}"; var current = AllBackgrounds().FirstOrDefault(); if (current?.CaptureKey == newKey) return; _ = RedesignateBackgroundAsync(newKey); } else if (AllBackgrounds().Any(b => b.ShowDesktop)) { var desktopKey = $"{MonitorKeyPrefix}{_fullScreenDetector.PrimaryMonitorIndex()}"; var current = AllBackgrounds().FirstOrDefault(); if (current?.CaptureKey == desktopKey) return; _ = RedesignateBackgroundAsync(desktopKey); } } // Manual refresh: the "Refresh Capture" context menu item. Reads the // current foreground directly (no caching needed — the hook handles // automatic detection). public void RefreshBackgroundAutoCapture() { var detected = _fullScreenDetector.GetForegroundFullScreenMonitorIndex(); if (detected == null) return; _ = RedesignateBackgroundAsync($"{MonitorKeyPrefix}{detected}"); } /// Whether the active background is in Show Desktop mode. /// Bindable IsChecked property for the context menu toggle. public bool BackgroundShowDesktop { get => StagedScene?.Elements.OfType().FirstOrDefault(s => s.IsBackground)?.ShowDesktop ?? false; set { var background = StagedScene?.Elements.OfType().FirstOrDefault(s => s.IsBackground); if (background == null || background.ShowDesktop == value) return; background.ShowDesktop = value; OnPropertyChanged(nameof(BackgroundShowDesktop)); ScheduleSave(); if (value) { _ = RedesignateBackgroundAsync($"{MonitorKeyPrefix}{_fullScreenDetector.PrimaryMonitorIndex()}"); } else { var gameMonitor = _fullScreenDetector.GetForegroundFullScreenMonitorIndex(); if (gameMonitor != null) _ = RedesignateBackgroundAsync($"{MonitorKeyPrefix}{gameMonitor}"); else _ = ClearBackgroundCaptureAsync(); } } } private void ToggleBackgroundShowDesktop() => BackgroundShowDesktop = !BackgroundShowDesktop; private void BrowseBackground() { var scene = StagedScene; if (scene == null) return; var bg = scene.Elements.OfType().FirstOrDefault(s => s.IsBackground); if (bg == null) return; var dlg = new Microsoft.Win32.OpenFileDialog { Title = "Select background image", Filter = "Image files (*.jpg;*.jpeg;*.png;*.bmp)|*.jpg;*.jpeg;*.png;*.bmp|All files (*.*)|*.*", }; if (dlg.ShowDialog() != true) return; bg.CustomBackgroundPath = dlg.FileName; bg.UseDefaultBackground = false; try { using var fs = new System.IO.FileStream(dlg.FileName, System.IO.FileMode.Open, System.IO.FileAccess.Read); var bytes = new byte[fs.Length]; fs.Read(bytes, 0, bytes.Length); var assetId = AddAsset(bytes); if (assetId != null) bg.AssetId = assetId; } catch { /* non-critical */ } UpdateActiveBackground(); RefreshSnapshotsAsync(); ScheduleSave(); } /// Release the background capture so the static placeholder image shows. private async Task ClearBackgroundCaptureAsync() { var oldKeys = AllBackgrounds() .Select(b => b.CaptureKey!) .Where(k => !string.IsNullOrWhiteSpace(k)) .ToHashSet(); foreach (var background in AllBackgrounds()) { background.CaptureKey = null; background.VideoImageSource = null; } foreach (var key in oldKeys) await _screenCaptureManager.ReleaseAllAsync(key); _liveCaptureKeys.Clear(); UpdateBackgroundImage(); ScheduleSave(); } private async Task RedesignateBackgroundAsync(string newKey) { var oldKeys = AllBackgrounds() .Select(b => b.CaptureKey!) .Where(k => !string.IsNullOrWhiteSpace(k)) .ToHashSet(); foreach (var background in AllBackgrounds()) background.CaptureKey = newKey; foreach (var oldKey in oldKeys.Where(k => k != newKey)) await _screenCaptureManager.ReleaseAllAsync(oldKey); if (!oldKeys.Contains(newKey)) await _screenCaptureManager.AcquireAsync(newKey); _liveCaptureKeys = new HashSet { newKey }; UpdateBackgroundImage(); ScheduleSave(); } // "Change Capture…": the OS GraphicsCapturePicker designates the target. // Picks are transient (see ScreenCaptureSourceFactory) — a reload falls // back to auto-detection. public async Task ChangeBackgroundCaptureAsync() { var key = await _screenCaptureFactory.PickAsync(); if (key == null) return; await RedesignateBackgroundAsync(key); } // In-app display picker: point every background at a specific monitor. private void SetBackgroundCapture(DisplayInfo? display) { if (display == null) return; _ = RedesignateBackgroundAsync($"{MonitorKeyPrefix}{display.Index}"); } // ─── Background model (TASK 25): exactly ONE Background per screen, index 0, // never removable or re-addable. Static default art on Starting/BRB/Chat/Ending; // on Live it's the capture-flavored row (Show Desktop / monitor switch / // fullscreen-game detect) falling back to live-background.jpg. ─── private static string? DefaultBackgroundAsset(string sceneName) => sceneName switch { SceneCatalog.Starting => "starting-background.jpg", SceneCatalog.Brb => "brb-background.jpg", SceneCatalog.Chat => "chat-background.jpg", SceneCatalog.Ending => "ending-background.jpg", SceneCatalog.Live => "live-background.jpg", _ => null, }; /// Heals every scene to the locked Background model after a layout /// load: keeps the correct flavor per scene at index 0 named "Background", /// drops duplicates/stale rows, re-seeds missing ones. private void HealBackgrounds() { NormalizeBackgrounds(Scenes); foreach (var scene in Scenes) { var background = scene.Elements.OfType().FirstOrDefault(s => s.IsBackground); if (background != null) StampDefaultBackgroundAsset(background, scene.Name); } OnPropertyChanged(nameof(ShowSourcesEmptyHint)); } /// Points a background row at its scene's shipped default art when it /// carries none. A custom image (Browse…) always wins and is never overwritten. private void StampDefaultBackgroundAsset(Source background, string sceneName) { if (!string.IsNullOrWhiteSpace(background.AssetId)) return; if (!background.UseDefaultBackground && !string.IsNullOrWhiteSpace(background.CustomBackgroundPath)) return; var asset = DefaultBackgroundAsset(sceneName); if (asset == null) return; try { var uri = new Uri($"pack://application:,,,/Assets/{asset}", UriKind.Absolute); using var stream = Application.GetResourceStream(uri)?.Stream; if (stream == null) return; using var ms = new MemoryStream(); stream.CopyTo(ms); background.AssetId = AddAsset(ms.ToArray()); } catch { /* non-critical — the app works without default art */ } } }