Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db068abbc7 | |||
| dcdfcb9cee |
@@ -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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static System.Windows.Media.Imaging.BitmapSource? LoadBackgroundImage(Scene scene)
|
||||||
|
{
|
||||||
|
var bg = scene.Elements.OfType<Source>().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<string, System.Windows.Media.Brush>(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:";
|
||||||
|
|
||||||
|
/// <summary>Every background-enabled scene has exactly one background, kept at index 0 (bottom layer).
|
||||||
|
/// Returns null for a scene with <see cref="Scene.HasBackground"/> = false.</summary>
|
||||||
|
internal static Source? EnsureBackground(Scene scene)
|
||||||
|
{
|
||||||
|
if (!scene.HasBackground) return null;
|
||||||
|
var background = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
||||||
|
if (background != null) return background;
|
||||||
|
|
||||||
|
background = CreateBackground(scene.Name);
|
||||||
|
scene.Elements.Insert(0, background);
|
||||||
|
return background;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
internal static void NormalizeBackgrounds(IEnumerable<Scene> scenes)
|
||||||
|
{
|
||||||
|
foreach (var scene in scenes)
|
||||||
|
{
|
||||||
|
if (!SceneCatalog.HasBackground(scene.Name))
|
||||||
|
{
|
||||||
|
scene.HasBackground = false;
|
||||||
|
foreach (var stale in scene.Elements.OfType<Source>().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<Source>().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<Source> AllBackgrounds()
|
||||||
|
=> Scenes.Where(s => SceneCatalog.Is(s.Name, SceneCatalog.Live))
|
||||||
|
.SelectMany(s => s.Elements.OfType<Source>().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<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)
|
||||||
|
{
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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)
|
||||||
|
{
|
||||||
|
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 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Collections.Specialized;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Windows;
|
||||||
|
using ytLive;
|
||||||
|
using ytLive.Models;
|
||||||
|
using ytLive.Services;
|
||||||
|
|
||||||
|
namespace ytLive.ViewModels;
|
||||||
|
|
||||||
|
public partial class MainViewModel
|
||||||
|
{
|
||||||
|
private Scene? _stagedScene;
|
||||||
|
private Scene? _liveScene;
|
||||||
|
|
||||||
|
// TASK 20 step 2 — hotkey bindings (custom chord per action)
|
||||||
|
private Dictionary<HotkeyId, HotkeyChord> _hotkeyBindings = new();
|
||||||
|
|
||||||
|
public ObservableCollection<Scene> Scenes { get; } = new();
|
||||||
|
|
||||||
|
public Scene? StagedScene
|
||||||
|
{
|
||||||
|
get => _stagedScene;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (value != null && value.IsHidden) return;
|
||||||
|
if (SetProperty(ref _stagedScene, value))
|
||||||
|
{
|
||||||
|
// Update IsStaged flags for thumbnail glow
|
||||||
|
foreach (var s in Scenes)
|
||||||
|
s.IsStaged = s == value;
|
||||||
|
|
||||||
|
SelectedElement = null;
|
||||||
|
if (value != null)
|
||||||
|
{
|
||||||
|
var background = EnsureBackground(value);
|
||||||
|
if (background != null)
|
||||||
|
StampDefaultBackgroundAsset(background, value.Name);
|
||||||
|
}
|
||||||
|
OnPropertyChanged(nameof(ShowChatInactiveMessage));
|
||||||
|
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
||||||
|
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
|
||||||
|
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
||||||
|
OnPropertyChanged(nameof(CanChangeBackground));
|
||||||
|
OnPropertyChanged(nameof(CanAddWebcam));
|
||||||
|
OnPropertyChanged(nameof(CanAddYouTubeChat));
|
||||||
|
OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
|
||||||
|
OnPropertyChanged(nameof(IsCountdownAvailable));
|
||||||
|
UpdateActiveBackground();
|
||||||
|
UpdateBackgroundImage();
|
||||||
|
RefreshSnapshotsAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The scene the encoder outputs. Set on Go Live (StagedScene → LiveScene)
|
||||||
|
/// or on transition completion. FramePump reads this every tick.</summary>
|
||||||
|
public Scene? LiveScene
|
||||||
|
{
|
||||||
|
get => _liveScene;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _liveScene, value))
|
||||||
|
{
|
||||||
|
foreach (var s in Scenes)
|
||||||
|
s.IsLive = s == value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True when offline — edit mode is available. When live/recording,
|
||||||
|
/// all edit operations are locked out (gun safety).</summary>
|
||||||
|
public bool IsEditMode => StreamStatus == StreamStatus.Offline && !IsRecording;
|
||||||
|
|
||||||
|
// ─── Transitions (TASK 19/23) ───
|
||||||
|
|
||||||
|
public TransitionType SelectedTransitionType
|
||||||
|
{
|
||||||
|
get => _selectedTransitionType;
|
||||||
|
set => SetProperty(ref _selectedTransitionType, value);
|
||||||
|
}
|
||||||
|
private TransitionType _selectedTransitionType = TransitionType.Fade;
|
||||||
|
|
||||||
|
public double TransitionDurationMs
|
||||||
|
{
|
||||||
|
get => _transitionDurationMs;
|
||||||
|
set => SetProperty(ref _transitionDurationMs, value);
|
||||||
|
}
|
||||||
|
private double _transitionDurationMs = 300;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called when a scene thumbnail is clicked. If live, triggers a transition from
|
||||||
|
/// LiveScene to the selected StagedScene. Always sets StagedScene first.
|
||||||
|
/// </summary>
|
||||||
|
public void TransitionToScene(Scene scene)
|
||||||
|
{
|
||||||
|
if (scene.IsHidden || scene == StagedScene) return;
|
||||||
|
StagedScene = scene;
|
||||||
|
if (IsLive)
|
||||||
|
_transition.Start(SelectedTransitionType, TransitionDurationMs, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Apply the current transition type to the next StagedScene change while live.
|
||||||
|
/// Called from XAML when user picks a transition from the dropdown.
|
||||||
|
/// </summary>
|
||||||
|
public void ApplyTransitionToLive()
|
||||||
|
{
|
||||||
|
if (IsLive)
|
||||||
|
_transition.Start(SelectedTransitionType, TransitionDurationMs, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Global hotkeys (TASK 20) — dispatch a registered hotkey to its action ───
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Callback set by MainWindow so the VM can trigger hotkey re-registration
|
||||||
|
/// after the user saves custom bindings from the config dialog.
|
||||||
|
/// </summary>
|
||||||
|
public Action<Dictionary<HotkeyId, HotkeyChord>>? HotkeyBindingsChanged { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The current hotkey bindings (loaded from DB, updated on dialog save).</summary>
|
||||||
|
public Dictionary<HotkeyId, HotkeyChord> CurrentHotkeyBindings => _hotkeyBindings;
|
||||||
|
|
||||||
|
public void HandleHotkey(Services.HotkeyId id)
|
||||||
|
{
|
||||||
|
switch (id)
|
||||||
|
{
|
||||||
|
case Services.HotkeyId.SceneStarting: TransitionToCanonical(SceneCatalog.Starting); break;
|
||||||
|
case Services.HotkeyId.SceneLive: TransitionToCanonical(SceneCatalog.Live); break;
|
||||||
|
case Services.HotkeyId.SceneBrb: TransitionToCanonical(SceneCatalog.Brb); break;
|
||||||
|
case Services.HotkeyId.SceneChat: TransitionToCanonical(SceneCatalog.Chat); break;
|
||||||
|
case Services.HotkeyId.SceneEnding: TransitionToCanonical(SceneCatalog.Ending); break;
|
||||||
|
case Services.HotkeyId.StartStopStream:
|
||||||
|
if (IsLive || IsRecording)
|
||||||
|
{
|
||||||
|
if (EndStreamCommand.CanExecute(null)) EndStreamCommand.Execute(null);
|
||||||
|
}
|
||||||
|
else if (StartStreamCommand.CanExecute(null))
|
||||||
|
{
|
||||||
|
StartStreamCommand.Execute(null);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case Services.HotkeyId.ToggleMicMute: ToggleMicMuteCommand.Execute(null); break;
|
||||||
|
case Services.HotkeyId.ToggleGameMute: ToggleGameMuteCommand.Execute(null); break;
|
||||||
|
case Services.HotkeyId.TraxPlayPause: TraxLeftClick(); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TransitionToCanonical(string name)
|
||||||
|
{
|
||||||
|
var scene = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, name));
|
||||||
|
if (scene != null) TransitionToScene(scene);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenHotkeyConfig()
|
||||||
|
{
|
||||||
|
var vm = new HotkeyConfigViewModel(_layoutStore);
|
||||||
|
var dialog = new HotkeyConfigDialog(vm) { Owner = Application.Current.MainWindow };
|
||||||
|
if (dialog.ShowDialog() == true)
|
||||||
|
{
|
||||||
|
// Reload bindings from DB (the dialog already saved them).
|
||||||
|
_hotkeyBindings = _layoutStore.LoadHotkeyBindings();
|
||||||
|
HotkeyBindingsChanged?.Invoke(_hotkeyBindings);
|
||||||
|
RefreshHotkeyLabels();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshHotkeyLabels()
|
||||||
|
{
|
||||||
|
foreach (var scene in Scenes)
|
||||||
|
{
|
||||||
|
if (SceneCatalog.SceneHotkeys.TryGetValue(scene.Name, out var hid)
|
||||||
|
&& _hotkeyBindings.TryGetValue(hid, out var chord))
|
||||||
|
scene.HotkeyLabel = chord.ToDisplayString();
|
||||||
|
else if (SceneCatalog.SceneHotkeys.TryGetValue(scene.Name, out var defId)
|
||||||
|
&& GlobalHotkeyManager.Defaults.TryGetValue(defId, out var defChord))
|
||||||
|
scene.HotkeyLabel = defChord.ToDisplayString();
|
||||||
|
else
|
||||||
|
scene.HotkeyLabel = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool ShowEmptySceneHint => StagedScene != null && StagedScene.Elements.Count == 0;
|
||||||
|
public bool ShowSourcesEmptyHint => StagedScene == null || StagedScene.Elements.Count == 0;
|
||||||
|
|
||||||
|
public bool IsCountdownAvailable => StagedScene != null
|
||||||
|
&& (SceneCatalog.Is(StagedScene.Name, "Starting") || SceneCatalog.Is(StagedScene.Name, "BRB"));
|
||||||
|
|
||||||
|
/// <summary>The canonical scenes (SceneCatalog) not present right now — what the
|
||||||
|
/// "+" button may re-add. Work with less, never more.</summary>
|
||||||
|
public IEnumerable<string> MissingScenes
|
||||||
|
=> SceneCatalog.All.Where(canonical => Scenes.All(s => !SceneCatalog.Is(s.Name, canonical)));
|
||||||
|
|
||||||
|
/// <summary>Only show the "+" button when one of the five canonical scenes is missing.</summary>
|
||||||
|
public bool ShowAddScene => MissingScenes.Any();
|
||||||
|
|
||||||
|
/// <summary>"Change Capture…"/"Refresh Capture" apply to the active scene's background.</summary>
|
||||||
|
// Capture controls (Show Desktop / monitor pick / refresh) exist only on
|
||||||
|
// the Live screen's Background. Static-art screens never expose them.
|
||||||
|
public bool CanChangeBackground =>
|
||||||
|
StagedScene != null && SceneCatalog.Is(StagedScene.Name, SceneCatalog.Live);
|
||||||
|
|
||||||
|
// ─── Auto-save wiring ───
|
||||||
|
private void OnScenesChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.NewItems != null)
|
||||||
|
foreach (Scene scene in e.NewItems)
|
||||||
|
WireScene(scene);
|
||||||
|
if (e.OldItems != null)
|
||||||
|
foreach (Scene scene in e.OldItems)
|
||||||
|
UnwireScene(scene);
|
||||||
|
OnPropertyChanged(nameof(MissingScenes));
|
||||||
|
OnPropertyChanged(nameof(ShowAddScene));
|
||||||
|
ScheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WireScene(Scene scene)
|
||||||
|
{
|
||||||
|
scene.PropertyChanged += OnScenePropertyChanged;
|
||||||
|
scene.Elements.CollectionChanged += OnElementsChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UnwireScene(Scene scene)
|
||||||
|
{
|
||||||
|
scene.PropertyChanged -= OnScenePropertyChanged;
|
||||||
|
scene.Elements.CollectionChanged -= OnElementsChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnScenePropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
// A rename can drop a canonical scene in/out of MissingScenes.
|
||||||
|
OnPropertyChanged(nameof(MissingScenes));
|
||||||
|
OnPropertyChanged(nameof(ShowAddScene));
|
||||||
|
ScheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnElementsChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.NewItems != null)
|
||||||
|
foreach (SceneElement element in e.NewItems)
|
||||||
|
element.PropertyChanged += OnElementPropertyChanged;
|
||||||
|
if (e.OldItems != null)
|
||||||
|
foreach (SceneElement element in e.OldItems)
|
||||||
|
element.PropertyChanged -= OnElementPropertyChanged;
|
||||||
|
OnPropertyChanged(nameof(CanAddWebcam));
|
||||||
|
OnPropertyChanged(nameof(CanAddYouTubeChat));
|
||||||
|
ScheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnElementPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is WebcamSceneConfig && e.PropertyName == nameof(SceneElement.IsVisible))
|
||||||
|
OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
|
||||||
|
ScheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adds a canonical scene by name — only when it's actually missing. The
|
||||||
|
// background flag follows the policy (Live yes, everyone else no).
|
||||||
|
private void AddScene(string name)
|
||||||
|
{
|
||||||
|
if (!SceneCatalog.IsCanonical(name)) return;
|
||||||
|
if (Scenes.Any(s => SceneCatalog.Is(s.Name, name))) return;
|
||||||
|
var scene = new Scene
|
||||||
|
{
|
||||||
|
Name = name.Trim(),
|
||||||
|
IsChatScene = SceneCatalog.IsChat(name),
|
||||||
|
HasBackground = SceneCatalog.HasBackground(name),
|
||||||
|
};
|
||||||
|
EnsureBackground(scene);
|
||||||
|
Scenes.Add(scene);
|
||||||
|
StagedScene = scene;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,10 +31,7 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
private readonly DispatcherTimer _gameVolumeFlashTimer;
|
private readonly DispatcherTimer _gameVolumeFlashTimer;
|
||||||
private readonly DispatcherTimer _healthPollTimer;
|
private readonly DispatcherTimer _healthPollTimer;
|
||||||
|
|
||||||
private Scene? _stagedScene;
|
|
||||||
private Scene? _liveScene;
|
|
||||||
private SceneElement? _selectedElement;
|
private SceneElement? _selectedElement;
|
||||||
private ImageSource? _activeBackgroundImage;
|
|
||||||
private double _audioLevel;
|
private double _audioLevel;
|
||||||
private bool _volumeAdjusting;
|
private bool _volumeAdjusting;
|
||||||
private bool _volumeFlash;
|
private bool _volumeFlash;
|
||||||
@@ -135,12 +132,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
private readonly FramePump _framePump;
|
private readonly FramePump _framePump;
|
||||||
private readonly TransitionService _transition = new();
|
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 readonly ChatBoxRenderer _chatBoxRenderer;
|
||||||
private WebView2Manager? _webView2Manager;
|
private WebView2Manager? _webView2Manager;
|
||||||
|
|
||||||
@@ -154,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 = "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 },
|
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<string> _liveCaptureKeys = new();
|
|
||||||
|
|
||||||
// Chat fade timer: clears chat overlay after N seconds of inactivity.
|
// Chat fade timer: clears chat overlay after N seconds of inactivity.
|
||||||
private System.Windows.Threading.DispatcherTimer? _chatFadeTimer;
|
private System.Windows.Threading.DispatcherTimer? _chatFadeTimer;
|
||||||
|
|
||||||
@@ -170,9 +156,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
private bool _brandFlashEnabled = true;
|
private bool _brandFlashEnabled = true;
|
||||||
private bool _brandFlashActive;
|
private bool _brandFlashActive;
|
||||||
|
|
||||||
// TASK 20 step 2 — hotkey bindings (custom chord per action)
|
|
||||||
private Dictionary<HotkeyId, HotkeyChord> _hotkeyBindings = new();
|
|
||||||
|
|
||||||
private const string SupportEmail = "gramps@llamachile.shop";
|
private const string SupportEmail = "gramps@llamachile.shop";
|
||||||
// The creator-hub link targets (About overlay) — public so the hub's
|
// The creator-hub link targets (About overlay) — public so the hub's
|
||||||
// integration test can assert they point at real destinations.
|
// integration test can assert they point at real destinations.
|
||||||
@@ -190,98 +173,7 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
/// shipped THIRD-PARTY-NOTICES.txt (never opens the OS viewer).</summary>
|
/// shipped THIRD-PARTY-NOTICES.txt (never opens the OS viewer).</summary>
|
||||||
public string LicensesText { get; private set; } = string.Empty;
|
public string LicensesText { get; private set; } = string.Empty;
|
||||||
|
|
||||||
public ObservableCollection<Scene> Scenes { get; } = new();
|
|
||||||
public ObservableCollection<ChatMessage> ChatMessages { get; } = new();
|
public ObservableCollection<ChatMessage> ChatMessages { get; } = new();
|
||||||
public ObservableCollection<DisplayInfo> Displays { get; } = new();
|
|
||||||
|
|
||||||
public Scene? StagedScene
|
|
||||||
{
|
|
||||||
get => _stagedScene;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
if (value != null && value.IsHidden) return;
|
|
||||||
if (SetProperty(ref _stagedScene, value))
|
|
||||||
{
|
|
||||||
// Update IsStaged flags for thumbnail glow
|
|
||||||
foreach (var s in Scenes)
|
|
||||||
s.IsStaged = s == value;
|
|
||||||
|
|
||||||
SelectedElement = null;
|
|
||||||
if (value != null)
|
|
||||||
{
|
|
||||||
var background = EnsureBackground(value);
|
|
||||||
if (background != null)
|
|
||||||
StampDefaultBackgroundAsset(background, value.Name);
|
|
||||||
}
|
|
||||||
OnPropertyChanged(nameof(ShowChatInactiveMessage));
|
|
||||||
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
|
||||||
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
|
|
||||||
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
|
||||||
OnPropertyChanged(nameof(CanChangeBackground));
|
|
||||||
OnPropertyChanged(nameof(CanAddWebcam));
|
|
||||||
OnPropertyChanged(nameof(CanAddYouTubeChat));
|
|
||||||
OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
|
|
||||||
OnPropertyChanged(nameof(IsCountdownAvailable));
|
|
||||||
UpdateActiveBackground();
|
|
||||||
UpdateBackgroundImage();
|
|
||||||
RefreshSnapshotsAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>The scene the encoder outputs. Set on Go Live (StagedScene → LiveScene)
|
|
||||||
/// or on transition completion. FramePump reads this every tick.</summary>
|
|
||||||
public Scene? LiveScene
|
|
||||||
{
|
|
||||||
get => _liveScene;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
if (SetProperty(ref _liveScene, value))
|
|
||||||
{
|
|
||||||
foreach (var s in Scenes)
|
|
||||||
s.IsLive = s == value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>True when offline — edit mode is available. When live/recording,
|
|
||||||
/// all edit operations are locked out (gun safety).</summary>
|
|
||||||
public bool IsEditMode => StreamStatus == StreamStatus.Offline && !IsRecording;
|
|
||||||
|
|
||||||
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 SceneElement? SelectedElement
|
public SceneElement? SelectedElement
|
||||||
{
|
{
|
||||||
@@ -320,114 +212,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
=> StagedScene != null
|
=> StagedScene != null
|
||||||
&& !Scenes.Any(s => s.Elements.OfType<Source>().Any(e => e.Type == SourceType.ChatBox));
|
&& !Scenes.Any(s => s.Elements.OfType<Source>().Any(e => e.Type == SourceType.ChatBox));
|
||||||
|
|
||||||
// ─── Transitions (TASK 19/23) ───
|
|
||||||
|
|
||||||
public TransitionType SelectedTransitionType
|
|
||||||
{
|
|
||||||
get => _selectedTransitionType;
|
|
||||||
set => SetProperty(ref _selectedTransitionType, value);
|
|
||||||
}
|
|
||||||
private TransitionType _selectedTransitionType = TransitionType.Fade;
|
|
||||||
|
|
||||||
public double TransitionDurationMs
|
|
||||||
{
|
|
||||||
get => _transitionDurationMs;
|
|
||||||
set => SetProperty(ref _transitionDurationMs, value);
|
|
||||||
}
|
|
||||||
private double _transitionDurationMs = 300;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Called when a scene thumbnail is clicked. If live, triggers a transition from
|
|
||||||
/// LiveScene to the selected StagedScene. Always sets StagedScene first.
|
|
||||||
/// </summary>
|
|
||||||
public void TransitionToScene(Scene scene)
|
|
||||||
{
|
|
||||||
if (scene.IsHidden || scene == StagedScene) return;
|
|
||||||
StagedScene = scene;
|
|
||||||
if (IsLive)
|
|
||||||
_transition.Start(SelectedTransitionType, TransitionDurationMs, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Apply the current transition type to the next StagedScene change while live.
|
|
||||||
/// Called from XAML when user picks a transition from the dropdown.
|
|
||||||
/// </summary>
|
|
||||||
public void ApplyTransitionToLive()
|
|
||||||
{
|
|
||||||
if (IsLive)
|
|
||||||
_transition.Start(SelectedTransitionType, TransitionDurationMs, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Global hotkeys (TASK 20) — dispatch a registered hotkey to its action ───
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Callback set by MainWindow so the VM can trigger hotkey re-registration
|
|
||||||
/// after the user saves custom bindings from the config dialog.
|
|
||||||
/// </summary>
|
|
||||||
public Action<Dictionary<HotkeyId, HotkeyChord>>? HotkeyBindingsChanged { get; set; }
|
|
||||||
|
|
||||||
/// <summary>The current hotkey bindings (loaded from DB, updated on dialog save).</summary>
|
|
||||||
public Dictionary<HotkeyId, HotkeyChord> CurrentHotkeyBindings => _hotkeyBindings;
|
|
||||||
|
|
||||||
public void HandleHotkey(Services.HotkeyId id)
|
|
||||||
{
|
|
||||||
switch (id)
|
|
||||||
{
|
|
||||||
case Services.HotkeyId.SceneStarting: TransitionToCanonical(SceneCatalog.Starting); break;
|
|
||||||
case Services.HotkeyId.SceneLive: TransitionToCanonical(SceneCatalog.Live); break;
|
|
||||||
case Services.HotkeyId.SceneBrb: TransitionToCanonical(SceneCatalog.Brb); break;
|
|
||||||
case Services.HotkeyId.SceneChat: TransitionToCanonical(SceneCatalog.Chat); break;
|
|
||||||
case Services.HotkeyId.SceneEnding: TransitionToCanonical(SceneCatalog.Ending); break;
|
|
||||||
case Services.HotkeyId.StartStopStream:
|
|
||||||
if (IsLive || IsRecording)
|
|
||||||
{
|
|
||||||
if (EndStreamCommand.CanExecute(null)) EndStreamCommand.Execute(null);
|
|
||||||
}
|
|
||||||
else if (StartStreamCommand.CanExecute(null))
|
|
||||||
{
|
|
||||||
StartStreamCommand.Execute(null);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case Services.HotkeyId.ToggleMicMute: ToggleMicMuteCommand.Execute(null); break;
|
|
||||||
case Services.HotkeyId.ToggleGameMute: ToggleGameMuteCommand.Execute(null); break;
|
|
||||||
case Services.HotkeyId.TraxPlayPause: TraxLeftClick(); break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void TransitionToCanonical(string name)
|
|
||||||
{
|
|
||||||
var scene = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, name));
|
|
||||||
if (scene != null) TransitionToScene(scene);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OpenHotkeyConfig()
|
|
||||||
{
|
|
||||||
var vm = new HotkeyConfigViewModel(_layoutStore);
|
|
||||||
var dialog = new HotkeyConfigDialog(vm) { Owner = Application.Current.MainWindow };
|
|
||||||
if (dialog.ShowDialog() == true)
|
|
||||||
{
|
|
||||||
// Reload bindings from DB (the dialog already saved them).
|
|
||||||
_hotkeyBindings = _layoutStore.LoadHotkeyBindings();
|
|
||||||
HotkeyBindingsChanged?.Invoke(_hotkeyBindings);
|
|
||||||
RefreshHotkeyLabels();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RefreshHotkeyLabels()
|
|
||||||
{
|
|
||||||
foreach (var scene in Scenes)
|
|
||||||
{
|
|
||||||
if (SceneCatalog.SceneHotkeys.TryGetValue(scene.Name, out var hid)
|
|
||||||
&& _hotkeyBindings.TryGetValue(hid, out var chord))
|
|
||||||
scene.HotkeyLabel = chord.ToDisplayString();
|
|
||||||
else if (SceneCatalog.SceneHotkeys.TryGetValue(scene.Name, out var defId)
|
|
||||||
&& GlobalHotkeyManager.Defaults.TryGetValue(defId, out var defChord))
|
|
||||||
scene.HotkeyLabel = defChord.ToDisplayString();
|
|
||||||
else
|
|
||||||
scene.HotkeyLabel = string.Empty;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Right-click-on-preview → "Show Webcam": offered when the active scene has no
|
/// Right-click-on-preview → "Show Webcam": offered when the active scene has no
|
||||||
/// webcam config (add one) or hides it (unhide — keeps the config row).
|
/// webcam config (add one) or hides it (unhide — keeps the config row).
|
||||||
@@ -1008,26 +792,7 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
public bool ShowLogInButton => !IsConnected && IsOffline && !IsRecording;
|
public bool ShowLogInButton => !IsConnected && IsOffline && !IsRecording;
|
||||||
|
|
||||||
public bool ShowChatInactiveMessage => !IsLive;
|
public bool ShowChatInactiveMessage => !IsLive;
|
||||||
public bool ShowEmptySceneHint => StagedScene != null && StagedScene.Elements.Count == 0;
|
|
||||||
public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null && BackgroundImage == null;
|
public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null && BackgroundImage == null;
|
||||||
public bool ShowSourcesEmptyHint => StagedScene == null || StagedScene.Elements.Count == 0;
|
|
||||||
|
|
||||||
public bool IsCountdownAvailable => StagedScene != null
|
|
||||||
&& (SceneCatalog.Is(StagedScene.Name, "Starting") || SceneCatalog.Is(StagedScene.Name, "BRB"));
|
|
||||||
|
|
||||||
/// <summary>The canonical scenes (SceneCatalog) not present right now — what the
|
|
||||||
/// "+" button may re-add. Work with less, never more.</summary>
|
|
||||||
public IEnumerable<string> MissingScenes
|
|
||||||
=> SceneCatalog.All.Where(canonical => Scenes.All(s => !SceneCatalog.Is(s.Name, canonical)));
|
|
||||||
|
|
||||||
/// <summary>Only show the "+" button when one of the five canonical scenes is missing.</summary>
|
|
||||||
public bool ShowAddScene => MissingScenes.Any();
|
|
||||||
|
|
||||||
/// <summary>"Change Capture…"/"Refresh Capture" apply to the active scene's background.</summary>
|
|
||||||
// Capture controls (Show Desktop / monitor pick / refresh) exist only on
|
|
||||||
// the Live screen's Background. Static-art screens never expose them.
|
|
||||||
public bool CanChangeBackground =>
|
|
||||||
StagedScene != null && SceneCatalog.Is(StagedScene.Name, SceneCatalog.Live);
|
|
||||||
|
|
||||||
// Paid unlock flips this off (see ai.md "Monetization"). When disabled the
|
// Paid unlock flips this off (see ai.md "Monetization"). When disabled the
|
||||||
// cadence timer is stopped and any active flash is hidden immediately.
|
// cadence timer is stopped and any active flash is hidden immediately.
|
||||||
@@ -1056,141 +821,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
private set => SetProperty(ref _brandFlashActive, value);
|
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<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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static System.Windows.Media.Imaging.BitmapSource? LoadBackgroundImage(Scene scene)
|
|
||||||
{
|
|
||||||
var bg = scene.Elements.OfType<Source>().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<string, System.Windows.Media.Brush>(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
|
public StreamHealth CurrentHealth
|
||||||
{
|
{
|
||||||
get => _currentHealth;
|
get => _currentHealth;
|
||||||
@@ -1500,11 +1130,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
public ICommand ToggleElementVisibilityCommand { get; }
|
public ICommand ToggleElementVisibilityCommand { get; }
|
||||||
public ICommand ChangeWebcamCommand { get; }
|
public ICommand ChangeWebcamCommand { get; }
|
||||||
public ICommand ShowWebcamCommand { 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 ToggleMicMuteCommand { get; }
|
||||||
public ICommand ToggleGameMuteCommand { get; }
|
public ICommand ToggleGameMuteCommand { get; }
|
||||||
public ICommand OpenMicPickerCommand { get; }
|
public ICommand OpenMicPickerCommand { get; }
|
||||||
@@ -1822,155 +1447,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
WebcamError = $"Webcam offline: {message}";
|
WebcamError = $"Webcam offline: {message}";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Screen background capture (live desktop/game) ───
|
|
||||||
|
|
||||||
private const string MonitorKeyPrefix = "monitor:";
|
|
||||||
|
|
||||||
/// <summary>Every background-enabled scene has exactly one background, kept at index 0 (bottom layer).
|
|
||||||
/// Returns null for a scene with <see cref="Scene.HasBackground"/> = false.</summary>
|
|
||||||
internal static Source? EnsureBackground(Scene scene)
|
|
||||||
{
|
|
||||||
if (!scene.HasBackground) return null;
|
|
||||||
var background = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
|
||||||
if (background != null) return background;
|
|
||||||
|
|
||||||
background = CreateBackground(scene.Name);
|
|
||||||
scene.Elements.Insert(0, background);
|
|
||||||
return background;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>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.</summary>
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>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.</summary>
|
|
||||||
internal static void NormalizeBackgrounds(IEnumerable<Scene> scenes)
|
|
||||||
{
|
|
||||||
foreach (var scene in scenes)
|
|
||||||
{
|
|
||||||
if (!SceneCatalog.HasBackground(scene.Name))
|
|
||||||
{
|
|
||||||
scene.HasBackground = false;
|
|
||||||
foreach (var stale in scene.Elements.OfType<Source>().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<Source>().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<Source> AllBackgrounds()
|
|
||||||
=> Scenes.Where(s => SceneCatalog.Is(s.Name, SceneCatalog.Live))
|
|
||||||
.SelectMany(s => s.Elements.OfType<Source>().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()
|
private void RegisterLoadedWebSources()
|
||||||
{
|
{
|
||||||
if (_webView2Manager == null) return;
|
if (_webView2Manager == null) return;
|
||||||
@@ -1987,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<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)
|
|
||||||
{
|
|
||||||
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}");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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}");
|
|
||||||
}
|
|
||||||
|
|
||||||
public void InitWebView2(Panel hostPanel)
|
public void InitWebView2(Panel hostPanel)
|
||||||
{
|
{
|
||||||
_webView2Manager = new WebView2Manager(hostPanel, System.Windows.Application.Current.Dispatcher);
|
_webView2Manager = new WebView2Manager(hostPanel, System.Windows.Application.Current.Dispatcher);
|
||||||
@@ -2193,60 +1506,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Auto-save wiring ───
|
|
||||||
private void OnScenesChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
|
||||||
{
|
|
||||||
if (e.NewItems != null)
|
|
||||||
foreach (Scene scene in e.NewItems)
|
|
||||||
WireScene(scene);
|
|
||||||
if (e.OldItems != null)
|
|
||||||
foreach (Scene scene in e.OldItems)
|
|
||||||
UnwireScene(scene);
|
|
||||||
OnPropertyChanged(nameof(MissingScenes));
|
|
||||||
OnPropertyChanged(nameof(ShowAddScene));
|
|
||||||
ScheduleSave();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void WireScene(Scene scene)
|
|
||||||
{
|
|
||||||
scene.PropertyChanged += OnScenePropertyChanged;
|
|
||||||
scene.Elements.CollectionChanged += OnElementsChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UnwireScene(Scene scene)
|
|
||||||
{
|
|
||||||
scene.PropertyChanged -= OnScenePropertyChanged;
|
|
||||||
scene.Elements.CollectionChanged -= OnElementsChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnScenePropertyChanged(object? sender, PropertyChangedEventArgs e)
|
|
||||||
{
|
|
||||||
// A rename can drop a canonical scene in/out of MissingScenes.
|
|
||||||
OnPropertyChanged(nameof(MissingScenes));
|
|
||||||
OnPropertyChanged(nameof(ShowAddScene));
|
|
||||||
ScheduleSave();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnElementsChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
|
||||||
{
|
|
||||||
if (e.NewItems != null)
|
|
||||||
foreach (SceneElement element in e.NewItems)
|
|
||||||
element.PropertyChanged += OnElementPropertyChanged;
|
|
||||||
if (e.OldItems != null)
|
|
||||||
foreach (SceneElement element in e.OldItems)
|
|
||||||
element.PropertyChanged -= OnElementPropertyChanged;
|
|
||||||
OnPropertyChanged(nameof(CanAddWebcam));
|
|
||||||
OnPropertyChanged(nameof(CanAddYouTubeChat));
|
|
||||||
ScheduleSave();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnElementPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
|
||||||
{
|
|
||||||
if (sender is WebcamSceneConfig && e.PropertyName == nameof(SceneElement.IsVisible))
|
|
||||||
OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
|
|
||||||
ScheduleSave();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ScheduleSave()
|
private void ScheduleSave()
|
||||||
{
|
{
|
||||||
if (_isLoading || _saveDebounce == null) return;
|
if (_isLoading || _saveDebounce == null) return;
|
||||||
@@ -2254,74 +1513,6 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
_saveDebounce.Start();
|
_saveDebounce.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adds a canonical scene by name — only when it's actually missing. The
|
|
||||||
// background flag follows the policy (Live yes, everyone else no).
|
|
||||||
private void AddScene(string name)
|
|
||||||
{
|
|
||||||
if (!SceneCatalog.IsCanonical(name)) return;
|
|
||||||
if (Scenes.Any(s => SceneCatalog.Is(s.Name, name))) return;
|
|
||||||
var scene = new Scene
|
|
||||||
{
|
|
||||||
Name = name.Trim(),
|
|
||||||
IsChatScene = SceneCatalog.IsChat(name),
|
|
||||||
HasBackground = SceneCatalog.HasBackground(name),
|
|
||||||
};
|
|
||||||
EnsureBackground(scene);
|
|
||||||
Scenes.Add(scene);
|
|
||||||
StagedScene = scene;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 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)
|
|
||||||
{
|
|
||||||
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 */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
private void BeginEditElement(SceneElement? element)
|
private void BeginEditElement(SceneElement? element)
|
||||||
{
|
{
|
||||||
if (element == null) return;
|
if (element == null) return;
|
||||||
|
|||||||
+21
-2
@@ -31,12 +31,31 @@ Progress (Commit N/12 · tag):
|
|||||||
(Polar license state: `LicenseKey`/`LicenseMessage`/`IsLicenseValidating`/
|
(Polar license state: `LicenseKey`/`LicenseMessage`/`IsLicenseValidating`/
|
||||||
`IsLicenseEntryOpen`/`IsHubVisible`/`IsPremium`, `OfflineGracePeriod`,
|
`IsLicenseEntryOpen`/`IsHubVisible`/`IsPremium`, `OfflineGracePeriod`,
|
||||||
`ValidateLicenseAtStartupAsync`, `ActivateLicenseAsync`).
|
`ValidateLicenseAtStartupAsync`, `ActivateLicenseAsync`).
|
||||||
|
- **Commit 3/12 · `refactor-commit-3`: Scenes landed** → `MainViewModel.Scenes.cs`
|
||||||
|
(`Scenes` + `StagedScene`/`LiveScene`/`IsEditMode`, transitions
|
||||||
|
`SelectedTransitionType`/`TransitionDurationMs`/`TransitionToScene`/
|
||||||
|
`ApplyTransitionToLive`, global hotkeys `HandleHotkey`/`OpenHotkeyConfig`/
|
||||||
|
`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`,
|
- Core file keeps: fields, ctor, `Shutdown`/`SaveLayoutNow`/`ScheduleSave`/`LoadLayout`,
|
||||||
`SetPanel`/`ShowOverlay`/`ShowLicenses`, `CreateBrush`, `OpenUrl`/`ComposeEmail`,
|
`SetPanel`/`ShowOverlay`/`ShowLicenses`, `CreateBrush`, `OpenUrl`/`ComposeEmail`,
|
||||||
`SubmitBug`/`SubmitFeature`, window-geometry pass-through.
|
`SubmitBug`/`SubmitFeature`, window-geometry pass-through.
|
||||||
- Partials: `Account` ✔ (Commit 1), `License` ✔ (Commit 2), `Scenes` (scene catalog + hotkeys + transitions), `Background` (background model + screen
|
- Partials: `Account` ✔ (Commit 1), `License` ✔ (Commit 2), `Scenes` ✔ (Commit 3), `Background` ✔ (Commit 4),
|
||||||
capture + display picker), `Webcam` (webcam identity + per-scene configs + captures),
|
`Webcam` (webcam identity + per-scene configs + captures),
|
||||||
`Audio` (mic + game bars + TRAX), `Trax` (music player), `Socials` (social bar + dialog),
|
`Audio` (mic + game bars + TRAX), `Trax` (music player), `Socials` (social bar + dialog),
|
||||||
`Streaming` (go-live/record + health + quality tiers + media source pipeline),
|
`Streaming` (go-live/record + health + quality tiers + media source pipeline),
|
||||||
`Chat` (YouTube chat + mock preview + chatbox render), `Overlays` (settings/bug/feature/about
|
`Chat` (YouTube chat + mock preview + chatbox render), `Overlays` (settings/bug/feature/about
|
||||||
|
|||||||
Reference in New Issue
Block a user