diff --git a/ViewModels/MainViewModel.Background.Model.cs b/ViewModels/MainViewModel.Background.Model.cs new file mode 100644 index 0000000..71b003a --- /dev/null +++ b/ViewModels/MainViewModel.Background.Model.cs @@ -0,0 +1,164 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using ytLive.Helpers; +using ytLive.Models; +using ytLive.ViewModels; + +namespace ytLive.ViewModels; + +public partial class MainViewModel +{ + 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)); +} diff --git a/ViewModels/MainViewModel.Background.cs b/ViewModels/MainViewModel.Background.cs index 4ac2c21..7864ff6 100644 --- a/ViewModels/MainViewModel.Background.cs +++ b/ViewModels/MainViewModel.Background.cs @@ -146,156 +146,6 @@ public partial class MainViewModel } } - 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 @@ -565,4 +415,4 @@ public partial class MainViewModel } catch { /* non-critical — the app works without default art */ } } -} \ No newline at end of file +} diff --git a/ViewModels/index.md b/ViewModels/index.md index 7f72c57..4c69042 100644 --- a/ViewModels/index.md +++ b/ViewModels/index.md @@ -68,5 +68,6 @@ source files under 500 lines. One roll-back commit per file; same drill commit, tag `refactor-commit-N`, no per-commit push). - [x] **Commit A · `refactor-commit-A`** — **core** → `MainViewModel.cs` 758 → **495**. Moved source add/remove/image block to new **`MainViewModel.Sources.cs`** (234 lines: `AddSource`, `NextSourceName`, `AddImage`, `BuildImageCandidates`, `PickImageBytes`, `AddAsset`, `AddImageSource`, `AddReusedImage`, `RemoveElement`) and WebView2 hosting (`RegisterLoadedWebSources`, `InitWebView2`, `OnWebView2PreviewBitmapChanged`) to new **`MainViewModel.Web.cs`**. Pruned now-dead usings (`Microsoft.Win32`, `NAudio.CoreAudioApi`, `System.Windows.Media.Imaging`, `Collections.ObjectModel/Specialized`). Pending: Streaming, Background, SocialsDialogViewModel, LayoutStore. - [x] **Commit B · `refactor-commit-B`** — **Streaming** → `MainViewModel.Streaming.cs` 789 → **330** + new **`MainViewModel.Streaming.Operations.cs`** (459 lines). Moved the go-live/record/health operation bodies (`StartSession`, `BeginRecordOnly`, `BeginGoLive`, `StartRecordFile`, `PrepareAndStartLiveAsync`, `PollHealthAsync`/`OnHealthPollTick`/`ApplyHealthIssue`, `StopStream`, `FinalizeRecordingAsync`, `UniquePath`, `ChooseRecordFolder`, `ResetRecordFolder`, `DefaultRecordFolder`, `OnFramePumpFailed`, `OnFramePumpHealthUpdated`, `ApplyHealth`, `ResetHealth`, `BuildEncoderOptions`) into the Operations partial; Streaming.cs keeps state + quality/resolution tiers + session timer + command declarations. Pruned dead usings both files. Pending: Background, SocialsDialogViewModel, LayoutStore. +- [x] **Commit C · `refactor-commit-C`** — **Background** → `MainViewModel.Background.cs` 567 → **418** + new **`MainViewModel.Background.Model.cs`** (163 lines). Moved the static scene-model helpers (`LoadBackgroundImage`, `CreatePlaceholderSnapshot`, `EnsureBackground`, `CreateBackground`, `NormalizeBackgrounds`, `AllBackgrounds`, `ResolveAutoCaptureKey`, `MonitorKeyPrefix`) into the Model partial; Background.cs keeps capture/live state + operations (`UpdateActiveBackground`, `UpdateBackgroundImage`, `RefreshSnapshotsAsync`, `ReacquireScreenCaptures`, `OnScreenPreviewBitmapChanged`, `OnFullscreenMonitorChanged`, `RefreshBackgroundAutoCapture`, `BrowseBackground`, `ClearBackgroundCaptureAsync`, `RedesignateBackgroundAsync`, `ChangeBackgroundCaptureAsync`, `SetBackgroundCapture`, `DefaultBackgroundAsset`, `HealBackgrounds`, `StampDefaultBackgroundAsset`). Pending: SocialsDialogViewModel, LayoutStore. The partial `.cs` files land next to `MainViewModel.cs` in this folder as the split proceeds; each partial carries its own `using`s and re-declares nothing from core. \ No newline at end of file