From db068abbc7d896df3401885e608178f9fe19de01 Mon Sep 17 00:00:00 2001 From: gramps Date: Sun, 30 Aug 2026 07:30:24 -0700 Subject: [PATCH] =?UTF-8?q?refactor:=20split=20Background=20out=20of=20Mai?= =?UTF-8?q?nViewModel=20=E2=80=94=20Commit=204/12=20(tag=20refactor-commit?= =?UTF-8?q?-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ViewModels/MainViewModel.Background.cs | 568 +++++++++++++++++++++++++ ViewModels/MainViewModel.cs | 551 ------------------------ ViewModels/index.md | 16 +- 3 files changed, 582 insertions(+), 553 deletions(-) create mode 100644 ViewModels/MainViewModel.Background.cs diff --git a/ViewModels/MainViewModel.Background.cs b/ViewModels/MainViewModel.Background.cs new file mode 100644 index 0000000..4ac2c21 --- /dev/null +++ b/ViewModels/MainViewModel.Background.cs @@ -0,0 +1,568 @@ +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 */ } + } +} \ No newline at end of file diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index 9c1aad0..345202d 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -32,7 +32,6 @@ public partial class MainViewModel : ViewModelBase private readonly DispatcherTimer _healthPollTimer; private SceneElement? _selectedElement; - private ImageSource? _activeBackgroundImage; private double _audioLevel; private bool _volumeAdjusting; private bool _volumeFlash; @@ -133,12 +132,6 @@ public partial class MainViewModel : ViewModelBase private readonly FramePump _framePump; private readonly TransitionService _transition = new(); - // 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 readonly ChatBoxRenderer _chatBoxRenderer; private WebView2Manager? _webView2Manager; @@ -152,11 +145,6 @@ public partial class MainViewModel : ViewModelBase new ChatMessage { AuthorName = "LlamaCasty", Message = "thanks for watching! llamacasty.com", Timestamp = DateTime.Now.AddMinutes(-1), IsMember = true }, new ChatMessage { AuthorName = "LlamaDog", Message = "Subscribe so they can buy the subscription at llamacasty.com!", Timestamp = DateTime.Now, IsMember = true }, }; - private ImageSource? _backgroundImage; - private bool _backgroundSizeMismatch; - private string _backgroundSizeMismatchText = ""; - private HashSet _liveCaptureKeys = new(); - // Chat fade timer: clears chat overlay after N seconds of inactivity. private System.Windows.Threading.DispatcherTimer? _chatFadeTimer; @@ -186,42 +174,6 @@ public partial class MainViewModel : ViewModelBase public string LicensesText { get; private set; } = string.Empty; public ObservableCollection ChatMessages { get; } = 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 SceneElement? SelectedElement { @@ -869,141 +821,6 @@ public partial class MainViewModel : ViewModelBase private set => SetProperty(ref _brandFlashActive, value); } - 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; - } - public StreamHealth CurrentHealth { get => _currentHealth; @@ -1313,11 +1130,6 @@ public partial class MainViewModel : ViewModelBase public ICommand ToggleElementVisibilityCommand { get; } public ICommand ChangeWebcamCommand { get; } public ICommand ShowWebcamCommand { get; } - public ICommand ChangeCaptureCommand { get; } - public ICommand RefreshCaptureCommand { get; } - public ICommand SetBackgroundDisplayCommand { get; } - public ICommand ToggleShowDesktopCommand { get; } - public ICommand BrowseBackgroundCommand { get; } public ICommand ToggleMicMuteCommand { get; } public ICommand ToggleGameMuteCommand { get; } public ICommand OpenMicPickerCommand { get; } @@ -1635,155 +1447,6 @@ public partial class MainViewModel : ViewModelBase WebcamError = $"Webcam offline: {message}"; } - // ─── 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(); - } - private void RegisterLoadedWebSources() { if (_webView2Manager == null) return; @@ -1800,169 +1463,6 @@ public partial class MainViewModel : ViewModelBase } } - // 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}"); - } - public void InitWebView2(Panel hostPanel) { _webView2Manager = new WebView2Manager(hostPanel, System.Windows.Application.Current.Dispatcher); @@ -2013,57 +1513,6 @@ public partial class MainViewModel : ViewModelBase _saveDebounce.Start(); } - // ─── 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 */ } - } - private void BeginEditElement(SceneElement? element) { if (element == null) return; diff --git a/ViewModels/index.md b/ViewModels/index.md index 4a4d2d8..0c03f39 100644 --- a/ViewModels/index.md +++ b/ViewModels/index.md @@ -38,12 +38,24 @@ Progress (Commit N/12 · tag): `RefreshHotkeyLabels`, scene-gating hints, `MissingScenes`/`ShowAddScene`/ `CanChangeBackground`, scene wiring `OnScenesChanged`/`WireScene`/`UnwireScene`, `AddScene`). +- **Commit 4/12 · `refactor-commit-4`: Background landed** → `MainViewModel.Background.cs` + (background model `EnsureBackground`/`CreateBackground`/`NormalizeBackgrounds`/ + `HealBackgrounds`/`StampDefaultBackgroundAsset`, display picker `Displays` + + `SetBackgroundCapture`, screen capture `ReacquireScreenCaptures`/ + `ResolveAutoCaptureKey`/`RedesignateBackgroundAsync`/`ClearBackgroundCaptureAsync`/ + `OnScreenPreviewBitmapChanged`/`OnFullscreenMonitorChanged`/ + `RefreshBackgroundAutoCapture`/`BackgroundShowDesktop`, preview fns + `ActiveBackgroundImage`/`BackgroundImage`/`UpdateActiveBackground`/ + `UpdateBackgroundImage`/`RefreshSnapshotsAsync`/`LoadBackgroundImage`, + `ChangeCaptureCommand`/`RefreshCaptureCommand`/`SetBackgroundDisplayCommand`/ + `ToggleShowDesktopCommand`/`BrowseBackgroundCommand`). `ShowPreviewPlaceholder` + stays in core. - Core file keeps: fields, ctor, `Shutdown`/`SaveLayoutNow`/`ScheduleSave`/`LoadLayout`, `SetPanel`/`ShowOverlay`/`ShowLicenses`, `CreateBrush`, `OpenUrl`/`ComposeEmail`, `SubmitBug`/`SubmitFeature`, window-geometry pass-through. -- Partials: `Account` ✔ (Commit 1), `License` ✔ (Commit 2), `Scenes` ✔ (Commit 3), `Background` (background model + screen - capture + display picker), `Webcam` (webcam identity + per-scene configs + captures), +- Partials: `Account` ✔ (Commit 1), `License` ✔ (Commit 2), `Scenes` ✔ (Commit 3), `Background` ✔ (Commit 4), + `Webcam` (webcam identity + per-scene configs + captures), `Audio` (mic + game bars + TRAX), `Trax` (music player), `Socials` (social bar + dialog), `Streaming` (go-live/record + health + quality tiers + media source pipeline), `Chat` (YouTube chat + mock preview + chatbox render), `Overlays` (settings/bug/feature/about