using System.Collections.ObjectModel; using System.Collections.Specialized; 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.Media.Imaging; using System.Windows.Threading; using Microsoft.Win32; using NAudio.CoreAudioApi; 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 class MainViewModel : ViewModelBase { private readonly YouTubeAuthService _youtubeAuth; private readonly YouTubeStreamService _youtubeStream; private readonly YouTubeChatService _youtubeChat; private readonly DispatcherTimer _liveTimer; private readonly DispatcherTimer _volumeFlashTimer; private readonly DispatcherTimer _gameVolumeFlashTimer; private readonly DispatcherTimer _healthPollTimer; private Scene? _stagedScene; private Scene? _liveScene; private SceneElement? _selectedElement; private ImageSource? _activeBackgroundImage; private bool _isConnected; private string _accountAvatarUrl = string.Empty; private string _accountDisplayName = string.Empty; private double _audioLevel; private bool _volumeAdjusting; private bool _volumeFlash; private double _micVolume = 0.8; private bool _micMuted; private double? _volumeBeforeMute; private string? _micSourceName; private MicStatus _micStatus = MicStatus.NotConnected; private double _gameAudioLevel; private bool _gameVolumeAdjusting; private bool _gameVolumeFlash; private double _gameVolume = 1.0; private bool _gameMuted; private double? _gameVolumeBeforeMute; // TRAX (TASK 9): free background music at a fixed quiet volume, playing to // the default device so it rides the loopback into the stream. The dot is // red (no track) / yellow (loaded, stopped) / green (playing). private readonly MusicPlayer _musicPlayer = new(); private Music? _music; private bool _musicPlaying; private AudioGainProvider? _gainProvider; private StreamStatus _streamStatus = StreamStatus.Offline; private StreamHealth _currentHealth = new(); private string _streamTitle = string.Empty; private string _streamDescription = string.Empty; private string _streamVisibility = "Private"; private string? _currentBroadcastId; private string? _reusableStreamUrl; private ReusableStream? _reusableStream; private string? _healthIssueBanner; private string _healthIssueBackground = "Transparent"; private string _windowTitle = "LlamaCasty"; private string _topBarBackground = "#16213e"; private string _previewGlowBrush = "Transparent"; private Thickness _previewGlowThickness = new(0); private string _liveElapsedText = "00:00:00"; private double _recDotPulse = 1.0; private TimeSpan _liveElapsed; private bool _isSettingsOpen; private bool _isBugOpen; private bool _isFeatureOpen; private bool _isAboutOpen; private bool _isLicensesOpen; private string _overlayTitle = string.Empty; private string _defaultStreamTitle = string.Empty; private string _defaultStreamDescription = string.Empty; private string _bugReportText = string.Empty; private string _bugReportEmail = string.Empty; private string _featureRequestText = string.Empty; private string _featureRequestEmail = string.Empty; 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(); // 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 VideoFrame? _socialBarFrame; private readonly IMicrophoneEnumerator _microphoneEnumerator; // Live audio capture (TASK 4 ship step 4): desktop/game via WASAPI loopback, // mic via WASAPI capture, both owned by the mixer. Capture runs for the app's // lifetime (started at startup, stopped on shutdown) so the footer meters // stay live in preview. Mic level feeds AudioLevel (the meter); loopback // feeds the game audio bar's meter. Private by design — the mixer surfaces // the levels + mic connection state to the UI. private readonly AudioMixer _audioMixer; // 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 _rtmpUrlProvider; private readonly FramePump _framePump; 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; // Mock chat messages for the ChatBox preview — branded placeholders so the // creator sees where the overlay lands before going live. private static readonly IReadOnlyList MockChatMessages = new[] { new ChatMessage { AuthorName = "LlamaCasty", Message = "welcome to the stream!", Timestamp = DateTime.Now.AddMinutes(-4), IsMember = true }, new ChatMessage { AuthorName = "LlamaCastyFan", Message = "$10 — LlamaCasty is the best!", Timestamp = DateTime.Now.AddMinutes(-3), IsSuperChat = true }, new ChatMessage { AuthorName = "GamerDude", Message = "what streaming software is this?", Timestamp = DateTime.Now.AddMinutes(-2) }, 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 }, }; private ImageSource? _backgroundImage; private bool _backgroundSizeMismatch; private string _backgroundSizeMismatchText = ""; private HashSet _liveCaptureKeys = new(); // Chat fade timer: clears chat overlay after N seconds of inactivity. private System.Windows.Threading.DispatcherTimer? _chatFadeTimer; // 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; // TASK 10 — Polar license validation private string _licenseKey = string.Empty; private string _licenseMessage = string.Empty; private bool _isLicenseValidating; private bool _isLicenseEntryOpen; private static readonly TimeSpan OfflineGracePeriod = TimeSpan.FromDays(14); private const string SupportEmail = "gramps@llamachile.shop"; // The creator-hub link targets (About overlay) — public so the hub's // integration test can assert they point at real destinations. public const string ChannelUrl = "https://youtube.com/@llamachileshop"; public const string MastodonUrl = "https://mastodon.llamachile.tube/@gramps"; public const string PremiumUrl = "https://buy.polar.sh/polar_cl_ueMy8AyAClO0o0yo19IroZwKTbD587gzUsjEW3bFapw"; public const string CoffeeUrl = "https://buymeacoffee.com/llamachiley"; public static string AppVersionLabel => $"Version {typeof(MainViewModel).Assembly.GetName().Version?.ToString(3)}"; /// The About hub's Unlock Premium button lights up only once the /// billing URL is set (Polar — polar.sh, pending TASK 10 implementation). public bool IsPremiumAvailable => PremiumUrl.Length > 0; /// Full in-app licensing/legal text, loaded on demand from the /// shipped THIRD-PARTY-NOTICES.txt (never opens the OS viewer). public string LicensesText { get; private set; } = string.Empty; public ObservableCollection Scenes { get; } = new(); public ObservableCollection ChatMessages { get; } = new(); public ObservableCollection Displays { get; } = new(); public Scene? StagedScene { get => _stagedScene; set { if (value != null && value.IsHidden) return; if (SetProperty(ref _stagedScene, value)) { // Update IsStaged flags for thumbnail glow foreach (var s in Scenes) s.IsStaged = s == value; SelectedElement = null; if (value != null) { var background = EnsureBackground(value); if (background != null) StampDefaultBackgroundAsset(background, value.Name); } OnPropertyChanged(nameof(ShowChatInactiveMessage)); OnPropertyChanged(nameof(ShowEmptySceneHint)); OnPropertyChanged(nameof(ShowPreviewPlaceholder)); OnPropertyChanged(nameof(ShowSourcesEmptyHint)); OnPropertyChanged(nameof(CanChangeBackground)); OnPropertyChanged(nameof(CanAddWebcam)); OnPropertyChanged(nameof(CanAddYouTubeChat)); OnPropertyChanged(nameof(CanShowWebcamInStagedScene)); OnPropertyChanged(nameof(IsCountdownAvailable)); UpdateActiveBackground(); UpdateBackgroundImage(); RefreshSnapshotsAsync(); } } } /// The scene the encoder outputs. Set on Go Live (StagedScene → LiveScene) /// or on transition completion. FramePump reads this every tick. public Scene? LiveScene { get => _liveScene; set { if (SetProperty(ref _liveScene, value)) { foreach (var s in Scenes) s.IsLive = s == value; } } } /// True when offline — edit mode is available. When live/recording, /// all edit operations are locked out (gun safety). public bool IsEditMode => StreamStatus == StreamStatus.Offline; public ImageSource? ActiveBackgroundImage { get => _activeBackgroundImage; private set => SetProperty(ref _activeBackgroundImage, value); } /// The active scene's live-capture background frame (rendered below the /// static background). Set from the shared capture bitmap as it arrives. public ImageSource? BackgroundImage { get => _backgroundImage; private set { if (SetProperty(ref _backgroundImage, value)) { OnPropertyChanged(nameof(ShowPreviewPlaceholder)); OnPropertyChanged(nameof(BackgroundVisible)); } } } public bool BackgroundVisible => StagedScene?.Elements.OfType().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 { 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)) { UpdateBackgroundImage(); RefreshSnapshotsAsync(); ScheduleSave(); } } /// The Add → Webcam menu item: enabled only while no webcam exists anywhere — /// one camera identity app-wide; re-adding always opens the picker. public bool CanAddWebcam => StagedScene != null && _webcam == null; /// The Add → YouTube Chat menu item: enabled only while no chat layer exists /// anywhere — one chat overlay per layout. public bool CanAddYouTubeChat => StagedScene != null && !Scenes.Any(s => s.Elements.OfType().Any(e => e.Type == SourceType.ChatBox)); // ─── Transitions (TASK 19/23) ─── public TransitionType SelectedTransitionType { get => _selectedTransitionType; set => SetProperty(ref _selectedTransitionType, value); } private TransitionType _selectedTransitionType = TransitionType.Fade; public double TransitionDurationMs { get => _transitionDurationMs; set => SetProperty(ref _transitionDurationMs, value); } private double _transitionDurationMs = 300; /// /// Called when a scene thumbnail is clicked. If live, triggers a transition from /// LiveScene to the selected StagedScene. Always sets StagedScene first. /// public void TransitionToScene(Scene scene) { if (scene.IsHidden || scene == StagedScene) return; StagedScene = scene; if (IsLive) _transition.Start(SelectedTransitionType, TransitionDurationMs, null); } /// /// Apply the current transition type to the next StagedScene change while live. /// Called from XAML when user picks a transition from the dropdown. /// public void ApplyTransitionToLive() { if (IsLive) _transition.Start(SelectedTransitionType, TransitionDurationMs, null); } // ─── Global hotkeys (TASK 20) — dispatch a registered hotkey to its action ─── public void HandleHotkey(Services.HotkeyId id) { switch (id) { case Services.HotkeyId.SceneStarting: TransitionToCanonical(SceneCatalog.Starting); break; case Services.HotkeyId.SceneLive: TransitionToCanonical(SceneCatalog.Live); break; case Services.HotkeyId.SceneBrb: TransitionToCanonical(SceneCatalog.Brb); break; case Services.HotkeyId.SceneChat: TransitionToCanonical(SceneCatalog.Chat); break; case Services.HotkeyId.SceneEnding: TransitionToCanonical(SceneCatalog.Ending); break; case Services.HotkeyId.StartStopStream: if (IsLive) { if (EndStreamCommand.CanExecute(null)) EndStreamCommand.Execute(null); } else if (StartStreamCommand.CanExecute(null)) { StartStreamCommand.Execute(null); } break; case Services.HotkeyId.ToggleMicMute: ToggleMicMuteCommand.Execute(null); break; case Services.HotkeyId.ToggleGameMute: ToggleGameMuteCommand.Execute(null); break; case Services.HotkeyId.TraxPlayPause: TraxLeftClick(); break; } } private void TransitionToCanonical(string name) { var scene = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, name)); if (scene != null) TransitionToScene(scene); } /// /// 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). /// public bool CanShowWebcamInStagedScene => StagedScene is { } scene && (scene.WebcamConfig == null || !scene.WebcamConfig.IsVisible); /// Swap-the-device item: enabled once a camera has been picked at all. public bool CanChangeWebcam => _webcam != null; // ─── Social bar (global resource, shown on every scene) ─── 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)!; /// True when the global socials config has at least one validated entry. public bool HasSocials => _socials != null && _socials.Entries.Count > 0; /// The bar is on the stream when it's enabled and has entries. public bool SocialBarVisible => (_socials?.BarEnabled ?? false) && HasSocials; /// Footer indicator dot: green when the bar is on the stream, red otherwise. public SolidColorBrush SocialBarDotBrush => SocialBarVisible ? BarOnBrush : BarOffBrush; /// Green glow on the bar while it's on the stream. public SolidColorBrush SocialBarGlowBrush => BarOnBrush; /// The global socials config (entries + bar settings), or null. public SocialsConfig? Socials => _socials; /// True when the user holds a valid LlamaCasty license (Polar). When /// false, the branding flash runs on every live stream. private bool _isPremium; public bool IsPremium { get => _isPremium; private set { if (!SetProperty(ref _isPremium, value)) return; // Flip the branding flash when premium state changes. BrandFlashEnabled = !value; } } /// Canvas.Top for the social bar: 0 = top, 1040 = bottom (40px from the 1080 edge). public double SocialBarTop => _socials?.BarPosition == SocialBarPosition.Top ? 0 : 1040; private readonly ISocialValidator _socialValidator = new HttpSocialValidator(); /// Shows the mirror / clip-shape row in the source chip when a webcam is selected. public bool IsWebcamSelected => SelectedElement is WebcamSceneConfig; /// True when the Elements panel should show the "No configurable elements" hint. public bool IsElementsPanelEmpty => SelectedElement is { } el && !el.IsDraggable && !el.IsWebcam && el is not Source { Type: SourceType.Countdown or SourceType.WebSource or SourceType.ChatBox }; /// /// 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. /// public string? WebcamError { get => _webcamError; private set => SetProperty(ref _webcamError, value); } public StreamStatus StreamStatus { get => _streamStatus; set { if (SetProperty(ref _streamStatus, value)) { OnPropertyChanged(nameof(IsOffline)); OnPropertyChanged(nameof(IsLive)); OnPropertyChanged(nameof(IsEditMode)); OnPropertyChanged(nameof(RecDotBrush)); OnPropertyChanged(nameof(RecTextBrush)); OnPropertyChanged(nameof(RecDotOpacity)); OnPropertyChanged(nameof(OnAirBrush)); OnPropertyChanged(nameof(IsLivePrivate)); OnPropertyChanged(nameof(ShowStartStream)); OnPropertyChanged(nameof(ShowChatInactiveMessage)); OnPropertyChanged(nameof(ShowPreviewPlaceholder)); UpdateLiveVisuals(); } } } public bool IsConnected { get => _isConnected; set { if (SetProperty(ref _isConnected, value)) OnPropertyChanged(nameof(ShowStartStream)); } } /// The connected YouTube account's avatar + name — shown in the top /// bar so the creator always sees WHICH account is about to go live. public string AccountAvatarUrl { get => _accountAvatarUrl; private set => SetProperty(ref _accountAvatarUrl, value); } public string AccountDisplayName { get => _accountDisplayName; private set => SetProperty(ref _accountDisplayName, value); } // ─── Audio: the mic bar (meter + volume + mute + status dot) is always // ─── visible; the game bar (meter + volume + mute) appears only while a // ─── full-screen game is producing sound. Both meters preview live. ─── /// Live mic input level (0..1) — fed by the audio mixer once capture /// lands; 0 with no input. Read by the meter, scaled by MicVolume. public double AudioLevel { get => _audioLevel; set { if (SetProperty(ref _audioLevel, Math.Clamp(value, 0, 1))) { OnPropertyChanged(nameof(MeterFillWidth)); OnPropertyChanged(nameof(MeterBrush)); } } } /// Displayed meter level: 0 while muted; the volume position while /// the slider is being dragged (or briefly after an unmute, so the restored /// level flashes on the bar) so the creator sees where they're setting it; /// otherwise the realtime live level scaled by volume (raising the volume /// moves ambient noise up the bar). Read-only meter. private double MeterLevel => MicMuted ? 0 : _volumeAdjusting || _volumeFlash ? MicVolume : Math.Min(1, AudioLevelMeter.ToDisplay((float)AudioLevel) * 1.2 * MicVolume); public double MeterFillWidth => MeterLevel * 288; public string MeterBrush => MeterLevel switch { < 0.6 => "#22c55e", < 0.8 => "#eab308", _ => "#ef4444", }; /// Previewes the volume position while the slider is dragged; the /// bar returns to the live level on release (empty when there is no input). /// Starting a drag cancels any pending unmute flash. public void SetVolumeAdjusting(bool adjusting) { if (adjusting) CancelVolumeFlash(); if (SetProperty(ref _volumeAdjusting, adjusting)) { OnPropertyChanged(nameof(MeterFillWidth)); OnPropertyChanged(nameof(MeterBrush)); } } private void BeginVolumeFlash() { _volumeFlash = true; OnPropertyChanged(nameof(MeterFillWidth)); OnPropertyChanged(nameof(MeterBrush)); _volumeFlashTimer.Stop(); _volumeFlashTimer.Start(); } private void EndVolumeFlash() { _volumeFlashTimer.Stop(); if (_volumeFlash) { _volumeFlash = false; OnPropertyChanged(nameof(MeterFillWidth)); OnPropertyChanged(nameof(MeterBrush)); } } private void CancelVolumeFlash() { _volumeFlashTimer.Stop(); _volumeFlash = false; } /// Mic gain (0..1). Running to 0 mutes (the speaker flips to muted); /// moving up from 0 unmutes (the mute indicator clears). The level being left /// is remembered so the speaker button can restore it. public double MicVolume { get => _micVolume; set { var clamped = Math.Clamp(value, 0, 1); if (clamped == 0 && !_micMuted) _volumeBeforeMute ??= _micVolume; if (SetProperty(ref _micVolume, clamped)) { var muted = clamped == 0; if (_micMuted != muted) { _micMuted = muted; OnPropertyChanged(nameof(MicMuted)); OnPropertyChanged(nameof(MicMuteText)); } if (!muted) _volumeBeforeMute = null; OnPropertyChanged(nameof(MeterFillWidth)); OnPropertyChanged(nameof(MeterBrush)); } } } /// Read-only: true whenever the volume is 0. Only MicVolume may /// change it, so the slider and the speaker can never disagree. public bool MicMuted => _micMuted; public string MicMuteText => MicMuted ? "Unmute" : "Mute"; /// Name of the picked voice source, shown left-justified INSIDE the meter /// bar (FontSize 10, ellipsized to the bar) — ai.md is the authority here. public string? MicSourceName { get => _micSourceName; private set => SetProperty(ref _micSourceName, value); } private void ToggleMicMute() { if (MicMuted) { MicVolume = _volumeBeforeMute ?? 0.8; BeginVolumeFlash(); } else { _volumeBeforeMute = MicVolume; MicVolume = 0; } } private void PickMicrophone() { var dialog = new MicPickerDialog(new MicPickerViewModel(_microphoneEnumerator)) { Owner = System.Windows.Application.Current?.MainWindow }; if (dialog.ShowDialog() == true && dialog.PickedDevice != null) { MicSourceName = dialog.PickedDevice.DisplayName; _layoutStore.SaveMicSourceName(MicSourceName); // persist across restarts _audioMixer.RestartMic(); // swap the live device immediately } } // ─── Mic status dot (the MIC button) ─── private static readonly SolidColorBrush MicProblemBrush = CreateBrush("#f1c40f"); private static readonly SolidColorBrush TraxIdleBrush = CreateBrush("#555"); /// Mic connection state, driven by the mixer's MicConnected/MicFailed /// events and the startup device check (see StartMicCaptureAsync). public MicStatus MicStatus { get => _micStatus; private set { if (SetProperty(ref _micStatus, value)) { OnPropertyChanged(nameof(MicStatusBrush)); OnPropertyChanged(nameof(MicStatusToolTip)); } } } /// Status dot: green = connected, yellow = problem with the requested /// connection, red = not connected (no device / not started). public SolidColorBrush MicStatusBrush => MicStatus switch { MicStatus.Connected => BarOnBrush, MicStatus.Problem => MicProblemBrush, _ => BarOffBrush, }; public string MicStatusToolTip => MicStatus switch { MicStatus.Connected => "Mic connected — click to change", MicStatus.Problem => "Mic problem — the requested microphone is unavailable (in use or unplugged). Click to change", _ => "No mic connected — click to choose a microphone", }; // ─── Game audio bar (desktop/game): always visible — a mirror of the mic bar // ─── (meter + mute + volume). Desktop/game audio is automatic (WASAPI loopback). ─── /// True while the TRAX background track is playing. public bool IsMusicPlaying { get => _musicPlaying; private set { if (SetProperty(ref _musicPlaying, value)) { OnPropertyChanged(nameof(TraxDotBrush)); OnPropertyChanged(nameof(TraxToolTip)); } } } private string? _traxError; /// Error message from the last TRAX load/playback failure; null = no error. public string? TraxError { get => _traxError; private set => SetProperty(ref _traxError, value); } /// TRAX status dot: green = playing, gray = idle or no track. public SolidColorBrush TraxDotBrush => _musicPlayer.IsPlaying ? BarOnBrush : TraxIdleBrush; public string TraxToolTip => _musicPlayer.TrackName != null ? $"TRAX: {_musicPlayer.TrackName} — left-click {(IsMusicPlaying ? "pauses" : "plays")}, right-click chooses a track" : "No track — left-click or right-click to choose background music"; /// Left-click on TRAX: no track → pick one; otherwise toggle play/pause. public void TraxLeftClick() { if (_music == null || string.IsNullOrWhiteSpace(_music.TrackPath)) PickTraxTrack(); else if (IsMusicPlaying) PauseTrax(); else PlayTrax(); } /// Right-click on TRAX: always open the track picker (never the OS viewer). public void TraxRightClick() => PickTraxTrack(); private void PickTraxTrack() { var dialog = new OpenFileDialog { Title = "Choose background music", Filter = "Music (*.mp3;*.wav;*.m4a;*.aac;*.flac;*.ogg)|*.mp3;*.wav;*.m4a;*.aac;*.flac;*.ogg|All files (*.*)|*.*", }; if (dialog.ShowDialog() != true) return; _music = new Music { TrackPath = dialog.FileName, IsEnabled = true }; LoadTrax(play: true); } private void LoadTrax(bool play) { if (_music == null || string.IsNullOrWhiteSpace(_music.TrackPath)) return; try { _musicPlayer.Load(_music.TrackPath); UpdateTraxLocalGain(); _music.IsEnabled = true; TraxError = null; if (play) _musicPlayer.Play(); } catch (Exception ex) { AppLog.Write($"TRAX: failed to load '{_music.TrackPath}': {ex.Message}"); TraxError = $"Failed to load: {Path.GetFileName(_music.TrackPath)} — {ex.Message}"; } RefreshTraxUi(); ScheduleSave(); } private void PlayTrax() => _musicPlayer.Play(); private void PauseTrax() => _musicPlayer.Pause(); private void OnTraxPlaybackEnded(Exception? ex) { if (ex != null) { AppLog.Write($"TRAX: playback error: {ex.Message}"); TraxError = $"Playback error: {ex.Message}"; } RefreshTraxUi(); } private void RefreshTraxUi() { IsMusicPlaying = _musicPlayer.IsPlaying; OnPropertyChanged(nameof(TraxDotBrush)); OnPropertyChanged(nameof(TraxToolTip)); } /// Mirrors the desktop/game audio bar's volume and mute onto the /// music so the creator hears the controls work in the headphones (the /// track rides the loopback channel, so it must duck the same way locally). /// Called whenever the game volume/mute change and on TRAX load. private void UpdateTraxLocalGain() { _musicPlayer.LocalGain = GameMuted ? 0f : 1f; } private static void PushSystemVolume(double volume) { try { using var enumerator = new MMDeviceEnumerator(); using var device = enumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia); device.AudioEndpointVolume.MasterVolumeLevelScalar = (float)volume; } catch { } } /// Live desktop/game input level (0..1), fed by the mixer's loopback /// capture. Read by the game meter, scaled by GameAudioVolume. public double GameAudioLevel { get => _gameAudioLevel; set { if (SetProperty(ref _gameAudioLevel, Math.Clamp(value, 0, 1))) { OnPropertyChanged(nameof(GameMeterFillWidth)); OnPropertyChanged(nameof(GameMeterBrush)); } } } /// Displayed game meter level: 0 while muted; the volume position /// while the slider is dragged (or briefly after an unmute flash); otherwise /// the realtime live level scaled by volume. private double GameMeterLevel => GameMuted ? 0 : _gameVolumeAdjusting || _gameVolumeFlash ? GameAudioVolume : Math.Min(1, AudioLevelMeter.ToDisplay((float)GameAudioLevel)); public double GameMeterFillWidth => GameMeterLevel * 288; public string GameMeterBrush => GameMeterLevel switch { < 0.6 => "#22c55e", < 0.8 => "#eab308", _ => "#ef4444", }; public void SetGameVolumeAdjusting(bool adjusting) { if (adjusting) CancelGameVolumeFlash(); if (SetProperty(ref _gameVolumeAdjusting, adjusting)) { OnPropertyChanged(nameof(GameMeterFillWidth)); OnPropertyChanged(nameof(GameMeterBrush)); } } private void BeginGameVolumeFlash() { _gameVolumeFlash = true; OnPropertyChanged(nameof(GameMeterFillWidth)); OnPropertyChanged(nameof(GameMeterBrush)); _gameVolumeFlashTimer.Stop(); _gameVolumeFlashTimer.Start(); } private void EndGameVolumeFlash() { _gameVolumeFlashTimer.Stop(); if (_gameVolumeFlash) { _gameVolumeFlash = false; OnPropertyChanged(nameof(GameMeterFillWidth)); OnPropertyChanged(nameof(GameMeterBrush)); } } private void CancelGameVolumeFlash() { _gameVolumeFlashTimer.Stop(); _gameVolumeFlash = false; } /// Game audio gain (0..1, unity default). Running to 0 mutes; the /// prior level is remembered so the speaker button can restore it. public double GameAudioVolume { get => _gameVolume; set { var clamped = Math.Clamp(value, 0, 1); if (clamped == 0 && !_gameMuted) _gameVolumeBeforeMute ??= _gameVolume; if (SetProperty(ref _gameVolume, clamped)) { var muted = clamped == 0; if (_gameMuted != muted) { _gameMuted = muted; OnPropertyChanged(nameof(GameMuted)); OnPropertyChanged(nameof(GameMuteText)); } if (!muted) _gameVolumeBeforeMute = null; OnPropertyChanged(nameof(GameMeterFillWidth)); OnPropertyChanged(nameof(GameMeterBrush)); UpdateTraxLocalGain(); PushSystemVolume(clamped); } } } /// Read-only: true whenever the volume is 0 (the slider and the /// speaker can never disagree). public bool GameMuted => _gameMuted; public string GameMuteText => GameMuted ? "Unmute" : "Mute"; private void ToggleGameMute() { if (GameMuted) { GameAudioVolume = _gameVolumeBeforeMute ?? 1.0; BeginGameVolumeFlash(); } else { _gameVolumeBeforeMute = GameAudioVolume; GameAudioVolume = 0; } } public bool IsOffline => StreamStatus == StreamStatus.Offline; public bool IsLive => StreamStatus == StreamStatus.Streaming; public bool IsLivePrivate => IsLive && string.Equals(StreamVisibility, "Private", StringComparison.OrdinalIgnoreCase); public string RecDotBrush => !IsLive ? "#555555" : IsLivePrivate ? "#8f1f1f" : "#e94560"; public string RecTextBrush => IsLive ? "#ffffff" : "#888888"; public double RecDotOpacity => IsLive ? _recDotPulse : 0.55; public string OnAirBrush => !IsLive ? "#555555" : IsLivePrivate ? "#22c55e" : "#e94560"; public bool ShowStartStream => IsOffline && IsConnected; public bool ShowChatInactiveMessage => !IsLive; public bool ShowEmptySceneHint => StagedScene != null && StagedScene.Elements.Count == 0; public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null && BackgroundImage == null; public bool ShowSourcesEmptyHint => StagedScene == null || StagedScene.Elements.Count == 0; public bool IsCountdownAvailable => StagedScene != null && (SceneCatalog.Is(StagedScene.Name, "Starting") || SceneCatalog.Is(StagedScene.Name, "BRB")); /// The canonical scenes (SceneCatalog) not present right now — what the /// "+" button may re-add. Work with less, never more. public IEnumerable MissingScenes => SceneCatalog.All.Where(canonical => Scenes.All(s => !SceneCatalog.Is(s.Name, canonical))); /// Only show the "+" button when one of the five canonical scenes is missing. public bool ShowAddScene => MissingScenes.Any(); /// "Change Capture…"/"Refresh Capture" apply to the active scene's background. // Capture controls (Show Desktop / monitor pick / refresh) exist only on // the Live screen's Background. Static-art screens never expose them. public bool CanChangeBackground => StagedScene != null && SceneCatalog.Is(StagedScene.Name, SceneCatalog.Live); // Paid unlock flips this off (see ai.md "Monetization"). When disabled the // 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); } 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().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().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().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(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 { get => _currentHealth; set => SetProperty(ref _currentHealth, value); } /// Report-by-exception YouTube health (TASK 5 item 3): non-null only /// while a warning/error configuration issue exists — the banner's text. /// good/ok/noData keep it null so nothing renders. public string? HealthIssueBanner { get => _healthIssueBanner; private set => SetProperty(ref _healthIssueBanner, value); } /// Banner background: app red (#8f1f1f) for an error issue, amber /// (#b8860b) for a warning. Transparent when no banner is shown. public string HealthIssueBackground { get => _healthIssueBackground; private set => SetProperty(ref _healthIssueBackground, value); } public string StreamTitle { get => _streamTitle; set => SetProperty(ref _streamTitle, value); } public string StreamDescription { get => _streamDescription; set => SetProperty(ref _streamDescription, value); } public string StreamVisibility { get => _streamVisibility; set { if (SetProperty(ref _streamVisibility, value)) { OnPropertyChanged(nameof(RecDotBrush)); OnPropertyChanged(nameof(RecTextBrush)); OnPropertyChanged(nameof(OnAirBrush)); OnPropertyChanged(nameof(IsLivePrivate)); } } } public string WindowTitle { get => _windowTitle; set => SetProperty(ref _windowTitle, value); } public string TopBarBackground { get => _topBarBackground; set => SetProperty(ref _topBarBackground, value); } public string PreviewGlowBrush { get => _previewGlowBrush; set => SetProperty(ref _previewGlowBrush, value); } public Thickness PreviewGlowThickness { get => _previewGlowThickness; set => SetProperty(ref _previewGlowThickness, value); } public string LiveElapsedText { get => _liveElapsedText; set => SetProperty(ref _liveElapsedText, value); } // Overlay panels public bool IsSettingsOpen { get => _isSettingsOpen; private set => SetPanel(ref _isSettingsOpen, value); } public bool IsBugOpen { get => _isBugOpen; private set => SetPanel(ref _isBugOpen, value); } public bool IsFeatureOpen { get => _isFeatureOpen; private set => SetPanel(ref _isFeatureOpen, value); } public bool IsAboutOpen { get => _isAboutOpen; private set => SetPanel(ref _isAboutOpen, value); } /// About hub sub-view: the full licensing/legal texts panel /// (in-app scrolling — no Notepad, no browser tab). public bool IsLicensesOpen { get => _isLicensesOpen; set { if (SetProperty(ref _isLicensesOpen, value)) OnPropertyChanged(nameof(IsHubVisible)); } } public bool IsAnyOverlayOpen => IsSettingsOpen || IsBugOpen || IsFeatureOpen || IsAboutOpen; public string OverlayTitle { get => _overlayTitle; private set => SetProperty(ref _overlayTitle, value); } public string DefaultStreamTitle { get => _defaultStreamTitle; set => SetProperty(ref _defaultStreamTitle, value); } public string DefaultStreamDescription { get => _defaultStreamDescription; set => SetProperty(ref _defaultStreamDescription, value); } // ─── Resolution quality dropdown (bottom bar) ─── // Tiers offered to the creator before going live. First = default. The // dropdown is disabled while live because YouTube stream resolution is // immutable after stream creation (see TASKS.md compliance notes). // The composition master frame. Every tier is an output rect + target // resolution over this frame (see ai.md "Resolution tiers"): 16:9 tiers // use the full frame; the vertical tier uses a centered 9:16 window and // the preview dims the cropped strips (semi-crop). private const double MasterFrameWidth = 1920; 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; } = { new("1080p60", 60, 8.0, 1920, 1080), new("1080p30", 30, 8.0, 1920, 1080), new("720p60", 60, 6.0, 1280, 720), new("720p30", 30, 6.0, 1280, 720), new("Vertical 1080p60", 60, 8.0, 1080, 1920), }; public string ResolutionHelp { get; } = "Pick the highest resolution your upload bandwidth can comfortably sustain. " + "Find your upload speed on your ISP plan or with a speed test (e.g. fast.com), " + "then choose a tier your upload comfortably exceeds. 1080p60 is the default."; private QualityOption _selectedQuality; public QualityOption SelectedQuality { get => _selectedQuality; set { if (SetProperty(ref _selectedQuality, value)) ApplyStreamQuality(value); } } // Output frame of the selected tier over the 1920x1080 master. The strips // outside it are dimmed in the preview so the cropped area stays visible. public double OutputRectX { get; private set; } public double OutputRectY { get; private set; } public double OutputRectWidth { get; private set; } public double OutputRectHeight { get; private set; } public bool IsOutputCropped { get; private set; } public string ResolutionBadgeText { get; private set; } = string.Empty; public ObservableCollection DimRects { get; } = new(); private void ApplyStreamQuality(QualityOption quality) { var health = CurrentHealth; health.CurrentBitrate = quality.Bitrate; health.FPS = quality.Fps; OnPropertyChanged(nameof(CurrentHealth)); var scale = Math.Min(MasterFrameWidth / quality.Width, MasterFrameHeight / quality.Height); var rectW = quality.Width * scale; var rectH = quality.Height * scale; var rectX = (MasterFrameWidth - rectW) / 2; var rectY = (MasterFrameHeight - rectH) / 2; OutputRectX = rectX; OutputRectY = rectY; OutputRectWidth = rectW; OutputRectHeight = rectH; DimRects.Clear(); if (rectX > 0) DimRects.Add(new Rect(0, 0, rectX, MasterFrameHeight)); if (rectX + rectW < MasterFrameWidth) DimRects.Add(new Rect(rectX + rectW, 0, MasterFrameWidth - rectX - rectW, MasterFrameHeight)); if (rectY > 0) DimRects.Add(new Rect(0, 0, MasterFrameWidth, rectY)); if (rectY + rectH < MasterFrameHeight) DimRects.Add(new Rect(0, rectY + rectH, MasterFrameWidth, MasterFrameHeight - rectY - rectH)); IsOutputCropped = DimRects.Count > 0; ResolutionBadgeText = $"{quality.Label} · {quality.Width}×{quality.Height}"; OnPropertyChanged(nameof(OutputRectX)); OnPropertyChanged(nameof(OutputRectY)); OnPropertyChanged(nameof(OutputRectWidth)); OnPropertyChanged(nameof(OutputRectHeight)); OnPropertyChanged(nameof(IsOutputCropped)); OnPropertyChanged(nameof(ResolutionBadgeText)); } public string BugReportText { get => _bugReportText; set => SetProperty(ref _bugReportText, value); } public string BugReportEmail { get => _bugReportEmail; set => SetProperty(ref _bugReportEmail, value); } public string FeatureRequestText { get => _featureRequestText; set => SetProperty(ref _featureRequestText, value); } public string FeatureRequestEmail { get => _featureRequestEmail; set => SetProperty(ref _featureRequestEmail, value); } // TASK 10 — license key entry public string LicenseKey { get => _licenseKey; set => SetProperty(ref _licenseKey, value); } public string LicenseMessage { get => _licenseMessage; private set => SetProperty(ref _licenseMessage, value); } public bool IsLicenseValidating { get => _isLicenseValidating; private set => SetProperty(ref _isLicenseValidating, value); } public bool IsLicenseEntryOpen { get => _isLicenseEntryOpen; set { if (SetProperty(ref _isLicenseEntryOpen, value)) OnPropertyChanged(nameof(IsHubVisible)); } } /// The About hub is visible only when neither the licenses panel /// nor the license entry panel is open. public bool IsHubVisible => !IsLicensesOpen && !IsLicenseEntryOpen; // Commands public ICommand AddSceneCommand { get; } public ICommand AddSourceCommand { get; } public ICommand AddWebcamCommand { get; } public ICommand AddImageCommand { get; } public ICommand RemoveSourceCommand { get; } public ICommand EditElementCommand { 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 ToggleGameMuteCommand { get; } public ICommand OpenMicPickerCommand { get; } public ICommand OpenSocialDialogCommand { get; } public ICommand StartStreamCommand { get; } public ICommand EndStreamCommand { get; } public ICommand OpenSettingsCommand { get; } public ICommand OpenBugCommand { get; } public ICommand OpenFeatureCommand { get; } public ICommand OpenAboutCommand { get; } public ICommand CloseOverlayCommand { get; } public ICommand SubmitBugCommand { get; } public ICommand SubmitFeatureCommand { get; } public ICommand OpenChannelCommand { get; } public ICommand OpenMastodonCommand { get; } public ICommand OpenCoffeeCommand { get; } public ICommand OpenPremiumCommand { get; } public ICommand OpenLicensesCommand { get; } public ICommand SaveLayoutCommand { get; } public ICommand SaveLayoutAsCommand { get; } public ICommand OpenLayoutCommand { get; } public ICommand ActivateLicenseCommand { get; } public ICommand OpenLicenseEntryCommand { 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)); 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)); 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(_ => BeginGoLive()); EndStreamCommand = new RelayCommand(_ => StopStream(), _ => IsLive); SaveLayoutCommand = new RelayCommand(_ => SaveLayoutNow()); SaveLayoutAsCommand = new RelayCommand(_ => SaveLayoutAs()); OpenLayoutCommand = new RelayCommand(_ => OpenLayoutFile()); ActivateLicenseCommand = new RelayCommand(_ => _ = ActivateLicenseAsync(), _ => !IsLicenseValidating); OpenLicenseEntryCommand = new RelayCommand(_ => IsLicenseEntryOpen = !IsLicenseEntryOpen); _layoutStore = new LayoutStore(LayoutPathOverride ?? DefaultLayoutPath); _activeLayoutPath = _layoutStore.ActivePath; // 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(); _cameraEnumerator = new MediaCaptureCameraEnumerator(); _cameraManager = new CameraManager( _cameraEnumerator, 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); _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 += (key, message) => AppLog.Write($"ScreenCaptureManager: capture '{key}' failed: {message}"); _chatBoxRenderer = new ChatBoxRenderer(); _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), transition: _transition, fromSceneProvider: () => LiveScene); _framePump.Failed += OnFramePumpFailed; _framePump.HealthUpdated += OnFramePumpHealthUpdated; LoadLayout(); // 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"); } // Restores the DPAPI-saved OAuth session so sign-in survives restarts. // Best-effort: refresh a near-expiry access token; a session that can no // longer refresh is discarded and the user signs in again. private async Task LoadSavedSessionAsync() { try { var saved = TokenStore.Load(); if (saved == null) return; _youtubeAuth.SetSession(saved); if (saved.TokenExpiry <= DateTime.UtcNow.AddMinutes(5)) { var refreshed = await Task.Run(() => _youtubeAuth.RefreshToken()); if (!refreshed) { TokenStore.Clear(); AppLog.Write("Saved session could not be refreshed; signed out"); _notifications.Info("Signed out of YouTube", "The saved session expired — sign in again when you next go live."); return; } } IsConnected = true; SyncConnectedAccount(); } catch (Exception ex) { AppLog.Write($"LoadSavedSessionAsync failed: {ex}"); } } private void SyncConnectedAccount() { var channel = _youtubeAuth.CurrentChannel; AccountAvatarUrl = channel?.ProfileImageUrl ?? string.Empty; AccountDisplayName = channel?.DisplayName ?? string.Empty; } // ─── TASK 10 — Polar license validation ─── /// At startup, load the cached license state and re-validate online /// if a key exists. If offline and within the 14-day grace period, the cached /// premium state is trusted. If the grace period is exceeded, premium lapses. private async Task ValidateLicenseAtStartupAsync() { var savedKey = _layoutStore.LoadLicenseKey(); if (string.IsNullOrWhiteSpace(savedKey)) { IsPremium = false; return; } LicenseKey = savedKey; var validatedAt = _layoutStore.LoadLicenseValidatedAt(); var cachedPremium = _layoutStore.LoadIsPremium(); // If the last validation was within the grace period, trust the cache // while we attempt an online re-validation in the background. if (validatedAt.HasValue && DateTime.UtcNow - validatedAt.Value < OfflineGracePeriod) { IsPremium = cachedPremium; } // Try an online check — if it succeeds, it overrides the cache. try { var service = new PolarLicenseService(); var result = await service.ValidateAsync(savedKey); if (result.IsValid) { IsPremium = true; _layoutStore.SaveIsPremium(true); _layoutStore.SaveLicenseValidatedAt(DateTime.UtcNow); } else { // Key revoked or expired — clear premium. IsPremium = false; _layoutStore.SaveIsPremium(false); _layoutStore.SaveLicenseValidatedAt(DateTime.UtcNow); AppLog.Write($"License validation failed: {result.Error}"); _notifications.Warning("Premium is no longer active", $"{result.Error} — watermark branding returns until the license is renewed."); } } catch (Exception ex) { // Offline or network error — keep the cached state, log it. AppLog.Write($"License re-validation skipped (offline?): {ex.Message}"); if (!validatedAt.HasValue || DateTime.UtcNow - validatedAt.Value >= OfflineGracePeriod) { IsPremium = false; _layoutStore.SaveIsPremium(false); } } } /// Called from the Activate button in the About overlay. Validates /// the entered key, persists it, and flips premium state. private async Task ActivateLicenseAsync() { if (string.IsNullOrWhiteSpace(LicenseKey)) return; IsLicenseValidating = true; LicenseMessage = "Validating\u2026"; try { var service = new PolarLicenseService(); var result = await service.ValidateAsync(LicenseKey); if (result.IsValid) { IsPremium = true; _layoutStore.SaveLicenseKey(LicenseKey.Trim()); _layoutStore.SaveIsPremium(true); _layoutStore.SaveLicenseValidatedAt(DateTime.UtcNow); LicenseMessage = "Premium activated! The watermark has been removed."; AppLog.Write("License activated successfully"); } else { IsPremium = false; _layoutStore.SaveLicenseKey(null); _layoutStore.SaveIsPremium(false); LicenseMessage = result.Error ?? "Invalid license key."; AppLog.Write($"License activation failed: {result.Error}"); } } catch (Exception ex) { LicenseMessage = $"Activation error: {ex.Message}"; AppLog.Write($"License activation error: {ex}"); } finally { IsLicenseValidating = false; } } 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; } 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(); foreach (var scene in Scenes) foreach (var config in scene.Elements.OfType()) { 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()) 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(); StartMockChatPreview(); UpdateBackgroundImage(); ReacquireWebcam(); ReacquireScreenCaptures(); _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(transType, true, out var parsed)) SelectedTransitionType = parsed; TransitionDurationMs = transDur; ScheduleSave(); 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())) _ = _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())) 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()) 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:"; /// Every background-enabled scene has exactly one background, kept at index 0 (bottom layer). /// Returns null for a scene with = false. internal static Source? EnsureBackground(Scene scene) { if (!scene.HasBackground) return null; var background = scene.Elements.OfType().FirstOrDefault(s => s.IsBackground); if (background != null) return background; background = CreateBackground(scene.Name); scene.Elements.Insert(0, background); return background; } /// 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. 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, }; } /// 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. internal static void NormalizeBackgrounds(IEnumerable scenes) { foreach (var scene in scenes) { if (!SceneCatalog.HasBackground(scene.Name)) { scene.HasBackground = false; foreach (var stale in scene.Elements.OfType().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().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 AllBackgrounds() => Scenes.Where(s => SceneCatalog.Is(s.Name, SceneCatalog.Live)) .SelectMany(s => s.Elements.OfType().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().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}"); } /// Whether the active background is in Show Desktop mode. /// Bindable IsChecked property for the context menu toggle. public bool BackgroundShowDesktop { get => StagedScene?.Elements.OfType().FirstOrDefault(s => s.IsBackground)?.ShowDesktop ?? false; set { var background = StagedScene?.Elements.OfType().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().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(); } /// Release the background capture so the static placeholder image shows. 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 { 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 Shutdown() { _saveDebounce?.Stop(); SaveLayoutNow(); _audioMixer.Dispose(); _framePump.Dispose(); _cameraManager.Dispose(); _screenCaptureManager.Dispose(); _layoutStore.Dispose(); _musicPlayer.Dispose(); _youtubeChat.Dispose(); } public void SaveLayoutNow() { _saveDebounce?.Stop(); try { _layoutStore.Save(Scenes, _webcam, _socials, _music); _layoutStore.SaveTransitionSettings( SelectedTransitionType.ToString(), TransitionDurationMs); } catch (Exception ex) { Debug.WriteLine($"Layout save failed: {ex.Message}"); } } private void SaveLayoutAs() { var dialog = new SaveFileDialog { Title = "Save Layout As", Filter = "LlamaCasty layout (*.yll)|*.yll|All files (*.*)|*.*", DefaultExt = ".yll", FileName = "my-layout.yll", }; if (dialog.ShowDialog() != true) return; _layoutStore.Dispose(); _layoutStore = new LayoutStore(dialog.FileName); _activeLayoutPath = dialog.FileName; SaveLayoutNow(); } private void OpenLayoutFile() { var dialog = new OpenFileDialog { Title = "Open Layout", Filter = "LlamaCasty layout (*.yll;*.db)|*.yll;*.db|All files (*.*)|*.*", }; if (dialog.ShowDialog() != true) return; _layoutStore.Dispose(); _layoutStore = new LayoutStore(dialog.FileName); _activeLayoutPath = dialog.FileName; LoadLayout(); } // ─── Auto-save wiring ─── private void OnScenesChanged(object? sender, NotifyCollectionChangedEventArgs e) { if (e.NewItems != null) foreach (Scene scene in e.NewItems) WireScene(scene); if (e.OldItems != null) foreach (Scene scene in e.OldItems) UnwireScene(scene); OnPropertyChanged(nameof(MissingScenes)); OnPropertyChanged(nameof(ShowAddScene)); ScheduleSave(); } private void WireScene(Scene scene) { scene.PropertyChanged += OnScenePropertyChanged; scene.Elements.CollectionChanged += OnElementsChanged; } private void UnwireScene(Scene scene) { scene.PropertyChanged -= OnScenePropertyChanged; scene.Elements.CollectionChanged -= OnElementsChanged; } private void OnScenePropertyChanged(object? sender, PropertyChangedEventArgs e) { // A rename can drop a canonical scene in/out of MissingScenes. OnPropertyChanged(nameof(MissingScenes)); OnPropertyChanged(nameof(ShowAddScene)); ScheduleSave(); } private void OnElementsChanged(object? sender, NotifyCollectionChangedEventArgs e) { if (e.NewItems != null) foreach (SceneElement element in e.NewItems) element.PropertyChanged += OnElementPropertyChanged; if (e.OldItems != null) foreach (SceneElement element in e.OldItems) element.PropertyChanged -= OnElementPropertyChanged; OnPropertyChanged(nameof(CanAddWebcam)); OnPropertyChanged(nameof(CanAddYouTubeChat)); ScheduleSave(); } private void OnElementPropertyChanged(object? sender, PropertyChangedEventArgs e) { if (sender is WebcamSceneConfig && e.PropertyName == nameof(SceneElement.IsVisible)) OnPropertyChanged(nameof(CanShowWebcamInStagedScene)); ScheduleSave(); } private void ScheduleSave() { if (_isLoading || _saveDebounce == null) return; _saveDebounce.Stop(); _saveDebounce.Start(); } // Adds a canonical scene by name — only when it's actually missing. The // background flag follows the policy (Live yes, everyone else no). private void AddScene(string name) { if (!SceneCatalog.IsCanonical(name)) return; if (Scenes.Any(s => SceneCatalog.Is(s.Name, name))) return; var scene = new Scene { Name = name.Trim(), IsChatScene = SceneCatalog.IsChat(name), HasBackground = SceneCatalog.HasBackground(name), }; EnsureBackground(scene); Scenes.Add(scene); StagedScene = scene; } // ─── Background model (TASK 25): exactly ONE Background per screen, index 0, // never removable or re-addable. Static default art on Starting/BRB/Chat/Ending; // on Live it's the capture-flavored row (Show Desktop / monitor switch / // fullscreen-game detect) falling back to live-background.jpg. ─── private static string? DefaultBackgroundAsset(string sceneName) => sceneName switch { SceneCatalog.Starting => "starting-background.jpg", SceneCatalog.Brb => "brb-background.jpg", SceneCatalog.Chat => "chat-background.jpg", SceneCatalog.Ending => "ending-background.jpg", SceneCatalog.Live => "live-background.jpg", _ => null, }; /// 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. private void HealBackgrounds() { NormalizeBackgrounds(Scenes); foreach (var scene in Scenes) { var background = scene.Elements.OfType().FirstOrDefault(s => s.IsBackground); if (background != null) StampDefaultBackgroundAsset(background, scene.Name); } OnPropertyChanged(nameof(ShowSourcesEmptyHint)); } /// 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. 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) { 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)); } private void AddSource(object? parameter) { var scene = StagedScene; if (scene == null) return; var sourceType = parameter is SourceType typed ? typed : Enum.TryParse(parameter?.ToString(), true, out var parsed) ? parsed : SourceType.Image; var baseName = sourceType switch { SourceType.Image => "Image", SourceType.TextOverlay => "Text", SourceType.Countdown => "Countdown", SourceType.WebSource => "Web", SourceType.ChatBox => "YouTube Chat", _ => "Layer", }; // The (+) menu only adds regular layers. The Background is a locked // per-screen layer managed by EnsureBackground/NormalizeBackgrounds — // never user-created, never deleted. YouTube Chat is one-per-layout. if (sourceType is SourceType.DisplayCapture or SourceType.WindowCapture or SourceType.Background) return; if (sourceType == SourceType.ChatBox && !CanAddYouTubeChat) return; var source = new Source { Name = NextSourceName(scene, baseName), Type = sourceType, X = sourceType == SourceType.ChatBox ? 1400 : 0, Y = sourceType == SourceType.ChatBox ? 20 : 0, Width = sourceType == SourceType.ChatBox ? 500 : 0, Height = sourceType == SourceType.ChatBox ? 648 : 0, }; scene.Elements.Add(source); if (sourceType == SourceType.ChatBox) { source.PropertyChanged += (_, e) => { if (e.PropertyName is nameof(Source.ChatFontSize) or nameof(Source.ChatFontColor) or nameof(Source.ChatBackgroundColor) or nameof(Source.ChatBackgroundOpacity) or nameof(Source.ChatMaxMessages) or nameof(Source.ChatShowTimestamps) or nameof(Source.ChatShowBadges) or nameof(Source.ChatMemberColor) or nameof(Source.ChatSuperChatColor) or nameof(Source.ChatFontFamily) or nameof(Source.ChatFadeSeconds) or nameof(Source.Width) or nameof(Source.Height)) UpdateChatBoxPreview(source); }; UpdateChatBoxPreview(source); } OnPropertyChanged(nameof(ShowEmptySceneHint)); OnPropertyChanged(nameof(ShowSourcesEmptyHint)); UpdateActiveBackground(); } // Duplicate resource names get an incrementing suffix with no space: Image, // Image2, Image3… The next free number is derived from the names actually in // the scene, so deleting a middle resource never collides with a survivor. private static string NextSourceName(Scene scene, string baseName) { var taken = scene.Elements.OfType() .Select(s => s.Name) .Where(n => string.Equals(n, baseName, StringComparison.OrdinalIgnoreCase) || (n.Length > baseName.Length && n.StartsWith(baseName, StringComparison.OrdinalIgnoreCase) && int.TryParse(n.Substring(baseName.Length), out _))) .ToHashSet(StringComparer.OrdinalIgnoreCase); if (!taken.Contains(baseName)) return baseName; for (var i = 2; ; i++) if (!taken.Contains($"{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())) { 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() { var scene = StagedScene; if (scene == null) return; var candidates = BuildImageCandidates(); if (candidates.Count == 0) { var bytes = PickImageBytes("Choose an image"); if (bytes != null) AddImageSource(bytes); return; } var dialog = new ReuseImageDialog(new ReuseImageViewModel(candidates)) { Owner = Application.Current.MainWindow }; if (dialog.ShowDialog() != true) return; if (dialog.WantsNew) { var bytes = PickImageBytes("Choose an image"); if (bytes != null) AddImageSource(bytes); } else { AddReusedImage(dialog.PickedAssetId!); } } private List BuildImageCandidates() { var candidates = new List(); foreach (var scene in Scenes) foreach (var source in scene.Elements.OfType().Where(s => s.Type == SourceType.Image && !string.IsNullOrWhiteSpace(s.AssetId))) { candidates.Add(new ReuseImageCandidate { SceneName = scene.Name, SourceName = source.Name, AssetId = source.AssetId! }); } return candidates; } private byte[]? PickImageBytes(string title) { var dialog = new OpenFileDialog { Title = title, Filter = "Image files (*.png;*.jpg;*.jpeg;*.bmp;*.gif;*.webp)|*.png;*.jpg;*.jpeg;*.bmp;*.gif;*.webp|All files (*.*)|*.*" }; if (dialog.ShowDialog() != true) return null; try { return File.ReadAllBytes(dialog.FileName); } catch (Exception ex) { _notifications.Warning("Couldn't read that image", ex.Message); return null; } } private string? AddAsset(byte[] bytes) { try { var image = ImageCache.FromBytes(bytes); var id = _layoutStore.UpsertAsset(bytes, image?.PixelWidth ?? 0, image?.PixelHeight ?? 0); if (id != null && image != null) ImageCache.Put(id, image); return id; } catch (Exception ex) { Debug.WriteLine($"Asset store failed: {ex.Message}"); return null; } } private void AddImageSource(byte[] bytes) { if (bytes.Length == 0) return; var assetId = AddAsset(bytes); if (assetId != null) AddReusedImage(assetId); } private void AddReusedImage(string assetId) { var scene = StagedScene; if (scene == null || string.IsNullOrWhiteSpace(assetId)) return; var source = new Source { Name = NextSourceName(scene, "Image"), Type = SourceType.Image, AssetId = assetId }; var image = ImageCache.Get(assetId); if (image != null) { var scale = Math.Min(640.0 / image.PixelWidth, 480.0 / image.PixelHeight); source.Width = image.PixelWidth * scale; source.Height = image.PixelHeight * scale; source.X = (1920 - source.Width) / 2; source.Y = (1080 - source.Height) / 2; } scene.Elements.Add(source); SelectedElement = source; OnPropertyChanged(nameof(ShowEmptySceneHint)); OnPropertyChanged(nameof(ShowSourcesEmptyHint)); UpdateActiveBackground(); } // Removes an element from the active scene. For a webcam config this drops the // scene's usage and releases one camera reference (CameraManager stops the // session when the last using scene lets go). When the last config anywhere is // removed, the webcam identity is cleared too — re-adding opens the picker // again instead of silently resurrecting the old camera. private void RemoveElement(SceneElement? element) { var scene = StagedScene; if (scene == null || element == null) return; if (element is Source { IsBackground: true }) return; if (element is WebcamSceneConfig && _webcam != null) { if (SelectedElement == element) SelectedElement = null; _ = _cameraManager.ReleaseAsync(_webcam.DeviceId); } scene.Elements.Remove(element); if (element is WebcamSceneConfig && _webcam != null && !Scenes.Any(s => s.Elements.OfType().Any())) { _webcam = null; OnPropertyChanged(nameof(CanChangeWebcam)); OnPropertyChanged(nameof(CanAddWebcam)); OnPropertyChanged(nameof(CanShowWebcamInStagedScene)); } OnPropertyChanged(nameof(ShowEmptySceneHint)); OnPropertyChanged(nameof(ShowSourcesEmptyHint)); UpdateActiveBackground(); } private void SetPanel(ref bool field, bool value, [System.Runtime.CompilerServices.CallerMemberName] string? propertyName = null) { if (SetProperty(ref field, value, propertyName)) OnPropertyChanged(nameof(IsAnyOverlayOpen)); } private void ShowOverlay(string? panel = null, string title = "") { IsSettingsOpen = panel == nameof(IsSettingsOpen); IsBugOpen = panel == nameof(IsBugOpen); IsFeatureOpen = panel == nameof(IsFeatureOpen); IsAboutOpen = panel == nameof(IsAboutOpen); OverlayTitle = title; if (panel != nameof(IsAboutOpen)) IsLicensesOpen = false; } /// Shows the in-app licensing panel, loading the full texts from /// the shipped THIRD-PARTY-NOTICES.txt on first open (never the OS viewer). /// Falls back to a "not found" note if the file is missing so the panel /// never hangs on nothing. private void ShowLicenses() { if (string.IsNullOrEmpty(LicensesText)) { try { var path = Path.Combine(AppContext.BaseDirectory, "THIRD-PARTY-NOTICES.txt"); LicensesText = File.Exists(path) ? File.ReadAllText(path) : "THIRD-PARTY-NOTICES.txt not found next to the executable."; } catch (Exception ex) { LicensesText = $"Could not read THIRD-PARTY-NOTICES.txt: {ex.Message}"; } } IsLicensesOpen = true; } private void SubmitBug() { var body = string.Join(Environment.NewLine, BugReportText.Trim(), string.Empty, $"LlamaCasty {AppVersionLabel}", string.IsNullOrWhiteSpace(BugReportEmail) ? string.Empty : $"Reply-to: {BugReportEmail.Trim()}"); ComposeEmail("[LlamaCasty Bug Report]", body); ShowOverlay(); } private void SubmitFeature() { var body = string.Join(Environment.NewLine, FeatureRequestText.Trim(), string.Empty, $"LlamaCasty {AppVersionLabel}", string.IsNullOrWhiteSpace(FeatureRequestEmail) ? string.Empty : $"Reply-to: {FeatureRequestEmail.Trim()}"); ComposeEmail("[LlamaCasty Feature Request]", body); ShowOverlay(); } private static void ComposeEmail(string subject, string body) { var uri = $"mailto:{SupportEmail}?subject={Uri.EscapeDataString(subject)}&body={Uri.EscapeDataString(body)}"; OpenUrl(uri); } private static void OpenUrl(string url) { Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); } // Runs the OAuth flow (browser + loopback callback) on a background thread. // The resulting channel is returned to the caller and persisted via the // auth service's sessionChanged hook. private async Task SignInAsync() { try { var channel = await Task.Run(() => _youtubeAuth.AuthenticateAsync()); if (channel == null) { _notifications.Warning("Sign-in was unsuccessful", "Please try again."); return null; } IsConnected = true; SyncConnectedAccount(); return channel; } catch (Exception ex) { _notifications.Error("Sign-in failed", ex.Message); return null; } } private void OnChatMessageReceived(ChatMessage message) { System.Windows.Application.Current.Dispatcher.Invoke(() => { ChatMessages.Add(message); if (ChatMessages.Count > 500) ChatMessages.RemoveAt(0); // Reset chat fade timer on every message ResetChatFadeTimer(); StopMockChatPreview(); // Switch ChatBox previews from mock to real messages if (StagedScene != null) foreach (var chatBox in StagedScene.Elements.OfType().Where(s => s.Type == SourceType.ChatBox)) UpdateChatBoxPreview(chatBox); }); } private void ResetChatFadeTimer() { _chatFadeTimer?.Stop(); var chatBoxes = Scenes.SelectMany(s => s.Elements).OfType() .Where(s => s.Type == SourceType.ChatBox && s.ChatFadeSeconds > 0).ToList(); if (chatBoxes.Count == 0) return; var delay = chatBoxes.Min(s => s.ChatFadeSeconds); _chatFadeTimer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromSeconds(delay) }; _chatFadeTimer.Tick += (_, _) => { _chatFadeTimer?.Stop(); _chatFadeTimer = null; ChatMessages.Clear(); foreach (var chatBox in Scenes.SelectMany(s => s.Elements).OfType() .Where(s => s.Type == SourceType.ChatBox)) UpdateChatBoxPreview(chatBox); StartMockChatPreview(); }; _chatFadeTimer.Start(); } private void BeginGoLive() { var dialog = new GoLiveViewModel(() => SignInAsync(), _youtubeAuth.CurrentChannel) { StreamTitle = DefaultStreamTitle, StreamDescription = DefaultStreamDescription, }; var window = new ytLive.GoLiveWindow(dialog) { Owner = System.Windows.Application.Current.MainWindow }; if (window.ShowDialog() == true) { StreamTitle = dialog.StreamTitle; StreamDescription = dialog.StreamDescription; StreamVisibility = dialog.Visibility; // always "Private" (ship step 7) WindowTitle = string.IsNullOrWhiteSpace(dialog.StreamTitle) ? "LlamaCasty" : $"{dialog.StreamTitle} — LlamaCasty"; StreamStatus = StreamStatus.Streaming; ResetHealth(StreamStatus.Streaming); _audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName); _ = PrepareAndStartLiveAsync(); } } /// One-click go-live (TASK 5): ensure the reusable stream, create a /// broadcast bound to it, then start the frame pump. The pump reads the RTMP /// URL once at startup (FramePump.StartAsync), so the stream URL must be set /// before the pump starts — hence the ordering here. Failures surface as an /// error health state, never a crash. private async Task PrepareAndStartLiveAsync() { try { var stream = await _youtubeStream.GetOrCreateReusableStreamAsync(); if (stream == null) { AppLog.Write("Reusable stream unavailable; check the OAuth session"); _notifications.Error("Couldn't go live", "The reusable stream is unavailable — check the YouTube sign-in and try again."); StreamStatus = StreamStatus.Error; return; } _reusableStream = stream; _reusableStreamUrl = stream.RtmpUrl; _layoutStore.SaveReusableStream(stream); _currentBroadcastId = await _youtubeStream.CreateBroadcast( StreamTitle, StreamDescription, DateTime.UtcNow, stream.Id); if (_currentBroadcastId == null) { AppLog.Write("Broadcast creation failed; check the OAuth session"); _notifications.Error("Couldn't go live", "YouTube rejected the broadcast — check the sign-in and try again."); StreamStatus = StreamStatus.Error; return; } AppLog.Write($"Broadcast created: {_currentBroadcastId}"); // Start chat polling (TASK 9.4): fetch the liveChatId from the broadcast's // contentDetails, then start the poll loop. Chat is non-fatal — if it fails, // the stream continues without on-panel chat. try { var liveChatId = await _youtubeStream.GetBroadcastLiveChatIdAsync(_currentBroadcastId); if (liveChatId != null) { _youtubeChat.Start(liveChatId); AppLog.Write($"Chat polling started: {liveChatId}"); } else { AppLog.Write("Could not retrieve liveChatId; chat polling disabled"); } } catch (Exception chatEx) { AppLog.Write($"Chat start failed (non-fatal): {chatEx.Message}"); } } catch (Exception ex) { AppLog.Write($"Go-live preparation failed: {ex.Message}"); _notifications.Error("Couldn't go live", ex.Message); StreamStatus = StreamStatus.Error; return; } await _framePump.StartAsync(); // never throws; failures log + surface via Failed // Health polling (TASK 5 item 3): poll immediately, then every 30s while // live. Report-by-exception — a banner appears only on warning/error. _healthPollTimer.Start(); _ = PollHealthAsync(); } private void OnHealthPollTick(object? sender, EventArgs e) => _ = PollHealthAsync(); /// One liveStreams.list poll (TASK 5 item 3): updates the YouTube /// health state + the report-by-exception banner. Never throws — a poll /// failure logs and leaves the last state untouched. private async Task PollHealthAsync() { if (_reusableStream == null || !IsLive) return; try { var health = await _youtubeStream.GetStreamHealthAsync(_reusableStream.Id); if (health == null) { AppLog.Write("Stream health poll failed; check the OAuth session"); return; } CurrentHealth.HealthStatus = health.HealthStatus; CurrentHealth.HealthMessage = health.HealthStatus; CurrentHealth.ConfigurationIssues.Clear(); CurrentHealth.ConfigurationIssues.AddRange(health.ConfigurationIssues); OnPropertyChanged(nameof(CurrentHealth)); ApplyHealthIssue(health.ConfigurationIssues); } catch (Exception ex) { AppLog.Write($"Stream health poll failed: {ex.Message}"); } } // Report-by-exception: good/ok/noData (or info-only issues) clear the banner; // the first warning/error issue raises it, colored by the worst severity. private void ApplyHealthIssue(List issues) { var report = StreamHealthReporter.BannerFor(issues); if (report.Text == null) { HealthIssueBanner = null; HealthIssueBackground = "Transparent"; return; } HealthIssueBackground = report.IsError ? "#8f1f1f" : "#b8860b"; HealthIssueBanner = report.Text; } private void StopStream() { StreamStatus = StreamStatus.Offline; WindowTitle = "LlamaCasty"; ResetHealth(StreamStatus.Offline); // Stop chat polling first (TASK 9.4) — no more messages arriving while // we're tearing down the stream. _youtubeChat.Stop(); // Close the audio pipe FIRST so ffmpeg sees EOF on the audio input, // then stop the pump (stdin EOF) — both inputs end and the encoder // finalizes the FLV. Audio capture itself is always-on (preview // monitoring); only the live pipe, the pump, and the session stop here. _audioMixer.StopLive(); _ = _framePump.StopAsync(); _currentBroadcastId = null; // Graceful end completes the session = signs out (the DPAPI token is // cleared so the next Start Stream requires a fresh sign-in). A crash // never runs this, so the token survives and the creator stays signed in. _youtubeAuth.ClearSession(); TokenStore.Clear(); IsConnected = false; SyncConnectedAccount(); AppLog.Write("Stream ended; session signed out"); } private void OnMicLevelChanged(float level) { // NAudio raises on its capture thread; marshal to the UI thread so the // meter binding updates safely. var dispatcher = System.Windows.Application.Current?.Dispatcher; if (dispatcher != null && !dispatcher.CheckAccess()) dispatcher.BeginInvoke(() => AudioLevel = level); else AudioLevel = level; } private void OnLoopbackLevelChanged(float level) { var dispatcher = System.Windows.Application.Current?.Dispatcher; if (dispatcher != null && !dispatcher.CheckAccess()) dispatcher.BeginInvoke(() => GameAudioLevel = level); else GameAudioLevel = level; } private void OnMicConnected() { var dispatcher = System.Windows.Application.Current?.Dispatcher; if (dispatcher != null && !dispatcher.CheckAccess()) dispatcher.BeginInvoke(() => MicStatus = MicStatus.Connected); else MicStatus = MicStatus.Connected; } private void OnMicFailed(Exception ex) { // The mixer logs the failure detail; here we only flip the dot to yellow. var dispatcher = System.Windows.Application.Current?.Dispatcher; if (dispatcher != null && !dispatcher.CheckAccess()) dispatcher.BeginInvoke(() => MicStatus = MicStatus.Problem); else MicStatus = MicStatus.Problem; } /// Starts capture once at startup: with no mic device present the /// dot stays red and capture never starts; otherwise the mixer starts and /// raises MicConnected (green) or MicFailed (yellow). private async Task StartMicCaptureAsync() { try { var mics = await _microphoneEnumerator.GetMicrophonesAsync(); if (mics.Count == 0) { MicStatus = MicStatus.NotConnected; AppLog.Write("Mic: no capture devices found — mic capture not started"); _notifications.Warning("No microphone found", "Mic capture wasn't started — plug one in and pick it from the mixer."); return; } _audioMixer.Start(); } catch (Exception ex) { AppLog.Write($"Mic: device check failed: {ex.Message}"); } } // 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) { return element switch { WebcamSceneConfig webcam => _cameraManager.GetLatestFrame(webcam.WebcamId), Source { IsLiveCapture: true, CaptureKey: not null } live => _screenCaptureManager.GetLatestFrame(live.CaptureKey), Source { Type: SourceType.ChatBox } chatBox => RenderChatBox(chatBox), Source { AssetId: not null } image => StaticPixelCache.Get(image.AssetId), _ => null, }; } private VideoFrame? RenderChatBox(Source chatBox) { if (ChatMessages.Count == 0) return null; var width = (int)Math.Max(1, chatBox.Width); var height = (int)Math.Max(1, chatBox.Height); return _chatBoxRenderer.Render( ChatMessages, width, height, chatBox.ChatFontSize, chatBox.ChatFontFamily, chatBox.ChatFontColor, chatBox.ChatBackgroundColor, chatBox.ChatMaxMessages, chatBox.ChatShowTimestamps, chatBox.ChatShowBadges, chatBox.ChatMemberColor, chatBox.ChatSuperChatColor); } /// /// Renders the ChatBox preview: real messages when connected, mock when not. /// private void UpdateChatBoxPreview(Source chatBox, IReadOnlyList? messages = null) { try { messages ??= ChatMessages.Count > 0 ? (IReadOnlyList)ChatMessages : MockChatMessages; var width = (int)Math.Max(32, chatBox.Width); var height = (int)Math.Max(32, chatBox.Height); var frame = _chatBoxRenderer.Render( messages, width, height, chatBox.ChatFontSize, chatBox.ChatFontFamily, chatBox.ChatFontColor, chatBox.ChatBackgroundColor, chatBox.ChatMaxMessages, chatBox.ChatShowTimestamps, chatBox.ChatShowBadges, chatBox.ChatMemberColor, chatBox.ChatSuperChatColor); var bitmap = new WriteableBitmap(width, height, 96, 96, System.Windows.Media.PixelFormats.Bgra32, null); bitmap.WritePixels(new System.Windows.Int32Rect(0, 0, width, height), frame.BgraPixels, frame.Stride, 0); chatBox.VideoImageSource = bitmap; } catch (Exception ex) { AppLog.Write($"UpdateChatBoxPreview failed: {ex.Message}"); } } private CancellationTokenSource? _mockChatCts; private async Task RunMockChatPreviewAsync(Source chatBox, CancellationToken ct) { var n = 0; while (!ct.IsCancellationRequested) { UpdateChatBoxPreview(chatBox, MockChatMessages.Take(n + 1).ToList()); await Task.Delay(1000, ct); n++; if (n == MockChatMessages.Count) { await Task.Delay(3000, ct); n = 0; } } } private void StartMockChatPreview() { StopMockChatPreview(); _mockChatCts = new CancellationTokenSource(); var ct = _mockChatCts.Token; foreach (var chatBox in Scenes.SelectMany(s => s.Elements).OfType() .Where(s => s.Type == SourceType.ChatBox && s.ChatPreviewEnabled)) _ = RunMockChatPreviewAsync(chatBox, ct); } private void StopMockChatPreview() { _mockChatCts?.Cancel(); _mockChatCts?.Dispose(); _mockChatCts = null; } /// /// Render previews + wire property-change subscriptions for all ChatBox /// sources across every scene. Called after layout load so existing ChatBox /// sources show mock content and respond to config changes. /// private void UpdateActiveChatBoxPreviews() { foreach (var scene in Scenes) foreach (var chatBox in scene.Elements.OfType().Where(s => s.Type == SourceType.ChatBox)) { chatBox.PropertyChanged += (_, e) => { if (e.PropertyName is nameof(Source.ChatFontSize) or nameof(Source.ChatFontColor) or nameof(Source.ChatBackgroundColor) or nameof(Source.ChatBackgroundOpacity) or nameof(Source.ChatMaxMessages) or nameof(Source.ChatShowTimestamps) or nameof(Source.ChatShowBadges) or nameof(Source.ChatMemberColor) or nameof(Source.ChatSuperChatColor) or nameof(Source.ChatFontFamily) or nameof(Source.ChatFadeSeconds) or nameof(Source.Width) or nameof(Source.Height)) UpdateChatBoxPreview(chatBox); }; UpdateChatBoxPreview(chatBox); } } // 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, }; } // Full encoder options for the current tier, or null when no RTMP URL is // available — the pump then skips the encoder entirely (TASK 5 fills the seam). private EncoderOptions? BuildEncoderOptions() { var url = _rtmpUrlProvider(); if (string.IsNullOrWhiteSpace(url)) return null; var quality = SelectedQuality; return new EncoderOptions { RtmpUrl = url, Width = quality.Width, Height = quality.Height, Fps = quality.Fps, BitrateKbps = (int)Math.Round(quality.Bitrate * 1000), }; } private void OnFramePumpFailed(object? sender, string message) { AppLog.Write($"Frame pump failed: {message}"); _notifications.Error("The stream pipeline stopped", message); if (IsLive) StreamStatus = StreamStatus.Error; } // TASK 4 ship step 6: the encoder's parsed health (bitrate/FPS/dropped/ // duration) lands in the bottom bar. The stderr loop raises on a background // thread — marshal to the UI thread like the audio level handlers. private void OnFramePumpHealthUpdated(object? sender, StreamHealth health) { var dispatcher = System.Windows.Application.Current?.Dispatcher; if (dispatcher != null && !dispatcher.CheckAccess()) dispatcher.BeginInvoke(() => ApplyHealth(health)); else ApplyHealth(health); } private void ApplyHealth(StreamHealth health) { CurrentHealth.Status = health.Status; CurrentHealth.CurrentBitrate = health.CurrentBitrate; CurrentHealth.FPS = health.FPS; CurrentHealth.DroppedFrames = health.DroppedFrames; CurrentHealth.StreamDuration = health.StreamDuration; CurrentHealth.LastError = health.LastError; CurrentHealth.HealthMessage = health.HealthMessage; OnPropertyChanged(nameof(CurrentHealth)); } // Keeps the bottom-bar stats honest across sessions: dropped frames and the // elapsed duration must not linger from a previous go-live (bitrate/FPS stay // on the tier's targets — ApplyStreamQuality sets them on pick). private void ResetHealth(StreamStatus status) { CurrentHealth.Status = status; CurrentHealth.DroppedFrames = 0; CurrentHealth.StreamDuration = TimeSpan.Zero; CurrentHealth.LastError = null; CurrentHealth.HealthStatus = null; CurrentHealth.HealthMessage = null; CurrentHealth.ConfigurationIssues.Clear(); HealthIssueBanner = null; HealthIssueBackground = "Transparent"; OnPropertyChanged(nameof(CurrentHealth)); } private void UpdateLiveVisuals() { var live = IsLive; TopBarBackground = live ? "#e94560" : "#16213e"; PreviewGlowBrush = live ? "#e94560" : "Transparent"; PreviewGlowThickness = live ? new Thickness(3) : new Thickness(0); if (live) { _liveElapsed = TimeSpan.Zero; LiveElapsedText = "00:00:00"; _recDotPulse = 1.0; OnPropertyChanged(nameof(RecDotOpacity)); _liveTimer.Start(); if (BrandFlashEnabled) StartBrandFlashTimer(); } else { _liveTimer.Stop(); _healthPollTimer.Stop(); _brandFlashTimer.Stop(); _brandFlashOffTimer.Stop(); BrandFlashActive = false; LiveElapsedText = "00:00:00"; _recDotPulse = 1.0; OnPropertyChanged(nameof(RecDotOpacity)); } } // 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(); } private void OnLiveTimerTick(object? sender, EventArgs e) { _liveElapsed = _liveElapsed.Add(TimeSpan.FromSeconds(1)); LiveElapsedText = _liveElapsed.ToString(@"hh\:mm\:ss"); _recDotPulse = _recDotPulse > 0.5 ? 0.35 : 1.0; OnPropertyChanged(nameof(RecDotOpacity)); } // ─── Social bar ─── /// /// Resolves a missing nodeinfo software name (mastodon, peertube, ...) for every /// fediverse entry that doesn't have one, so the bar shows the instance's real /// logo instead of the generic fediverse glyph. Best-effort: a failed resolution /// leaves the glyph untouched. Returns handle → software for what was resolved — /// the caller applies + persists (the app hops to the dispatcher; a test applies /// directly). /// public static async Task> HealFediverseSoftwareAsync( SocialsConfig socials, ISocialValidator validator, Action? log = null) { var resolved = new Dictionary(); if (socials == null || validator == null) return resolved; foreach (var entry in socials.Entries) { if (entry.Service != SocialService.Fediverse || !string.IsNullOrWhiteSpace(entry.FediverseSoftware)) continue; if (!SocialServiceIcons.TryParseFediverse(entry.Handle, out _, out var domain)) continue; var software = await validator.ResolveFediverseSoftwareAsync( domain, System.Threading.CancellationToken.None); if (string.IsNullOrWhiteSpace(software)) continue; resolved[entry.Handle] = software; log?.Invoke($"Socials heal: {entry.Handle} → {software}"); } return resolved; } /// Runs the heal off the UI thread and applies + saves any result. private void HealFediverseSoftwareInBackground() { var socials = _socials; if (socials == null) return; _ = Task.Run(async () => { try { var resolved = await HealFediverseSoftwareAsync( socials, _socialValidator, m => AppLog.Write(m)); if (resolved.Count == 0) return; await Application.Current.Dispatcher.InvokeAsync(() => { var current = _socials; if (current == null) return; var applied = false; foreach (var entry in current.Entries) { if (!resolved.TryGetValue(entry.Handle, out var software)) continue; if (entry.FediverseSoftware == software) continue; entry.FediverseSoftware = software; applied = true; } if (applied) NotifySocialsChanged(); // re-renders the bar + saves }); } catch (Exception ex) { AppLog.Write($"Socials heal failed: {ex.Message}"); } }); } /// /// Re-rasterizes the social bar strip the output compositor overlays. UI thread /// only (WPF rendering); the resulting frame is immutable, so the frame pump may /// read it from its own thread. Null when the bar is off or empty. /// private void RenderSocialBarFrame() { _socialBarFrame = _socials != null && _socials.BarEnabled ? SocialBarRenderer.Render(_socials.Entries) : null; } /// /// Opens the Social Media Site Promotion dialog (6 slots, sign-in gate, /// validation). On save the working copy replaces . /// private void OpenSocialDialog() { var dialog = new SocialsDialogViewModel( _socialValidator, SignInAsync, SignOutYouTubeAsync, _youtubeAuth.CurrentChannel, _socials); var window = new ytLive.SocialsDialog(dialog) { Owner = Application.Current.MainWindow }; if (window.ShowDialog() == true) { _socials = dialog.BuildSocialsConfig(); NotifySocialsChanged(); } } private void NotifySocialsChanged() { RenderSocialBarFrame(); OnPropertyChanged(nameof(HasSocials)); OnPropertyChanged(nameof(SocialBarVisible)); OnPropertyChanged(nameof(SocialBarDotBrush)); ScheduleSave(); } /// Sets the bar's edge; called by the preview click-toggle. public void SetSocialBarPosition(SocialBarPosition position) { if (_socials == null || _socials.BarPosition == position) return; _socials.BarPosition = position; OnPropertyChanged(nameof(SocialBarTop)); ScheduleSave(); } /// Preview click flips the bar top ⇄ bottom (KISS — no drag math). public void ToggleSocialBarPosition() => SetSocialBarPosition(_socials?.BarPosition == SocialBarPosition.Top ? SocialBarPosition.Bottom : SocialBarPosition.Top); /// Signs out of YouTube (the delete-the-YouTube-slot action in the dialog). private async Task SignOutYouTubeAsync() { _youtubeAuth.ClearSession(); TokenStore.Clear(); IsConnected = false; SyncConnectedAccount(); NotifySocialsChanged(); AppLog.Write("Socials: signed out of YouTube"); await Task.CompletedTask; } }