TASK 20 step 1: global hotkeys F1-F9 (scenes, stream, mutes, TRAX)
This commit is contained in:
+10
-4
@@ -1,28 +1,34 @@
|
|||||||
# HANDOFF — Session State
|
# HANDOFF — Session State
|
||||||
|
|
||||||
## Branch
|
## 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**
|
(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).
|
(TASK 24/25/26+27 fast-forwarded into main; stale `social-bar`/`webcam-validation` removed too).
|
||||||
|
|
||||||
## Shipped (this session, now all on main)
|
## 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 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.
|
- 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
|
## ⚠️ 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.
|
- **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.
|
- **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.
|
- **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.
|
- **`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.
|
- **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`.
|
- 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.
|
- **Dead code flagged, not touched:** `ShowWebcamCommand` / `CanShowWebcamInStagedScene` are wired in the VM but bound nowhere — audit item, separate branch if wanted.
|
||||||
|
|
||||||
## Next steps
|
## 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.
|
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. Relaunch app once so the real `%APPDATA%` DB self-heals (backgrounds + chat label).
|
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
|
## Build
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ public partial class MainWindow : Window
|
|||||||
AppLog.Write("MainWindow loaded");
|
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)
|
private void Window_ContextMenuOpening(object sender, ContextMenuEventArgs e)
|
||||||
{
|
{
|
||||||
if (!_viewModel.IsEditMode)
|
if (!_viewModel.IsEditMode)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GlobalHotkeyManager
|
||||||
|
{
|
||||||
|
public const int WM_HOTKEY = 0x0312;
|
||||||
|
|
||||||
|
internal static Func<IHotkeyRegistrar>? RegistrarOverride;
|
||||||
|
|
||||||
|
private static readonly Dictionary<HotkeyId, uint> 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<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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
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).
|
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).
|
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.
|
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. Small scope, huge UX impact.
|
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.
|
6. **TASK 17** — Web source (WebView2) — enables alert ecosystem.
|
||||||
7. **TASK 18** — Local recording — independent, but pairs with stream.
|
7. **TASK 18** — Local recording — independent, but pairs with stream.
|
||||||
8. **TASK 21** — Media source — video file playback for non-Live scenes.
|
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.
|
**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
|
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 for the 5 canonical scenes (configurable)
|
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 — Start/Stop stream, Mute/Unmute mic, Mute/Unmute game, Toggle TRAX
|
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
|
4. ☐ Hotkey configuration UI — Settings dialog or per-scene hotkey picker
|
||||||
5. ☐ Global hotkeys — work even when app is not focused (for gaming)
|
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
|
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)
|
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
|
### Design decisions
|
||||||
|
|
||||||
|
|||||||
@@ -352,6 +352,39 @@ public class MainViewModel : ViewModelBase
|
|||||||
_transition.Start(SelectedTransitionType, TransitionDurationMs, null);
|
_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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Right-click-on-preview → "Show Webcam": offered when the active scene has no
|
/// 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).
|
/// webcam config (add one) or hides it (unhide — keeps the config row).
|
||||||
|
|||||||
@@ -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
|
- 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`.
|
- 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.
|
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).
|
**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
|
### 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
|
**The Background is a locked per-screen layer (TASK 25): exactly one per canonical
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using Xunit;
|
||||||
|
using ytLive.Services;
|
||||||
|
|
||||||
|
namespace ytLive.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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<HotkeyId> Registered = new();
|
||||||
|
public readonly List<HotkeyId> 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user