Screen backdrop (schema v5/v6) + five-scene catalog + webcam polish: live desktop/game capture as a permanent non-deletable bottom layer (Source.IsBackdrop), auto full-screen game detection (Win32FullScreenDetector) else primary display, GraphicsCapturePicker re-designation, refcounted shared ScreenCaptureManager, Live-only backdrop by policy (HasBackdrop + v5-v6 backfill + EnforceBackdropPolicy, checkbox gone), SceneCatalog (Starting/Live/BRB/Chat/Ending) with + button re-adding missing scenes, webcam mid-session transparent-container fix (GetPreviewBitmap propagation), Chat half-screen-area cap, 'Add Webcam' always opens the picker (SwapWebcamIdentityAsync, no silent resurrect), WindowsRuntimeMarshal frame-read + 5s-throttled errors, docs updated, tests (65 passing)
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
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:<n>", "window:<hwnd>", "picker:<...>"). 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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user