using System.Collections.ObjectModel; using System.Collections.Specialized; using System.ComponentModel; using System.Diagnostics; using System.IO; using System.Windows; using System.Windows.Input; using System.Windows.Media; using System.Windows.Threading; using Microsoft.Win32; using ytLive.Helpers; using ytLive.Models; using ytLive.Services; namespace ytLive.ViewModels; public class MainViewModel : ViewModelBase { private readonly YouTubeAuthService _youtubeAuth; private readonly YouTubeStreamService _youtubeStream; private readonly YouTubeChatService _youtubeChat; private readonly DispatcherTimer _liveTimer; private Scene? _activeScene; private Source? _selectedSource; private ImageSource? _activeBackgroundImage; private bool _isConnected; private StreamStatus _streamStatus = StreamStatus.Offline; private StreamHealth _currentHealth = new(); private string _streamTitle = string.Empty; private string _streamDescription = string.Empty; private string _streamVisibility = "Public"; private string _windowTitle = "ytLlive"; private string _topBarBackground = "#16213e"; private string _previewGlowBrush = "Transparent"; private Thickness _previewGlowThickness = new(0); private string _liveElapsedText = "00:00:00"; private double _livePulseOpacity = 1.0; private TimeSpan _liveElapsed; private bool _isSettingsOpen; private bool _isBugOpen; private bool _isFeatureOpen; private bool _isAboutOpen; private string _overlayTitle = string.Empty; private string _defaultStreamTitle = string.Empty; private string _defaultStreamDescription = string.Empty; private string _defaultStreamVisibility = "Public"; 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; // Branding flash (monetization): a full-frame "made with ytLlive!" 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"; private const string ChannelUrl = "https://youtube.com/@llamachileshop"; public static string AppVersionLabel => $"Version {typeof(MainViewModel).Assembly.GetName().Version?.ToString(3)}"; public ObservableCollection Scenes { get; } = new(); public ObservableCollection ChatMessages { get; } = new(); public string[] Visibilities { get; } = { "Public", "Unlisted", "Private" }; public Scene? ActiveScene { get => _activeScene; set { if (value != null && value.IsHidden) return; if (SetProperty(ref _activeScene, value)) { SelectedSource = null; OnPropertyChanged(nameof(ShowChatInactiveMessage)); OnPropertyChanged(nameof(ShowEmptySceneHint)); OnPropertyChanged(nameof(ShowPreviewPlaceholder)); OnPropertyChanged(nameof(ShowSourcesEmptyHint)); UpdateActiveBackground(); } } } public ImageSource? ActiveBackgroundImage { get => _activeBackgroundImage; private set => SetProperty(ref _activeBackgroundImage, value); } public Source? SelectedSource { get => _selectedSource; set => SetProperty(ref _selectedSource, value); } public StreamStatus StreamStatus { get => _streamStatus; set { if (SetProperty(ref _streamStatus, value)) { OnPropertyChanged(nameof(IsOffline)); OnPropertyChanged(nameof(IsLive)); OnPropertyChanged(nameof(LiveIndicatorVisible)); OnPropertyChanged(nameof(ShowStartStream)); OnPropertyChanged(nameof(ShowChatInactiveMessage)); OnPropertyChanged(nameof(ShowPreviewPlaceholder)); UpdateLiveVisuals(); } } } public bool IsConnected { get => _isConnected; set { if (SetProperty(ref _isConnected, value)) { OnPropertyChanged(nameof(IsNotConnected)); OnPropertyChanged(nameof(ShowStartStream)); } } } public bool IsOffline => StreamStatus == StreamStatus.Offline; public bool IsLive => StreamStatus == StreamStatus.Streaming; public bool LiveIndicatorVisible => IsLive; public bool IsNotConnected => !IsConnected; public bool ShowStartStream => IsConnected && IsOffline; public bool ShowChatInactiveMessage => !IsLive; public bool ShowEmptySceneHint => ActiveScene != null && ActiveScene.Sources.Count == 0; public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null; public bool ShowSourcesEmptyHint => ActiveScene == null || ActiveScene.Sources.Count == 0; // 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() { var background = ActiveScene?.Sources.FirstOrDefault(s => s.Type == SourceType.Background); ActiveBackgroundImage = background != null && !string.IsNullOrWhiteSpace(background.AssetId) ? ImageCache.Get(background.AssetId) : null; OnPropertyChanged(nameof(ShowPreviewPlaceholder)); } public StreamHealth CurrentHealth { get => _currentHealth; set => SetProperty(ref _currentHealth, 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 => SetProperty(ref _streamVisibility, value); } 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); } public double LivePulseOpacity { get => _livePulseOpacity; set => SetProperty(ref _livePulseOpacity, 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); } 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); } public string DefaultStreamVisibility { get => _defaultStreamVisibility; set => SetProperty(ref _defaultStreamVisibility, 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; 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 EditSceneCommand { get; } public ICommand RemoveSceneCommand { get; } public ICommand ToggleSceneVisibilityCommand { get; } public ICommand AddSourceCommand { get; } public ICommand AddImageCommand { get; } public ICommand RemoveSourceCommand { get; } public ICommand ConnectCommand { 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 SaveLayoutCommand { get; } public ICommand SaveLayoutAsCommand { get; } public ICommand OpenLayoutCommand { get; } public MainViewModel() { AppLog.Write("MainViewModel ctor begin"); _youtubeAuth = new YouTubeAuthService(OAuthCredentials.ClientId, OAuthCredentials.ClientSecret); _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; _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; }; Scenes.CollectionChanged += OnScenesChanged; AddSceneCommand = new RelayCommand(_ => AddScene()); EditSceneCommand = new RelayCommand(scene => BeginEditScene(scene as Scene)); RemoveSceneCommand = new RelayCommand(scene => RemoveScene(scene as Scene)); ToggleSceneVisibilityCommand = new RelayCommand(scene => ToggleSceneVisibility(scene as Scene)); AddSourceCommand = new RelayCommand(type => AddSource(type as string)); AddImageCommand = new RelayCommand(_ => AddImage()); RemoveSourceCommand = new RelayCommand(source => RemoveSource(source as Source)); 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)); ConnectCommand = new RelayCommand(_ => _ = ConnectAsync()); StartStreamCommand = new RelayCommand(_ => BeginGoLive()); EndStreamCommand = new RelayCommand(_ => StopStream(), _ => IsLive); SaveLayoutCommand = new RelayCommand(_ => SaveLayoutNow()); SaveLayoutAsCommand = new RelayCommand(_ => SaveLayoutAs()); OpenLayoutCommand = new RelayCommand(_ => OpenLayoutFile()); _layoutStore = new LayoutStore(DefaultLayoutPath); _activeLayoutPath = _layoutStore.ActivePath; LoadLayout(); AppLog.Write("MainViewModel ctor end"); } private static string DefaultLayoutPath => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ytLlive", "ytLlive.db"); 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) { AddScene("Starting"); AddScene("Live"); AddScene("BRB"); AddScene("Chat", isChatScene: true); AddScene("Ending"); } AppLog.Write($"LoadLayout: {Scenes.Count} scenes loaded"); } finally { _isLoading = false; } ActiveScene = Scenes.FirstOrDefault(); UpdateActiveBackground(); ScheduleSave(); AppLog.Write("LoadLayout end"); } public void Shutdown() { _saveDebounce?.Stop(); SaveLayoutNow(); _layoutStore.Dispose(); } public void SaveLayoutNow() { _saveDebounce?.Stop(); try { _layoutStore.Save(Scenes); } catch (Exception ex) { Debug.WriteLine($"Layout save failed: {ex.Message}"); } } private void SaveLayoutAs() { var dialog = new SaveFileDialog { Title = "Save Layout As", Filter = "ytLlive 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 = "ytLlive 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); ScheduleSave(); } private void WireScene(Scene scene) { scene.PropertyChanged += OnScenePropertyChanged; scene.Sources.CollectionChanged += OnSourcesChanged; } private void UnwireScene(Scene scene) { scene.PropertyChanged -= OnScenePropertyChanged; scene.Sources.CollectionChanged -= OnSourcesChanged; } private void OnScenePropertyChanged(object? sender, PropertyChangedEventArgs e) => ScheduleSave(); private void OnSourcesChanged(object? sender, NotifyCollectionChangedEventArgs e) { if (e.NewItems != null) foreach (Source source in e.NewItems) source.PropertyChanged += OnSourcePropertyChanged; if (e.OldItems != null) foreach (Source source in e.OldItems) source.PropertyChanged -= OnSourcePropertyChanged; ScheduleSave(); } private void OnSourcePropertyChanged(object? sender, PropertyChangedEventArgs e) => ScheduleSave(); private void ScheduleSave() { if (_isLoading || _saveDebounce == null) return; _saveDebounce.Stop(); _saveDebounce.Start(); } private void AddScene(string? name = null, bool isChatScene = false) { var scene = new Scene { Name = name ?? $"New Scene {Scenes.Count + 1}", IsChatScene = isChatScene }; Scenes.Add(scene); ActiveScene = scene; } private void BeginEditScene(Scene? scene) { if (scene == null) return; scene.IsEditing = true; } private void ToggleSceneVisibility(Scene? scene) { if (scene == null) return; scene.IsHidden = !scene.IsHidden; if (scene.IsHidden && ActiveScene == scene) ActiveScene = Scenes.FirstOrDefault(s => !s.IsHidden); } private void RemoveScene(Scene? scene) { if (scene == null) return; Scenes.Remove(scene); if (ActiveScene == scene) ActiveScene = Scenes.FirstOrDefault(); } private void AddSource(string? type) { var scene = ActiveScene; if (scene == null) return; var sourceType = type?.ToLowerInvariant() switch { "webcam" => SourceType.Webcam, "screen" => SourceType.DisplayCapture, "window" => SourceType.WindowCapture, "background" => SourceType.Background, "text" => SourceType.TextOverlay, _ => SourceType.Image, }; var baseName = sourceType switch { SourceType.Webcam => "Webcam", SourceType.DisplayCapture => "Screen", SourceType.WindowCapture => "Window", SourceType.Background => "Background", SourceType.Image => "Image", SourceType.TextOverlay => "Text", _ => "Source", }; if (sourceType == SourceType.Background) { var bytes = PickImageBytes("Choose a backdrop image"); if (bytes == null) return; var assetId = AddAsset(bytes); if (assetId == null) return; var existing = scene.Sources.FirstOrDefault(s => s.Type == SourceType.Background); if (existing != null) { existing.AssetId = assetId; UpdateActiveBackground(); return; } scene.Sources.Add(new Source { Name = baseName, Type = SourceType.Background, AssetId = assetId }); OnPropertyChanged(nameof(ShowEmptySceneHint)); OnPropertyChanged(nameof(ShowSourcesEmptyHint)); UpdateActiveBackground(); return; } var count = scene.Sources.Count(s => s.Type == sourceType); var name = count == 0 ? baseName : $"{baseName} {count + 1}"; scene.Sources.Add(new Source { Name = name, Type = sourceType }); OnPropertyChanged(nameof(ShowEmptySceneHint)); OnPropertyChanged(nameof(ShowSourcesEmptyHint)); UpdateActiveBackground(); } private void AddImage() { var scene = ActiveScene; 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.Sources.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 static 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) { MessageBox.Show($"Couldn't read that image: {ex.Message}", "ytLlive", MessageBoxButton.OK, MessageBoxImage.Warning); 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 = ActiveScene; if (scene == null || string.IsNullOrWhiteSpace(assetId)) return; var count = scene.Sources.Count(s => s.Type == SourceType.Image); var name = count == 0 ? "Image" : $"Image {count + 1}"; var source = new Source { Name = name, 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.Sources.Add(source); SelectedSource = source; OnPropertyChanged(nameof(ShowEmptySceneHint)); OnPropertyChanged(nameof(ShowSourcesEmptyHint)); UpdateActiveBackground(); } private void RemoveSource(Source? source) { var scene = ActiveScene; if (scene == null || source == null) return; scene.Sources.Remove(source); 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; } private void SubmitBug() { var body = string.Join(Environment.NewLine, BugReportText.Trim(), string.Empty, $"ytLlive {AppVersionLabel}", string.IsNullOrWhiteSpace(BugReportEmail) ? string.Empty : $"Reply-to: {BugReportEmail.Trim()}"); ComposeEmail("[ytLlive Bug Report]", body); ShowOverlay(); } private void SubmitFeature() { var body = string.Join(Environment.NewLine, FeatureRequestText.Trim(), string.Empty, $"ytLlive {AppVersionLabel}", string.IsNullOrWhiteSpace(FeatureRequestEmail) ? string.Empty : $"Reply-to: {FeatureRequestEmail.Trim()}"); ComposeEmail("[ytLlive 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 async Task ConnectAsync() { try { var channel = await Task.Run(() => _youtubeAuth.AuthenticateAsync()); if (channel == null) { MessageBox.Show("Sign-in was unsuccessful. Please try again.", "ytLlive", MessageBoxButton.OK, MessageBoxImage.Warning); return; } IsConnected = true; } catch (Exception ex) { MessageBox.Show($"Sign-in failed: {ex.Message}", "ytLlive", MessageBoxButton.OK, MessageBoxImage.Error); } } private void OnChatMessageReceived(ChatMessage message) { System.Windows.Application.Current.Dispatcher.Invoke(() => { ChatMessages.Add(message); if (ChatMessages.Count > 500) ChatMessages.RemoveAt(0); }); } private void BeginGoLive() { var dialog = new GoLiveViewModel { StreamTitle = DefaultStreamTitle, StreamDescription = DefaultStreamDescription, Visibility = DefaultStreamVisibility, }; 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; WindowTitle = string.IsNullOrWhiteSpace(dialog.StreamTitle) ? "ytLlive" : $"{dialog.StreamTitle} — ytLlive"; StreamStatus = StreamStatus.Streaming; } } private void StopStream() { StreamStatus = StreamStatus.Offline; WindowTitle = "ytLlive"; } 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"; LivePulseOpacity = 1.0; _liveTimer.Start(); if (BrandFlashEnabled) StartBrandFlashTimer(); } else { _liveTimer.Stop(); _brandFlashTimer.Stop(); _brandFlashOffTimer.Stop(); BrandFlashActive = false; LiveElapsedText = "00:00:00"; LivePulseOpacity = 1.0; } } // 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"); LivePulseOpacity = LivePulseOpacity > 0.5 ? 0.35 : 1.0; } }