Files
LlamaCasty/ViewModels/MainViewModel.cs
T
gramps deb74529a6 feat(scoreboard): unit 6 — it is a LAYER (source type, strip geometry, resolver, session hook)
Adds SourceType.Scoreboard with the same per-scene eye toggle, drag and presence as every other
element — 'prominent on BRB, dialled back on Live' is geometry, not a second mechanism. Default
geometry is the strip: 1920 wide, 56 tall, bottom edge.

The output resolver returns the presenter's cached frame exactly like the chat box and alert box do,
and every session start clears the tallies. Preview publication writes one shared WriteableBitmap on
the UI thread (the pump thread must never touch one), refreshed only when a displayed value moves.

Guard: ScoreboardLayerTests pins the enum member, the geometry constants, the resolver case and the
session hook, so the layer cannot quietly become a special widget.
2026-10-30 14:40:31 -07:00

629 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 => RemoveElement(element as SceneElement));
EditElementCommand = new RelayCommand(element => BeginEditElement(element as SceneElement));
ToggleElementVisibilityCommand = new RelayCommand(element => 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"));
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();
MonoOutput = _layoutStore.LoadMonoOutput(); // applied to the mixer by the setter
_musicPlayer.UserGain = (float)Helpers.GainDb.FromDb(TraxDefaultDb); // background by default
InitScoreboard();
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;
}
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).
}