using System; using System.Collections.Generic; using System.ComponentModel; using System.Linq; using System.Windows.Media.Imaging; using Microsoft.Win32; using ytLive.Models; using ytLive.Services; using ytLive.Services.Audio; namespace ytLive.ViewModels; // Media source (TASK 21): decode sessions are owned app-wide by // MediaVideoSourceManager, refcounted by MediaPath. Modeled on the Webcam/Background // partials: the readonly manager field is assigned in the core constructor, the // shared WriteableBitmap published per file is adopted by every media Source whose // MediaPath matches, and ResolveOutputFrame feeds the live compositor. // // TASK 64 changed what a "session" is: it is now keyed by file path + the layer's LOOK // (green/blue screen settings), because the key is applied inside the decode pipe. Two // layers with the same look share one decoder; two different looks get two. public partial class MainViewModel { // Decode resolution: the 1920x1080 master. Media frames are scaled to the // element rect by the compositor (UniformToFill), so this is the capture size. private const int MediaDecodeWidth = 1920; private const int MediaDecodeHeight = 1080; private const string VideoFileFilter = "Video files (*.mp4;*.mov;*.avi;*.mkv;*.webm)|*.mp4;*.mov;*.avi;*.mkv;*.webm|All files (*.*)|*.*"; private readonly MediaVideoSourceManager _mediaManager; // Reads one frame of a clip so the layer can offer "looks like a green screen" (TASK 64). private readonly IChromaProbe? _chromaProbe; // One probe per file per launch — the answer belongs to the file, not to the layer. private readonly HashSet _chromaProbedPaths = new(StringComparer.OrdinalIgnoreCase); private bool _autoChromaScreen; /// "Auto Green/Blue Screen": apply a detected screen without asking. Off by default — /// the app offers, and only a creator who turns this on gets it done for them. Fires only on /// high-confidence detections, because a wrong automatic key eats the picture. public bool AutoChromaScreen { get => _autoChromaScreen; set { if (!SetProperty(ref _autoChromaScreen, value)) return; _layoutStore.SaveAutoChromaScreen(value); } } // One subscription per media Source, so removing a layer stops the listener as // well as the decoder (a scene element outlives its layer list row). private readonly Dictionary _mediaHandlers = new(); /// What a decode session is: the file plus the look applied in the pipe (TASK 64). /// A path cannot contain '|', so the key is unambiguous and human-readable in logs. internal static string MediaSessionKey(string path, ChromaKeyOptions key) => $"{path}|{key.Signature}"; internal static string MediaSessionKey(Source source) => source.MediaPath is { } path ? MediaSessionKey(path, source.ChromaKey) : string.Empty; // MediaVideoSourceManager publishes one shared WriteableBitmap per session; every // media Source pointing at that path+look picks it up for the preview/canvas. private void OnMediaPreviewBitmapChanged(string sessionKey, WriteableBitmap bitmap) { foreach (var source in Scenes.SelectMany(s => s.Elements).OfType()) if (source.IsMediaSource && MediaSessionKey(source) == sessionKey) source.VideoImageSource = bitmap; } private void OnMediaFailed(string path, string message) { Helpers.AppLog.Write($"MediaSource: '{path}' failed: {message}"); _notifications.Warning("Couldn't play that video", System.IO.Path.GetFileName(SessionPath(path))); } // ---------- add / change the file ---------- private void AddMedia() { if (PickVideoFile() is { } path) AddMediaSource(path); } private string? PickVideoFile(string title = "Choose a video file") { var dialog = new OpenFileDialog { Title = title, Filter = VideoFileFilter, }; return dialog.ShowDialog() == true ? dialog.FileName : null; } /// Adds a MediaSource layer for to the staged scene. /// Internal so the Good Dog test drives the real add path without a file dialog. internal void AddMediaSource(string path) { if (string.IsNullOrWhiteSpace(path)) return; AddSource(SourceType.MediaSource, path); } private void ChangeMediaFile(Source? source) { if (source is not { IsMediaSource: true }) return; if (PickVideoFile("Replace the video") is not { } path) return; PushPreviewUndo(); source.ChromaSuggestion = default; source.MediaPath = path; } // ---------- green / blue screen detection (TASK 64) ---------- /// Looks at one frame of the clip and stores the offer on the layer. Runs off the /// UI thread through the probe seam; a file that cannot be read simply gets no offer. /// Works for a video layer and for the alert box's own clip — a clip is a clip. private async System.Threading.Tasks.Task SuggestChromaKeyAsync(Source source) { if (source.ChromaKeyEnabled) return; if (ClipPathFor(source) is not { } path) return; if (_chromaProbe == null) return; var frame = await _chromaProbe.ProbeAsync(path).ConfigureAwait(true); if (frame == null) return; if (!string.Equals(ClipPathFor(source), path, StringComparison.OrdinalIgnoreCase)) return; var suggestion = ChromaKeyDetector.Detect(frame); source.ChromaSuggestion = suggestion; if (AutoChromaScreen && suggestion.IsHighConfidence) { AcceptChromaSuggestion(source); source.ChromaKeyAutoApplied = true; } } /// The file a layer plays: a video layer's clip, or the alert box's replacement clip. /// The shipped alert default is not probed — it is ours, and it is not a screen shoot. private static string? ClipPathFor(Source source) => source.IsMediaSource ? source.MediaPath : source.Type == SourceType.AlertBox && !source.AlertUseDefaultVideo ? source.AlertVideoPath : null; /// Accepts the detector's offer: the layer keys the colour the camera actually saw. private void AcceptChromaSuggestion(Source? source) { if (source is not { IsMediaSource: true } || source.ChromaSuggestion.Screen == ChromaScreen.None) return; PushPreviewUndo(); var key = ChromaKeyOptions.For(source.ChromaSuggestion); source.ChromaKeyColor = key.Color; source.ChromaKeySimilarity = key.Similarity; source.ChromaKeySoftness = key.Softness; source.ChromaKeySpill = key.Spill; source.ChromaScreen = key.Screen; source.ChromaKeyAutoApplied = false; source.ChromaKeyEnabled = true; } // ---------- session lifecycle (one session per path + look) ---------- /// Starts decoding the file if nothing else already is, and subscribes to the /// layer so a file swap, a loop flip or a look change reaches the right session. private void RegisterMediaSource(Source source) { if (_mediaHandlers.ContainsKey(source.Id)) return; var lastKey = MediaSessionKey(source); void OnSourceChanged(object? _, PropertyChangedEventArgs e) { if (e.PropertyName is nameof(Source.MediaPath) or nameof(Source.ChromaKeyEnabled) or nameof(Source.ChromaKeyColor) or nameof(Source.ChromaKeySimilarity) or nameof(Source.ChromaKeySoftness) or nameof(Source.ChromaKeySpill) or nameof(Source.ChromaScreen)) { var next = MediaSessionKey(source); if (string.Equals(next, lastKey, StringComparison.OrdinalIgnoreCase)) return; var previous = lastKey; lastKey = next; // A look change is a different decode: let the old session go (if nothing // else uses it) and start the new one. ReleaseMediaIfUnused(previous); EnsureMediaSession(next); return; } if (e.PropertyName == nameof(Source.MediaIsLooping) && MediaSessionKey(source) is { Length: > 0 } key) { var looping = AnyMediaReferenceLoops(key); _mediaManager.SetLooping(key, looping); // Loop is only consulted at end-of-file by a LIVE decoder, so flipping it on a clip // that already finished used to do nothing at all (reported 2026-11-05). Start it. if (looping && !_mediaManager.IsRunning(key)) _ = RestartMediaAsync(source, key); } else if (e.PropertyName is nameof(Source.MediaVolume) or nameof(Source.MediaMuted) && MediaSessionKey(source) is { Length: > 0 } audioKey) { ApplyMediaAudioSettings(audioKey); } } _mediaHandlers[source.Id] = OnSourceChanged; source.PropertyChanged += OnSourceChanged; if (source.MediaPath is { Length: > 0 } path) { EnsureMediaSession(MediaSessionKey(path, source.ChromaKey)); // One probe per file per launch: the answer depends on the file, not the layer. if (source.ChromaSuggestion.Screen == ChromaScreen.None && !_chromaProbedPaths.Contains(path)) { _chromaProbedPaths.Add(path); _ = SuggestChromaKeyAsync(source); } } } private void UnregisterMediaSource(Source source) { if (_mediaHandlers.Remove(source.Id, out var handler)) source.PropertyChanged -= handler; } private void EnsureMediaSession(string? sessionKey) { if (string.IsNullOrWhiteSpace(sessionKey)) return; if (_mediaManager.HasSession(sessionKey)) { ApplyMediaAudioSettings(sessionKey); return; } _ = AcquireMediaSessionAsync(sessionKey); } private async System.Threading.Tasks.Task AcquireMediaSessionAsync(string sessionKey) { var source = Scenes.SelectMany(s => s.Elements).OfType() .FirstOrDefault(s => s.IsMediaSource && MediaSessionKey(s) == sessionKey); if (source?.MediaPath is not { } path) return; if (!await _mediaManager.AcquireAsync(sessionKey, path, source.ChromaKey)) return; source.IsMediaRunning = true; source.MediaPlaybackState = MediaPlaybackState.Playing; // A session can be running before this layer existed (shared look), in which // case the bitmap is already published and will not be raised again for us. if (_mediaManager.GetPreviewBitmap(sessionKey) is { } bitmap) OnMediaPreviewBitmapChanged(sessionKey, bitmap); ApplyMediaAudioSettings(sessionKey); } /// The session's sound controls: one decoder is shared per path+look, so the level /// and mute are per session — the layer that was last touched is the one that wins. private void ApplyMediaAudioSettings(string sessionKey) { var source = Scenes.SelectMany(s => s.Elements).OfType() .FirstOrDefault(s => s.IsMediaSource && MediaSessionKey(s) == sessionKey); if (source == null) return; _mediaManager.SetAudioSettings(sessionKey, source.MediaVolume, source.MediaMuted); } /// A clip's own soundtrack joins the desktop/game group, so the desktop fader and /// the voice ducker treat it exactly like game audio (TASK 21 audio slice). /// A clip played out: say so on the layer, so the panel stops pretending it is playing. private void OnMediaSessionCompleted(string sessionKey) { foreach (var source in Scenes.SelectMany(s => s.Elements).OfType()) if (source.IsMediaSource && MediaSessionKey(source) == sessionKey) { source.IsMediaRunning = false; source.MediaPlaybackState = MediaPlaybackState.Stopped; } } /// Starts a finished clip again from the top (the panel's "Play again"). private void ReplayMedia(Source? source) { if (source is not { IsMediaSource: true }) return; var key = MediaSessionKey(source); if (key.Length == 0) return; _ = RestartMediaAsync(source, key); } private async System.Threading.Tasks.Task RestartMediaAsync(Source source, string sessionKey) { if (!_mediaManager.HasSession(sessionKey)) { EnsureMediaSession(sessionKey); return; } if (await _mediaManager.RestartAsync(sessionKey)) { source.IsMediaRunning = true; source.MediaPlaybackState = MediaPlaybackState.Playing; } } private void OnMediaAudioAvailable(string sessionKey, AudioSample sample) => _audioMixer.EnqueueDesktopMediaAudio(sample); /// Shared sessions or not, the loop state is the session's: any live reference /// asking for a loop makes that one decoder loop (recorded rule, TASK 21). private bool AnyMediaReferenceLoops(string sessionKey) => Scenes.SelectMany(s => s.Elements).OfType() .Any(s => s.IsMediaSource && MediaSessionKey(s) == sessionKey && s.MediaIsLooping); private bool HasMediaReference(string sessionKey) => Scenes.SelectMany(s => s.Elements).OfType() .Any(s => s.IsMediaSource && string.Equals(MediaSessionKey(s), sessionKey, StringComparison.OrdinalIgnoreCase)); private void ReleaseMediaIfUnused(string? sessionKey) { if (string.IsNullOrWhiteSpace(sessionKey)) return; if (!_mediaManager.HasSession(sessionKey)) return; if (HasMediaReference(sessionKey)) return; _ = _mediaManager.ReleaseAsync(sessionKey); } /// After a layout load: subscribe every persisted media layer and start one /// session per distinct path+look; release sessions the layout no longer uses. private void ReacquireMediaSources() { var sources = Scenes.SelectMany(s => s.Elements).OfType() .Where(s => s.IsMediaSource && !string.IsNullOrWhiteSpace(s.MediaPath)) .ToList(); foreach (var source in sources) RegisterMediaSource(source); var wanted = sources.Select(MediaSessionKey) .Where(key => key.Length > 0) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); foreach (var stale in _mediaManager.GetActiveKeys() .Where(key => !wanted.Contains(key, StringComparer.OrdinalIgnoreCase)) .ToList()) _ = _mediaManager.ReleaseAllAsync(stale); } /// A session key carries a file path and a look; the toast wants the file name. private static string SessionPath(string sessionKey) => sessionKey.Split('|')[0]; }