0bd17ab428
- Source.ShowDesktop bool persisted in DB (schema migration + LayoutStore) - ClearBackdropCaptureAsync clears CaptureKey + VideoImageSource for static fallback - Top bar: red/green/pulsing status light, avatar via BitmapImage, Log In button - ShowStartStream now requires IsConnected - RunMockChatPreviewAsync: simple async loop, ChatPreviewEnabled property on Source - EnsureDefaultBackdrop called on every scene switch, seeds all 5 scenes - SeedEndingBackdrop replaces existing, SeedChatBackdrop added - SceneCatalog: Chat scene display name corrected (layer = 'YouTube Chat') - Left panel spacing consistency, context menu reordered - Border animation rendering via code-behind Storyboard approach - 3 new backdrop assets (ending-backdrop.jpg, chat-backdrop.jpg, live-backdrop.jpg) - Build 0 warnings, 178 tests passing
294 lines
11 KiB
C#
294 lines
11 KiB
C#
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;
|
||
private const uint EventSystemForeground = 0x0003;
|
||
private const uint WINEVENTOutofContext = 0x0000;
|
||
private const int GWL_EXSTYLE = -20;
|
||
|
||
private IntPtr _hookHandle;
|
||
private WinEventProc? _hookProc;
|
||
private int? _lastReportedMonitor;
|
||
|
||
public event Action<int?>? FullscreenMonitorChanged;
|
||
|
||
public void StartWatching()
|
||
{
|
||
if (_hookHandle != IntPtr.Zero) return;
|
||
_hookProc = ForegroundHookCallback;
|
||
_hookHandle = SetWinEventHook(EventSystemForeground, EventSystemForeground,
|
||
IntPtr.Zero, _hookProc, 0, 0, WINEVENTOutofContext);
|
||
|
||
var existing = FindAnyFullscreenGameMonitor();
|
||
if (existing != null)
|
||
{
|
||
_lastReportedMonitor = existing;
|
||
FullscreenMonitorChanged?.Invoke(existing);
|
||
}
|
||
}
|
||
|
||
public void StopWatching()
|
||
{
|
||
if (_hookHandle == IntPtr.Zero) return;
|
||
UnhookWinEvent(_hookHandle);
|
||
_hookHandle = IntPtr.Zero;
|
||
_hookProc = null;
|
||
_lastReportedMonitor = null;
|
||
}
|
||
|
||
private void ForegroundHookCallback(IntPtr hWinEventHook, uint eventType,
|
||
IntPtr hwnd, int idObject, int idChild, uint dwEventThread, uint dwmsEventTime)
|
||
{
|
||
if (hwnd == IntPtr.Zero || IsOwnWindow(hwnd)) return;
|
||
var monitor = GetForegroundFullScreenMonitorIndex();
|
||
if (monitor == _lastReportedMonitor) return;
|
||
_lastReportedMonitor = monitor;
|
||
FullscreenMonitorChanged?.Invoke(monitor);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Scans every visible top-level window (not just the foreground one) for
|
||
/// full-screen state. Used at startup to catch a game already running on
|
||
/// another monitor before the hook has a chance to fire.
|
||
/// </summary>
|
||
private int? FindAnyFullscreenGameMonitor()
|
||
{
|
||
int? found = null;
|
||
EnumWindows((hwnd, _) =>
|
||
{
|
||
if (found != null) return false;
|
||
if (hwnd == IntPtr.Zero || IsOwnWindow(hwnd)) return true;
|
||
if (!IsWindowVisible(hwnd)) return true;
|
||
if (!HasAppWindowStyle(hwnd)) return true;
|
||
if (!TryGetExtendedFrameBounds(hwnd, out var bounds)) return true;
|
||
|
||
var monitor = MonitorFromWindow(hwnd, MonitorDefaultToNearest);
|
||
if (monitor == IntPtr.Zero || !TryGetMonitorInfo(monitor, out var info)) return true;
|
||
|
||
var r = info.RcMonitor;
|
||
if (bounds.Left <= r.Left && bounds.Top <= r.Top &&
|
||
bounds.Right >= r.Right && bounds.Bottom >= r.Bottom)
|
||
found = MonitorIndex(monitor);
|
||
return found == null;
|
||
}, IntPtr.Zero);
|
||
return found;
|
||
}
|
||
|
||
private static bool HasAppWindowStyle(IntPtr hwnd)
|
||
{
|
||
const uint WS_EX_APPWINDOW = 0x00040000;
|
||
const uint WS_EX_TOOLWINDOW = 0x00000080;
|
||
var exStyle = (uint)GetWindowLongPtr(hwnd, GWL_EXSTYLE);
|
||
return (exStyle & WS_EX_TOOLWINDOW) == 0 &&
|
||
(exStyle & WS_EX_APPWINDOW) != 0;
|
||
}
|
||
|
||
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);
|
||
|
||
private delegate void WinEventProc(IntPtr hWinEventHook, uint eventType, IntPtr hwnd, int idObject, int idChild, uint dwEventThread, uint dwmsEventTime);
|
||
|
||
[DllImport("user32.dll")]
|
||
private static extern IntPtr SetWinEventHook(uint eventMin, uint eventMax, IntPtr hmodWinEventProc, WinEventProc pfnWinEventProc, uint idProcess, uint idThread, uint dwFlags);
|
||
|
||
[DllImport("user32.dll")]
|
||
private static extern bool UnhookWinEvent(IntPtr hWinEventHook);
|
||
|
||
private delegate bool EnumWindowsProc(IntPtr hwnd, IntPtr lParam);
|
||
|
||
[DllImport("user32.dll")]
|
||
private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
|
||
|
||
[DllImport("user32.dll")]
|
||
private static extern bool IsWindowVisible(IntPtr hwnd);
|
||
|
||
[DllImport("user32.dll", EntryPoint = "GetWindowLongPtr")]
|
||
private static extern IntPtr GetWindowLongPtr(IntPtr hWnd, int nIndex);
|
||
}
|