using System; using System.Collections.Generic; using System.Runtime.InteropServices; using AppLog = ytLive.Helpers.AppLog; namespace ytLive.Services; public enum HotkeyId { SceneStarting = 1, SceneLive = 2, SceneBrb = 3, SceneChat = 4, SceneEnding = 5, StartStopStream = 6, ToggleMicMute = 7, ToggleGameMute = 8, TraxPlayPause = 9, } /// Registers/revokes the OS-level hotkeys. Seam so tests never touch /// user32 (a real registration is global to the machine and needs a pump). public interface IHotkeyRegistrar { bool Register(IntPtr hwnd, HotkeyId id, uint modifiers, uint virtualKey); void Unregister(IntPtr hwnd, HotkeyId id); } public sealed class Win32HotkeyRegistrar : IHotkeyRegistrar { public bool Register(IntPtr hwnd, HotkeyId id, uint modifiers, uint virtualKey) => RegisterHotKey(hwnd, (int)id, modifiers, virtualKey); public void Unregister(IntPtr hwnd, HotkeyId id) => UnregisterHotKey(hwnd, (int)id); [DllImport("user32.dll", SetLastError = true)] private static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk); [DllImport("user32.dll", SetLastError = true)] private static extern bool UnregisterHotKey(IntPtr hWnd, int id); } /// /// TASK 20: global hotkeys — work while the app is unfocused (streamers are in-game). /// Defaults: F1-F5 stage/transition the five canonical scenes, F6 start/end stream, /// F7 mute mic, F8 mute desktop audio, F9 TRAX play/pause. /// Attach after the window has a source (OnSourceInitialized); Detach on close. /// Pass a custom bindings dictionary to override defaults. /// public sealed class GlobalHotkeyManager { public const int WM_HOTKEY = 0x0312; internal static Func? RegistrarOverride; internal static readonly Dictionary Defaults = new() { [HotkeyId.SceneStarting] = new HotkeyChord(0, 0x70), // F1 [HotkeyId.SceneLive] = new HotkeyChord(0, 0x71), // F2 [HotkeyId.SceneBrb] = new HotkeyChord(0, 0x72), // F3 [HotkeyId.SceneChat] = new HotkeyChord(0, 0x73), // F4 [HotkeyId.SceneEnding] = new HotkeyChord(0, 0x74), // F5 [HotkeyId.StartStopStream]= new HotkeyChord(0, 0x75), // F6 [HotkeyId.ToggleMicMute] = new HotkeyChord(0, 0x76), // F7 [HotkeyId.ToggleGameMute] = new HotkeyChord(0, 0x77), // F8 [HotkeyId.TraxPlayPause] = new HotkeyChord(0, 0x78), // F9 }; private readonly IHotkeyRegistrar _registrar; private readonly List _registered = new(); private IntPtr _hwnd; private System.Windows.Interop.HwndSource? _source; /// Raised on the UI thread for each WM_HOTKEY that carries a known id. public event Action? Activated; public GlobalHotkeyManager() : this(RegistrarOverride?.Invoke() ?? new Win32HotkeyRegistrar()) { } internal GlobalHotkeyManager(IHotkeyRegistrar registrar) => _registrar = registrar; /// /// Attach to a window and register hotkeys. If is /// non-null, it overrides the defaults for the provided entries; any HotkeyId /// not present in the dictionary falls back to the built-in default. /// public void Attach(System.Windows.Window window, Dictionary? bindings = null) { _hwnd = new System.Windows.Interop.WindowInteropHelper(window).Handle; _source = System.Windows.Interop.HwndSource.FromHwnd(_hwnd); _source?.AddHook(WndProc); RegisterAll(bindings); window.Closed += OnWindowClosed; AppLog.Write($"hotkey: attached, {_registered.Count} registered"); } /// /// Hot-swap bindings: detach current registrations, re-register with new /// bindings. Call after the user saves custom hotkey config. /// public void Reattach(System.Windows.Window window, Dictionary bindings) { Detach(); _hwnd = new System.Windows.Interop.WindowInteropHelper(window).Handle; _source = System.Windows.Interop.HwndSource.FromHwnd(_hwnd); _source?.AddHook(WndProc); RegisterAll(bindings); AppLog.Write($"hotkey: reattached, {_registered.Count} registered"); } private void RegisterAll(Dictionary? bindings) { foreach (HotkeyId id in Enum.GetValues(typeof(HotkeyId))) { var chord = bindings != null && bindings.TryGetValue(id, out var custom) ? custom : Defaults.TryGetValue(id, out var def) ? def : null; if (chord == null) continue; // unbound action if (_registrar.Register(_hwnd, id, chord.Modifiers, chord.VirtualKey)) _registered.Add(id); else AppLog.Write($"Hotkey: {id} ({chord.ToDisplayString()}) not registered — likely owned by another app"); } } private void OnWindowClosed(object? sender, EventArgs e) => Detach(); private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) { if (msg != WM_HOTKEY) return IntPtr.Zero; var raw = wParam.ToInt64(); if (raw is < 1 or > int.MaxValue || !Enum.IsDefined(typeof(HotkeyId), (int)raw)) return IntPtr.Zero; Activated?.Invoke((HotkeyId)raw); handled = true; return IntPtr.Zero; } public void Detach() { if (_hwnd != IntPtr.Zero) foreach (var id in _registered) _registrar.Unregister(_hwnd, id); _registered.Clear(); _source?.RemoveHook(WndProc); _source = null; _hwnd = IntPtr.Zero; } }