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. 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; // 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(); // MediaVideoSourceManager publishes one shared WriteableBitmap per file; every // media Source pointing at that path picks it up for the preview/canvas. private void OnMediaPreviewBitmapChanged(string path, WriteableBitmap bitmap) { foreach (var source in Scenes.SelectMany(s => s.Elements.OfType()) .Where(s => s.IsMediaSource && s.MediaPath == path)) 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(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.MediaPath = path; } // ---------- session lifecycle (one session per distinct path) ---------- /// Starts decoding the file if nothing else already is, and subscribes to the /// layer so a file swap or a loop flip reaches the session. private void RegisterMediaSource(Source source) { if (_mediaHandlers.ContainsKey(source.Id)) return; string? lastPath = source.MediaPath; void OnSourceChanged(object? _, PropertyChangedEventArgs e) { if (e.PropertyName == nameof(Source.MediaPath)) { var previous = lastPath; lastPath = source.MediaPath; if (string.Equals(previous, source.MediaPath, StringComparison.OrdinalIgnoreCase)) return; // The layer now points elsewhere, so the old file may be unreferenced — // release it before acquiring the replacement. ReleaseMediaIfUnused(previous); EnsureMediaSession(source.MediaPath); } else if (e.PropertyName == nameof(Source.MediaIsLooping) && source.MediaPath is { } path) { _mediaManager.SetLooping(path, AnyMediaReferenceLoops(path)); } else if (e.PropertyName is nameof(Source.MediaVolume) or nameof(Source.MediaMuted) && source.MediaPath is { } audioPath) { ApplyMediaAudioSettings(audioPath); } } _mediaHandlers[source.Id] = OnSourceChanged; source.PropertyChanged += OnSourceChanged; EnsureMediaSession(source.MediaPath); } private void UnregisterMediaSource(Source source) { if (_mediaHandlers.Remove(source.Id, out var handler)) source.PropertyChanged -= handler; } private void EnsureMediaSession(string? path) { if (string.IsNullOrWhiteSpace(path)) return; if (_mediaManager.HasSession(path)) { ApplyMediaAudioSettings(path); return; } _ = AcquireMediaAsync(path); } private async System.Threading.Tasks.Task AcquireMediaAsync(string path) { if (!await _mediaManager.AcquireAsync(path)) return; // A session can be running before this layer existed (shared path), in which // case the bitmap is already published and will not be raised again for us. if (_mediaManager.GetPreviewBitmap(path) is { } bitmap) OnMediaPreviewBitmapChanged(path, bitmap); ApplyMediaAudioSettings(path); } /// The file's sound controls: one decoder is shared per path, so the level and /// mute are per session — the layer that was last touched is the one that wins. private void ApplyMediaAudioSettings(string path) { var source = Scenes.SelectMany(s => s.Elements).OfType() .FirstOrDefault(s => s.IsMediaSource && s.MediaPath == path); if (source == null) return; _mediaManager.SetAudioSettings(path, 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). private void OnMediaAudioAvailable(string path, AudioSample sample) => _audioMixer.EnqueueDesktopMediaAudio(sample); /// Shared sessions or not, the loop state is the path's: any live media /// reference asking for a loop makes the one decoder loop (recorded rule, TASK 21). private bool AnyMediaReferenceLoops(string path) => Scenes.SelectMany(s => s.Elements).OfType() .Any(s => s.IsMediaSource && s.MediaPath == path && s.MediaIsLooping); private bool HasMediaReference(string path) => Scenes.SelectMany(s => s.Elements).OfType() .Any(s => s.IsMediaSource && s.MediaPath == path); private void ReleaseMediaIfUnused(string? path) { if (string.IsNullOrWhiteSpace(path)) return; if (!_mediaManager.HasSession(path)) return; if (HasMediaReference(path)) return; _ = _mediaManager.ReleaseAsync(path); } /// After a layout load: subscribe every persisted media layer and start one /// session per distinct file; release sessions for files 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(s => s.MediaPath!).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); foreach (var stale in _mediaManager.GetActiveKeys() .Where(key => !wanted.Contains(key, StringComparer.OrdinalIgnoreCase)) .ToList()) _ = _mediaManager.ReleaseAllAsync(stale); } }