Files
LlamaCasty/ViewModels/MainViewModel.cs
T
gramps 15734a5477 feat(zoom): Ctrl closes the box, and a Follow pill that re-anchors the capture
Two creator directives, 2026-11-01.

**Ctrl-down dismisses an open zoom box.** Window-level PreviewKeyDown, so the same hand that
opened it can put it away. It cannot cancel itself at creation: Ctrl is already held before
the right-click that makes the box, so the keydown fires while nothing is open.

**Follow mode, default Anchor.** A PillToggle in App Settings ("Follow the box"), persisted
as Zoom.FollowBox. Off — the default — keeps the 2026-10-08 model: the capture stays on the
clicked point and dragging only moves the box over it. On, UpdateZoomMove re-anchors the
capture to the box centre (half the box's size, clamped per axis), so the magnifier shows
whatever it hovers. Flipping to Follow while the box is open snaps it immediately.

Three tests: anchor drags leave CaptureX alone, follow moves it to the expected quarter-offset
centre, and the setting round-trips through LayoutStore (tested against the store, not via two
view models — two live VMs fight over the temp DB, which is a fixture problem, not a bug).

Both new test classes share a collection tag: MainViewModel.LayoutPathOverride is static, and
xUnit runs classes in parallel, so without it the two view models collided (one failure in a
combined run, clean when run apart).

Release build 0 warnings; 14 tests pass across zoom, entitlement and preview history. Debug
build is blocked by the running app (PID 47388) — close it and the exe updates.
2026-11-01 08:51:28 -08:00

634 lines
31 KiB
C#

using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Threading;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.Services;
using ytLive.Services.Audio;
using ytLive.Services.Compositor;
using ytLive.Services.Encoder;
namespace ytLive.ViewModels;
public partial class MainViewModel : ViewModelBase
{
private readonly YouTubeAuthService _youtubeAuth;
private readonly YouTubeStreamService _youtubeStream;
private readonly YouTubeChatService _youtubeChat;
private SceneElement? _selectedElement;
private LayoutStore _layoutStore;
private string? _activeLayoutPath;
private DispatcherTimer? _saveDebounce;
private bool _isLoading;
private ZoomState _zoomState = new();
// Toasts (TASK 24): non-blocking user-facing messages. Parameterless ctor
// news up the real service; the VM is built from XAML so no DI seam exists.
private readonly INotificationService _notifications = new NotificationService();
// Live frame pipeline (TASK 4 ship step 5): composites the active scene at the
// tier's FPS and paces frames into the encoder. The RTMP URL seam stays null
// until the live-stream create flow (TASK 5) supplies the reusable stream URL.
private readonly Func<string?> _rtmpUrlProvider;
// Scene graph (TASK 31): owns the scene collection + element inventory and the
// baked-crust compositor cache. The ViewModel exposes Scenes/StagedScene/LiveScene
// on top of it; the scene collection itself lives here.
// Field-initialized, NOT ctor-assigned: the ctor touches Scenes (CollectionChanged
// wiring) before its later statements run — first-ctor-line NRE if the graph isn't
// born yet (startup crash regression from TASK 31, 2026-09-01; headless RealApp
// tests never ran it, so it waited for the first native launch).
private SceneGraph _sceneGraph = new();
private readonly TransitionService _transition = new();
private WebView2Manager? _webView2Manager;
// Branding credit: "made with LlamaCasty!" composed into the OUTPUT by FramePump, not
// just previewed — see BrandFlashPresenter. OFF by default since 2026-10-09
// (ShowBrandFlash = false, free-forever: no branding on the stream); the code stays.
private readonly BrandFlashPresenter _brandFlash;
public SceneElement? SelectedElement
{
get => _selectedElement;
set
{
if (_selectedElement != null)
_selectedElement.PropertyChanged -= OnSelectedElementPropertyChanged;
if (SetProperty(ref _selectedElement, value))
{
OnPropertyChanged(nameof(IsWebcamSelected));
OnPropertyChanged(nameof(IsElementsPanelEmpty));
OnPropertyChanged(nameof(CanToggleClipShape));
if (_selectedElement != null)
_selectedElement.PropertyChanged += OnSelectedElementPropertyChanged;
}
}
}
public ZoomState ZoomState
{
get => _zoomState;
set => SetProperty(ref _zoomState, value);
}
public bool IsZoomActive => _zoomState.IsActive;
public bool ShowZoomOverlay => _zoomState.IsActive;
// Zoom activation/move/dismiss and the zoom frame builder live in
// MainViewModel.Zoom.cs — the zoom box is a real composited layer (see TASK 52).
private void RebuildQualityDimRects()
{
DimRects.Clear();
if (OutputRectWidth <= 0 || OutputRectHeight <= 0) return;
if (OutputRectX > 0) DimRects.Add(new Rect(0, 0, OutputRectX, MasterFrameHeight));
if (OutputRectX + OutputRectWidth < MasterFrameWidth)
DimRects.Add(new Rect(OutputRectX + OutputRectWidth, 0, MasterFrameWidth - OutputRectX - OutputRectWidth, MasterFrameHeight));
if (OutputRectY > 0) DimRects.Add(new Rect(0, 0, MasterFrameWidth, OutputRectY));
if (OutputRectY + OutputRectHeight < MasterFrameHeight)
DimRects.Add(new Rect(0, OutputRectY + OutputRectHeight, MasterFrameWidth, MasterFrameHeight - OutputRectY - OutputRectHeight));
}
private void OnSelectedElementPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(SceneElement.UseDefaultBackground))
{
if (StagedScene != null) _sceneGraph.InvalidateBake(StagedScene);
UpdateBackgroundImage();
RefreshSnapshotsAsync();
ScheduleSave();
}
}
private static SolidColorBrush BarOnBrush => ThemeBrush.Get("Brush.Positive");
private static SolidColorBrush BarOffBrush => ThemeBrush.Get("Brush.Primary");
private static SolidColorBrush CreateBrush(string hex)
=> (SolidColorBrush)new BrushConverter().ConvertFromString(hex)!;
/// <summary>True when the Elements panel should show the "No configurable elements" hint.</summary>
public bool IsElementsPanelEmpty => SelectedElement is { } el
&& !el.IsDraggable && !el.IsWebcam
&& el is not Source { Type: SourceType.Countdown or SourceType.WebSource or SourceType.ChatBox };
public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null && BackgroundImage == null;
/// <summary>Master switch for the "made with LlamaCasty!" credit (creator ruling
/// 2026-10-09: turn it OFF, but keep the feature). <c>false</c> = the credit never
/// shows — not in the preview, the stream, or recordings. Read when the view model is
/// constructed (and on every licence change), so set it before a <c>MainWindow</c> is
/// created; the tests that exercise the credit opt in that way.</summary>
internal static bool ShowBrandFlash { get; set; } = false;
// When disabled the presenter stops emitting, so any active credit dies on the next
// tick — and a credit already in flight is cut, not run out. Derived, never
// assignable from the UI: the master switch AND the licence must both allow it.
public bool BrandFlashEnabled => ShowBrandFlash && !IsPremium;
// Window geometry pass-through (code-behind owns the WPF specifics).
public (double Left, double Top, double Width, double Height, string State)? RestoreWindowState()
=> _layoutStore.LoadWindowState();
public void PersistWindowState(double left, double top, double width, double height, string state)
=> _layoutStore.SaveWindowState(left, top, width, height, state);
// Commands
public ICommand AddSceneCommand { get; }
public ICommand AddSourceCommand { get; }
public ICommand AddImageCommand { get; }
public ICommand RemoveSourceCommand { get; }
public ICommand EditElementCommand { get; }
public ICommand ToggleElementVisibilityCommand { get; }
public ICommand BrowseAlertVideoCommand { get; }
public ICommand ResetAlertVideoCommand { get; }
public ICommand ActivateLicenseCommand { get; }
public ICommand OpenLicenseEntryCommand { get; }
public ICommand OpenHotkeyConfigCommand { get; }
public ICommand ChangeAccountCommand { get; }
public ICommand LogoutCommand { get; }
public MainViewModel()
{
AppLog.Write("MainViewModel ctor begin");
_youtubeAuth = new YouTubeAuthService(OAuthCredentials.ClientId, OAuthCredentials.ClientSecret,
sessionChanged: ch => TokenStore.Save(ch));
_youtubeStream = new YouTubeStreamService(_youtubeAuth);
_youtubeChat = new YouTubeChatService(_youtubeAuth);
AppLog.Write("MainViewModel ctor: services created");
_selectedQuality = QualityOptions[0];
ApplyStreamQuality(_selectedQuality);
_youtubeChat.MessageReceived += OnChatMessageReceived;
_liveTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_liveTimer.Tick += OnLiveTimerTick;
_volumeFlashTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
_volumeFlashTimer.Tick += (_, _) => EndVolumeFlash();
_gameVolumeFlashTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
_gameVolumeFlashTimer.Tick += (_, _) => EndGameVolumeFlash();
_saveDebounce = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1500) };
// Dock meters beat on their own ~30 Hz clock instead of riding the mic's level callback.
// Coupling them to capture was a bug: with a silent or absent mic the ladder never
// refreshed, so a playing TRAX showed nothing (creator, 2026-10-30).
_meterTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(33) };
_meterTimer.Tick += (_, _) => TickMeters();
_meterTimer.Start();
_saveDebounce.Tick += (_, _) => SaveLayoutNow();
_brandFlash = new BrandFlashPresenter();
_brandFlash.OnFrame = PublishBrandFlashPreview;
// Start the cadence ONCE for the life of the app, here. CREATOR RULING
// 2026-09-26: the credit must show in every scene and in recordings, so it
// cannot be tied to IsLive (that gate meant a recording made without going
// live carried no credit). The pump reads BrandFlashFrame() whenever it runs
// — go-live and local recording are the same FramePump, so one start covers
// output, recording and the preview pane alike. Enabled is seeded from the
// current licence state; ValidateLicenseAtStartupAsync re-pushes it later.
_brandFlash.Enabled = BrandFlashEnabled;
_brandFlash.Start();
_healthPollTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(30) };
_healthPollTimer.Tick += OnHealthPollTick;
Scenes.CollectionChanged += OnScenesChanged;
AddSceneCommand = new RelayCommand(name => AddScene(name as string ?? string.Empty));
AddSourceCommand = new RelayCommand(parameter => AddSource(parameter));
AddWebcamCommand = new RelayCommand(_ => _ = AddWebcamToStagedSceneAsync());
AddImageCommand = new RelayCommand(_ => AddImage());
RemoveSourceCommand = new RelayCommand(element => { PushPreviewUndo(); RemoveElement(element as SceneElement); });
EditElementCommand = new RelayCommand(element => BeginEditElement(element as SceneElement));
ToggleElementVisibilityCommand = new RelayCommand(element => { PushPreviewUndo(); ToggleElementVisibility(element as SceneElement); });
BrowseAlertVideoCommand = new RelayCommand(element => BrowseAlertVideo(element as Source), element => element is Source { Type: SourceType.AlertBox });
ResetAlertVideoCommand = new RelayCommand(element => ResetAlertVideo(element as Source), element => element is Source { Type: SourceType.AlertBox });
ChangeWebcamCommand = new RelayCommand(_ => _ = ChangeWebcamAsync(), _ => CanChangeWebcam);
ShowWebcamCommand = new RelayCommand(_ => ShowWebcamInStagedScene(), _ => CanShowWebcamInStagedScene);
OpenSocialDialogCommand = new RelayCommand(_ => OpenSocialDialog());
ChangeCaptureCommand = new RelayCommand(_ => _ = ChangeBackgroundCaptureAsync());
RefreshCaptureCommand = new RelayCommand(_ => RefreshBackgroundAutoCapture());
SetBackgroundDisplayCommand = new RelayCommand(display => SetBackgroundCapture(display as DisplayInfo));
_windowEnumerator = WindowEnumeratorOverride ?? new Win32WindowEnumerator();
RefreshWindowsCommand = new RelayCommand(_ => RefreshOpenWindows());
CaptureWindowCommand = new RelayCommand(window =>
{
if (window is not WindowInfo info) return;
_ = RedesignateBackgroundAsync(info.CaptureKey);
});
RefreshOpenWindows();
ToggleShowDesktopCommand = new RelayCommand(_ => ToggleBackgroundShowDesktop());
BrowseBackgroundCommand = new RelayCommand(_ => BrowseBackground());
ToggleMicMuteCommand = new RelayCommand(_ => ToggleMicMute());
ToggleGameMuteCommand = new RelayCommand(_ => ToggleGameMute());
OpenMicPickerCommand = new RelayCommand(_ => PickMicrophone());
OpenSettingsCommand = new RelayCommand(_ => ShowOverlay(nameof(IsSettingsOpen), "App Settings"));
InitPreviewHistory();
OpenBugCommand = new RelayCommand(_ => ShowOverlay(nameof(IsBugOpen), "Report Bug"));
OpenFeatureCommand = new RelayCommand(_ => ShowOverlay(nameof(IsFeatureOpen), "Feature Request"));
OpenAboutCommand = new RelayCommand(_ => ShowOverlay(nameof(IsAboutOpen), "About"));
CloseOverlayCommand = new RelayCommand(_ => ShowOverlay());
SubmitBugCommand = new RelayCommand(_ => SubmitBug());
SubmitFeatureCommand = new RelayCommand(_ => SubmitFeature());
OpenChannelCommand = new RelayCommand(_ => OpenUrl(ChannelUrl));
OpenMastodonCommand = new RelayCommand(_ => OpenUrl(MastodonUrl));
OpenCoffeeCommand = new RelayCommand(_ => OpenUrl(CoffeeUrl));
OpenPremiumCommand = new RelayCommand(_ => OpenUrl(PremiumUrl), _ => IsPremiumAvailable);
OpenLicensesCommand = new RelayCommand(_ => ShowLicenses());
StartStreamCommand = new RelayCommand(_ => StartSession());
StartTestCommand = new RelayCommand(_ => BeginTestStream(), _ => CanStartTest);
SignInCommand = new RelayCommand(_ => _ = SignInAsync(), _ => !IsConnected);
EndStreamCommand = new RelayCommand(_ => StopStream(), _ => IsLive || IsRecording);
ChooseRecordFolderCommand = new RelayCommand(_ => ChooseRecordFolder());
ResetRecordFolderCommand = new RelayCommand(_ => ResetRecordFolder());
RetryWebcamLockCommand = new RelayCommand(_ => _ = ValidateWebcamResourceStartupAsync());
ActivateLicenseCommand = new RelayCommand(_ => _ = ActivateLicenseAsync(), _ => !IsLicenseValidating);
OpenLicenseEntryCommand = new RelayCommand(_ => IsLicenseEntryOpen = !IsLicenseEntryOpen);
OpenHotkeyConfigCommand = new RelayCommand(_ => OpenHotkeyConfig());
ChangeAccountCommand = new RelayCommand(_ => _ = ChangeAccountAsync());
LogoutCommand = new RelayCommand(_ => _ = SignOutYouTubeAsync());
_layoutStore = new LayoutStore(LayoutPathOverride ?? DefaultLayoutPath);
_activeLayoutPath = _layoutStore.ActivePath;
_recordFolder = _layoutStore.LoadRecordFolder();
// Load saved hotkey bindings (filled with defaults for any unbound actions).
_hotkeyBindings = _layoutStore.LoadHotkeyBindings();
BroadcastForm = new Services.LiveBroadcastFormViewModel(
() => _layoutStore,
() => _youtubeStream,
() => CurrentBroadcastId,
() => IsLive);
InitYpp();
// Restore the last picked mic so the mixer's first capture attaches to
// the same, already-vetted device (green dot) or reports it missing
// (yellow) instead of silently falling back to the default.
MicSourceName = _layoutStore.LoadMicSourceName();
AudioSyncOffsetMs = _layoutStore.LoadAudioSyncOffsetMs();
_zoomFollowsBox = _layoutStore.LoadZoomFollowBox();
MonoOutput = _layoutStore.LoadMonoOutput(); // applied to the mixer by the setter
_musicPlayer.UserGain = (float)Helpers.GainDb.FromDb(TraxDefaultDb); // background by default
InitScoreboard();
ScoreboardEnabled = _layoutStore.LoadScoreboardEnabled();
ScoreboardScrollSpeed = _layoutStore.LoadScoreboardScrollSpeed();
try
{
var s = _layoutStore.GetSetting("ZoomShape");
if (!string.IsNullOrWhiteSpace(s) && Enum.TryParse<ZoomShape>(s, true, out var zs))
_zoomShape = zs;
}
catch { }
_cameraEnumerator = CameraEnumeratorOverride ?? new MediaCaptureCameraEnumerator();
_cameraManager = new CameraManager(
_cameraEnumerator,
CameraFrameSourceFactoryOverride ?? (id => new MediaCaptureFrameSource(id)),
System.Windows.Application.Current?.Dispatcher,
effects: new Services.BackgroundEffects.BackgroundEffectProcessor(
PersonSegmenterOverride ?? new Services.BackgroundEffects.OnnxPersonSegmenter()));
_cameraManager.PreviewBitmapChanged += OnCameraPreviewBitmapChanged;
_cameraManager.EffectPreviewBitmapChanged += OnCameraEffectPreviewBitmapChanged;
_cameraManager.CameraFailed += OnCameraFailed;
_microphoneEnumerator = new WinRtMicrophoneEnumerator();
// The mixer consumes these Func seams live each mix tick, so the volume
// sliders and mute buttons are stream-honest (they also update the
// meters' display). Before this wiring the gains defaulted to unity and
// the controls were decorative.
_gainProvider = new AudioGainProvider(
() => MicMuted,
() => MicVolume,
() => GameMuted,
() => GameAudioVolume);
_audioMixer = new AudioMixer(
new WasapiMicAudioSource(() => MicSourceName),
new WasapiLoopbackAudioSource(),
message => AppLog.Write(message),
micGain: _gainProvider.MicGain,
loopbackGain: _gainProvider.LoopbackGain,
syncOffsetMs: () => AudioSyncOffsetMs);
_audioMixer.MicLevelChanged += OnMicLevelChanged;
_audioMixer.LoopbackLevelChanged += OnLoopbackLevelChanged;
_audioMixer.MicConnected += OnMicConnected;
_audioMixer.MicFailed += OnMicFailed;
_musicPlayer.PlaybackEnded += OnTraxPlaybackEnded;
_ = StartMicCaptureAsync();
_fullScreenDetector = FullScreenDetectorOverride ?? new Win32FullScreenDetector();
_fullScreenDetector.FullscreenMonitorChanged += OnFullscreenMonitorChanged;
foreach (var display in _fullScreenDetector.GetDisplays())
Displays.Add(display);
_screenCaptureFactory = new ScreenCaptureSourceFactory(
() => new WindowInteropHelper(System.Windows.Application.Current?.MainWindow).Handle);
_screenCaptureManager = new ScreenCaptureManager(
_screenCaptureFactory.Resolve,
System.Windows.Application.Current?.Dispatcher);
_screenCaptureManager.PreviewBitmapChanged += OnScreenPreviewBitmapChanged;
_screenCaptureManager.CaptureFailed += OnScreenCaptureFailed;
_mediaManager = new MediaVideoSourceManager(
path => new MediaVideoSource(
path, MediaDecodeWidth, MediaDecodeHeight,
new FfmpegLocator(), () => new FfmpegDecodeProcess(),
frameRateProbe: new FfmpegFrameRateProbe(
new FfmpegLocator(), () => new FfmpegDecodeProcess())),
System.Windows.Application.Current?.Dispatcher);
_mediaManager.PreviewBitmapChanged += OnMediaPreviewBitmapChanged;
_mediaManager.MediaFailed += OnMediaFailed;
_chatLayer = new ChatOverlayLayer(new ChatBoxRenderer());
_alertLayer = new AlertOverlayLayer(
new AlertRenderer(),
clipDecoderFactory: AlertClipDecoderFor,
clipPathResolver: ResolveAlertClipPath,
audioSink: _audioMixer.EnqueueAlertAudio,
tickerPreviewSink: PublishAlertTickerPreview);
_rtmpUrlProvider = () => _reusableStreamUrl; // TASK 5: the reusable stream's ingest URL
_framePump = new FramePump(
sceneProvider: () => StagedScene,
frameResolver: ResolveOutputFrame,
compositorOptions: BuildCompositorOptions,
encoderOptions: BuildEncoderOptions,
encoderFactory: () => new FfmpegEncoder(new FfmpegLocator()),
log: message => AppLog.Write(message),
socialBar: () => (_socialBarFrame, _socials?.BarPosition ?? SocialBarPosition.Bottom),
alertTicker: () => _alertLayer.AlertTickerFrame,
brandFlash: BrandFlashFrame,
transition: _transition,
sceneGraph: _sceneGraph);
_framePump.Failed += OnFramePumpFailed;
_framePump.HealthUpdated += OnFramePumpHealthUpdated;
LoadLayout();
StampDefaultAlertVideo();
// Resource validation runs as soon as the layout is up: enumerate the
// OS once, and if exactly one webcam exists, lock it for the session
// (the saved identity's scene configs may already hold it — refcounted).
// Failures surface as a persistent red alert in the Layers panel.
WebcamStartupValidationTask = ValidateWebcamResourceStartupAsync();
// The cached reusable stream gives the pump its RTMP URL the moment go-live
// starts (the pump reads the URL once at startup — no waiting on YouTube).
_reusableStream = _layoutStore.LoadReusableStream();
_reusableStreamUrl = _reusableStream?.RtmpUrl;
_ = LoadSavedSessionAsync();
_ = ValidateLicenseAtStartupAsync();
AppLog.Write("MainViewModel ctor end");
}
// Per-instance in DEBUG so two dev instances cannot clobber each other's layout
// (see InstanceProfile). Release always resolves the one real profile.
private static string DefaultLayoutPath => Path.Combine(
InstanceProfile.DataRoot, "ytLlive.db");
// Test seam: a real MainWindow test must never read or write the user's
// real layout DB (it would persist test sources over real ones). Tests set
// this to a temp path before constructing MainWindow and reset it after.
internal static string? LayoutPathOverride { get; set; }
// Test seams (same pattern): startup resource validation must never touch a
// real camera while under test. Tests inject a fake enumerator / frame-source
// factory before constructing MainWindow and reset them after.
internal static ICameraEnumerator? CameraEnumeratorOverride { get; set; }
internal static Func<string, ICameraFrameSource>? CameraFrameSourceFactoryOverride { get; set; }
/// <summary>Test seam (TASK 51): a fake person segmenter so hermetic tests never load the
/// real ONNX model. Mirrors <see cref="CameraFrameSourceFactoryOverride"/>.</summary>
internal static Services.BackgroundEffects.IPersonSegmenter? PersonSegmenterOverride { get; set; }
private void LoadLayout()
{
_isLoading = true;
try
{
AppLog.Write("LoadLayout begin");
var scenes = _layoutStore.Load();
Scenes.Clear();
foreach (var scene in scenes)
Scenes.Add(scene);
if (Scenes.Count == 0)
{
foreach (var name in SceneCatalog.All)
AddScene(name);
}
ReorderScenesToCanonical();
HealBackgrounds();
HealZoomLayers();
RefreshHotkeyLabels();
foreach (var scene in Scenes)
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
{
ClampWebcamToBounds(config, scene.Name);
HealLegacySquareRect(config);
}
// One-time heal: ChatBox layers created before the "YouTube Chat"
// default still carry the old "Chat" name — creator renames untouched.
foreach (var scene in Scenes)
foreach (var source in scene.Elements.OfType<Source>())
if (source.Type == SourceType.ChatBox
&& string.Equals(source.Name, "Chat", StringComparison.OrdinalIgnoreCase))
source.Name = "YouTube Chat";
AppLog.Write($"LoadLayout: {Scenes.Count} scenes loaded");
}
finally
{
_isLoading = false;
}
ClearPreviewHistory();
StagedScene = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, SceneCatalog.Starting)) ?? Scenes.FirstOrDefault();
UpdateActiveBackground();
UpdateActiveChatBoxPreviews();
UpdateActiveAlertBoxPreviews();
StartMockChatPreview();
UpdateBackgroundImage();
ReacquireWebcam();
ReacquireScreenCaptures();
RegisterLoadedWebSources();
_socials = _layoutStore.Socials;
RenderSocialBarFrame();
HealFediverseSoftwareInBackground();
OnPropertyChanged(nameof(HasSocials));
OnPropertyChanged(nameof(SocialBarVisible));
OnPropertyChanged(nameof(SocialBarDotBrush));
_music = _layoutStore.Music;
LoadTrax(play: false);
var (transType, transDur) = _layoutStore.LoadTransitionSettings();
if (Enum.TryParse<TransitionType>(transType, true, out var parsed))
SelectedTransitionType = parsed;
TransitionDurationMs = transDur;
BroadcastForm.Load();
ScheduleSave();
AppLog.Write("LoadLayout end");
}
public void Shutdown()
{
_saveDebounce?.Stop();
SaveLayoutNow();
_fullScreenDetector.StopWatching();
_audioMixer.Dispose();
_framePump.Dispose();
_cameraManager.Dispose();
_screenCaptureManager.Dispose();
_mediaManager.Dispose();
_webView2Manager?.Dispose();
_layoutStore.Dispose();
_musicPlayer.Dispose();
_youtubeChat.Dispose();
_alertLayer.Dispose();
}
public void SaveLayoutNow()
{
_saveDebounce?.Stop();
try
{
_layoutStore.Save(Scenes, _webcam, _socials, _music);
_layoutStore.SaveTransitionSettings(
SelectedTransitionType.ToString(), TransitionDurationMs);
_layoutStore.SaveAudioSyncOffsetMs(AudioSyncOffsetMs);
}
catch (Exception ex)
{
Debug.WriteLine($"Layout save failed: {ex.Message}");
}
}
private void ScheduleSave()
{
if (_isLoading || _saveDebounce == null) return;
_saveDebounce.Stop();
_saveDebounce.Start();
}
private void BeginEditElement(SceneElement? element)
{
if (element == null) return;
element.IsEditing = true;
}
private void ToggleElementVisibility(SceneElement? element)
{
if (element == null) return;
element.IsVisible = !element.IsVisible;
if (element is Source { IsBackground: true })
OnPropertyChanged(nameof(BackgroundVisible));
}
// Scene-element → latest frame, for the live compositor. The map mirrors the
// preview: webcam by DeviceId, live captures (the background) by CaptureKey,
// images/background by AssetId. A null frame leaves the element transparent.
private VideoFrame? ResolveOutputFrame(SceneElement element)
{
var frame = ResolveOutputFrameCore(element);
// Output-side eyes (2026-09-01): preview showed the webcam but the recording
// didn't — a null resolver frame silently DROPS the layer. Log once per 5s
// per kind so the repro run tells acquire-mismatch from compose-skip.
if (frame == null && element is WebcamSceneConfig webcam)
{
var now = DateTime.UtcNow;
if (now - _lastWebcamNullLog >= TimeSpan.FromSeconds(5))
{
_lastWebcamNullLog = now;
AppLog.Write($"Output resolver: webcam '{webcam.WebcamId}' frame is null — layer skipped in composite");
}
}
// Alerts (TASK 47, 2026-09-26): a healthy decode logged frames=240 while the
// box stayed blank, so name the two possibilities per tick — did the resolver
// ever get asked for the alert box, and did it have a frame to hand back?
if (element is Source { Type: SourceType.AlertBox } alert)
{
var now = DateTime.UtcNow;
bool hasFrame = frame != null;
bool wasLoggedThisState = hasFrame == _lastAlertHadFrame;
if (!wasLoggedThisState || now - _lastAlertResolveLog >= TimeSpan.FromSeconds(10))
{
_lastAlertHadFrame = hasFrame;
_lastAlertResolveLog = now;
AppLog.Write(hasFrame
? $"Output resolver: alert box '{alert.Id}' frame {frame!.Width}x{frame!.Height} (visible={alert.IsVisible} playing={_alertLayer.IsPlaying})"
: $"Output resolver: alert box '{alert.Id}' frame is null (visible={alert.IsVisible} playing={_alertLayer.IsPlaying}) — skipped in composite");
}
}
return frame;
}
private DateTime _lastAlertResolveLog = DateTime.MinValue;
private bool _lastAlertHadFrame = false;
private DateTime _lastWebcamNullLog = DateTime.MinValue;
/// <summary>Maps a config's identity GUID to the CameraManager session key (the
/// DEVICE id). internal = test seam. Take-2 fix: looking up the identity GUID
/// against a device-keyed dictionary returned null forever.</summary>
internal string DeviceKeyForWebcam(string webcamId) =>
_webcam is { } w && w.Id == webcamId && !string.IsNullOrWhiteSpace(w.DeviceId)
? w.DeviceId
: webcamId;
private VideoFrame? ResolveOutputFrameCore(SceneElement element)
{
return element switch
{
// CameraManager sessions are keyed by DEVICE id (AcquireAsync(_webcam.DeviceId));
// configs carry the identity GUID (WebcamSceneConfig.WebcamId = _webcam.Id).
// Looking up by the GUID returned null forever — the webcam was structurally
// invisible on the output while the preview (bitmap path) looked fine
// (caught by the resolver logging, 2026-09-01 take two).
WebcamSceneConfig webcam => _cameraManager.GetLatestFrame(DeviceKeyForWebcam(webcam.WebcamId), EffectKeyFor(webcam)),
Source { IsLiveCapture: true, CaptureKey: not null } live => _screenCaptureManager.GetLatestFrame(live.CaptureKey),
Source { Type: SourceType.ChatBox } chatBox => RenderChatBox(chatBox),
Source { Type: SourceType.Scoreboard } => _scoreboardPresenter?.Frame,
Source { Type: SourceType.AlertBox } alertBox => RenderAlertBox(alertBox),
Source { Type: SourceType.WebSource } web => _webView2Manager?.GetLatestFrame(web.Id),
Source { IsMediaSource: true, MediaPath: not null } media => _mediaManager.GetLatestFrame(media.MediaPath),
Source { AssetId: not null } image => StaticPixelCache.Get(image.AssetId),
_ => null,
};
}
// The tier's crop rect over the 1920x1080 master, integer-aligned (the VM's
// OutputRect* are doubles — the vertical 607.5 half-pixel crop rounds to 608).
// internal = test seam (the zoom layer must NOT change the output crop — the
// box is a composited layer, never the crop source).
internal CompositorOptions BuildCompositorOptions()
{
var quality = SelectedQuality;
return new CompositorOptions
{
SourceRectX = (int)Math.Round(OutputRectX),
SourceRectY = (int)Math.Round(OutputRectY),
SourceRectWidth = (int)Math.Round(OutputRectWidth),
SourceRectHeight = (int)Math.Round(OutputRectHeight),
OutputWidth = quality.Width,
OutputHeight = quality.Height,
};
} // The credit's cadence, timing, placement and licence gate all live in
// BrandFlashPresenter; MainViewModel only routes it to the frame pump and the
// preview (see MainViewModel.BrandFlash.cs).
}