From b0bace74328bd47ff31908114d9b30df3d5bcc7b Mon Sep 17 00:00:00 2001 From: gramps Date: Mon, 24 Aug 2026 09:32:54 -0700 Subject: [PATCH] TASK 20 step 1: global hotkeys F1-F9 (scenes, stream, mutes, TRAX) --- HANDOFF.md | 14 +++- MainWindow.xaml.cs | 10 +++ Services/GlobalHotkeys.cs | 123 ++++++++++++++++++++++++++++++ TASKS.md | 18 ++--- ViewModels/MainViewModel.cs | 33 ++++++++ ai.md | 9 ++- ytLive.Tests/GlobalHotkeyTests.cs | 93 ++++++++++++++++++++++ 7 files changed, 286 insertions(+), 14 deletions(-) create mode 100644 Services/GlobalHotkeys.cs create mode 100644 ytLive.Tests/GlobalHotkeyTests.cs diff --git a/HANDOFF.md b/HANDOFF.md index faf79bd..c9fd7e9 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,28 +1,34 @@ # HANDOFF — Session State ## Branch -**`main`** @ `708b336` — committed and pushed, working tree clean. **No feature branches pre-1.0 +**`main`** @ HEAD — committed and pushed, working tree clean. **No feature branches pre-1.0 (decided 2026-08-24): all work lands on `main` per work unit; every `taskNN-*` branch deleted** (TASK 24/25/26+27 fast-forwarded into main; stale `social-bar`/`webcam-validation` removed too). ## Shipped (this session, now all on main) - TASK 26 — webcam (+) menu gates on the app-wide identity (`CanAddWebcam` = staged != null && `_webcam == null`; XAML binding + tooltip; test `WebcamMenuGateTests`). - TASK 27 — YouTube Chat layer one-per-layout gate (`CanAddYouTubeChat`, AddSource refuses a second) + legacy label heal ("Chat" → "YouTube Chat" on LoadLayout; creator renames untouched; test `ChatLayerGateTests`). Creator's ask: "do the same thing with the YouTube chat layer" + make the Layers row match the (+) picklist. -- Suite: 225 total, 224 pass — only failure is the known pre-existing `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`. Build 0 warnings. Docs (ai.md/TASKS rows 27-28/HANDOFF) shipped in the same commits. +- TASK 19/23 status flip — code survey confirmed Control Surface UX fully shipped (thumbnail strip, two-state left panel, right panel gone, transitions, edit gating); map corrected. +- **TASK 20 step 1 — global hotkeys** (`Services/GlobalHotkeys.cs`, `MainViewModel.HandleHotkey`, `MainWindow.OnSourceInitialized` wiring): F1-F5 canonical scenes, F6 start/end stream, F7 mic mute, F8 desktop mute, F9 TRAX. `IHotkeyRegistrar` seam + `RegistrarOverride`. Test `GlobalHotkeyTests.WmHotkey_StagesScene_And_TogglesMic_And_RevokesOnClose`. +- Suite: 226 total, 225 pass — only failure is the known pre-existing `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`. Build 0 warnings. Docs shipped same-commit. +- Visual pass on TASK 26/27 validated by creator (2026-08-24). ## ⚠️ Landmines - **User verdict (2026-08-23):** "The AI wrote fucked up code after repeatedly and intentionally misunderstanding instructions." Extends the ai.md 2026-08-22 audit flag — trust nothing from those sessions without manual verification. - **Pre-existing test failure is ONE** (`AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`) — not caused by these branches. - **Real-MainWindow tests MUST set `LayoutPathOverride`** (ai.md) — DB pollution wiped real data once. +- **WPF handle forcing:** `WindowInteropHelper.Handle` pre-show returns Zero and does NOT raise `SourceInitialized`; use `EnsureHandle()` (does both). Cost a debug cycle in GlobalHotkeyTests. - **`Source.Type` setter derives `IsBackground`** (Models/Source.cs:40) — any Type change must re-set IsBackground after. - **vstest quirk:** only one `--tests:` param allowed; plain substrings work, alternation regexes silently match nothing. - Notification.Wpf gotchas (ai.md/TASKS.md): area routing by XAML `Name`; unknown AreaName silently drops; `NeverExpires()` = `TimeSpan.MaxValue`; `ToHex()` = `#AARRGGBB`. - **Dead code flagged, not touched:** `ShowWebcamCommand` / `CanShowWebcamInStagedScene` are wired in the VM but bound nowhere — audit item, separate branch if wanted. ## Next steps -1. Creator visual pass on Windows: add YouTube Chat to any scene → (+) item greys everywhere; legacy "Chat"-named row (if present) shows as "YouTube Chat"; remove → re-enables. Same for the webcam row. -2. Relaunch app once so the real `%APPDATA%` DB self-heals (backgrounds + chat label). +1. Creator runtime check of hotkeys: launch app, F3 stages BRB offline, F9 toggles TRAX, F7 flips mic dot. Note: F-keys are machine-global while the app runs. +2. Continue TASK 20: config UI + schema persistence for bindings (then conflict detection). +3. Queue after TASK 20: TASK 21 media source, TASK 22 audio sync offset, TASK 17 web source, TASK 18 local recording. +4. Loose ends: dead `ShowWebcamCommand`, AudioPipelineTests failure, Velopack update URL. ## Build ```bash diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 3c5c62d..09a741e 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -37,6 +37,16 @@ public partial class MainWindow : Window AppLog.Write("MainWindow loaded"); } + private ytLive.Services.GlobalHotkeyManager? _hotkeys; + + protected override void OnSourceInitialized(EventArgs e) + { + base.OnSourceInitialized(e); + _hotkeys = new ytLive.Services.GlobalHotkeyManager(); + _hotkeys.Activated += id => _viewModel.HandleHotkey(id); + _hotkeys.Attach(this); + } + private void Window_ContextMenuOpening(object sender, ContextMenuEventArgs e) { if (!_viewModel.IsEditMode) diff --git a/Services/GlobalHotkeys.cs b/Services/GlobalHotkeys.cs new file mode 100644 index 0000000..20ccde5 --- /dev/null +++ b/Services/GlobalHotkeys.cs @@ -0,0 +1,123 @@ +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 virtualKey); + void Unregister(IntPtr hwnd, HotkeyId id); +} + +public sealed class Win32HotkeyRegistrar : IHotkeyRegistrar +{ + public bool Register(IntPtr hwnd, HotkeyId id, uint virtualKey) + => RegisterHotKey(hwnd, (int)id, 0, 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. +/// +public sealed class GlobalHotkeyManager +{ + public const int WM_HOTKEY = 0x0312; + + internal static Func? RegistrarOverride; + + private static readonly Dictionary Defaults = new() + { + [HotkeyId.SceneStarting] = 0x70, // F1 + [HotkeyId.SceneLive] = 0x71, // F2 + [HotkeyId.SceneBrb] = 0x72, // F3 + [HotkeyId.SceneChat] = 0x73, // F4 + [HotkeyId.SceneEnding] = 0x74, // F5 + [HotkeyId.StartStopStream] = 0x75, // F6 + [HotkeyId.ToggleMicMute] = 0x76, // F7 + [HotkeyId.ToggleGameMute] = 0x77, // F8 + [HotkeyId.TraxPlayPause] = 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; + + public void Attach(System.Windows.Window window) + { + _hwnd = new System.Windows.Interop.WindowInteropHelper(window).Handle; + _source = System.Windows.Interop.HwndSource.FromHwnd(_hwnd); + _source?.AddHook(WndProc); + foreach (var (id, vk) in Defaults) + { + if (_registrar.Register(_hwnd, id, vk)) + _registered.Add(id); + else + AppLog.Write($"Hotkey: {(HotkeyId)id} (vk {vk}) not registered — likely owned by another app"); + } + window.Closed += OnWindowClosed; + AppLog.Write($"hotkey: attached, {_registered.Count} registered"); + } + + 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; + } + + private 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; + } +} diff --git a/TASKS.md b/TASKS.md index 5201c3b..f625177 100644 --- a/TASKS.md +++ b/TASKS.md @@ -958,8 +958,8 @@ The tasks below are ordered by dependency and risk. Each task builds on the prev 1. ✅ **TASK 9.4** — Live chat (right panel) — wire `YouTubeChatService.Start()`, parse messages, render in panel. Foundation for chat box source. 2. ✅ **TASK 3.18** — Chat box source — renders chat ON the stream. Depends on TASK 9.4 (same message parsing). 3. ✅ **TASK 10** — Polar billing — license key entry + watermark toggle + Velopack auto-updates (steps 1-7 shipped; Velopack update URL pending). -4. **TASK 19/23** — Control Surface UX — director's control room: thumbnails above central monitor, transitions (Cut/Fade/Move), edit mode offline only, left panel two-state (layers/props ↔ chat), right panel eliminated. -5. **TASK 20** — Hotkeys — global keyboard shortcuts. Small scope, huge UX impact. +4. ✅ **TASK 19/23** — Control Surface UX — director's control room: thumbnails above central monitor, transitions (Cut/Fade/Move), edit mode offline only, left panel two-state (layers/props ↔ chat), right panel eliminated. Verified shipped 2026-08-24. +5. **TASK 20** — Hotkeys — global keyboard shortcuts. Step 1 (F1-F9 defaults) shipped 2026-08-24; config UI + persistence remain. 6. **TASK 17** — Web source (WebView2) — enables alert ecosystem. 7. **TASK 18** — Local recording — independent, but pairs with stream. 8. **TASK 21** — Media source — video file playback for non-Live scenes. @@ -987,16 +987,16 @@ The tasks below are ordered by dependency and risk. Each task builds on the prev **Goal:** keyboard shortcuts for scene switching and common actions — the single biggest UX gap. -### Status: ☐ Not started — required for v1 +### Status: ◐ In progress — step 1 shipped 2026-08-24 -1. ☐ `HotkeyManager` service — register/unregister hotkeys per scene + common actions -2. ☐ Scene switching hotkeys — F1-F5 for the 5 canonical scenes (configurable) -3. ☐ Common action hotkeys — Start/Stop stream, Mute/Unmute mic, Mute/Unmute game, Toggle TRAX +1. ✅ `GlobalHotkeyManager` service — `Services/GlobalHotkeys.cs`: registers OS-level hotkeys on the window HWND via `RegisterHotKey`/`WM_HOTKEY` (`IHotkeyRegistrar` seam for tests; `RegistrarOverride` mirrors `LayoutPathOverride`). Wired in `MainWindow.OnSourceInitialized`. +2. ☑ Scene switching hotkeys — F1-F5 stage/transition the five canonical scenes. Fixed defaults; configurability deferred to the config UI step. +3. ☑ Common action hotkeys — F6 start/end stream, F7 mute mic, F8 mute desktop audio, F9 TRAX play/pause. Dispatch lives in `MainViewModel.HandleHotkey` and honors command gating. 4. ☐ Hotkey configuration UI — Settings dialog or per-scene hotkey picker -5. ☐ Global hotkeys — work even when app is not focused (for gaming) -6. ☐ Conflict detection — warn when two actions share the same hotkey +5. ✅ Global hotkeys — work even when app is not focused (that is what `RegisterHotKey` gives us) +6. ☐ Conflict detection — warn when two actions share the same hotkey (impossible with fixed defaults; arrives with configurability) 7. ☐ Schema migration for hotkey bindings (persist across restarts) -8. ☐ Tests: hotkey registration, conflict detection, scene switch via hotkey +8. ☑ Test: `GlobalHotkeyTests.WmHotkey_StagesScene_And_TogglesMic_And_RevokesOnClose` — real window + fake registrar, a genuine `WM_HOTKEY` posted through the window's own `HwndSource` hook stages the mapped scene and flips mic mute; close revokes every registration. ### Design decisions diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index abe33fd..a70c3b5 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -352,6 +352,39 @@ public class MainViewModel : ViewModelBase _transition.Start(SelectedTransitionType, TransitionDurationMs, null); } + // ─── Global hotkeys (TASK 20) — dispatch a registered hotkey to its action ─── + + public void HandleHotkey(Services.HotkeyId id) + { + switch (id) + { + case Services.HotkeyId.SceneStarting: TransitionToCanonical(SceneCatalog.Starting); break; + case Services.HotkeyId.SceneLive: TransitionToCanonical(SceneCatalog.Live); break; + case Services.HotkeyId.SceneBrb: TransitionToCanonical(SceneCatalog.Brb); break; + case Services.HotkeyId.SceneChat: TransitionToCanonical(SceneCatalog.Chat); break; + case Services.HotkeyId.SceneEnding: TransitionToCanonical(SceneCatalog.Ending); break; + case Services.HotkeyId.StartStopStream: + if (IsLive) + { + if (EndStreamCommand.CanExecute(null)) EndStreamCommand.Execute(null); + } + else if (StartStreamCommand.CanExecute(null)) + { + StartStreamCommand.Execute(null); + } + break; + case Services.HotkeyId.ToggleMicMute: ToggleMicMuteCommand.Execute(null); break; + case Services.HotkeyId.ToggleGameMute: ToggleGameMuteCommand.Execute(null); break; + case Services.HotkeyId.TraxPlayPause: TraxLeftClick(); break; + } + } + + private void TransitionToCanonical(string name) + { + var scene = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, name)); + if (scene != null) TransitionToScene(scene); + } + /// /// Right-click-on-preview → "Show Webcam": offered when the active scene has no /// webcam config (add one) or hides it (unhide — keeps the config row). diff --git a/ai.md b/ai.md index b8ae5c5..628ecbb 100644 --- a/ai.md +++ b/ai.md @@ -165,7 +165,7 @@ The AI hallucinated through multiple commits tonight on background/scene propert - TASK 22: Audio sync offset — lip-sync correction for USB mics/capture cards - These were identified through competitive analysis and are table-stakes for any streaming software in 2025-2026. Full details in `TASKS.md`. -### Control Surface UX (TASK 19/23 merged — active) +### Control Surface UX (TASK 19/23 — shipped 2026-08-24, verified by code survey) The app is a **director's control surface**, not an editor. This replaces the OBS-style scene list with a TV-control-room metaphor. @@ -185,6 +185,13 @@ The app is a **director's control surface**, not an editor. This replaces the OB **Thumbnails:** `Scene.Snapshot` property. `RefreshSnapshotsAsync` fills minis from each scene's background art (`LoadBackgroundImage`); **minis never render live captures** — while the Live screen is staged its mini shows the green placeholder, unstaged it shows `live-background.jpg` (real-time surface = center monitor only; user decision 2026-08-23). +### Global hotkeys (TASK 20 — step 1 shipped 2026-08-24) + +- `Services/GlobalHotkeys.cs`: `HotkeyId` enum (9 actions), `IHotkeyRegistrar` seam, `Win32HotkeyRegistrar` (`RegisterHotKey`, no modifiers), `GlobalHotkeyManager`. Fixed defaults: F1-F5 canonical scenes, F6 start/end stream, F7 mic mute, F8 desktop-audio mute, F9 TRAX play/pause. +- `MainWindow.OnSourceInitialized` creates the manager and forwards `Activated` → `MainViewModel.HandleHotkey`; window `Closed` detaches (unregister all). +- `MainViewModel.HandleHotkey` maps ids to `TransitionToScene`/start-end commands (honoring `CanExecute`)/mute toggles/`TraxLeftClick`. +- Tests: `RegistrarOverride` static seam mirrors `LayoutPathOverride`. **Landmine:** forcing a WPF window's handle with `WindowInteropHelper.Handle` does NOT raise `SourceInitialized` and returns Zero pre-show — use `EnsureHandle()` in tests; it both creates the HWND and fires the event. + ### Screen backdrop capture (TASK 3 ship task #1) — model reworked by TASK 25 **The Background is a locked per-screen layer (TASK 25): exactly one per canonical diff --git a/ytLive.Tests/GlobalHotkeyTests.cs b/ytLive.Tests/GlobalHotkeyTests.cs new file mode 100644 index 0000000..dd54b3f --- /dev/null +++ b/ytLive.Tests/GlobalHotkeyTests.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using Xunit; +using ytLive.Services; + +namespace ytLive.Tests; + +/// +/// TASK 20 integration: global hotkeys reach their commands. A real MainWindow +/// attaches the GlobalHotkeyManager (fake registrar — no OS-wide registration in +/// tests); posting a genuine WM_HOTKEY through the window's own HwndSource hook +/// must stage the mapped scene and flip the mic mute. Closing must revoke every +/// registration made. +/// +[Collection("RealApp")] +public sealed class GlobalHotkeyTests : IDisposable +{ + [Fact] + public void WmHotkey_StagesScene_And_TogglesMic_And_RevokesOnClose() + { + _app.Run(Run); + } + + private readonly RealAppHost _app; + + public GlobalHotkeyTests(RealAppHost app) => _app = app; + + private sealed class FakeRegistrar : IHotkeyRegistrar + { + public readonly List Registered = new(); + public readonly List Unregistered = new(); + + public bool Register(IntPtr hwnd, HotkeyId id, uint virtualKey) + { + Registered.Add(id); + return true; + } + + public void Unregister(IntPtr hwnd, HotkeyId id) => Unregistered.Add(id); + } + + private void Run() + { + var tempDb = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"ytLlive-hotkeys-{Guid.NewGuid():N}.db"); + ViewModels.MainViewModel.LayoutPathOverride = tempDb; + GlobalHotkeyManager.RegistrarOverride = () => _registrar; + var window = default(MainWindow); + try + { + using (var schema = new ytLive.Services.LayoutStore(tempDb)) { } + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + + window = new MainWindow(); + var vm = (ViewModels.MainViewModel)window.DataContext; + + new System.Windows.Interop.WindowInteropHelper(window).EnsureHandle(); + var hwnd = new System.Windows.Interop.WindowInteropHelper(window).Handle; + + Assert.Equal(Enum.GetValues(typeof(HotkeyId)).Length, _registrar.Registered.Count); + + SendMessage(hwnd, GlobalHotkeyManager.WM_HOTKEY, (IntPtr)HotkeyId.SceneBrb, IntPtr.Zero); + Assert.NotNull(vm.StagedScene); + Assert.Equal("BRB", vm.StagedScene!.Name); + + SendMessage(hwnd, GlobalHotkeyManager.WM_HOTKEY, (IntPtr)HotkeyId.SceneLive, IntPtr.Zero); + Assert.Equal("Live", vm.StagedScene!.Name); + + var wasMuted = vm.MicMuted; + SendMessage(hwnd, GlobalHotkeyManager.WM_HOTKEY, (IntPtr)HotkeyId.ToggleMicMute, IntPtr.Zero); + Assert.NotEqual(wasMuted, vm.MicMuted); + + window.Close(); + window = null; + Assert.Equal(_registrar.Registered.Count, _registrar.Unregistered.Count); + } + finally + { + window?.Close(); + GlobalHotkeyManager.RegistrarOverride = null; + ViewModels.MainViewModel.LayoutPathOverride = null; + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + try { System.IO.File.Delete(tempDb); } catch { /* best-effort cleanup */ } + } + } + + private readonly FakeRegistrar _registrar = new(); + + [DllImport("user32.dll")] + private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam); + + public void Dispose() => GC.SuppressFinalize(this); +}