7f14ffb11e
The creator confirmed the clip fix ("the video plays now"), then asked where the
scrolling text was — it was invisible, for a structural reason. AlertTickerFrame
existed only as a frame-pump callback blitted into the OUTPUT; PreviewPane.xaml had
no element for it, because the strip is master-width and global, not a Source, so it
cannot ride a per-element Image. Nothing was wrong in the renderer: there was no
consumer in the preview. Same class of defect as the missing IsAlertBox trigger, one
layer up (MyMistakes RULE 5/6).
- tickerPreviewSink on AlertOverlayLayer, published from RefreshAlertPreviews() so it
is always the UI thread; MainViewModel.AlertTicker writes it into one reused
WriteableBitmap bound to a new global AlertTickerElement, mirroring SocialBarElement.
- Source.AlertDisplayMethod + panel "Display" selector: TickerScroll / Flash / Solid.
Flash pulses 0.5s on / 0.5s off for the whole alert; Solid is centred and still.
- The marquee was also unreadable: a fixed 140px/s took ~17s per pass, so a 10s alert
showed the text once, entering from the right and never crossing. Paced in reads per
alert instead (TickerReadsPerAlert = 3 inside the alert's own length, speed derived
from it) — never px/s. Research (websearch: how do OBS/Streamlabs/StreamElements
alert boxes present announcement timing?) settled the unit: Streamlabs exposes "Alert
Duration: choose how long your alert stays on your stream" and "Text Delay", never a
scroll-speed slider (https://support.streamlabs.com/hc/en-us/articles/52499995174299-Setting-up-Your-Streamlabs-Alerts).
Run is phase-started half a frame in so the first frame isn't blank.
- Persistence: AlertDisplayMethod INTEGER NOT NULL DEFAULT 0 via the idempotent
table_info migration, appended LAST in the SELECT because the Source reader is
positional (GetInt32(32..34)) — a mid-list insert would silently shift a neighbour.
Tests: 15 new facts (suite 339/339). RealApp STA host: the pane draws the strip and
collapses at alert end; the layer publishes a real 1920x48 frame for all three methods
and nothing when the ticker is off; three passes counted in 10s by the pill's leading
edge resetting (a seamless marquee never blanks, so an empty frame cannot count a pass);
Solid byte-identical at every moment; Flash on for half of each second; the panel shows
and writes back the choice; the DB round-trips all three alert fields together.
Incidental finding: a bound ItemsSource ComboBox in LeftPanel.xaml broke
LayerReorderPersistenceTests.RealMouseDrag (that test injects PHYSICAL mouse input, so a
load-time re-measure moves the rows out from under the cursor). Rewritten as inline
ComboBoxItems, the shape the chat Font selector already uses in that panel. Recorded as
MyMistakes RULE (8).
603 lines
27 KiB
C#
603 lines
27 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;
|
|
|
|
// 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 flash (monetization): a full-frame "made with LlamaCasty!" shown
|
|
// for ~1s every 300s on the live output, ~25% opacity. Paid unlock sets
|
|
// BrandFlashEnabled = false. See ai.md "Monetization".
|
|
private readonly DispatcherTimer _brandFlashTimer;
|
|
private readonly DispatcherTimer _brandFlashOffTimer;
|
|
private bool _brandFlashEnabled = true;
|
|
private bool _brandFlashActive;
|
|
|
|
public SceneElement? SelectedElement
|
|
{
|
|
get => _selectedElement;
|
|
set
|
|
{
|
|
if (_selectedElement != null)
|
|
_selectedElement.PropertyChanged -= OnSelectedElementPropertyChanged;
|
|
if (SetProperty(ref _selectedElement, value))
|
|
{
|
|
OnPropertyChanged(nameof(IsWebcamSelected));
|
|
OnPropertyChanged(nameof(IsElementsPanelEmpty));
|
|
if (_selectedElement != null)
|
|
_selectedElement.PropertyChanged += OnSelectedElementPropertyChanged;
|
|
}
|
|
}
|
|
}
|
|
|
|
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 readonly SolidColorBrush BarOnBrush = CreateBrush("#2ecc71");
|
|
private static readonly SolidColorBrush BarOffBrush = CreateBrush("#e94560");
|
|
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;
|
|
|
|
// Paid unlock flips this off (see ai.md "Monetization"). When disabled the
|
|
// cadence timer is stopped and any active flash is hidden immediately.
|
|
public bool BrandFlashEnabled
|
|
{
|
|
get => _brandFlashEnabled;
|
|
set
|
|
{
|
|
if (!SetProperty(ref _brandFlashEnabled, value)) return;
|
|
if (value)
|
|
{
|
|
if (IsLive) StartBrandFlashTimer();
|
|
}
|
|
else
|
|
{
|
|
_brandFlashTimer.Stop();
|
|
_brandFlashOffTimer.Stop();
|
|
BrandFlashActive = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
public bool BrandFlashActive
|
|
{
|
|
get => _brandFlashActive;
|
|
private set => SetProperty(ref _brandFlashActive, value);
|
|
}
|
|
|
|
// 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) };
|
|
_saveDebounce.Tick += (_, _) => SaveLayoutNow();
|
|
|
|
_brandFlashTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(300) };
|
|
_brandFlashTimer.Tick += OnBrandFlashTimerTick;
|
|
_brandFlashOffTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(750) };
|
|
_brandFlashOffTimer.Tick += (_, _) => { _brandFlashOffTimer.Stop(); BrandFlashActive = false; };
|
|
|
|
_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();
|
|
|
|
_cameraEnumerator = CameraEnumeratorOverride ?? new MediaCaptureCameraEnumerator();
|
|
_cameraManager = new CameraManager(
|
|
_cameraEnumerator,
|
|
CameraFrameSourceFactoryOverride ?? (id => new MediaCaptureFrameSource(id)),
|
|
System.Windows.Application.Current?.Dispatcher);
|
|
_cameraManager.PreviewBitmapChanged += OnCameraPreviewBitmapChanged;
|
|
_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 = 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,
|
|
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");
|
|
}
|
|
|
|
private static string DefaultLayoutPath => Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
|
"ytLlive",
|
|
"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; }
|
|
|
|
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);
|
|
}
|
|
HealBackgrounds();
|
|
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();
|
|
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;
|
|
if (now - _lastAlertResolveLog >= TimeSpan.FromSeconds(1))
|
|
{
|
|
_lastAlertResolveLog = now;
|
|
AppLog.Write(frame == null
|
|
? $"Output resolver: alert box '{alert.Id}' frame is null (visible={alert.IsVisible} playing={_alertLayer.IsPlaying}) — skipped in composite"
|
|
: $"Output resolver: alert box '{alert.Id}' frame {frame!.Width}x{frame!.Height} (visible={alert.IsVisible} playing={_alertLayer.IsPlaying})");
|
|
}
|
|
}
|
|
return frame;
|
|
}
|
|
|
|
private DateTime _lastAlertResolveLog = DateTime.MinValue;
|
|
|
|
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)),
|
|
Source { IsLiveCapture: true, CaptureKey: not null } live => _screenCaptureManager.GetLatestFrame(live.CaptureKey),
|
|
Source { Type: SourceType.ChatBox } chatBox => RenderChatBox(chatBox),
|
|
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).
|
|
private 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,
|
|
};
|
|
}
|
|
|
|
// First flash shortly after go-live, then every 300s (the cadence resets on
|
|
// the first tick so the interval is 5s only for that one shot).
|
|
private void StartBrandFlashTimer()
|
|
{
|
|
_brandFlashTimer.Stop();
|
|
_brandFlashTimer.Interval = TimeSpan.FromSeconds(5);
|
|
_brandFlashTimer.Start();
|
|
}
|
|
|
|
private void OnBrandFlashTimerTick(object? sender, EventArgs e)
|
|
{
|
|
_brandFlashTimer.Stop();
|
|
_brandFlashTimer.Interval = TimeSpan.FromSeconds(300);
|
|
_brandFlashTimer.Start();
|
|
ShowBrandFlash();
|
|
}
|
|
|
|
private void ShowBrandFlash()
|
|
{
|
|
if (!IsLive || !BrandFlashEnabled) return;
|
|
BrandFlashActive = true;
|
|
_brandFlashOffTimer.Stop();
|
|
_brandFlashOffTimer.Start();
|
|
}
|
|
|
|
}
|