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:
@@ -150,6 +150,18 @@ public sealed class CameraManager : IDisposable
|
||||
return _sessions.TryGetValue(deviceId, out var session) ? session.LatestFrame : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The shared WriteableBitmap for a running session, or null when the camera
|
||||
/// hasn't produced its first frame yet. <see cref="PreviewBitmapChanged"/> only
|
||||
/// fires once (first frame), so configs created after the session started must
|
||||
/// pick the bitmap up from here.
|
||||
/// </summary>
|
||||
public WriteableBitmap? GetPreviewBitmap(string deviceId)
|
||||
{
|
||||
lock (_gate)
|
||||
return _sessions.TryGetValue(deviceId, out var session) ? session.PreviewBitmap : null;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
List<CameraSession> sessions;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using Windows.Graphics.Capture;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Desktop-interop bridges for Graphics Capture that CsWinRT can't project:
|
||||
/// creating a <see cref="GraphicsCaptureItem"/> from an HMONITOR/HWND via the
|
||||
/// activation factory's IGraphicsCaptureItemInterop, and wiring the
|
||||
/// GraphicsCapturePicker to an owner window via IInitializeWithWindow.
|
||||
/// GUIDs and signatures come from the official Windows GraphicsCapture samples.
|
||||
/// </summary>
|
||||
internal static class CaptureInterop
|
||||
{
|
||||
private static readonly Guid GraphicsCaptureItemGuid = new("79C3F95B-31F7-4EC2-A464-632EF5D30760");
|
||||
|
||||
public static GraphicsCaptureItem CreateForMonitor(IntPtr hmonitor)
|
||||
{
|
||||
var interop = GraphicsCaptureItem.As<IGraphicsCaptureItemInterop>();
|
||||
return GraphicsCaptureItem.FromAbi(interop.CreateForMonitor(hmonitor, GraphicsCaptureItemGuid));
|
||||
}
|
||||
|
||||
public static GraphicsCaptureItem CreateForWindow(IntPtr hwnd)
|
||||
{
|
||||
var interop = GraphicsCaptureItem.As<IGraphicsCaptureItemInterop>();
|
||||
return GraphicsCaptureItem.FromAbi(interop.CreateForWindow(hwnd, GraphicsCaptureItemGuid));
|
||||
}
|
||||
|
||||
public static void SetWindowOwner(GraphicsCapturePicker picker, IntPtr hwnd)
|
||||
{
|
||||
var interop = (IInitializeWithWindow)(object)picker;
|
||||
interop.Initialize(hwnd);
|
||||
}
|
||||
|
||||
[ComImport]
|
||||
[Guid("3628E81B-3CAC-4C60-B7F4-23CE0E0C3356")]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
private interface IGraphicsCaptureItemInterop
|
||||
{
|
||||
IntPtr CreateForWindow([In] IntPtr window, [In] ref Guid iid);
|
||||
IntPtr CreateForMonitor([In] IntPtr monitor, [In] ref Guid iid);
|
||||
}
|
||||
|
||||
[ComImport]
|
||||
[Guid("3E68D4BD-7135-4D10-8018-9FB6D9F33FA1")]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
private interface IInitializeWithWindow
|
||||
{
|
||||
void Initialize(IntPtr hwnd);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using Windows.Graphics.DirectX.Direct3D11;
|
||||
using WinRT;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Bridges a native D3D11 device into the WinRT <see cref="IDirect3DDevice"/>
|
||||
/// the capture API needs. CsWinRT doesn't project the Direct3D11Helper static,
|
||||
/// so this creates the device with D3D11CreateDevice and converts it via the
|
||||
/// WinRT interop export <c>CreateDirect3D11DeviceFromDXGIDevice</c> (d3d11.dll).
|
||||
///
|
||||
/// This deliberately does NOT use the older QI-for-IDirect3DDxgiInterfaceAccess
|
||||
/// trick: the raw D3D11 device stopped exposing that interface on newer Windows
|
||||
/// (verified E_NOINTERFACE on build 26200, hardware and WARP alike), while
|
||||
/// CreateDirect3D11DeviceFromDXGIDevice keeps working. One shared device per
|
||||
/// process.
|
||||
/// </summary>
|
||||
internal static class Direct3D11Helper
|
||||
{
|
||||
private const uint D3D11CreateDeviceBgraSupport = 0x20;
|
||||
private const uint D3D11SdkVersion = 7;
|
||||
private const int DriverTypeHardware = 1;
|
||||
|
||||
// IDirect3DDxgiInterfaceAccess (the legacy bridge) is only exposed on an
|
||||
// 11.1 device AND requires the 11.1 runtime to be in the requested set —
|
||||
// with pFeatureLevels = NULL D3D11CreateDevice never creates 11.1, so the
|
||||
// array must be explicit (11.1 first, then descending).
|
||||
private static readonly int[] FeatureLevels = { 0xB100, 0xB000, 0xA100, 0xA000, 0x9300, 0x9200, 0x9100 };
|
||||
|
||||
private static IDirect3DDevice? _sharedDevice;
|
||||
private static readonly object Gate = new();
|
||||
|
||||
public static IDirect3DDevice CreateDevice()
|
||||
{
|
||||
if (_sharedDevice != null) return _sharedDevice;
|
||||
lock (Gate)
|
||||
{
|
||||
return _sharedDevice ??= CreateDeviceCore();
|
||||
}
|
||||
}
|
||||
|
||||
private static IDirect3DDevice CreateDeviceCore()
|
||||
{
|
||||
var hr = D3D11CreateDevice(IntPtr.Zero, DriverTypeHardware, IntPtr.Zero, D3D11CreateDeviceBgraSupport,
|
||||
FeatureLevels, (uint)FeatureLevels.Length, D3D11SdkVersion, out var devicePtr, out _, out var contextPtr);
|
||||
if (hr != 0)
|
||||
throw Marshal.GetExceptionForHR(hr)!;
|
||||
|
||||
try
|
||||
{
|
||||
var dxgiGuid = new Guid("54EC77FA-1377-44E6-8C32-88FD5F44C84C");
|
||||
var qiHr = Marshal.QueryInterface(devicePtr, ref dxgiGuid, out var dxgiDevice);
|
||||
if (qiHr != 0)
|
||||
throw Marshal.GetExceptionForHR(qiHr)!;
|
||||
try
|
||||
{
|
||||
hr = CreateDirect3D11DeviceFromDXGIDevice(dxgiDevice, out var winrtDevice);
|
||||
if (hr != 0)
|
||||
throw Marshal.GetExceptionForHR(hr)!;
|
||||
return MarshalInterface<IDirect3DDevice>.FromAbi(winrtDevice);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.Release(dxgiDevice);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.Release(devicePtr);
|
||||
Marshal.Release(contextPtr);
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("d3d11.dll")]
|
||||
private static extern int D3D11CreateDevice(IntPtr pAdapter, int driverType, IntPtr software,
|
||||
uint flags, int[]? featureLevels, uint featureLevelsCount, uint sdkVersion,
|
||||
out IntPtr device, out int featureLevel, out IntPtr immediateContext);
|
||||
|
||||
[DllImport("d3d11.dll", ExactSpelling = true)]
|
||||
private static extern int CreateDirect3D11DeviceFromDXGIDevice(IntPtr dxgiDevice, out IntPtr graphicsDevice);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies which monitor holds a full-screen window (a game/app running
|
||||
/// borderless or exclusive fullscreen). Seam so ScreenCaptureManager is
|
||||
/// testable without Win32 interop.
|
||||
/// </summary>
|
||||
public interface IFullScreenDetector
|
||||
{
|
||||
/// <summary>
|
||||
/// The index of the monitor a full-screen foreground window covers, or null
|
||||
/// when the foreground window is windowed, missing, or one of our own.
|
||||
/// </summary>
|
||||
int? GetForegroundFullScreenMonitorIndex();
|
||||
|
||||
/// <summary>Index of the primary (taskbar-hosting) monitor.</summary>
|
||||
int PrimaryMonitorIndex();
|
||||
|
||||
/// <summary>All monitors in capture-key order (enumeration order).</summary>
|
||||
IReadOnlyList<DisplayInfo> GetDisplays();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// A running screen-capture source for one target key ("monitor:<n>" or
|
||||
/// "window:<hwnd>"). Raises normalized BGRA frames from a worker thread;
|
||||
/// callers must marshal to the UI thread. Seam so ScreenCaptureManager is
|
||||
/// testable without WinRT.
|
||||
/// </summary>
|
||||
public interface IScreenCaptureSource
|
||||
{
|
||||
string Key { get; }
|
||||
event Action<VideoFrame>? FrameAvailable;
|
||||
Task StartAsync();
|
||||
Task StopAsync();
|
||||
}
|
||||
+76
-7
@@ -40,6 +40,7 @@ public class LayoutStore : IDisposable
|
||||
Name TEXT NOT NULL,
|
||||
IsHidden INTEGER NOT NULL DEFAULT 0,
|
||||
IsChatScene INTEGER NOT NULL DEFAULT 0,
|
||||
HasBackdrop INTEGER NOT NULL DEFAULT 1,
|
||||
SortOrder INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
""",
|
||||
@@ -69,6 +70,8 @@ public class LayoutStore : IDisposable
|
||||
DeviceId TEXT,
|
||||
ClipShape TEXT NOT NULL DEFAULT 'Traditional',
|
||||
IsMirrored INTEGER NOT NULL DEFAULT 0,
|
||||
IsBackdrop INTEGER NOT NULL DEFAULT 0,
|
||||
CaptureKey TEXT,
|
||||
SortOrder INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
""",
|
||||
@@ -110,11 +113,12 @@ public class LayoutStore : IDisposable
|
||||
}
|
||||
MigrateSourceTable();
|
||||
MigrateWebcamConfigTable();
|
||||
MigrateSceneTable();
|
||||
if (GetUserVersion() < 3)
|
||||
MigrateToV3();
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "PRAGMA user_version = 4;";
|
||||
cmd.CommandText = "PRAGMA user_version = 6;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
@@ -153,6 +157,20 @@ public class LayoutStore : IDisposable
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsMirrored INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!columns.Contains("IsBackdrop"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsBackdrop INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!columns.Contains("CaptureKey"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN CaptureKey TEXT;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
// v3 → v4: WebcamSceneConfig gains RectWidth/RectHeight (the pre-Round rect,
|
||||
@@ -260,6 +278,47 @@ public class LayoutStore : IDisposable
|
||||
tx.Commit();
|
||||
}
|
||||
|
||||
// v5 → v6: Scene gains HasBackdrop. Added columns default to true; the
|
||||
// ONE-TIME backfill (runs only when the column is first added) turns every
|
||||
// non-Live canonical scene off and drops their backdrop sources — the fix for
|
||||
// DBs saved before the Live-only policy. MainViewModel.EnforceBackdropPolicy
|
||||
// re-runs the same normalization on every load for DBs that miss the backfill.
|
||||
private void MigrateSceneTable()
|
||||
{
|
||||
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "PRAGMA table_info(Scene);";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
columns.Add(reader.GetString(1));
|
||||
}
|
||||
|
||||
if (columns.Contains("HasBackdrop")) return;
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "ALTER TABLE Scene ADD COLUMN HasBackdrop INTEGER NOT NULL DEFAULT 1;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
const string noBackdrop = "('starting', 'brb', 'chat', 'ending')";
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = $"UPDATE Scene SET HasBackdrop = 0 WHERE LOWER(TRIM(Name)) IN {noBackdrop};";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = $"""
|
||||
DELETE FROM Source
|
||||
WHERE IsBackdrop = 1 AND SceneId IN
|
||||
(SELECT Id FROM Scene WHERE LOWER(TRIM(Name)) IN {noBackdrop});
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
public List<Scene> Load()
|
||||
{
|
||||
Webcam = null;
|
||||
@@ -269,7 +328,7 @@ public class LayoutStore : IDisposable
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene FROM Scene ORDER BY SortOrder";
|
||||
cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene, HasBackdrop FROM Scene ORDER BY SortOrder";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
@@ -279,6 +338,7 @@ public class LayoutStore : IDisposable
|
||||
Name = reader.GetString(1),
|
||||
IsHidden = reader.GetInt32(2) != 0,
|
||||
IsChatScene = reader.GetInt32(3) != 0,
|
||||
HasBackdrop = reader.GetInt32(4) != 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -302,7 +362,8 @@ public class LayoutStore : IDisposable
|
||||
{
|
||||
cmd.CommandText = """
|
||||
SELECT Id, SceneId, AssetId, Type, Name, IsEnabled,
|
||||
X, Y, Width, Height, Opacity, MonitorIndex, ClipShape, IsMirrored
|
||||
X, Y, Width, Height, Opacity, MonitorIndex, ClipShape, IsMirrored,
|
||||
IsBackdrop, CaptureKey
|
||||
FROM Source ORDER BY SortOrder
|
||||
""";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
@@ -324,6 +385,8 @@ public class LayoutStore : IDisposable
|
||||
MonitorIndex = reader.IsDBNull(11) ? null : reader.GetInt32(11),
|
||||
ClipShape = Enum.TryParse<ClipShape>(reader.GetString(12), out var clip) ? clip : ClipShape.Traditional,
|
||||
IsMirrored = reader.GetInt32(13) != 0,
|
||||
IsBackdrop = reader.GetInt32(14) != 0,
|
||||
CaptureKey = reader.IsDBNull(15) ? null : reader.GetString(15),
|
||||
};
|
||||
if (!sourcesByScene.TryGetValue(sceneId, out var list))
|
||||
sourcesByScene[sceneId] = list = new List<Source>();
|
||||
@@ -412,14 +475,15 @@ public class LayoutStore : IDisposable
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, SortOrder)
|
||||
VALUES ($id, $name, $isHidden, $isChat, $sort)
|
||||
INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackdrop, SortOrder)
|
||||
VALUES ($id, $name, $isHidden, $isChat, $hasBackdrop, $sort)
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||
var nameP = cmd.Parameters.Add("$name", SqliteType.Text);
|
||||
var hiddenP = cmd.Parameters.Add("$isHidden", SqliteType.Integer);
|
||||
var chatP = cmd.Parameters.Add("$isChat", SqliteType.Integer);
|
||||
var backdropP = cmd.Parameters.Add("$hasBackdrop", SqliteType.Integer);
|
||||
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
|
||||
|
||||
var sort = 0;
|
||||
@@ -429,6 +493,7 @@ public class LayoutStore : IDisposable
|
||||
nameP.Value = scene.Name;
|
||||
hiddenP.Value = scene.IsHidden ? 1 : 0;
|
||||
chatP.Value = scene.IsChatScene ? 1 : 0;
|
||||
backdropP.Value = scene.HasBackdrop ? 1 : 0;
|
||||
sortP.Value = sort++;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
@@ -439,10 +504,10 @@ public class LayoutStore : IDisposable
|
||||
cmd.CommandText = """
|
||||
INSERT INTO Source (Id, SceneId, AssetId, Type, Name, IsEnabled,
|
||||
X, Y, Width, Height, Opacity, MonitorIndex,
|
||||
ClipShape, IsMirrored, SortOrder)
|
||||
ClipShape, IsMirrored, IsBackdrop, CaptureKey, SortOrder)
|
||||
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
|
||||
$x, $y, $w, $h, $opacity, $monitor,
|
||||
$clip, $mirrored, $sort)
|
||||
$clip, $mirrored, $isBackdrop, $captureKey, $sort)
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||
@@ -459,6 +524,8 @@ public class LayoutStore : IDisposable
|
||||
var monitorP = cmd.Parameters.Add("$monitor", SqliteType.Integer);
|
||||
var clipP = cmd.Parameters.Add("$clip", SqliteType.Text);
|
||||
var mirroredP = cmd.Parameters.Add("$mirrored", SqliteType.Integer);
|
||||
var isBackdropP = cmd.Parameters.Add("$isBackdrop", SqliteType.Integer);
|
||||
var captureKeyP = cmd.Parameters.Add("$captureKey", SqliteType.Text);
|
||||
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
|
||||
|
||||
foreach (var scene in scenes)
|
||||
@@ -481,6 +548,8 @@ public class LayoutStore : IDisposable
|
||||
monitorP.Value = (object?)source.MonitorIndex ?? DBNull.Value;
|
||||
clipP.Value = source.ClipShape.ToString();
|
||||
mirroredP.Value = source.IsMirrored ? 1 : 0;
|
||||
isBackdropP.Value = source.IsBackdrop ? 1 : 0;
|
||||
captureKeyP.Value = (object?)source.CaptureKey ?? DBNull.Value;
|
||||
sortP.Value = sort++;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.InteropServices.WindowsRuntime;
|
||||
using System.Threading.Tasks;
|
||||
using Windows.Graphics;
|
||||
using Windows.Graphics.Capture;
|
||||
using Windows.Graphics.DirectX;
|
||||
using Windows.Graphics.DirectX.Direct3D11;
|
||||
using Windows.Graphics.Imaging;
|
||||
using ytLive.Helpers;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// A live screen capture for one target key ("monitor:<n>" or
|
||||
/// "window:<hwnd>"). Owns the GraphicsCaptureItem, a free-threaded
|
||||
/// Direct3D11CaptureFramePool and the capture session; frames are converted on
|
||||
/// the capture worker thread from the GPU surface to a CPU BGRA VideoFrame.
|
||||
/// Known OS limits: DRM content captures as black frames; capture pauses while
|
||||
/// the app is minimized (the frame pool simply stops delivering frames).
|
||||
/// </summary>
|
||||
public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private GraphicsCaptureItem _item;
|
||||
private Direct3D11CaptureFramePool? _framePool;
|
||||
private GraphicsCaptureSession? _session;
|
||||
private SizeInt32 _poolSize;
|
||||
private bool _started;
|
||||
private bool _framePending;
|
||||
private DateTime _lastErrorLog = DateTime.MinValue;
|
||||
|
||||
// The composition master frame (see ai.md "Resolution tiers"): the backdrop
|
||||
// is an input layer, so we never hold a CPU frame bigger than the master.
|
||||
private const int MaxBackdropWidth = 1920;
|
||||
private const int MaxBackdropHeight = 1080;
|
||||
|
||||
// A failing conversion must not re-flood the log at frame rate.
|
||||
private static readonly TimeSpan ErrorLogThrottle = TimeSpan.FromSeconds(5);
|
||||
|
||||
public string Key { get; }
|
||||
public event Action<VideoFrame>? FrameAvailable;
|
||||
|
||||
internal ScreenCaptureFrameSource(string key, GraphicsCaptureItem item)
|
||||
{
|
||||
Key = key;
|
||||
_item = item;
|
||||
}
|
||||
|
||||
/// <summary>Wraps an item chosen through the OS GraphicsCapturePicker.</summary>
|
||||
public static ScreenCaptureFrameSource CreateForPicker(string key, GraphicsCaptureItem item)
|
||||
=> new(key, item);
|
||||
|
||||
public static ScreenCaptureFrameSource CreateForMonitor(int monitorIndex)
|
||||
{
|
||||
var hmonitor = Win32FullScreenDetector.GetMonitorHandle(monitorIndex);
|
||||
if (hmonitor == IntPtr.Zero)
|
||||
throw new InvalidOperationException($"Monitor {monitorIndex} is not connected");
|
||||
var item = CaptureInterop.CreateForMonitor(hmonitor);
|
||||
return new ScreenCaptureFrameSource($"monitor:{monitorIndex}", item);
|
||||
}
|
||||
|
||||
public static ScreenCaptureFrameSource CreateForWindow(IntPtr hwnd)
|
||||
{
|
||||
var item = CaptureInterop.CreateForWindow(hwnd);
|
||||
return new ScreenCaptureFrameSource($"window:0x{hwnd.ToInt64():X}", item);
|
||||
}
|
||||
|
||||
public Task StartAsync()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_started) return Task.CompletedTask;
|
||||
var item = _item;
|
||||
var device = Direct3D11Helper.CreateDevice();
|
||||
var framePool = Direct3D11CaptureFramePool.CreateFreeThreaded(
|
||||
device, DirectXPixelFormat.B8G8R8A8UIntNormalized, 2, item.Size);
|
||||
_poolSize = item.Size;
|
||||
var session = framePool.CreateCaptureSession(item);
|
||||
framePool.FrameArrived += OnFrameArrived;
|
||||
session.StartCapture();
|
||||
_framePool = framePool;
|
||||
_session = session;
|
||||
_started = true;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_started = false;
|
||||
_framePending = false;
|
||||
if (_framePool != null)
|
||||
_framePool.FrameArrived -= OnFrameArrived;
|
||||
_session?.Dispose();
|
||||
_session = null;
|
||||
_framePool?.Dispose();
|
||||
_framePool = null;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void OnFrameArrived(Direct3D11CaptureFramePool sender, object args)
|
||||
{
|
||||
var frame = sender.TryGetNextFrame();
|
||||
if (frame == null) return;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_started)
|
||||
{
|
||||
frame.Dispose();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (frame.ContentSize.Width != _poolSize.Width || frame.ContentSize.Height != _poolSize.Height)
|
||||
{
|
||||
sender.Recreate(Direct3D11Helper.CreateDevice(),
|
||||
DirectXPixelFormat.B8G8R8A8UIntNormalized, 2, frame.ContentSize);
|
||||
_poolSize = frame.ContentSize;
|
||||
}
|
||||
|
||||
if (_framePending)
|
||||
{
|
||||
frame.Dispose();
|
||||
return;
|
||||
}
|
||||
_framePending = true;
|
||||
_ = ProcessFrameAsync(frame);
|
||||
}
|
||||
|
||||
private async Task ProcessFrameAsync(Direct3D11CaptureFrame frame)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (frame)
|
||||
using (var softwareBitmap = await SoftwareBitmap.CreateCopyFromSurfaceAsync(
|
||||
frame.Surface, BitmapAlphaMode.Ignore))
|
||||
{
|
||||
FrameAvailable?.Invoke(CopyToVideoFrame(softwareBitmap));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
if (now - _lastErrorLog >= ErrorLogThrottle)
|
||||
{
|
||||
_lastErrorLog = now;
|
||||
AppLog.Write($"ScreenCaptureFrameSource: frame conversion failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_framePending = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static VideoFrame CopyToVideoFrame(SoftwareBitmap bitmap)
|
||||
{
|
||||
var sw = bitmap.PixelWidth;
|
||||
var sh = bitmap.PixelHeight;
|
||||
using var buffer = bitmap.LockBuffer(BitmapBufferAccessMode.Read);
|
||||
using var reference = buffer.CreateReference();
|
||||
if (!WindowsRuntimeMarshal.TryGetDataUnsafe(reference, out var data, out var capacity))
|
||||
throw new InvalidOperationException("Could not access the frame buffer.");
|
||||
|
||||
var srcStride = sw * 4;
|
||||
var count = (int)Math.Min(capacity, (uint)(sh * srcStride));
|
||||
|
||||
// The master frame is 1920×1080; a larger monitor is scaled down here so
|
||||
// the CPU never holds a frame above the master (the rendered layer fills
|
||||
// the frame with UniformToFill regardless).
|
||||
if (sw > MaxBackdropWidth || sh > MaxBackdropHeight)
|
||||
{
|
||||
var scale = Math.Min(MaxBackdropWidth / (double)sw, MaxBackdropHeight / (double)sh);
|
||||
var dw = Math.Max(1, (int)(sw * scale));
|
||||
var dh = Math.Max(1, (int)(sh * scale));
|
||||
return new VideoFrame(dw, dh, DownscaleBgra(data, sw, sh, srcStride, dw, dh));
|
||||
}
|
||||
|
||||
var pixels = new byte[count];
|
||||
Marshal.Copy(data, pixels, 0, pixels.Length);
|
||||
return new VideoFrame(sw, sh, pixels);
|
||||
}
|
||||
|
||||
// Bilinear downscale to the master frame. Reads each source row pair through
|
||||
// Marshal.Copy (no unsafe), writing tightly packed BGRA output.
|
||||
private static byte[] DownscaleBgra(IntPtr src, int sw, int sh, int srcStride, int dw, int dh)
|
||||
{
|
||||
var row0 = new byte[srcStride];
|
||||
var row1 = new byte[srcStride];
|
||||
var dst = new byte[dw * dh * 4];
|
||||
var xs = sw / (double)dw;
|
||||
var ys = sh / (double)dh;
|
||||
|
||||
for (var y = 0; y < dh; y++)
|
||||
{
|
||||
var sy = Math.Min(sh - 1, (int)(y * ys));
|
||||
var sy1 = Math.Min(sh - 1, sy + 1);
|
||||
var fy = (y * ys) - sy;
|
||||
Marshal.Copy(IntPtr.Add(src, sy * srcStride), row0, 0, srcStride);
|
||||
Marshal.Copy(IntPtr.Add(src, sy1 * srcStride), row1, 0, srcStride);
|
||||
|
||||
var dRow = y * dw * 4;
|
||||
for (var x = 0; x < dw; x++)
|
||||
{
|
||||
var sx = Math.Min(sw - 1, (int)(x * xs));
|
||||
var sx1 = Math.Min(sw - 1, sx + 1);
|
||||
var fx = (x * xs) - sx;
|
||||
for (var c = 0; c < 4; c++)
|
||||
{
|
||||
var i0 = sx * 4 + c;
|
||||
var i1 = sx1 * 4 + c;
|
||||
var top = row0[i0] + (row0[i1] - row0[i0]) * fx;
|
||||
var bottom = row1[i0] + (row1[i1] - row1[i0]) * fx;
|
||||
dst[dRow + x * 4 + c] = (byte)(top + (bottom - top) * fy);
|
||||
}
|
||||
}
|
||||
}
|
||||
return dst;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Threading.Tasks;
|
||||
using Windows.Graphics.Capture;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Turns a capture key ("monitor:<n>", "window:<hwnd>", "picker:<...>")
|
||||
/// into a live <see cref="IScreenCaptureSource"/>, and hosts the OS
|
||||
/// GraphicsCapturePicker used by "Change Capture…". Picker picks are transient:
|
||||
/// the picked GraphicsCaptureItem has no HWND/monitor identity, so a
|
||||
/// "picker:" key resolves only within the current session and re-detection
|
||||
/// takes over after a reload (documented v1 limit).
|
||||
/// </summary>
|
||||
public sealed class ScreenCaptureSourceFactory
|
||||
{
|
||||
private readonly Func<IntPtr> _ownerHwndProvider;
|
||||
private readonly Dictionary<string, GraphicsCaptureItem> _picked = new();
|
||||
private readonly object _gate = new();
|
||||
|
||||
public ScreenCaptureSourceFactory(Func<IntPtr> ownerHwndProvider)
|
||||
{
|
||||
_ownerHwndProvider = ownerHwndProvider;
|
||||
}
|
||||
|
||||
/// <summary>Creates the source for a persisted key, or null when it can't be resolved.</summary>
|
||||
public IScreenCaptureSource? Resolve(string key)
|
||||
{
|
||||
if (key.StartsWith("monitor:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var indexText = key.AsSpan("monitor:".Length);
|
||||
if (int.TryParse(indexText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var index))
|
||||
return ScreenCaptureFrameSource.CreateForMonitor(index);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (key.StartsWith("window:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var hexText = key.AsSpan("window:".Length);
|
||||
if (long.TryParse(hexText, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var hwnd))
|
||||
return ScreenCaptureFrameSource.CreateForWindow(new IntPtr(hwnd));
|
||||
return null;
|
||||
}
|
||||
|
||||
if (key.StartsWith("picker:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
lock (_gate)
|
||||
return _picked.TryGetValue(key, out var item)
|
||||
? ScreenCaptureFrameSource.CreateForPicker(key, item)
|
||||
: null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows the OS capture picker (must run on the UI thread, owner window set
|
||||
/// via IInitializeWithWindow). Returns the session key, or null if cancelled.
|
||||
/// </summary>
|
||||
public async Task<string?> PickAsync()
|
||||
{
|
||||
var picker = new GraphicsCapturePicker();
|
||||
CaptureInterop.SetWindowOwner(picker, _ownerHwndProvider());
|
||||
var item = await picker.PickSingleItemAsync();
|
||||
if (item == null) return null;
|
||||
|
||||
var key = $"picker:{item.DisplayName}";
|
||||
lock (_gate)
|
||||
_picked[key] = item;
|
||||
return key;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows.Interop;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>A physical display: its capture-key index, geometry, and primary flag.</summary>
|
||||
public sealed record DisplayInfo(int Index, string Name, int Width, int Height, int X, int Y, bool IsPrimary)
|
||||
{
|
||||
public string Label
|
||||
=> IsPrimary ? $"{Name} — {Width}×{Height} (primary)" : $"{Name} — {Width}×{Height}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Win32 detection: <c>GetForegroundWindow</c> + <c>DwmGetWindowAttribute</c>
|
||||
/// (DWMWA_EXTENDED_FRAME_BOUNDS) + <c>MonitorFromWindow</c> + <c>GetMonitorInfo</c>.
|
||||
/// A foreground window whose extended frame bounds cover an entire monitor is
|
||||
/// treated as a full-screen game/app on that monitor's index (monitor order =
|
||||
/// <c>EnumDisplayMonitors</c> enumeration order, the same order the capture
|
||||
/// factory uses to map index → HMONITOR). Windows of our own process are excluded.
|
||||
/// DRM-protected content captures as black frames — a documented OS limit.
|
||||
/// </summary>
|
||||
public sealed class Win32FullScreenDetector : IFullScreenDetector
|
||||
{
|
||||
private const int DwmwaExtendedFrameBounds = 9;
|
||||
private const uint MonitorDefaultToNearest = 2;
|
||||
private const uint MonitorInfoFPrimary = 0x00000001;
|
||||
|
||||
public int? GetForegroundFullScreenMonitorIndex()
|
||||
{
|
||||
var hwnd = GetForegroundWindow();
|
||||
if (hwnd == IntPtr.Zero || IsOwnWindow(hwnd)) return null;
|
||||
if (!TryGetExtendedFrameBounds(hwnd, out var bounds)) return null;
|
||||
|
||||
var monitor = MonitorFromWindow(hwnd, MonitorDefaultToNearest);
|
||||
if (monitor == IntPtr.Zero || !TryGetMonitorInfo(monitor, out var info)) return null;
|
||||
|
||||
var r = info.RcMonitor;
|
||||
var coversMonitor = bounds.Left <= r.Left && bounds.Top <= r.Top &&
|
||||
bounds.Right >= r.Right && bounds.Bottom >= r.Bottom;
|
||||
if (!coversMonitor) return null;
|
||||
|
||||
return MonitorIndex(monitor);
|
||||
}
|
||||
|
||||
private static bool IsOwnWindow(IntPtr hwnd)
|
||||
{
|
||||
_ = GetWindowThreadProcessId(hwnd, out var pid);
|
||||
return pid == Environment.ProcessId;
|
||||
}
|
||||
|
||||
private static bool TryGetExtendedFrameBounds(IntPtr hwnd, out Win32Rect bounds)
|
||||
{
|
||||
bounds = default;
|
||||
return DwmGetWindowAttribute(hwnd, DwmwaExtendedFrameBounds, ref bounds, Marshal.SizeOf<Win32Rect>()) == 0;
|
||||
}
|
||||
|
||||
private static bool TryGetMonitorInfo(IntPtr monitor, out MonitorInfo info)
|
||||
{
|
||||
info = new MonitorInfo { CbSize = Marshal.SizeOf<MonitorInfo>() };
|
||||
return GetMonitorInfo(monitor, ref info);
|
||||
}
|
||||
|
||||
private static int? MonitorIndex(IntPtr monitor)
|
||||
{
|
||||
var handles = EnumerateMonitors();
|
||||
var index = handles.IndexOf(monitor);
|
||||
return index >= 0 ? index : null;
|
||||
}
|
||||
|
||||
public int PrimaryMonitorIndex()
|
||||
=> GetDisplays().FirstOrDefault(d => d.IsPrimary)?.Index ?? 0;
|
||||
|
||||
public IReadOnlyList<DisplayInfo> GetDisplays()
|
||||
{
|
||||
var result = new List<DisplayInfo>();
|
||||
var handles = EnumerateMonitors();
|
||||
for (var i = 0; i < handles.Count; i++)
|
||||
{
|
||||
if (!TryGetMonitorInfo(handles[i], out var info)) continue;
|
||||
var name = GetDisplayName(handles[i]);
|
||||
result.Add(new DisplayInfo(
|
||||
i,
|
||||
name,
|
||||
info.RcMonitor.Right - info.RcMonitor.Left,
|
||||
info.RcMonitor.Bottom - info.RcMonitor.Top,
|
||||
info.RcMonitor.Left,
|
||||
info.RcMonitor.Top,
|
||||
(info.DwFlags & MonitorInfoFPrimary) != 0));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string GetDisplayName(IntPtr monitor)
|
||||
{
|
||||
var info = new MonitorInfoEx { CbSize = Marshal.SizeOf<MonitorInfoEx>() };
|
||||
if (!GetMonitorInfo(monitor, ref info)) return $"Display {monitor}";
|
||||
var deviceName = info.SzDevice.TrimEnd('\0');
|
||||
var dev = new DisplayDevice { Cb = Marshal.SizeOf<DisplayDevice>() };
|
||||
if (!EnumDisplayDevices(deviceName, 0, ref dev, 0)) return deviceName;
|
||||
var friendly = dev.DeviceString.TrimEnd('\0');
|
||||
return string.IsNullOrWhiteSpace(friendly) ? deviceName : friendly;
|
||||
}
|
||||
|
||||
public static IntPtr GetMonitorHandle(int index)
|
||||
{
|
||||
var handles = EnumerateMonitors();
|
||||
return index >= 0 && index < handles.Count ? handles[index] : IntPtr.Zero;
|
||||
}
|
||||
|
||||
private static List<IntPtr> EnumerateMonitors()
|
||||
{
|
||||
var handles = new List<IntPtr>();
|
||||
EnumDisplayMonitors(IntPtr.Zero, IntPtr.Zero,
|
||||
(IntPtr hMonitor, IntPtr hdcMonitor, ref Win32Rect lprcMonitor, IntPtr dwData) =>
|
||||
{
|
||||
handles.Add(hMonitor);
|
||||
return true;
|
||||
}, IntPtr.Zero);
|
||||
return handles;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct Win32Rect
|
||||
{
|
||||
public int Left;
|
||||
public int Top;
|
||||
public int Right;
|
||||
public int Bottom;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct MonitorInfo
|
||||
{
|
||||
public int CbSize;
|
||||
public Win32Rect RcMonitor;
|
||||
public Win32Rect RcWork;
|
||||
public uint DwFlags;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct MonitorInfoEx
|
||||
{
|
||||
public int CbSize;
|
||||
public Win32Rect RcMonitor;
|
||||
public Win32Rect RcWork;
|
||||
public uint DwFlags;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
|
||||
public string SzDevice;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct DisplayDevice
|
||||
{
|
||||
public int Cb;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
|
||||
public string DeviceName;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
|
||||
public string DeviceString;
|
||||
public uint StateFlags;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
|
||||
public string DeviceId;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
|
||||
public string DeviceKey;
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr GetForegroundWindow();
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr MonitorFromWindow(IntPtr hwnd, uint dwFlags);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MonitorInfo lpmi);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MonitorInfoEx lpmi);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern bool EnumDisplayDevices(string lpDevice, uint iDevNum, ref DisplayDevice lpDisplayDevice, uint dwFlags);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool EnumDisplayMonitors(IntPtr hdc, IntPtr lprcClip,
|
||||
MonitorEnumProc lpfnEnum, IntPtr dwData);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
|
||||
|
||||
[DllImport("dwmapi.dll")]
|
||||
private static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, ref Win32Rect pvAttribute, int cbAttribute);
|
||||
|
||||
private delegate bool MonitorEnumProc(IntPtr hMonitor, IntPtr hdcMonitor, ref Win32Rect lprcMonitor, IntPtr dwData);
|
||||
}
|
||||
+10
-2
@@ -8,14 +8,22 @@ External-facing logic: YouTube API, persistence. See
|
||||
| `YouTubeAuthService.cs` | OAuth2 via Google: loopback callback (`http://localhost:8765/oauth2/callback`), token exchange, refresh, channel fetch. Constructor takes optional `HttpClient` + `sessionChanged` callback (test seam + save hook); session persists via `Helpers/TokenStore` (DPAPI); `ClearSession()` signs out (called by `MainViewModel.StopStream` on End Livestream) |
|
||||
| `YouTubeStreamService.cs` | Broadcast/stream management via the v3 API (`enableAutoStart/Stop`). **Not yet switched to the `variable` reusable stream** |
|
||||
| `YouTubeChatService.cs` | Polls `liveChat/messages`, raises `MessageReceived`; `IDisposable` |
|
||||
| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 4 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore) |
|
||||
| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 6 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey`, the live-capture backdrop; v6 = `Scene.HasBackdrop` — Live-only policy, one-time backfill turns Starting/BRB/Chat/Ending off + drops their backdrop sources; `MainViewModel.EnforceBackdropPolicy` re-normalizes on every load) |
|
||||
| `VideoFrame.cs` | Normalized CPU frame seam (`Width`/`Height`/tightly-packed BGRA `byte[]`) — the only pixel type the rest of the app knows about; future capture sources (screen, background-removed webcam) feed the same seam |
|
||||
| `CameraDeviceInfo.cs` | `(Id, DisplayName)` for a physical capture device |
|
||||
| `ICameraEnumerator.cs` | `GetCamerasAsync()` — seam so the picker/`CameraManager` never touch WinRT (tests inject fakes) |
|
||||
| `ICameraFrameSource.cs` | `StartAsync`/`StopAsync`/`FrameAvailable(VideoFrame)` — seam for a running capture source |
|
||||
| `MediaCaptureCameraEnumerator.cs` | WinRT enumeration via `DeviceInformation.FindAllAsync(DeviceClass.VideoCapture)` |
|
||||
| `MediaCaptureFrameSource.cs` | CPU-first MediaCapture source (`MemoryPreference = Cpu`, BGRA8 via `CreateFrameReaderAsync`); frames arrive on a worker thread |
|
||||
| `CameraManager.cs` | Webcam capture ownership: refcounted by `DeviceId`, one shared `WriteableBitmap` per camera, dispatcher-coalesced ~render-rate UI updates (latest-frame drop); `PreviewBitmapChanged`/`CameraFailed` events. `ReleaseAsync` decrements a ref (stops at zero); `ReleaseAllAsync` force-drops every ref + stops the source (webcam swap / layout reload) |
|
||||
| `CameraManager.cs` | Webcam capture ownership: refcounted by `DeviceId`, one shared `WriteableBitmap` per camera, dispatcher-coalesced ~render-rate UI updates (latest-frame drop); `PreviewBitmapChanged`/`CameraFailed` events. `ReleaseAsync` decrements a ref (stops at zero); `ReleaseAllAsync` force-drops every ref + stops the source (webcam swap / layout reload). `GetPreviewBitmap(deviceId)` returns the current shared bitmap so a `WebcamSceneConfig` added mid-session (after the first frame already created the bitmap) still receives the live frames |
|
||||
| `IFullScreenDetector.cs` | Seam for the win32 full-screen detector: `int? GetForegroundFullScreenMonitorIndex()`, `int PrimaryMonitorIndex()`, `IReadOnlyList<DisplayInfo> GetDisplays()` |
|
||||
| `Win32FullScreenDetector.cs` | `GetForegroundWindow` + `DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS)` + `MonitorFromWindow` + `GetMonitorInfo`; monitor covers all four edges → full-screen; own process excluded; monitor order = `EnumDisplayMonitors` order (static `GetMonitorHandle(int)` maps index→HMONITOR for the capture factory). `GetDisplays()` returns `DisplayInfo` (index/name/resolution/bounds/`IsPrimary`, friendly name via `EnumDisplayDevices`) for the in-app "Capture Display" picker; `PrimaryMonitorIndex()` is the auto-key fallback when no full-screen game is detected |
|
||||
| `IScreenCaptureSource.cs` | `Key` + `StartAsync`/`StopAsync`/`FrameAvailable(VideoFrame)` — seam so `ScreenCaptureManager` never touches WinRT (tests inject fakes) |
|
||||
| `Direct3D11Helper.cs` | The one shared `IDirect3DDevice` per process: P/Invoke `d3d11.dll!D3D11CreateDevice` (hardware, BGRA_SUPPORT, explicit 11.1-first feature array) → QI `IDXGIDevice` → the WinRT interop export `CreateDirect3D11DeviceFromDXGIDevice` → `MarshalInterface<IDirect3DDevice>.FromAbi`. CsWinRT does **not** project `Windows.Graphics.Direct3D11.Direct3D11Helper`, so this hand-rolls it. Do **not** revert to QI-for-`IDirect3DDxgiInterfaceAccess` — the device no longer exposes it on newer Windows (E_NOINTERFACE on build 26200) |
|
||||
| `CaptureInterop.cs` | ComImport bridges the WinRT-projection gap: `IGraphicsCaptureItemInterop` (`CreateForMonitor`/`CreateForWindow`), `IInitializeWithWindow` (`GraphicsCapturePicker` owner window so the OS picker shows) |
|
||||
| `ScreenCaptureFrameSource.cs` | One `Direct3D11CaptureFramePool` (free-threaded, 2 buffers) + session per target; frames → `SoftwareBitmap.CreateCopyFromSurfaceAsync` (BGRA, alpha ignored) → `VideoFrame`, bytes read via `WindowsRuntimeMarshal.TryGetDataUnsafe` (CsWinRT-safe — the `IMemoryBufferByteAccess` ComImport cast fails on every frame and is gone). Surfaces > 1920×1080 downscaled bilinearly to the master; conversion failures logged ≤ once/5 s. DRM content = black frames (OS limit). `CreateForMonitor`/`CreateForWindow`/`CreateForPicker` |
|
||||
| `ScreenCaptureManager.cs` | Screen-capture ownership mirroring `CameraManager`: refcounted by target key, one shared `WriteableBitmap`, dispatcher-coalesced latest-frame copies; `PreviewBitmapChanged`/`CaptureFailed` events; `ReleaseAllAsync` used on re-designation |
|
||||
| `ScreenCaptureSourceFactory.cs` | `Resolve(key)` parses `monitor:<n>` / `window:<hwnd>` / `picker:<name>` into a source; `PickAsync()` shows the OS `GraphicsCapturePicker` and returns the `picker:` key (transient — a reload falls back to auto-detection) |
|
||||
|
||||
Related: constructed in [`ViewModels/MainViewModel.cs`](../ViewModels/MainViewModel.cs)
|
||||
(no DI container yet). Models in [`Models/index.md`](../Models/index.md).
|
||||
|
||||
Reference in New Issue
Block a user