4e0915a36e
Click-to-pin an open window as the full-bleed Live backdrop, via the existing
window:<hwnd> capture path (same render layer as game/desktop). Session-scoped:
window:/picker: keys heal to auto on reload (HWND-recycle hazard); pin wins
while the window enumerates, then auto-fallbacks (game → desktop → static); a
dead window's capture session heals to auto, no dead ends.
Fix: ScreenCaptureSourceFactory.Resolve hex-parsed "window:0x<Hwnd>" WITHOUT
stripping the 0x prefix — NumberStyles.HexNumber rejects it, so every pin
resolved null ("No capture target for this key") and the heal silently rolled
back. Same flaw in IsAliveWindowPin (pin-wins guard was dead). Both fixed.
Commit also carries an out-of-scope prerequisite: PillRadioTests.cs:105 had a
committed stray token ("...PrimaryStartButtonLabel soil;", CS1003) blocking the
whole test-project compile — token removed.
Plus the previous session's uncommitted pricing docs (one-time $29/$49) that
share TASKS.md/ai.md/HANDOFF.md.
438 lines
18 KiB
C#
438 lines
18 KiB
C#
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 readonly IWindowEnumerator _windowEnumerator;
|
|
|
|
/// <summary>Test seam (mirrors CameraEnumeratorOverride): a real-Win32 test
|
|
/// swaps in a fake enumerator to drive the "window dies -> re-heal" flow.</summary>
|
|
internal static IWindowEnumerator? WindowEnumeratorOverride { get; set; }
|
|
|
|
private ImageSource? _activeBackgroundImage;
|
|
private ImageSource? _backgroundImage;
|
|
private bool _backgroundSizeMismatch;
|
|
private string _backgroundSizeMismatchText = "";
|
|
private HashSet<string> _liveCaptureKeys = new();
|
|
|
|
public ObservableCollection<DisplayInfo> Displays { get; } = new();
|
|
|
|
public ImageSource? ActiveBackgroundImage
|
|
{
|
|
get => _activeBackgroundImage;
|
|
private set => SetProperty(ref _activeBackgroundImage, value);
|
|
}
|
|
|
|
/// <summary>The active scene's live-capture background frame (rendered below the
|
|
/// static background). Set from the shared capture bitmap as it arrives.</summary>
|
|
public ImageSource? BackgroundImage
|
|
{
|
|
get => _backgroundImage;
|
|
private set
|
|
{
|
|
if (SetProperty(ref _backgroundImage, value))
|
|
{
|
|
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
|
|
OnPropertyChanged(nameof(BackgroundVisible));
|
|
}
|
|
}
|
|
}
|
|
|
|
public bool BackgroundVisible => StagedScene?.Elements.OfType<Source>().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<Source>().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<Source>().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);
|
|
}
|
|
}
|
|
|
|
|
|
// 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();
|
|
|
|
// Transient keys ("picker:" picks and "window:" pins) are session-scoped
|
|
// (see ScreenCaptureSourceFactory): a persisted HWND can be recycled to an
|
|
// unrelated window after restart, so a reload never resurrects them — the
|
|
// background falls back to auto-detection instead.
|
|
foreach (var transient in AllBackgrounds()
|
|
.Where(b => IsTransientCaptureKey(b.CaptureKey)))
|
|
transient.CaptureKey = null;
|
|
|
|
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<Source>().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)
|
|
{
|
|
// A live window pin outranks the fullscreen-game detector: while the
|
|
// pinned window still enumerates, keep it as the backdrop (the creator's
|
|
// explicit choice beats auto-detection). Only when the window is gone
|
|
// does auto-fallback (game → desktop → static) reclaim the background.
|
|
var current = AllBackgrounds().FirstOrDefault();
|
|
if (current != null && IsAliveWindowPin(current.CaptureKey)) return;
|
|
|
|
if (monitor != null)
|
|
{
|
|
var newKey = $"{MonitorKeyPrefix}{monitor}";
|
|
if (current?.CaptureKey == newKey) return;
|
|
_ = RedesignateBackgroundAsync(newKey);
|
|
}
|
|
else if (AllBackgrounds().Any(b => b.ShowDesktop))
|
|
{
|
|
var desktopKey = $"{MonitorKeyPrefix}{_fullScreenDetector.PrimaryMonitorIndex()}";
|
|
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}");
|
|
}
|
|
|
|
/// <summary>Whether the active background is in Show Desktop mode.
|
|
/// Bindable IsChecked property for the context menu toggle.</summary>
|
|
public bool BackgroundShowDesktop
|
|
{
|
|
get => StagedScene?.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground)?.ShowDesktop ?? false;
|
|
set
|
|
{
|
|
var background = StagedScene?.Elements.OfType<Source>().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<Source>().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();
|
|
}
|
|
|
|
/// <summary>Release the background capture so the static placeholder image shows.</summary>
|
|
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<string> { 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,
|
|
};
|
|
|
|
/// <summary>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.</summary>
|
|
private void HealBackgrounds()
|
|
{
|
|
NormalizeBackgrounds(Scenes);
|
|
foreach (var scene in Scenes)
|
|
{
|
|
_sceneGraph.InvalidateBake(scene);
|
|
var background = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
|
if (background != null)
|
|
StampDefaultBackgroundAsset(background, scene.Name);
|
|
}
|
|
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
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 */ }
|
|
}
|
|
}
|