Compare commits

..

2 Commits

4 changed files with 858 additions and 809 deletions
+568
View File
@@ -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 */ }
}
}
+269
View File
@@ -0,0 +1,269 @@
using System.Windows;
using System.Windows.Input;
using System.Windows.Media.Imaging;
using ytLive;
using ytLive.Models;
using ytLive.Services;
namespace ytLive.ViewModels;
public partial class MainViewModel
{
// Webcam: one camera input app-wide. The identity (DeviceId) lives on the
// Webcam entity; each scene that shows the webcam has a WebcamSceneConfig
// (webcam.{scene}.config). CameraManager still owns the single session.
private readonly ICameraEnumerator _cameraEnumerator;
private readonly CameraManager _cameraManager;
private Webcam? _webcam;
private string? _webcamError;
/// <summary>The Add → Webcam menu item: enabled only while no webcam exists anywhere —
/// one camera identity app-wide; re-adding always opens the picker.</summary>
public bool CanAddWebcam => StagedScene != null && _webcam == null;
/// <summary>
/// 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).
/// </summary>
public bool CanShowWebcamInStagedScene
=> StagedScene is { } scene && (scene.WebcamConfig == null || !scene.WebcamConfig.IsVisible);
/// <summary>Swap-the-device item: enabled once a camera has been picked at all.</summary>
public bool CanChangeWebcam => _webcam != null;
/// <summary>Shows the mirror / clip-shape row in the source chip when a webcam is selected.</summary>
public bool IsWebcamSelected => SelectedElement is WebcamSceneConfig;
/// <summary>
/// The reason the webcam feed is down (device in use, offline, locked, no frames),
/// or null when it's alive. Surfaced as a red chip so a dead camera is never a
/// silent empty box. Cleared the moment a real frame arrives.
/// </summary>
public string? WebcamError
{
get => _webcamError;
private set => SetProperty(ref _webcamError, value);
}
// Webcam size safeguard: no more than half the frame in any dimension
// (960x540 over the 1920x1080 master), and no less than 10% of it
// (192x108). Enforced at resize and on layout load. The Chat scene is
// exempt from the per-dimension half — its webcam may take half the
// screen AREA (~1358x764 @16:9) so the viewer sees the creator better,
// matched by canonical name (SceneCatalog.IsChat).
public const double WebcamMaxWidth = 960;
public const double WebcamMaxHeight = 540;
public const double WebcamChatMaxWidth = 1358;
public const double WebcamChatMaxHeight = 764;
public const double WebcamMinWidth = MasterFrameWidth * 0.1;
public const double WebcamMinHeight = MasterFrameHeight * 0.1;
public static double MaxWebcamWidthFor(string? sceneName)
=> SceneCatalog.IsChat(sceneName) ? WebcamChatMaxWidth : WebcamMaxWidth;
public static double MaxWebcamHeightFor(string? sceneName)
=> SceneCatalog.IsChat(sceneName) ? WebcamChatMaxHeight : WebcamMaxHeight;
internal static void ClampWebcamToBounds(WebcamSceneConfig config, string? sceneName)
{
var maxWidth = MaxWebcamWidthFor(sceneName);
var maxHeight = MaxWebcamHeightFor(sceneName);
var scale = Math.Min(maxWidth / config.Width, maxHeight / config.Height);
if (scale < 1)
{
config.Width = Math.Round(config.Width * scale);
config.Height = Math.Round(config.Height * scale);
return;
}
var minScale = Math.Max(WebcamMinWidth / config.Width, WebcamMinHeight / config.Height);
if (minScale > 1)
{
config.Width = Math.Round(config.Width * minScale);
config.Height = Math.Round(config.Height * minScale);
}
}
// One-time heal for layouts saved before the rect dims were persisted (v3→v4):
// a Traditional webcam that ended up square (Round resize then reload lost the
// pre-Round rect) gets widened to 16:9, keeping the height. Round is skipped —
// a square bounding box is correct there — and an explicit pre-Round rect wins.
internal static void HealLegacySquareRect(WebcamSceneConfig config)
{
if (config.ClipShape != ClipShape.Traditional) return;
if (config.RectWidth != null || config.RectHeight != null) return;
if (Math.Abs(config.Width - config.Height) >= 1) return;
config.Width = Math.Round(config.Height * 16.0 / 9.0);
}
public ICommand AddWebcamCommand { get; }
public ICommand ChangeWebcamCommand { get; }
public ICommand ShowWebcamCommand { get; }
// After a layout load / file open: re-point the webcam identity, stop the
// previous device if it changed, and acquire the current one once per scene
// that uses it (CameraManager refcounts by DeviceId — one camera session).
private void ReacquireWebcam()
{
var previousDevice = _webcam?.DeviceId;
_webcam = _layoutStore.Webcam;
var newDevice = _webcam?.DeviceId;
if (!string.IsNullOrWhiteSpace(previousDevice) && previousDevice != newDevice)
_ = _cameraManager.ReleaseAllAsync(previousDevice);
OnPropertyChanged(nameof(CanChangeWebcam));
OnPropertyChanged(nameof(CanAddWebcam));
if (_webcam == null || string.IsNullOrWhiteSpace(newDevice)) return;
if (previousDevice != newDevice)
foreach (var unused in Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()))
_ = _cameraManager.AcquireAsync(newDevice);
// A session already running (same device, or one that produced a first
// frame) has its shared bitmap; freshly loaded configs must adopt it here,
// because PreviewBitmapChanged never re-fires for an existing bitmap.
if (_cameraManager.GetPreviewBitmap(newDevice) is { } running)
foreach (var config in Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()))
config.VideoImageSource = running;
}
// CameraManager creates the shared WriteableBitmap on the UI thread at the
// device's frame size; every scene's webcam config picks it up from here. A
// bitmap means a real frame arrived — the camera is provably alive.
private void OnCameraPreviewBitmapChanged(string deviceId, WriteableBitmap bitmap)
{
if (_webcam?.DeviceId != deviceId) return;
WebcamError = null;
foreach (var scene in Scenes)
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
config.VideoImageSource = bitmap;
}
// The camera failed to start or died asynchronously (in use, offline, locked,
// no frames within the proof timeout) — surface it instead of a silent box.
private void OnCameraFailed(string deviceId, string message)
{
if (_webcam?.DeviceId != deviceId) return;
WebcamError = $"Webcam offline: {message}";
}
// Adds the webcam to the active scene. The creator ALWAYS picks from the
// cameras Windows has registered — never silently resurrects the previous
// camera (which is what happened after deleting one scene's webcam while
// another scene still used it). Picking a different camera than the current
// app-wide one swaps it everywhere, so the single-identity model stays honest;
// each scene's placement config is independent (webcam.{scene}.config).
private async Task AddWebcamToStagedSceneAsync()
{
var scene = StagedScene;
if (scene == null || scene.WebcamConfig != null) return;
var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator))
{
Owner = Application.Current.MainWindow
};
if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return;
var device = dialog.PickedDevice;
if (_webcam == null)
{
_webcam = new Webcam { DeviceId = device.Id, Name = device.DisplayName };
OnPropertyChanged(nameof(CanChangeWebcam));
OnPropertyChanged(nameof(CanAddWebcam));
}
else if (_webcam.DeviceId != device.Id)
{
await SwapWebcamIdentityAsync(device);
}
var config = new WebcamSceneConfig
{
WebcamId = _webcam.Id,
Name = _webcam.Name,
Width = 480,
Height = 270,
X = MasterFrameWidth - 480 - 32,
Y = MasterFrameHeight - 270 - 32,
};
scene.Elements.Add(config);
SelectedElement = config;
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
UpdateActiveBackground();
// PreviewBitmapChanged fires only on the camera's first frame, so a config
// added while the session is already running must pick up the shared bitmap
// directly (it's written in place from then on).
if (_cameraManager.GetPreviewBitmap(_webcam.DeviceId) is { } running)
config.VideoImageSource = running;
var started = await _cameraManager.AcquireAsync(_webcam.DeviceId);
if (!started)
{
_notifications.Warning("Camera unavailable",
WebcamError ?? "Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.");
}
}
// Swaps the device on the app-wide webcam identity. The old device is stopped
// unconditionally; each scene that uses the webcam re-acquires the new one so
// the per-config refcount stays honest. Only called when the device differs.
private async Task SwapWebcamIdentityAsync(CameraDeviceInfo device)
{
var oldDevice = _webcam!.DeviceId;
_webcam.DeviceId = device.Id;
_webcam.Name = device.DisplayName;
ScheduleSave();
if (!string.IsNullOrWhiteSpace(oldDevice) && oldDevice != device.Id)
await _cameraManager.ReleaseAllAsync(oldDevice);
foreach (var unused in Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()))
{
var started = await _cameraManager.AcquireAsync(device.Id);
if (!started)
{
_notifications.Warning("Camera unavailable",
WebcamError ?? "Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.");
break;
}
}
}
// "Change Webcam…" from the webcam's context menu: picker, then swap the
// app-wide identity if a different device was chosen.
private async Task ChangeWebcamAsync()
{
if (_webcam == null) return;
var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator))
{
Owner = Application.Current.MainWindow
};
if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return;
if (dialog.PickedDevice.Id == _webcam.DeviceId) return;
await SwapWebcamIdentityAsync(dialog.PickedDevice);
}
// "Show Webcam" from a right-click on the empty preview. Unhides this scene's
// existing (hidden) config — the config row survives Hide in this scene — or
// adds the webcam here for the first time (which opens the camera picker).
private void ShowWebcamInStagedScene()
{
var scene = StagedScene;
if (scene == null) return;
var config = scene.WebcamConfig;
if (config != null)
{
config.IsVisible = true;
SelectedElement = config;
return;
}
_ = AddWebcamToStagedSceneAsync();
}
}
-807
View File
@@ -32,7 +32,6 @@ public partial class MainViewModel : ViewModelBase
private readonly DispatcherTimer _healthPollTimer; private readonly DispatcherTimer _healthPollTimer;
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;
@@ -107,13 +106,6 @@ public partial class MainViewModel : ViewModelBase
// news up the real service; the VM is built from XAML so no DI seam exists. // news up the real service; the VM is built from XAML so no DI seam exists.
private readonly INotificationService _notifications = new NotificationService(); private readonly INotificationService _notifications = new NotificationService();
// Webcam: one camera input app-wide. The identity (DeviceId) lives on the
// Webcam entity; each scene that shows the webcam has a WebcamSceneConfig
// (webcam.{scene}.config). CameraManager still owns the single session.
private readonly ICameraEnumerator _cameraEnumerator;
private readonly CameraManager _cameraManager;
private Webcam? _webcam;
private string? _webcamError;
private SocialsConfig? _socials; private SocialsConfig? _socials;
private VideoFrame? _socialBarFrame; private VideoFrame? _socialBarFrame;
private readonly IMicrophoneEnumerator _microphoneEnumerator; private readonly IMicrophoneEnumerator _microphoneEnumerator;
@@ -133,12 +125,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;
@@ -152,11 +138,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;
@@ -186,42 +167,6 @@ public partial class MainViewModel : ViewModelBase
public string LicensesText { get; private set; } = string.Empty; public string LicensesText { get; private set; } = string.Empty;
public ObservableCollection<ChatMessage> ChatMessages { get; } = new(); public ObservableCollection<ChatMessage> ChatMessages { get; } = 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 SceneElement? SelectedElement public SceneElement? SelectedElement
{ {
@@ -250,26 +195,12 @@ public partial class MainViewModel : ViewModelBase
} }
} }
/// <summary>The Add → Webcam menu item: enabled only while no webcam exists anywhere —
/// one camera identity app-wide; re-adding always opens the picker.</summary>
public bool CanAddWebcam => StagedScene != null && _webcam == null;
/// <summary>The Add → YouTube Chat menu item: enabled only while no chat layer exists /// <summary>The Add → YouTube Chat menu item: enabled only while no chat layer exists
/// anywhere — one chat overlay per layout.</summary> /// anywhere — one chat overlay per layout.</summary>
public bool CanAddYouTubeChat public bool CanAddYouTubeChat
=> 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));
/// <summary>
/// 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).
/// </summary>
public bool CanShowWebcamInStagedScene
=> StagedScene is { } scene && (scene.WebcamConfig == null || !scene.WebcamConfig.IsVisible);
/// <summary>Swap-the-device item: enabled once a camera has been picked at all.</summary>
public bool CanChangeWebcam => _webcam != null;
// ─── Social bar (global resource, shown on every scene) ─── // ─── Social bar (global resource, shown on every scene) ───
private static readonly SolidColorBrush BarOnBrush = CreateBrush("#2ecc71"); private static readonly SolidColorBrush BarOnBrush = CreateBrush("#2ecc71");
@@ -297,25 +228,11 @@ public partial class MainViewModel : ViewModelBase
private readonly ISocialValidator _socialValidator = new HttpSocialValidator(); private readonly ISocialValidator _socialValidator = new HttpSocialValidator();
/// <summary>Shows the mirror / clip-shape row in the source chip when a webcam is selected.</summary>
public bool IsWebcamSelected => SelectedElement is WebcamSceneConfig;
/// <summary>True when the Elements panel should show the "No configurable elements" hint.</summary> /// <summary>True when the Elements panel should show the "No configurable elements" hint.</summary>
public bool IsElementsPanelEmpty => SelectedElement is { } el public bool IsElementsPanelEmpty => SelectedElement is { } el
&& !el.IsDraggable && !el.IsWebcam && !el.IsDraggable && !el.IsWebcam
&& el is not Source { Type: SourceType.Countdown or SourceType.WebSource or SourceType.ChatBox }; && el is not Source { Type: SourceType.Countdown or SourceType.WebSource or SourceType.ChatBox };
/// <summary>
/// The reason the webcam feed is down (device in use, offline, locked, no frames),
/// or null when it's alive. Surfaced as a red chip so a dead camera is never a
/// silent empty box. Cleared the moment a real frame arrives.
/// </summary>
public string? WebcamError
{
get => _webcamError;
private set => SetProperty(ref _webcamError, value);
}
public StreamStatus StreamStatus public StreamStatus StreamStatus
{ {
get => _streamStatus; get => _streamStatus;
@@ -869,141 +786,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;
@@ -1156,57 +938,6 @@ public partial class MainViewModel : ViewModelBase
private const double MasterFrameWidth = 1920; private const double MasterFrameWidth = 1920;
private const double MasterFrameHeight = 1080; private const double MasterFrameHeight = 1080;
// Webcam size safeguard: no more than half the frame in any dimension
// (960x540 over the 1920x1080 master), and no less than 10% of it
// (192x108). Enforced at resize and on layout load. The Chat scene is
// exempt from the per-dimension half — its webcam may take half the
// screen AREA (~1358x764 @16:9) so the viewer sees the creator better,
// matched by canonical name (SceneCatalog.IsChat).
public const double WebcamMaxWidth = 960;
public const double WebcamMaxHeight = 540;
public const double WebcamChatMaxWidth = 1358;
public const double WebcamChatMaxHeight = 764;
public const double WebcamMinWidth = MasterFrameWidth * 0.1;
public const double WebcamMinHeight = MasterFrameHeight * 0.1;
public static double MaxWebcamWidthFor(string? sceneName)
=> SceneCatalog.IsChat(sceneName) ? WebcamChatMaxWidth : WebcamMaxWidth;
public static double MaxWebcamHeightFor(string? sceneName)
=> SceneCatalog.IsChat(sceneName) ? WebcamChatMaxHeight : WebcamMaxHeight;
internal static void ClampWebcamToBounds(WebcamSceneConfig config, string? sceneName)
{
var maxWidth = MaxWebcamWidthFor(sceneName);
var maxHeight = MaxWebcamHeightFor(sceneName);
var scale = Math.Min(maxWidth / config.Width, maxHeight / config.Height);
if (scale < 1)
{
config.Width = Math.Round(config.Width * scale);
config.Height = Math.Round(config.Height * scale);
return;
}
var minScale = Math.Max(WebcamMinWidth / config.Width, WebcamMinHeight / config.Height);
if (minScale > 1)
{
config.Width = Math.Round(config.Width * minScale);
config.Height = Math.Round(config.Height * minScale);
}
}
// One-time heal for layouts saved before the rect dims were persisted (v3→v4):
// a Traditional webcam that ended up square (Round resize then reload lost the
// pre-Round rect) gets widened to 16:9, keeping the height. Round is skipped —
// a square bounding box is correct there — and an explicit pre-Round rect wins.
internal static void HealLegacySquareRect(WebcamSceneConfig config)
{
if (config.ClipShape != ClipShape.Traditional) return;
if (config.RectWidth != null || config.RectHeight != null) return;
if (Math.Abs(config.Width - config.Height) >= 1) return;
config.Width = Math.Round(config.Height * 16.0 / 9.0);
}
public QualityOption[] QualityOptions { get; } = public QualityOption[] QualityOptions { get; } =
{ {
new("1080p60", 60, 8.0, 1920, 1080), new("1080p60", 60, 8.0, 1920, 1080),
@@ -1306,18 +1037,10 @@ public partial class MainViewModel : ViewModelBase
// Commands // Commands
public ICommand AddSceneCommand { get; } public ICommand AddSceneCommand { get; }
public ICommand AddSourceCommand { get; } public ICommand AddSourceCommand { get; }
public ICommand AddWebcamCommand { get; }
public ICommand AddImageCommand { get; } public ICommand AddImageCommand { get; }
public ICommand RemoveSourceCommand { get; } public ICommand RemoveSourceCommand { get; }
public ICommand EditElementCommand { get; } public ICommand EditElementCommand { get; }
public ICommand ToggleElementVisibilityCommand { get; } public ICommand ToggleElementVisibilityCommand { get; }
public ICommand ChangeWebcamCommand { get; }
public ICommand ShowWebcamCommand { get; }
public ICommand ChangeCaptureCommand { get; }
public ICommand RefreshCaptureCommand { get; }
public ICommand SetBackgroundDisplayCommand { get; }
public ICommand ToggleShowDesktopCommand { get; }
public ICommand BrowseBackgroundCommand { get; }
public ICommand ToggleMicMuteCommand { get; } public ICommand ToggleMicMuteCommand { get; }
public ICommand ToggleGameMuteCommand { get; } public ICommand ToggleGameMuteCommand { get; }
public ICommand OpenMicPickerCommand { get; } public ICommand OpenMicPickerCommand { get; }
@@ -1587,203 +1310,6 @@ public partial class MainViewModel : ViewModelBase
AppLog.Write("LoadLayout end"); AppLog.Write("LoadLayout end");
} }
// After a layout load / file open: re-point the webcam identity, stop the
// previous device if it changed, and acquire the current one once per scene
// that uses it (CameraManager refcounts by DeviceId — one camera session).
private void ReacquireWebcam()
{
var previousDevice = _webcam?.DeviceId;
_webcam = _layoutStore.Webcam;
var newDevice = _webcam?.DeviceId;
if (!string.IsNullOrWhiteSpace(previousDevice) && previousDevice != newDevice)
_ = _cameraManager.ReleaseAllAsync(previousDevice);
OnPropertyChanged(nameof(CanChangeWebcam));
OnPropertyChanged(nameof(CanAddWebcam));
if (_webcam == null || string.IsNullOrWhiteSpace(newDevice)) return;
if (previousDevice != newDevice)
foreach (var unused in Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()))
_ = _cameraManager.AcquireAsync(newDevice);
// A session already running (same device, or one that produced a first
// frame) has its shared bitmap; freshly loaded configs must adopt it here,
// because PreviewBitmapChanged never re-fires for an existing bitmap.
if (_cameraManager.GetPreviewBitmap(newDevice) is { } running)
foreach (var config in Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()))
config.VideoImageSource = running;
}
// CameraManager creates the shared WriteableBitmap on the UI thread at the
// device's frame size; every scene's webcam config picks it up from here. A
// bitmap means a real frame arrived — the camera is provably alive.
private void OnCameraPreviewBitmapChanged(string deviceId, WriteableBitmap bitmap)
{
if (_webcam?.DeviceId != deviceId) return;
WebcamError = null;
foreach (var scene in Scenes)
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
config.VideoImageSource = bitmap;
}
// The camera failed to start or died asynchronously (in use, offline, locked,
// no frames within the proof timeout) — surface it instead of a silent box.
private void OnCameraFailed(string deviceId, string message)
{
if (_webcam?.DeviceId != deviceId) return;
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;
@@ -1800,169 +1326,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);
@@ -2013,57 +1376,6 @@ public partial class MainViewModel : ViewModelBase
_saveDebounce.Start(); _saveDebounce.Start();
} }
// ─── Background model (TASK 25): exactly ONE Background per screen, index 0,
// never removable or re-addable. Static default art on Starting/BRB/Chat/Ending;
// on Live it's the capture-flavored row (Show Desktop / monitor switch /
// fullscreen-game detect) falling back to live-background.jpg. ───
private static string? DefaultBackgroundAsset(string sceneName) => sceneName switch
{
SceneCatalog.Starting => "starting-background.jpg",
SceneCatalog.Brb => "brb-background.jpg",
SceneCatalog.Chat => "chat-background.jpg",
SceneCatalog.Ending => "ending-background.jpg",
SceneCatalog.Live => "live-background.jpg",
_ => null,
};
/// <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;
@@ -2167,125 +1479,6 @@ public partial class MainViewModel : ViewModelBase
return $"{baseName}{i}"; return $"{baseName}{i}";
} }
// Adds the webcam to the active scene. The creator ALWAYS picks from the
// cameras Windows has registered — never silently resurrects the previous
// camera (which is what happened after deleting one scene's webcam while
// another scene still used it). Picking a different camera than the current
// app-wide one swaps it everywhere, so the single-identity model stays honest;
// each scene's placement config is independent (webcam.{scene}.config).
private async Task AddWebcamToStagedSceneAsync()
{
var scene = StagedScene;
if (scene == null || scene.WebcamConfig != null) return;
var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator))
{
Owner = Application.Current.MainWindow
};
if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return;
var device = dialog.PickedDevice;
if (_webcam == null)
{
_webcam = new Webcam { DeviceId = device.Id, Name = device.DisplayName };
OnPropertyChanged(nameof(CanChangeWebcam));
OnPropertyChanged(nameof(CanAddWebcam));
}
else if (_webcam.DeviceId != device.Id)
{
await SwapWebcamIdentityAsync(device);
}
var config = new WebcamSceneConfig
{
WebcamId = _webcam.Id,
Name = _webcam.Name,
Width = 480,
Height = 270,
X = MasterFrameWidth - 480 - 32,
Y = MasterFrameHeight - 270 - 32,
};
scene.Elements.Add(config);
SelectedElement = config;
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
UpdateActiveBackground();
// PreviewBitmapChanged fires only on the camera's first frame, so a config
// added while the session is already running must pick up the shared bitmap
// directly (it's written in place from then on).
if (_cameraManager.GetPreviewBitmap(_webcam.DeviceId) is { } running)
config.VideoImageSource = running;
var started = await _cameraManager.AcquireAsync(_webcam.DeviceId);
if (!started)
{
_notifications.Warning("Camera unavailable",
WebcamError ?? "Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.");
}
}
// Swaps the device on the app-wide webcam identity. The old device is stopped
// unconditionally; each scene that uses the webcam re-acquires the new one so
// the per-config refcount stays honest. Only called when the device differs.
private async Task SwapWebcamIdentityAsync(CameraDeviceInfo device)
{
var oldDevice = _webcam!.DeviceId;
_webcam.DeviceId = device.Id;
_webcam.Name = device.DisplayName;
ScheduleSave();
if (!string.IsNullOrWhiteSpace(oldDevice) && oldDevice != device.Id)
await _cameraManager.ReleaseAllAsync(oldDevice);
foreach (var unused in Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()))
{
var started = await _cameraManager.AcquireAsync(device.Id);
if (!started)
{
_notifications.Warning("Camera unavailable",
WebcamError ?? "Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.");
break;
}
}
}
// "Change Webcam…" from the webcam's context menu: picker, then swap the
// app-wide identity if a different device was chosen.
private async Task ChangeWebcamAsync()
{
if (_webcam == null) return;
var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator))
{
Owner = Application.Current.MainWindow
};
if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return;
if (dialog.PickedDevice.Id == _webcam.DeviceId) return;
await SwapWebcamIdentityAsync(dialog.PickedDevice);
}
// "Show Webcam" from a right-click on the empty preview. Unhides this scene's
// existing (hidden) config — the config row survives Hide in this scene — or
// adds the webcam here for the first time (which opens the camera picker).
private void ShowWebcamInStagedScene()
{
var scene = StagedScene;
if (scene == null) return;
var config = scene.WebcamConfig;
if (config != null)
{
config.IsVisible = true;
SelectedElement = config;
return;
}
_ = AddWebcamToStagedSceneAsync();
}
private void AddImage() private void AddImage()
{ {
var scene = StagedScene; var scene = StagedScene;
+21 -2
View File
@@ -38,12 +38,31 @@ Progress (Commit N/12 · tag):
`RefreshHotkeyLabels`, scene-gating hints, `MissingScenes`/`ShowAddScene`/ `RefreshHotkeyLabels`, scene-gating hints, `MissingScenes`/`ShowAddScene`/
`CanChangeBackground`, scene wiring `OnScenesChanged`/`WireScene`/`UnwireScene`, `CanChangeBackground`, scene wiring `OnScenesChanged`/`WireScene`/`UnwireScene`,
`AddScene`). `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.
- **Commit 5/12 · `refactor-commit-5`: Webcam landed** → `MainViewModel.Webcam.cs`
(webcam identity `_webcam`/`ReacquireWebcam`/`SwapWebcamIdentityAsync`, per-scene
configs `AddWebcamToStagedSceneAsync`/`ShowWebcamInStagedScene`, capture lifetime
`OnCameraPreviewBitmapChanged`/`OnCameraFailed`, gating `CanAddWebcam`/
`CanChangeWebcam`/`CanShowWebcamInStagedScene`/`IsWebcamSelected`/`WebcamError`,
size safeguard `WebcamMax*`/`ClampWebcamToBounds`/`HealLegacySquareRect`,
`AddWebcamCommand`/`ChangeWebcamCommand`/`ShowWebcamCommand`).
- 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` ✔ (Commit 3), `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` ✔ (Commit 5),
`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