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 partial class MainViewModel : ViewModelBase { private readonly YouTubeAuthService _youtubeAuth; private readonly YouTubeStreamService _youtubeStream; private readonly YouTubeChatService _youtubeChat; private readonly DispatcherTimer _liveTimer; private readonly DispatcherTimer _healthPollTimer; private SceneElement? _selectedElement; 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; // ─── Recording (TASK 18): independent REC / ON-AIR pills. The pills are // ─── intent; IsRecording / IsLive are reality. ON-AIR needs a sign-in. private bool _recordPillOn; private bool _onAirPillOn; private bool _isRecording; private string? _recordFolder; /// Resolved record folder — the configured choice, or Downloads (0th case). public string RecordFolderDisplay => _recordFolder ?? DefaultRecordFolder(); private string? _activeRecordPath; private DateTime _recordStartTime; private TimeSpan _recordLength; private bool _isSettingsOpen; private bool _isBugOpen; private bool _isFeatureOpen; private bool _isAboutOpen; private bool _isLicensesOpen; private string _overlayTitle = 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(); // 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(); private readonly ChatBoxRenderer _chatBoxRenderer; private WebView2Manager? _webView2Manager; // 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 }, }; // 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; 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 ChatMessages { get; } = new(); 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 → 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)); 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 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 }; 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(ShowPrimaryStartButton)); OnPropertyChanged(nameof(ShowLogInButton)); OnPropertyChanged(nameof(ShowEndStreamButton)); OnPropertyChanged(nameof(EndButtonText)); OnPropertyChanged(nameof(AccountStatusLightToolTip)); OnPropertyChanged(nameof(ShowChatInactiveMessage)); OnPropertyChanged(nameof(ShowPreviewPlaceholder)); UpdateLiveVisuals(); } } } 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 => !IsRecording ? "#555555" : "#e94560"; public string RecTextBrush => (IsLive || IsRecording) ? "#ffffff" : "#888888"; public double RecDotOpacity => IsRecording ? _recDotPulse : 0.55; public string OnAirBrush => !IsLive ? "#555555" : IsLivePrivate ? "#22c55e" : "#e94560"; // ─── REC / ON-AIR pills (TASK 18) ─── public bool RecordPillOn { get => _recordPillOn; set { if (!SetProperty(ref _recordPillOn, value)) return; OnPropertyChanged(nameof(ShowPrimaryStartButton)); OnPropertyChanged(nameof(CanStartSession)); } } public bool OnAirPillOn { get => _onAirPillOn; set { if (value && !CanToggleOnAir) return; // needs a sign-in if (!SetProperty(ref _onAirPillOn, value)) return; OnPropertyChanged(nameof(ShowPrimaryStartButton)); OnPropertyChanged(nameof(CanStartSession)); } } /// The ON-AIR pill (and thus live streaming) needs a YouTube sign-in. public bool CanToggleOnAir => IsConnected; /// Whether the session is actively recording — the REC status light /// turns green only when this is true (the pill is intent, this is reality). public bool IsRecording { get => _isRecording; private set { if (!SetProperty(ref _isRecording, value)) return; OnPropertyChanged(nameof(RecDotBrush)); OnPropertyChanged(nameof(RecTextBrush)); OnPropertyChanged(nameof(RecDotOpacity)); OnPropertyChanged(nameof(IsEditMode)); OnPropertyChanged(nameof(ShowEndStreamButton)); OnPropertyChanged(nameof(ShowPrimaryStartButton)); OnPropertyChanged(nameof(EndButtonText)); UpdateSessionTimer(); } } /// The primary button shows when there's something to start, but never /// while a session is running — once recording/live the ONLY control is the End /// button. public bool ShowPrimaryStartButton => IsOffline && !IsRecording && !IsLive && (IsConnected || (RecordPillOn && !OnAirPillOn)); /// Either output selected makes the button actionable. public bool CanStartSession => RecordPillOn || OnAirPillOn; /// The End button shows while recording OR live. public bool ShowEndStreamButton => IsLive || IsRecording; /// The one button whose label changes: while recording it reads /// "Stop Recording", while live "End Stream". public string EndButtonText => IsRecording && !IsLive ? "Stop Recording" : "End Stream"; public bool ShowLogInButton => !IsConnected && IsOffline && !IsRecording; public bool ShowChatInactiveMessage => !IsLive; public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null && BackgroundImage == null; // Paid unlock flips this off (see ai.md "Monetization"). When disabled the // cadence timer is stopped and any active flash is hidden immediately. public bool BrandFlashEnabled { get => _brandFlashEnabled; set { if (!SetProperty(ref _brandFlashEnabled, value)) return; if (value) { if (IsLive) StartBrandFlashTimer(); } else { _brandFlashTimer.Stop(); _brandFlashOffTimer.Stop(); BrandFlashActive = false; } } } public bool BrandFlashActive { get => _brandFlashActive; private set => SetProperty(ref _brandFlashActive, value); } 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); } // ─── Broadcast metadata pull-out (the "Text" tab on the Live screen) ─── /// The broadcast id of the current/last session; null until Go Live /// succeeds. Gates the pull-out's Update button (nothing remote to patch). public string? CurrentBroadcastId => _currentBroadcastId; public Services.LiveBroadcastFormViewModel BroadcastForm { get; } // Window geometry pass-through (code-behind owns the WPF specifics). public (double Left, double Top, double Width, double Height, string State)? RestoreWindowState() => _layoutStore.LoadWindowState(); public void PersistWindowState(double left, double top, double width, double height, string state) => _layoutStore.SaveWindowState(left, top, width, height, state); // ─── 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; 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); } // Commands public ICommand AddSceneCommand { get; } public ICommand AddSourceCommand { get; } public ICommand AddImageCommand { get; } public ICommand RemoveSourceCommand { get; } public ICommand EditElementCommand { get; } public ICommand ToggleElementVisibilityCommand { get; } public ICommand StartStreamCommand { get; } public ICommand EndStreamCommand { get; } public ICommand ChooseRecordFolderCommand { get; } public ICommand ResetRecordFolderCommand { 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 ActivateLicenseCommand { get; } public ICommand OpenLicenseEntryCommand { get; } public ICommand OpenHotkeyConfigCommand { get; } public ICommand ChangeAccountCommand { get; } public ICommand LogoutCommand { get; } public MainViewModel() { AppLog.Write("MainViewModel ctor begin"); _youtubeAuth = new YouTubeAuthService(OAuthCredentials.ClientId, OAuthCredentials.ClientSecret, sessionChanged: ch => TokenStore.Save(ch)); _youtubeStream = new YouTubeStreamService(_youtubeAuth); _youtubeChat = new YouTubeChatService(_youtubeAuth); AppLog.Write("MainViewModel ctor: services created"); _selectedQuality = QualityOptions[0]; ApplyStreamQuality(_selectedQuality); _youtubeChat.MessageReceived += OnChatMessageReceived; _liveTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; _liveTimer.Tick += OnLiveTimerTick; _volumeFlashTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) }; _volumeFlashTimer.Tick += (_, _) => EndVolumeFlash(); _gameVolumeFlashTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) }; _gameVolumeFlashTimer.Tick += (_, _) => EndGameVolumeFlash(); _saveDebounce = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1500) }; _saveDebounce.Tick += (_, _) => SaveLayoutNow(); _brandFlashTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(300) }; _brandFlashTimer.Tick += OnBrandFlashTimerTick; _brandFlashOffTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(750) }; _brandFlashOffTimer.Tick += (_, _) => { _brandFlashOffTimer.Stop(); BrandFlashActive = false; }; _healthPollTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(30) }; _healthPollTimer.Tick += OnHealthPollTick; Scenes.CollectionChanged += OnScenesChanged; AddSceneCommand = new RelayCommand(name => AddScene(name as string ?? string.Empty)); AddSourceCommand = new RelayCommand(parameter => AddSource(parameter)); AddWebcamCommand = new RelayCommand(_ => _ = AddWebcamToStagedSceneAsync()); AddImageCommand = new RelayCommand(_ => AddImage()); RemoveSourceCommand = new RelayCommand(element => RemoveElement(element as SceneElement)); EditElementCommand = new RelayCommand(element => BeginEditElement(element as SceneElement)); ToggleElementVisibilityCommand = new RelayCommand(element => ToggleElementVisibility(element as SceneElement)); 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(_ => StartSession()); EndStreamCommand = new RelayCommand(_ => StopStream(), _ => IsLive || IsRecording); ChooseRecordFolderCommand = new RelayCommand(_ => ChooseRecordFolder()); ResetRecordFolderCommand = new RelayCommand(_ => ResetRecordFolder()); ActivateLicenseCommand = new RelayCommand(_ => _ = ActivateLicenseAsync(), _ => !IsLicenseValidating); OpenLicenseEntryCommand = new RelayCommand(_ => IsLicenseEntryOpen = !IsLicenseEntryOpen); OpenHotkeyConfigCommand = new RelayCommand(_ => OpenHotkeyConfig()); ChangeAccountCommand = new RelayCommand(_ => _ = ChangeAccountAsync()); LogoutCommand = new RelayCommand(_ => _ = SignOutYouTubeAsync()); _layoutStore = new LayoutStore(LayoutPathOverride ?? DefaultLayoutPath); _activeLayoutPath = _layoutStore.ActivePath; _recordFolder = _layoutStore.LoadRecordFolder(); // Load saved hotkey bindings (filled with defaults for any unbound actions). _hotkeyBindings = _layoutStore.LoadHotkeyBindings(); BroadcastForm = new Services.LiveBroadcastFormViewModel( () => _layoutStore, () => _youtubeStream, () => CurrentBroadcastId, () => IsLive); // 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"); } 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(); RefreshHotkeyLabels(); 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(); RegisterLoadedWebSources(); _socials = _layoutStore.Socials; RenderSocialBarFrame(); HealFediverseSoftwareInBackground(); OnPropertyChanged(nameof(HasSocials)); OnPropertyChanged(nameof(SocialBarVisible)); OnPropertyChanged(nameof(SocialBarDotBrush)); _music = _layoutStore.Music; LoadTrax(play: false); var (transType, transDur) = _layoutStore.LoadTransitionSettings(); if (Enum.TryParse(transType, true, out var parsed)) SelectedTransitionType = parsed; TransitionDurationMs = transDur; BroadcastForm.Load(); ScheduleSave(); AppLog.Write("LoadLayout end"); } private void RegisterLoadedWebSources() { if (_webView2Manager == null) return; foreach (var source in Scenes.SelectMany(s => s.Elements).OfType() .Where(s => s.Type == SourceType.WebSource)) { _webView2Manager.Register(source); source.PropertyChanged += (_, e) => { if (e.PropertyName == nameof(Source.WebUri)) _webView2Manager.OnWebUriChanged(source); }; } } public void InitWebView2(Panel hostPanel) { _webView2Manager = new WebView2Manager(hostPanel, System.Windows.Application.Current.Dispatcher); _webView2Manager.PreviewBitmapChanged += OnWebView2PreviewBitmapChanged; RegisterLoadedWebSources(); } private void OnWebView2PreviewBitmapChanged(string sourceId, WriteableBitmap bitmap) { foreach (var scene in Scenes) foreach (var el in scene.Elements.OfType().Where(s => s.Id == sourceId)) el.VideoImageSource = bitmap; } public void Shutdown() { _saveDebounce?.Stop(); SaveLayoutNow(); _audioMixer.Dispose(); _framePump.Dispose(); _cameraManager.Dispose(); _screenCaptureManager.Dispose(); _webView2Manager?.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 ScheduleSave() { if (_isLoading || _saveDebounce == null) return; _saveDebounce.Stop(); _saveDebounce.Start(); } private void BeginEditElement(SceneElement? element) { if (element == null) return; element.IsEditing = true; } private void ToggleElementVisibility(SceneElement? element) { if (element == null) return; element.IsVisible = !element.IsVisible; if (element is Source { IsBackground: true }) OnPropertyChanged(nameof(BackgroundVisible)); } 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 Resource", 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; // Web resources use "Web Resource-N" naming (0-indexed per scene) var webCount = scene.Elements.OfType().Count(s => s.Type == SourceType.WebSource); var source = new Source { Name = sourceType == SourceType.WebSource ? $"{baseName}-{webCount}" : NextSourceName(scene, baseName), Type = sourceType, X = sourceType == SourceType.ChatBox ? 1400 : 0, Y = sourceType == SourceType.ChatBox ? 20 : 0, Width = sourceType == SourceType.WebSource ? 640 : sourceType == SourceType.ChatBox ? 500 : 0, Height = sourceType == SourceType.WebSource ? 480 : sourceType == SourceType.ChatBox ? 648 : 0, }; scene.Elements.Add(source); if (sourceType == SourceType.WebSource) { _webView2Manager?.Register(source); source.PropertyChanged += (_, e) => { if (e.PropertyName == nameof(Source.WebUri)) _webView2Manager?.OnWebUriChanged(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}"; } 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); } if (element is Source { Type: SourceType.WebSource } webSource) _webView2Manager?.Unregister(webSource.Id); 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 }); } 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(); } /// Entry point for the primary Start button (and the start/stop hotkey): /// routes to a record session or the YouTube go-live dialog by the pill state. private void StartSession() { if (IsLive || IsRecording) return; var streaming = OnAirPillOn; var recording = RecordPillOn; if (!streaming && !recording) return; if (!streaming) { BeginRecordOnly(); return; } BeginGoLive(recording); } /// Record-only (REC pill on, ON-AIR off): no YouTube dialog — recording /// is local and works signed-out. Generates the auto-named file and starts the /// pipeline immediately. private async void BeginRecordOnly() { var path = StartRecordFile(); if (path == null) { _notifications.Error("Couldn't record", "No record output path could be created."); return; } _recordStartTime = DateTime.Now; _liveElapsed = TimeSpan.Zero; _recordLength = TimeSpan.Zero; ResetHealth(StreamStatus.Offline); _audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName); IsRecording = true; await _framePump.StartAsync(); } private void BeginGoLive(bool alsoRecord) { var dialog = new GoLiveViewModel(() => SignInAsync(), _youtubeAuth.CurrentChannel) { StreamTitle = BroadcastForm.Title, StreamDescription = BroadcastForm.Description, }; 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) BroadcastForm.CaptureGoLive(dialog.StreamTitle, dialog.StreamDescription, DateTime.UtcNow); WindowTitle = string.IsNullOrWhiteSpace(dialog.StreamTitle) ? "LlamaCasty" : $"{dialog.StreamTitle} — LlamaCasty"; StreamStatus = StreamStatus.Streaming; ResetHealth(StreamStatus.Streaming); _audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName); _liveElapsed = TimeSpan.Zero; _recordLength = TimeSpan.Zero; var recordStarted = false; if (alsoRecord) { if (StartRecordFile() == null) { _notifications.Error("Couldn't record", "No record output path could be created — streaming continues without recording."); } else { _recordStartTime = DateTime.Now; recordStarted = true; } } IsRecording = recordStarted; _ = PrepareAndStartLiveAsync(); } } /// Creates the record file (auto-named ty-…-0000.mp4 in the record /// folder) and returns its path, or null when the folder can't be prepared. private string? StartRecordFile() { try { var folder = _recordFolder ?? DefaultRecordFolder(); Directory.CreateDirectory(folder); var name = RecordingFile.BuildStartName(DateTime.Now) + ".mp4"; var path = Path.Combine(folder, name); _activeRecordPath = path; return path; } catch (Exception ex) { AppLog.Write(ex, "Recording: failed to prepare output path"); return null; } } /// 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}"); OnPropertyChanged(nameof(CurrentBroadcastId)); if (string.IsNullOrWhiteSpace(StreamTitle) && string.IsNullOrWhiteSpace(StreamDescription)) { BroadcastForm.IsDrawerOpen = true; _notifications.Warning("Complete your broadcast details", "Your title and description are empty — fill them in from the Text panel on the right."); } // 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 async void StopStream() { var wasLive = IsLive; var wasRecording = IsRecording; StreamStatus = StreamStatus.Offline; IsRecording = false; 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 output. Audio capture itself is always-on (preview // monitoring); only the live/report pipe, the pump, and the session stop here. _audioMixer.StopLive(); await _framePump.StopAsync(); _currentBroadcastId = null; OnPropertyChanged(nameof(CurrentBroadcastId)); if (wasRecording) await FinalizeRecordingAsync(); // Explicit sign-out only (TASK 18): stopping the stream does NOT clear // the session/token — the creator signs out deliberately via the // Logout / Change Account commands. AppLog.Write(wasLive ? "Stream ended" : "Recording ended"); } /// On stop the auto-named ty-…-0000.mp4 file is renamed to carry /// the real recording length (ty-…-hh2mm2.mp4), with a numeric suffix on /// name collisions. Runs only after the pump has fully closed the file. private async Task FinalizeRecordingAsync() { var startPath = _activeRecordPath; _activeRecordPath = null; if (string.IsNullOrEmpty(startPath)) return; try { while (_framePump.IsRunning) await Task.Delay(50); if (!File.Exists(startPath)) { AppLog.Write($"Recording: output not found for rename: {startPath}"); return; } _recordLength = _liveElapsed; var autoStem = RecordingFile.BuildFinalName(_recordStartTime, _recordLength); var dir = Path.GetDirectoryName(startPath)!; // Manual rename modal: give the creator a chance to name the recording; // null = keep the auto name (or the default recorded above). string stem = autoStem; var dialog = new RenameRecordingDialog(autoStem); if (dialog.ShowDialog() == true && !string.IsNullOrEmpty(dialog.ResultFileName)) stem = dialog.ResultFileName; var finalPath = UniquePath(Path.Combine(dir, stem + ".mp4")); File.Move(startPath, finalPath); AppLog.Write($"Recording saved: {finalPath}"); _notifications.Info("Recording saved", Path.GetFileName(finalPath) + " — you can rename it in your videos folder at any time."); } catch (Exception ex) { AppLog.Write(ex, "Recording: rename-on-stop failed"); _notifications.Error("Recording saved", $"The recording was written but couldn't be renamed: {Path.GetFileName(startPath)}"); } } private static string UniquePath(string path) { if (!File.Exists(path)) return path; var dir = Path.GetDirectoryName(path)!; var name = Path.GetFileNameWithoutExtension(path); var ext = Path.GetExtension(path); for (var i = 2; ; i++) { var candidate = Path.Combine(dir, $"{name}-{i}{ext}"); if (!File.Exists(candidate)) return candidate; } } private void ChooseRecordFolder() { var dialog = new Microsoft.Win32.OpenFolderDialog { Title = "Choose a folder for local recordings", InitialDirectory = _recordFolder ?? DefaultRecordFolder(), }; if (dialog.ShowDialog() == true) { _recordFolder = dialog.FolderName; _layoutStore.SaveRecordFolder(_recordFolder); OnPropertyChanged(nameof(RecordFolderDisplay)); _notifications.Info("Record folder set", _recordFolder); } } private void ResetRecordFolder() { _recordFolder = null; _layoutStore.SaveRecordFolder(null); OnPropertyChanged(nameof(RecordFolderDisplay)); _notifications.Info("Record folder reset", RecordFolderDisplay); } /// Falls back to the user's Downloads folder when no preference is set; /// returns MyVideos if Downloads doesn't exist. internal static string DefaultRecordFolder() { var downloads = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"); return Directory.Exists(downloads) ? downloads : Environment.GetFolderPath(Environment.SpecialFolder.MyVideos); } // 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 { Type: SourceType.WebSource } web => _webView2Manager?.GetLatestFrame(web.Id), 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(); var quality = SelectedQuality; return new EncoderOptions { RtmpUrl = url ?? string.Empty, StreamEnabled = OnAirPillOn, RecordEnabled = RecordPillOn, RecordPath = _activeRecordPath, 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) { if (BrandFlashEnabled) StartBrandFlashTimer(); } else { _healthPollTimer.Stop(); _brandFlashTimer.Stop(); _brandFlashOffTimer.Stop(); BrandFlashActive = false; } UpdateSessionTimer(); } /// Runs the elapsed/rec-dot-pulse timer while a session is active — /// live (Streaming) OR recording. Reset the elapsed when a new session starts. private void UpdateSessionTimer() { var active = IsLive || IsRecording; if (active) { _recDotPulse = 1.0; _liveTimer.Start(); } else { _liveTimer.Stop(); LiveElapsedText = "00:00:00"; _recDotPulse = 1.0; } OnPropertyChanged(nameof(RecDotOpacity)); OnPropertyChanged(nameof(LiveElapsedText)); } // 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"); _recordLength = _liveElapsed; _recDotPulse = _recDotPulse > 0.5 ? 0.35 : 1.0; OnPropertyChanged(nameof(RecDotOpacity)); } }