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; namespace ytLive.Services; /// /// Owns webcam capture sessions app-wide, refcounted by DeviceId. One device = /// at most one capture, 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) so a 60fps device doesn't drown the render thread. /// /// Allocation is proven, never assumed: treats a /// started reader as success only once the first frame actually arrives (within /// ), and an async /// tears the session down and surfaces — no silent /// empty box. /// public sealed class CameraManager : IDisposable { private sealed class CameraSession { public string DeviceId { get; } public ICameraFrameSource Source { get; } public Action? FrameHandler; public Action? FailureHandler; public int RefCount; public bool Started; public WriteableBitmap? PreviewBitmap; public VideoFrame? LatestFrame; public bool FramePending; public TaskCompletionSource? FirstFrame; public CameraSession(string deviceId, ICameraFrameSource source) { DeviceId = deviceId; Source = source; RefCount = 1; } } private readonly ICameraEnumerator _enumerator; private readonly Func _frameSourceFactory; private readonly Dispatcher? _uiDispatcher; private readonly TimeSpan _firstFrameTimeout; private readonly Dictionary _sessions = new(); private readonly object _gate = new(); /// Raised on the UI thread when a camera's shared preview bitmap is first created. public event Action? PreviewBitmapChanged; /// /// Raised when a capture fails: device in use, access denied, no preview source, /// or a started reader that never delivers a first frame within the timeout. /// public event Action? CameraFailed; public CameraManager(ICameraEnumerator enumerator, Func frameSourceFactory, Dispatcher? uiDispatcher = null, TimeSpan? firstFrameTimeout = null) { _enumerator = enumerator; _frameSourceFactory = frameSourceFactory; _uiDispatcher = uiDispatcher; _firstFrameTimeout = firstFrameTimeout ?? TimeSpan.FromSeconds(4); } public ICameraEnumerator Enumerator => _enumerator; /// Increments the refcount for a device, starting capture the first time. public async Task AcquireAsync(string deviceId) { if (string.IsNullOrWhiteSpace(deviceId)) return false; CameraSession session; bool shouldStart; lock (_gate) { if (_sessions.TryGetValue(deviceId, out var existing)) { existing.RefCount++; shouldStart = false; session = existing; } else { session = new CameraSession(deviceId, _frameSourceFactory(deviceId)); session.FrameHandler = frame => OnFrameAvailable(session, frame); session.Source.FrameAvailable += session.FrameHandler; session.FailureHandler = message => OnSourceFailed(session, message); session.Source.SourceFailed += session.FailureHandler; _sessions[deviceId] = session; shouldStart = true; } } if (!shouldStart) return session.Started; session.FirstFrame = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); try { await session.Source.StartAsync(); // First-frame proof: "the reader started" is not "the stream is live". // Success requires an actual frame within the timeout, else the session // is rolled back and reported — never left as a silent empty preview. var proven = _firstFrameTimeout <= TimeSpan.Zero || await WaitForFirstFrameAsync(session, _firstFrameTimeout); if (!proven) { var reason = $"Camera '{deviceId}' started but produced no frames within {_firstFrameTimeout.TotalSeconds:0.#}s."; RollbackSession(session, reason); return false; } // Keep the SourceFailed handler subscribed: an async failure after a // successful start (device lost, stream state Failed) must still surface. session.FirstFrame = null; session.Started = true; return true; } catch (Exception ex) { RollbackSession(session, ex.Message); return false; } } /// Decrements the refcount; stops and disposes the source at zero. public async Task ReleaseAsync(string deviceId) { CameraSession? toStop = null; lock (_gate) { if (!_sessions.TryGetValue(deviceId, out var session)) return; if (--session.RefCount > 0) return; _sessions.Remove(deviceId); toStop = session; } if (toStop == null) return; toStop.Source.FrameAvailable -= toStop.FrameHandler; await SafeStopAsync(toStop.Source); toStop.PreviewBitmap = null; } /// /// Releases a device unconditionally (every ref), regardless of how many scenes /// hold it. Used on identity changes (webcam swap, layout reload) where the old /// device's refcount isn't known after the scenes are replaced. /// public async Task ReleaseAllAsync(string deviceId) { CameraSession? toStop = null; lock (_gate) { if (!_sessions.TryGetValue(deviceId, out var session)) return; session.RefCount = 0; _sessions.Remove(deviceId); toStop = session; } if (toStop == null) return; toStop.Source.FrameAvailable -= toStop.FrameHandler; await SafeStopAsync(toStop.Source); toStop.PreviewBitmap = null; } public VideoFrame? GetLatestFrame(string deviceId) { lock (_gate) return _sessions.TryGetValue(deviceId, out var session) ? session.LatestFrame : null; } /// /// The shared WriteableBitmap for a running session, or null when the camera /// hasn't produced its first frame yet. only /// fires once (first frame), so configs created after the session started must /// pick the bitmap up from here. /// public WriteableBitmap? GetPreviewBitmap(string deviceId) { lock (_gate) return _sessions.TryGetValue(deviceId, 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.FailureHandler != null) session.Source.SourceFailed -= session.FailureHandler; _ = SafeStopAsync(session.Source); } } private static async Task WaitForFirstFrameAsync(CameraSession session, TimeSpan timeout) { var first = session.FirstFrame; if (first == null) return true; var completed = await Task.WhenAny(first.Task, Task.Delay(timeout)); return ReferenceEquals(completed, first.Task) && first.Task.Result; } private void RollbackSession(CameraSession session, string message) { lock (_gate) { if (TryGetActiveSession(session)) _sessions.Remove(session.DeviceId); } session.Source.FrameAvailable -= session.FrameHandler; if (session.FailureHandler != null) session.Source.SourceFailed -= session.FailureHandler; session.FirstFrame?.TrySetResult(false); var enriched = message; var suspects = CameraConflictProbe.GetRunningCameraApps(); if (suspects.Count > 0) enriched += $" Other camera apps running: {string.Join(", ", suspects)}."; AppLog.Write($"CameraManager: camera '{session.DeviceId}' failed: {enriched}"); CameraFailed?.Invoke(session.DeviceId, enriched); _ = SafeStopAsync(session.Source); } private void OnSourceFailed(CameraSession session, string message) { RollbackSession(session, message); } private static async Task SafeStopAsync(ICameraFrameSource source) { try { await source.StopAsync(); } catch (Exception ex) { AppLog.Write($"CameraManager: stopping camera failed: {ex.Message}"); } } private bool TryGetActiveSession(CameraSession session) { lock (_gate) return _sessions.TryGetValue(session.DeviceId, out var current) && ReferenceEquals(current, session); } private void OnFrameAvailable(CameraSession session, VideoFrame frame) { if (!TryGetActiveSession(session)) return; session.LatestFrame = frame; session.FirstFrame?.TrySetResult(true); 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(CameraSession 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.DeviceId, bitmap); } private void CopyFrame(CameraSession 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); } }