using System; using System.Collections.Generic; using System.Threading.Tasks; using System.Windows; using System.Windows.Media; using System.Windows.Media.Imaging; using System.Windows.Threading; using ytLive.Helpers; using ytLive.Services.Audio; namespace ytLive.Services; /// /// TASK 21: owns media-file decode sessions app-wide, refcounted by file path. /// One file = at most one decoder, one shared WriteableBitmap; the last release /// stops and disposes the source. Frames arrive on a worker thread and are /// coalesced onto the UI dispatcher (at most one pending copy per session, using /// the latest frame). Mirrors ScreenCaptureManager/CameraManager. /// public sealed class MediaVideoSourceManager : IDisposable { private sealed class MediaSession { public string Key { get; } public IMediaFrameSource Source { get; } public Action? FrameHandler; public Action? AudioHandler; public int RefCount; public bool Started; public bool AudioMuted; public double AudioVolume = 1.0; public WriteableBitmap? PreviewBitmap; public VideoFrame? LatestFrame; public bool FramePending; public MediaSession(string key, IMediaFrameSource source) { Key = key; Source = source; RefCount = 1; } } // (file path, the layer's look) → decoder. The SESSION key is separate: path + look. private readonly Func _sourceFactory; private readonly Dispatcher? _uiDispatcher; private readonly Func? _loopProvider; private readonly Dictionary _sessions = new(); private readonly object _gate = new(); /// Raised on the UI thread when a media file's shared preview bitmap is first created. public event Action? PreviewBitmapChanged; /// Raised when a media decoder cannot be created (bad path). public event Action? MediaFailed; /// Raised (worker thread) per audio chunk of a file's own soundtrack, already /// scaled by the session's level and dropped when muted. public event Action? AudioAvailable; public MediaVideoSourceManager( Func sourceFactory, Dispatcher? uiDispatcher = null, Func? loopProvider = null) { _sourceFactory = sourceFactory; _uiDispatcher = uiDispatcher; _loopProvider = loopProvider; } /// Increments the refcount for a file, starting the decoder the first time. /// is the layer's look (TASK 64): the caller keys sessions by /// path + look, so two looks on one file are two decoders and identical looks share one. public async Task AcquireAsync(string key, ChromaKeyOptions? keyOptions = null) => await AcquireAsync(key, key, keyOptions).ConfigureAwait(false); /// Acquires by session key, decoding with the given look. public async Task AcquireAsync(string key, string path, ChromaKeyOptions? keyOptions = null) { if (string.IsNullOrWhiteSpace(key)) return false; MediaSession session; bool shouldStart; lock (_gate) { if (_sessions.TryGetValue(key, out var existing)) { existing.RefCount++; shouldStart = false; session = existing; } else { IMediaFrameSource? source; try { source = _sourceFactory(path, keyOptions); } catch (Exception ex) { AppLog.Write($"MediaSource: creating decoder '{key}' failed: {ex.Message}"); MediaFailed?.Invoke(key, ex.Message); return false; } if (source == null) { MediaFailed?.Invoke(key, "No decoder for this file"); return false; } session = new MediaSession(key, source); session.FrameHandler = frame => OnFrameAvailable(session, frame); session.Source.FrameAvailable += session.FrameHandler; session.AudioHandler = sample => OnAudioAvailable(session, sample); session.Source.AudioAvailable += session.AudioHandler; // Loop intent lives on the Source (per scene), so the caller supplies the // per-path answer ("any live reference loops"). Applied before Start so a // zero-length or instantly-EOF file cannot slip past a later assignment. session.Source.Looping = _loopProvider?.Invoke(key) ?? false; _sessions[key] = session; shouldStart = true; } } if (!shouldStart) return session.Started; try { await session.Source.StartAsync(); session.Started = true; return true; } catch (Exception ex) { lock (_gate) _sessions.Remove(key); session.Source.FrameAvailable -= session.FrameHandler; if (session.AudioHandler != null) session.Source.AudioAvailable -= session.AudioHandler; AppLog.Write($"MediaSource: failed to start decoder '{key}': {ex.Message}"); MediaFailed?.Invoke(key, ex.Message); await SafeStopAsync(session.Source); return false; } } public async Task ReleaseAsync(string key) { MediaSession? toStop = null; lock (_gate) { if (!_sessions.TryGetValue(key, out var session)) return; if (--session.RefCount > 0) return; _sessions.Remove(key); toStop = session; } if (toStop == null) return; toStop.Source.FrameAvailable -= toStop.FrameHandler; if (toStop.AudioHandler != null) toStop.Source.AudioAvailable -= toStop.AudioHandler; await SafeStopAsync(toStop.Source); toStop.PreviewBitmap = null; } /// Releases a file unconditionally (every ref) — used on layout reloads. public async Task ReleaseAllAsync(string key) { MediaSession? toStop = null; lock (_gate) { if (!_sessions.TryGetValue(key, out var session)) return; session.RefCount = 0; _sessions.Remove(key); toStop = session; } if (toStop == null) return; toStop.Source.FrameAvailable -= toStop.FrameHandler; if (toStop.AudioHandler != null) toStop.Source.AudioAvailable -= toStop.AudioHandler; await SafeStopAsync(toStop.Source); toStop.PreviewBitmap = null; } /// The most recent decoded frame for a file, or null before the first /// frame (or if the file has no session). The live compositor reads media from here. public VideoFrame? GetLatestFrame(string key) { lock (_gate) return _sessions.TryGetValue(key, out var session) ? session.LatestFrame : null; } /// True while a decode session exists for the file — the caller uses this to /// keep one acquire per path instead of stacking refcounts on every layout pass. public bool HasSession(string key) { lock (_gate) return _sessions.ContainsKey(key); } /// The files with a live session, for reconciling a layout load. public IReadOnlyList GetActiveKeys() { lock (_gate) return new List(_sessions.Keys); } /// Applies a loop change to a running session (the creator flipped the /// toggle). No-op when the file has no session. public void SetLooping(string key, bool looping) { lock (_gate) { if (_sessions.TryGetValue(key, out var session)) session.Source.Looping = looping; } } /// The layer's sound controls for this file. One decoder is shared per path, /// so the level/mute are per SESSION: whichever layer the creator last touched wins — /// the alternative (one decoder per layer) would play the same file twice. public void SetAudioSettings(string key, double volume, bool muted) { lock (_gate) { if (!_sessions.TryGetValue(key, out var session)) return; session.AudioVolume = Math.Clamp(volume, 0, 1); session.AudioMuted = muted; } } private void OnAudioAvailable(MediaSession session, AudioSample sample) { if (!TryGetActiveSession(session)) return; double volume; bool muted; lock (_gate) { volume = session.AudioVolume; muted = session.AudioMuted; } if (muted || volume <= 0) return; if (volume < 0.999) { // Copy before scaling: the source owns its buffer and may recycle it. var scaled = new float[sample.Samples.Length]; var gain = (float)volume; for (var i = 0; i < scaled.Length; i++) scaled[i] = sample.Samples[i] * gain; AudioAvailable?.Invoke(session.Key, new AudioSample(scaled, sample.SampleRate, sample.Channels)); return; } AudioAvailable?.Invoke(session.Key, sample); } /// The shared preview bitmap for a file (so a config created after the /// first frame can adopt the running bitmap), or null. public WriteableBitmap? GetPreviewBitmap(string key) { lock (_gate) return _sessions.TryGetValue(key, out var session) ? session.PreviewBitmap : null; } public void Dispose() { List sessions; lock (_gate) { sessions = new List(_sessions.Values); _sessions.Clear(); } foreach (var session in sessions) { session.Source.FrameAvailable -= session.FrameHandler; if (session.AudioHandler != null) session.Source.AudioAvailable -= session.AudioHandler; _ = SafeStopAsync(session.Source); } } private static async Task SafeStopAsync(IMediaFrameSource source) { try { await source.StopAsync(); } catch (Exception ex) { AppLog.Write($"MediaSource: stopping decoder failed: {ex.Message}"); } } private bool TryGetActiveSession(MediaSession session) { lock (_gate) return _sessions.TryGetValue(session.Key, out var current) && ReferenceEquals(current, session); } private void OnFrameAvailable(MediaSession session, VideoFrame frame) { if (!TryGetActiveSession(session)) return; session.LatestFrame = frame; if (session.PreviewBitmap == null) { if (_uiDispatcher == null) return; if (_uiDispatcher.CheckAccess()) EnsurePreviewBitmap(session); else _uiDispatcher.BeginInvoke(() => { if (TryGetActiveSession(session)) EnsurePreviewBitmap(session); }, DispatcherPriority.Render); return; } if (_uiDispatcher == null || _uiDispatcher.CheckAccess()) { CopyFrame(session); return; } if (session.FramePending) return; session.FramePending = true; _uiDispatcher.BeginInvoke(() => { session.FramePending = false; if (TryGetActiveSession(session) && session.PreviewBitmap != null) CopyFrame(session); }, DispatcherPriority.Render); } private void EnsurePreviewBitmap(MediaSession session) { if (session.PreviewBitmap != null || session.LatestFrame == null) return; var frame = session.LatestFrame; var bitmap = new WriteableBitmap(frame.Width, frame.Height, 96, 96, PixelFormats.Bgra32, null); bitmap.WritePixels(new Int32Rect(0, 0, frame.Width, frame.Height), frame.BgraPixels, frame.Stride, 0); session.PreviewBitmap = bitmap; PreviewBitmapChanged?.Invoke(session.Key, bitmap); } private void CopyFrame(MediaSession session) { var bitmap = session.PreviewBitmap; var frame = session.LatestFrame; if (bitmap == null || frame == null) return; if (frame.Width != bitmap.PixelWidth || frame.Height != bitmap.PixelHeight) return; bitmap.WritePixels(new Int32Rect(0, 0, frame.Width, frame.Height), frame.BgraPixels, frame.Stride, 0); } }