using System; using System.Collections.ObjectModel; using System.IO; using System.Linq; using System.Windows; using Microsoft.Win32; using ytLive.Helpers; using ytLive.Models; using ytLive.Services; using ytLive.Services.Encoder; namespace ytLive.ViewModels; public partial class MainViewModel : ViewModelBase { private readonly ChatOverlayLayer _chatLayer; private readonly AlertOverlayLayer _alertLayer; /// The live chat message buffer — the same collection the /// owns; here so XAML can bind it and the /// left panel can subscribe to its changes. public ObservableCollection ChatMessages => _chatLayer.Messages; /// 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)); /// The Add → Stream Alerts menu item: one alert zone per layout /// (multiple zones would double-play every event). public bool CanAddAlerts => StagedScene != null && !Scenes.Any(s => s.Elements.OfType().Any(e => e.Type == SourceType.AlertBox)); public bool ShowChatInactiveMessage => !IsLive; private void OnChatMessageReceived(ChatMessage message) { _chatLayer.OnMessageReceived(message, Scenes); _alertLayer.OnMessageReceived(message, Scenes); } private void UpdateChatBoxPreview(Source chatBox, IReadOnlyList? messages = null) => _chatLayer.UpdatePreview(chatBox, messages); private VideoFrame? RenderChatBox(Source chatBox) => _chatLayer.RenderFrame(chatBox); private void UpdateAlertBoxPreview(Source alertBox) => _alertLayer.UpdatePreview(alertBox); private VideoFrame? RenderAlertBox(Source alertBox) => _alertLayer.RenderFrame(alertBox); private void StartMockChatPreview() => _chatLayer.StartMockPreview(Scenes); private void StopMockChatPreview() => _chatLayer.StopMockPreview(); private void UpdateActiveChatBoxPreviews() => _chatLayer.UpdateConfiguredBoxes(Scenes); private void UpdateActiveAlertBoxPreviews() => _alertLayer.UpdateConfiguredBoxes(Scenes); // ─── TASK 47: the alert clip (video) ─── private string? _alertDefaultVideoId; private string? _defaultAlertClipScratch; /// Runtime clip resolution for one alert box: the Creator's OWN video /// is a plain path on disk (never stored — the 2026-09-26 decision); the shipped /// llama placeholder lives as a BLOB in the internal Asset table and is /// materialized to a temp file once. Null → the six celebratory animations. /// Called from the output pump's alert frame path. private string? ResolveAlertClipPath(Source box) { if (!box.AlertUseDefaultVideo && !string.IsNullOrWhiteSpace(box.AlertVideoPath) && File.Exists(box.AlertVideoPath)) return box.AlertVideoPath; return MaterializeDefaultAlertClip(); } private string? MaterializeDefaultAlertClip() { if (_defaultAlertClipScratch != null && File.Exists(_defaultAlertClipScratch)) return _defaultAlertClipScratch; _alertDefaultVideoId ??= _layoutStore.GetAlertDefaultVideoId(); if (_alertDefaultVideoId == null) return null; var bytes = _layoutStore.GetAssetBytes(_alertDefaultVideoId); if (bytes == null) return null; var scratch = Path.Combine(Path.GetTempPath(), $"ytLive-alert-{_alertDefaultVideoId}.mp4"); try { File.WriteAllBytes(scratch, bytes); _defaultAlertClipScratch = scratch; return scratch; } catch (Exception ex) { AppLog.Write(ex, "Alert default materialization failed"); return null; } } /// One decoder per play (the refcounted MediaVideoSourceManager sessions /// can't restart per replay). The clip is full-box in video + 48k stereo audio; /// both steams pace themselves to real time inside the decoder — the frame-rate /// probe is what makes the VIDEO pace (without it every frame dumps in ~1-2s and /// the box freezes on the last frame for the rest of the audio). private IAlertClipDecoder AlertClipDecoderFor(string path, int width, int height) => new AlertClipDecoder( path, width, height, new FfmpegLocator(), () => new FfmpegDecodeProcess(), frameRateProbe: new FfmpegFrameRateProbe(new FfmpegLocator(), () => new FfmpegDecodeProcess())); /// Stamp the shipped llama placeholder into the Asset table at startup /// (idempotent — hash-keyed UpsertAsset dedupes). Best-effort: if it fails the /// alert box simply falls back to the six animations. private void StampDefaultAlertVideo() { try { _alertDefaultVideoId = _layoutStore.GetAlertDefaultVideoId(); if (!string.IsNullOrWhiteSpace(_alertDefaultVideoId)) return; var bytes = ReadAlertDefaultVideoResource(); if (bytes is null or { Length: 0 }) return; _alertDefaultVideoId = _layoutStore.EnsureAlertDefaultVideo(bytes); } catch (Exception ex) { AppLog.Write(ex, "Alert default video stamp failed"); } } private static byte[]? ReadAlertDefaultVideoResource() { try { var uri = new Uri("pack://application:,,,/Assets/alert-default.mp4", UriKind.Absolute); using var stream = Application.GetResourceStream(uri)?.Stream; if (stream == null) return null; using var ms = new MemoryStream(); stream.CopyTo(ms); return ms.ToArray(); } catch (Exception ex) { AppLog.Write(ex, "Alert default video resource missing"); return null; } } private void BrowseAlertVideo(Source? box) { if (box == null || box.Type != SourceType.AlertBox) return; var dialog = new OpenFileDialog { Title = "Choose the alert clip (mp4)", Filter = "Video files (*.mp4;*.mov;*.webm;*.mkv)|*.mp4;*.mov;*.webm;*.mkv|All files (*.*)|*.*" }; if (dialog.ShowDialog() != true) return; box.AlertVideoPath = dialog.FileName; box.AlertUseDefaultVideo = false; } private void ResetAlertVideo(Source? box) { if (box == null || box.Type != SourceType.AlertBox) return; box.AlertVideoPath = null; box.AlertShowTicker = true; box.AlertUseDefaultVideo = true; } }