Files
ytLlive/Services/ScreenCaptureManager.cs
T

256 lines
8.6 KiB
C#

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;
/// <summary>
/// Owns screen-capture sessions app-wide, refcounted by target key
/// ("monitor:&lt;n&gt;", "window:&lt;hwnd&gt;", "picker:&lt;...&gt;"). One target =
/// 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). Mirrors CameraManager.
/// </summary>
public sealed class ScreenCaptureManager : IDisposable
{
private sealed class CaptureSession
{
public string Key { get; }
public IScreenCaptureSource Source { get; }
public Action<VideoFrame>? FrameHandler;
public int RefCount;
public bool Started;
public WriteableBitmap? PreviewBitmap;
public VideoFrame? LatestFrame;
public bool FramePending;
public CaptureSession(string key, IScreenCaptureSource source)
{
Key = key;
Source = source;
RefCount = 1;
}
}
private readonly Func<string, IScreenCaptureSource?> _sourceFactory;
private readonly Dispatcher? _uiDispatcher;
private readonly Dictionary<string, CaptureSession> _sessions = new();
private readonly object _gate = new();
/// <summary>Raised on the UI thread when a capture's shared preview bitmap is first created.</summary>
public event Action<string, WriteableBitmap>? PreviewBitmapChanged;
/// <summary>Raised when a capture cannot be created or started (monitor gone, access denied, no target).</summary>
public event Action<string, string>? CaptureFailed;
public ScreenCaptureManager(Func<string, IScreenCaptureSource?> sourceFactory, Dispatcher? uiDispatcher = null)
{
_sourceFactory = sourceFactory;
_uiDispatcher = uiDispatcher;
}
/// <summary>Increments the refcount for a target key, starting capture the first time.</summary>
public async Task<bool> AcquireAsync(string key)
{
if (string.IsNullOrWhiteSpace(key)) return false;
CaptureSession session;
bool shouldStart;
lock (_gate)
{
if (_sessions.TryGetValue(key, out var existing))
{
existing.RefCount++;
shouldStart = false;
session = existing;
}
else
{
IScreenCaptureSource? source;
try
{
source = _sourceFactory(key);
}
catch (Exception ex)
{
AppLog.Write($"ScreenCaptureManager: creating capture '{key}' failed: {ex.Message}");
CaptureFailed?.Invoke(key, ex.Message);
return false;
}
if (source == null)
{
CaptureFailed?.Invoke(key, "No capture target for this key");
return false;
}
session = new CaptureSession(key, source);
session.FrameHandler = frame => OnFrameAvailable(session, frame);
session.Source.FrameAvailable += session.FrameHandler;
_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;
AppLog.Write($"ScreenCaptureManager: failed to start capture '{key}': {ex.Message}");
CaptureFailed?.Invoke(key, ex.Message);
await SafeStopAsync(session.Source);
return false;
}
}
/// <summary>Decrements the refcount; stops and disposes the source at zero.</summary>
public async Task ReleaseAsync(string key)
{
CaptureSession? 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;
await SafeStopAsync(toStop.Source);
toStop.PreviewBitmap = null;
}
/// <summary>
/// Releases a target unconditionally (every ref), regardless of how many
/// scenes hold it. Used on capture re-designation and layout reloads where
/// the old key's refcount isn't known after the scenes are replaced.
/// </summary>
public async Task ReleaseAllAsync(string key)
{
CaptureSession? 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;
await SafeStopAsync(toStop.Source);
toStop.PreviewBitmap = null;
}
/// <summary>The most recent frame for a capture key, or null before the first
/// frame arrives (or if the key has no session). The live compositor reads
/// the backdrop from here.</summary>
public VideoFrame? GetLatestFrame(string key)
{
lock (_gate)
return _sessions.TryGetValue(key, out var session) ? session.LatestFrame : null;
}
public void Dispose()
{
List<CaptureSession> sessions;
lock (_gate)
{
sessions = new List<CaptureSession>(_sessions.Values);
_sessions.Clear();
}
foreach (var session in sessions)
{
session.Source.FrameAvailable -= session.FrameHandler;
_ = SafeStopAsync(session.Source);
}
}
private static async Task SafeStopAsync(IScreenCaptureSource source)
{
try
{
await source.StopAsync();
}
catch (Exception ex)
{
AppLog.Write($"ScreenCaptureManager: stopping capture failed: {ex.Message}");
}
}
private bool TryGetActiveSession(CaptureSession session)
{
lock (_gate)
return _sessions.TryGetValue(session.Key, out var current) && ReferenceEquals(current, session);
}
private void OnFrameAvailable(CaptureSession 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(CaptureSession 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(CaptureSession 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);
}
}