Files

155 lines
5.7 KiB
C#

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,
}
/// <summary>Registers/revokes the OS-level hotkeys. Seam so tests never touch
/// user32 (a real registration is global to the machine and needs a pump).</summary>
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);
}
/// <summary>
/// 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.
/// </summary>
public sealed class GlobalHotkeyManager
{
public const int WM_HOTKEY = 0x0312;
internal static Func<IHotkeyRegistrar>? RegistrarOverride;
internal static readonly Dictionary<HotkeyId, HotkeyChord> 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<HotkeyId> _registered = new();
private IntPtr _hwnd;
private System.Windows.Interop.HwndSource? _source;
/// <summary>Raised on the UI thread for each WM_HOTKEY that carries a known id.</summary>
public event Action<HotkeyId>? Activated;
public GlobalHotkeyManager()
: this(RegistrarOverride?.Invoke() ?? new Win32HotkeyRegistrar())
{
}
internal GlobalHotkeyManager(IHotkeyRegistrar registrar) => _registrar = registrar;
/// <summary>
/// Attach to a window and register hotkeys. If <paramref name="bindings"/> 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.
/// </summary>
public void Attach(System.Windows.Window window, Dictionary<HotkeyId, HotkeyChord>? 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");
}
/// <summary>
/// Hot-swap bindings: detach current registrations, re-register with new
/// bindings. Call after the user saves custom hotkey config.
/// </summary>
public void Reattach(System.Windows.Window window, Dictionary<HotkeyId, HotkeyChord> 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<HotkeyId, HotkeyChord>? 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;
}
}