TASK 20 step 2: hotkey config UI + modifier chord persistence + conflict detection

This commit is contained in:
2026-08-26 06:48:14 -07:00
parent 89ab83be2e
commit 3a1484b3c5
12 changed files with 770 additions and 42 deletions
+16 -9
View File
@@ -1,7 +1,7 @@
# HANDOFF — Session State
## Branch
**`main`** @ `1009800`, committed LOCALLY, working tree clean. **NOT pushed — user rule: never
**`main`** @ `89ab83b`, committed LOCALLY, working tree clean. **NOT pushed — user rule: never
push without explicit instruction (2026-08-24).** No feature branches pre-1.0: all work lands on
`main` per work unit.
@@ -13,10 +13,18 @@ push without explicit instruction (2026-08-24).** No feature branches pre-1.0: a
- ON-AIR sign right of REC — gray offline / red public / green private (`OnAirBrush`), shared `StatusSignText` style. Test `OnAirSignTests`.
- **Broadcast metadata pull-out + launch geometry** (`b988e6d`) — white "Text" tab on preview's right edge slides out the broadcast form: Title, Description, Tags csv, Visibility, Made-for-Kids, read-only Scheduled Start. Edits persist to Settings `Broadcast.*` immediately; Update Broadcast PUTs `liveBroadcasts?part=snippet,status` via new `YouTubeStreamService.UpdateBroadcast`. Go Live prefills from the form + captures back what was inserted. Old Default Stream Title/Description removed from App Settings (properties deleted). Window: default 1920×1040, MinWidth 1366, MinHeight 768, Manual startup, size+position persisted on close and restored clamped. Test `BroadcastPullOutTests`.
- **Bug-fix pass 1** (`e6baa55` + `d9e352f`): Text tab **always visible** (creator revised the Live-only gating same day); tab **toggles** the drawer; **click-away collapses it** (`Window_PreviewMouseLeftButtonDown` checks `TextPullOutHost` ancestry); avatar initial-letter fallback added.
- **Bug-fix pass 2** (`1009800`, UNPUSHED): real crash fix + avatar root cause:
- Logo/text-Run crash: first fix was WRONG (`parent ?? LogicalTreeHelper.GetParent` — unreachable, because `VisualTreeHelper.GetParent(Run)` throws before `??`). Real fix in `IsDescendantOf`: type-guard `child is Visual or Visual3D` BEFORE choosing visual vs logical helper.
- Avatar showed "L" because WPF's BitmapImage downloader failed with "This Freezable cannot be frozen". Now `HttpClient.GetByteArrayAsync` → `MemoryStream` → `StreamSource` + OnLoad → `Freeze()` (`MainWindow.LoadAvatarImage`, static `AvatarHttp`). Initial fallback only for empty URL / failed download; failures log to startup.log.
- Suite: 228 total, 227 pass — only failure is the known pre-existing `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`. Build 0 warnings.
- **Bug-fix pass 2** (`1009800`): real crash fix + avatar root cause.
- **Update Broadcast button** (`89ab83b`): diff-before-push, offline=local only, live=API; Go Live empty-field prompt opens Text drawer.
- **TASK 20 step 2** (this session): hotkey config UI + persistence:
- `Services/HotkeyChord.cs` — modifier+key chord record with display/parse/FromWpf helpers
- `Services/GlobalHotkeys.cs` — modifier param on `IHotkeyRegistrar`, custom bindings dict, `Reattach()` for hot-swap
- `Services/LayoutStore.cs` — `LoadHotkeyBindings()` / `SaveHotkeyBindings()` (Settings table, no migration)
- `ViewModels/HotkeyConfigViewModel.cs` — working copy, capture mode, conflict detection, unbind, reset
- `HotkeyConfigDialog.xaml` + `.cs` — click-to-capture, Unbind per row, Reset/Save/Cancel
- `ViewModels/MainViewModel.cs` — loads bindings, `OpenHotkeyConfigCommand`, `HotkeyBindingsChanged` callback
- `MainWindow.xaml.cs` — passes bindings to manager, re-attaches on dialog save
- Tests: `HotkeyConfigTests` (round-trip, display string, storage serialization)
- Suite: 231 total, 230 pass — only failure is the known pre-existing `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`. Build 0 warnings.
## ⚠️ Landmines
@@ -38,10 +46,9 @@ push without explicit instruction (2026-08-24).** No feature branches pre-1.0: a
- **Dead code flagged, not touched:** `ShowWebcamCommand` / `CanShowWebcamInStagedScene` wired but unbound — audit item.
## Next steps
1. **QUEUED (explicit, do this first next session): the red button at the bottom of the Text drawer ("Update Broadcast", YtButton style) must ALSO close the drawer when clicked** — so the drawer closes three ways: tab click (toggle), off-form click-away, and that bottom button. Creator confirmed meaning after I wasted time asking ("what else would you call the red button at the bottom of the form"). Implementation hint: extend `CloseDrawerCommand` usage or have the button's click set `IsDrawerOpen = false` after `UpdateRemoteAsync` completes (creator's wording: "put code behind the event button" = wire the dismiss behavior; keep Update's remote push as-is). Add/extend test in `BroadcastPullOutTests`.
2. Verify unpushed fixes on next launch: logo click opens About (no crash); avatar shows the real profile image (startup.log free of `Avatar load failed`); then PUSH pending creator go-ahead.
3. Continue queue: TASK 20 step 2 (hotkey config UI + persistence), TASK 17 web source, TASK 18 local recording, TASK 21 media source.
4. Loose ends: dead `ShowWebcamCommand`, AudioPipelineTests failure, Velopack update URL.
1. Verify unpushed fixes on next launch: logo click opens About (no crash); avatar shows the real profile image (startup.log free of `Avatar load failed`); then PUSH pending creator go-ahead.
2. Continue queue: TASK 17 web source, TASK 18 local recording, TASK 21 media source, TASK 22 audio sync offset.
3. Loose ends: dead `ShowWebcamCommand`, AudioPipelineTests failure, Velopack update URL.
## Build
```bash
+103
View File
@@ -0,0 +1,103 @@
<Window x:Class="ytLive.HotkeyConfigDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Global Hotkey Bindings" Height="520" Width="480"
Icon="/Assets/llamacasty-icon.png"
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
ShowInTaskbar="False" Background="#1a1a2e">
<Window.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
</Window.Resources>
<Grid Margin="24">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Global Hotkey Bindings" FontSize="18" FontWeight="Bold"
Foreground="#e0e0e0" Margin="0,0,0,12"/>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Rows}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid Margin="0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="180"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="{Binding ActionName}"
Foreground="#e0e0e0" FontSize="13"
VerticalAlignment="Center" Margin="0,0,12,0"/>
<Button Grid.Column="1" Click="ChordButton_Click"
Tag="{Binding}" Cursor="Hand"
HorizontalAlignment="Left" MinWidth="140"
Padding="10,5" FontSize="13"
VerticalAlignment="Center">
<Border Background="#16213e" CornerRadius="4" Padding="10,5">
<TextBlock Text="{Binding DisplayText}" Foreground="#e0e0e0"
FontSize="13"/>
</Border>
</Button>
<Button Grid.Column="2" Content="✕" Click="UnbindButton_Click"
Tag="{Binding}" Cursor="Hand"
ToolTip="Remove binding"
FontSize="12" Foreground="#8a93a8"
Background="Transparent" BorderThickness="0"
Padding="6,4" VerticalAlignment="Center">
<Button.Style>
<Style TargetType="Button">
<Style.Triggers>
<DataTrigger Binding="{Binding Chord}" Value="{x:Null}">
<Setter Property="Visibility" Value="Hidden"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<!-- Conflict warning: hidden when no message -->
<Border Grid.Row="2" Background="#3d1a1a" CornerRadius="4" Padding="10,8"
Margin="0,8,0,0" BorderBrush="#e94560" BorderThickness="1">
<Border.Style>
<Style TargetType="Border">
<Style.Triggers>
<DataTrigger Binding="{Binding ConflictMessage}" Value="{x:Null}">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock Text="{Binding ConflictMessage}" Foreground="#e94560"
FontSize="12" TextWrapping="Wrap"/>
</Border>
<!-- Bottom buttons -->
<StackPanel Grid.Row="4" Orientation="Horizontal" Margin="0,12,0,0">
<Button Content="Reset to Defaults" Command="{Binding ResetToDefaultsCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0" FontSize="12"
Padding="12,6"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="Cancel" Command="{Binding CancelCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"
IsCancel="True" Padding="12,6"/>
<Button Content="Save" Command="{Binding SaveCommand}"
Style="{StaticResource YtButton}" MinWidth="110" Padding="12,6"/>
</StackPanel>
</StackPanel>
</Grid>
</Window>
+69
View File
@@ -0,0 +1,69 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using ytLive.Services;
using ytLive.ViewModels;
namespace ytLive;
public partial class HotkeyConfigDialog : Window
{
private readonly HotkeyConfigViewModel _viewModel;
public HotkeyConfigDialog(HotkeyConfigViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
_viewModel = viewModel;
viewModel.SaveRequested += () => DialogResult = true;
viewModel.CancelRequested += () => DialogResult = false;
}
private void ChordButton_Click(object sender, RoutedEventArgs e)
{
if (sender is not FrameworkElement { Tag: HotkeyBindingRow row }) return;
_viewModel.StartCaptureCommand.Execute(row);
// Enter capture mode: listen for the next key combo on this specific button.
if (row.IsCapturing && sender is Button button)
{
button.PreviewKeyDown += ChordButton_PreviewKeyDown;
button.Focus();
}
}
private void ChordButton_PreviewKeyDown(object sender, KeyEventArgs e)
{
if (sender is not Button button) return;
button.PreviewKeyDown -= ChordButton_PreviewKeyDown;
e.Handled = true;
// Escape cancels capture without changing the binding.
if (e.Key == Key.Escape)
{
_viewModel.CancelCapture();
return;
}
// Skip bare modifier-only presses — wait for a real key.
var key = e.Key == Key.System ? e.SystemKey : e.Key;
if (key is Key.LeftCtrl or Key.RightCtrl or Key.LeftAlt or Key.RightAlt
or Key.LeftShift or Key.RightShift or Key.LWin or Key.RWin)
{
// Re-enter capture — the button is still focused, re-subscribe.
button.PreviewKeyDown += ChordButton_PreviewKeyDown;
return;
}
var chord = HotkeyChord.FromWpf(key, Keyboard.Modifiers);
_viewModel.AssignCapturedChord(chord);
}
private void UnbindButton_Click(object sender, RoutedEventArgs e)
{
if (sender is FrameworkElement { Tag: HotkeyBindingRow row })
_viewModel.UnbindCommand.Execute(row);
}
}
+2 -1
View File
@@ -94,7 +94,8 @@ public partial class MainWindow : Window
base.OnSourceInitialized(e);
_hotkeys = new ytLive.Services.GlobalHotkeyManager();
_hotkeys.Activated += id => _viewModel.HandleHotkey(id);
_hotkeys.Attach(this);
_hotkeys.Attach(this, _viewModel.CurrentHotkeyBindings);
_viewModel.HotkeyBindingsChanged = bindings => _hotkeys.Reattach(this, bindings);
}
private void Window_ContextMenuOpening(object sender, ContextMenuEventArgs e)
+53 -22
View File
@@ -22,14 +22,14 @@ public enum HotkeyId
/// 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);
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 virtualKey)
=> RegisterHotKey(hwnd, (int)id, 0, virtualKey);
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);
@@ -46,6 +46,7 @@ public sealed class Win32HotkeyRegistrar : IHotkeyRegistrar
/// 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
{
@@ -53,17 +54,17 @@ public sealed class GlobalHotkeyManager
internal static Func<IHotkeyRegistrar>? RegistrarOverride;
private static readonly Dictionary<HotkeyId, uint> Defaults = new()
internal static readonly Dictionary<HotkeyId, HotkeyChord> 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
[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;
@@ -81,22 +82,52 @@ public sealed class GlobalHotkeyManager
internal GlobalHotkeyManager(IHotkeyRegistrar registrar) => _registrar = registrar;
public void Attach(System.Windows.Window window)
/// <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);
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");
}
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)
@@ -110,7 +141,7 @@ public sealed class GlobalHotkeyManager
return IntPtr.Zero;
}
private void Detach()
public void Detach()
{
if (_hwnd != IntPtr.Zero)
foreach (var id in _registered)
+117
View File
@@ -0,0 +1,117 @@
using System;
using System.Collections.Generic;
using System.Windows.Input;
namespace ytLive.Services;
/// <summary>
/// A modifier+key chord (e.g. Ctrl+F10, Alt+F1, F5).
/// Stored in the Settings table as "modifiers+vk" (both uint, decimal).
/// </summary>
public sealed record HotkeyChord(uint Modifiers, uint VirtualKey)
{
public const uint MOD_ALT = 0x0001;
public const uint MOD_CONTROL = 0x0002;
public const uint MOD_SHIFT = 0x0004;
public const uint MOD_WIN = 0x0008;
private static readonly Dictionary<uint, string> ModifierDisplay = new()
{
[MOD_CONTROL] = "Ctrl",
[MOD_ALT] = "Alt",
[MOD_SHIFT] = "Shift",
[MOD_WIN] = "Win",
};
/// <summary>Storage format: "modifiers+vk" (both decimal uint).</summary>
public override string ToString() => $"{Modifiers}+{VirtualKey}";
/// <summary>Parse from storage format ("modifiers+vk").</summary>
public static HotkeyChord ParseStorage(string s)
{
var parts = s.Split('+');
if (parts.Length != 2 ||
!uint.TryParse(parts[0], out var mod) ||
!uint.TryParse(parts[1], out var vk))
throw new FormatException($"Invalid hotkey storage format: {s}");
return new HotkeyChord(mod, vk);
}
/// <summary>Build from WPF Key + ModifierKeys (used during key capture).</summary>
public static HotkeyChord FromWpf(Key key, ModifierKeys modifiers)
{
uint vk = (uint)KeyInterop.VirtualKeyFromKey(key);
uint mod = 0;
if (modifiers.HasFlag(ModifierKeys.Control)) mod |= MOD_CONTROL;
if (modifiers.HasFlag(ModifierKeys.Alt)) mod |= MOD_ALT;
if (modifiers.HasFlag(ModifierKeys.Shift)) mod |= MOD_SHIFT;
if (modifiers.HasFlag(ModifierKeys.Windows)) mod |= MOD_WIN;
return new HotkeyChord(mod, vk);
}
/// <summary>Human-readable display string: "Ctrl+F10", "Alt+F1", "F5".</summary>
public string ToDisplayString()
{
var name = GetKeyName(VirtualKey);
if (Modifiers == 0) return name;
var sb = new System.Text.StringBuilder();
if ((Modifiers & MOD_CONTROL) != 0) { sb.Append("Ctrl"); sb.Append('+'); }
if ((Modifiers & MOD_ALT) != 0) { sb.Append("Alt"); sb.Append('+'); }
if ((Modifiers & MOD_SHIFT) != 0) { sb.Append("Shift"); sb.Append('+'); }
if ((Modifiers & MOD_WIN) != 0) { sb.Append("Win"); sb.Append('+'); }
sb.Append(name);
return sb.ToString();
}
/// <summary>Human-readable display string for a binding that may be null (unbound).</summary>
public static string DisplayString(HotkeyChord? chord) =>
chord?.ToDisplayString() ?? "None";
private static string GetKeyName(uint vk) =>
KeyNames.TryGetValue(vk, out var name) ? name : $"VK 0x{vk:X2}";
private static readonly Dictionary<uint, string> KeyNames = new()
{
// Letters A-Z (0x41-0x5A)
[0x41]="A",[0x42]="B",[0x43]="C",[0x44]="D",[0x45]="E",
[0x46]="F",[0x47]="G",[0x48]="H",[0x49]="I",[0x4A]="J",
[0x4B]="K",[0x4C]="L",[0x4D]="M",[0x4E]="N",[0x4F]="O",
[0x50]="P",[0x51]="Q",[0x52]="R",[0x53]="S",[0x54]="T",
[0x55]="U",[0x56]="V",[0x57]="W",[0x58]="X",[0x59]="Y",
[0x5A]="Z",
// Digits 0-9 (0x30-0x39)
[0x30]="0",[0x31]="1",[0x32]="2",[0x33]="3",[0x34]="4",
[0x35]="5",[0x36]="6",[0x37]="7",[0x38]="8",[0x39]="9",
// Function keys F1-F24 (0x70-0x87)
[0x70]="F1",[0x71]="F2",[0x72]="F3",[0x73]="F4",[0x74]="F5",
[0x75]="F6",[0x76]="F7",[0x77]="F8",[0x78]="F9",[0x79]="F10",
[0x7A]="F11",[0x7B]="F12",[0x7C]="F13",[0x7D]="F14",
[0x7E]="F15",[0x7F]="F16",[0x80]="F17",[0x81]="F18",
[0x82]="F19",[0x83]="F20",[0x84]="F21",[0x85]="F22",
[0x86]="F23",[0x87]="F24",
// Navigation
[0x08]="Bksp",[0x09]="Tab",[0x0D]="Enter",[0x13]="Pause",
[0x14]="CapsLock",[0x1B]="Esc",[0x20]="Space",[0x21]="PgUp",
[0x22]="PgDn",[0x23]="End",[0x24]="Home",[0x25]="←",
[0x26]="↑",[0x27]="→",[0x28]="↓",[0x2C]="PrintScreen",
[0x2D]="Insert",[0x2E]="Delete",
// Numpad
[0x60]="Num0",[0x61]="Num1",[0x62]="Num2",[0x63]="Num3",
[0x64]="Num4",[0x65]="Num5",[0x66]="Num6",[0x67]="Num7",
[0x68]="Num8",[0x69]="Num9",[0x6A]="*",[0x6B]="+",
[0x6C]=".",[0x6D]="-",[0x6E]=".",[0x6F]="/",
[0x90]="NumLock",[0x91]="ScrollLock",
// Punctuation / symbols
[0xBA]=";",[0xBB]="=",[0xBC]=",",[0xBD]="-",[0xBE]=".",
[0xBF]="/",[0xC0]="`",[0xDB]="[",[0xDC]="\\",
[0xDD]="]",[0xDE]="'",
// Media / browser / apps
[0xA0]="LShift",[0xA1]="RShift",
[0xA2]="LCtrl",[0xA3]="RCtrl",
[0xA4]="LAlt",[0xA5]="RAlt",
[0xAD]="Mute",[0xAE]="VolDown",[0xAF]="VolUp",
[0xB0]="Next",[0xB1]="Prev",[0xB3]="Stop",[0xB5]="Play",
[0xE2]="OEM_102",
};
}
+51
View File
@@ -1220,6 +1220,57 @@ public class LayoutStore : IDisposable
meta.ScheduledStartTime?.ToString("o") ?? string.Empty);
}
// ─── Hotkey bindings (TASK 20 step 2) ───
/// <summary>
/// Load saved hotkey bindings. Returns only entries that have been explicitly
/// saved — missing HotkeyIds are unbound (not in the returned dictionary).
/// Caller fills in defaults for any missing entries as needed.
/// </summary>
public Dictionary<HotkeyId, HotkeyChord> LoadHotkeyBindings()
{
var result = new Dictionary<HotkeyId, HotkeyChord>();
foreach (HotkeyId id in Enum.GetValues(typeof(HotkeyId)))
{
var raw = GetSetting($"Hotkey.{id}");
if (raw == null) continue; // not saved = unbound
if (raw == "none") continue; // explicitly unbound
try
{
result[id] = HotkeyChord.ParseStorage(raw);
}
catch (FormatException)
{
// corrupt entry — skip, will fall back to default
}
}
return result;
}
/// <summary>
/// Save hotkey bindings. Null entries in the dictionary mean "unbound" (stored
/// as "none"). Only the provided entries are saved; previously saved entries
/// for HotkeyIds not in the dictionary are deleted.
/// </summary>
public void SaveHotkeyBindings(Dictionary<HotkeyId, HotkeyChord?> bindings)
{
// Delete all existing hotkey settings first
foreach (HotkeyId id in Enum.GetValues(typeof(HotkeyId)))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "DELETE FROM Settings WHERE Key = $key;";
cmd.Parameters.AddWithValue("$key", $"Hotkey.{id}");
cmd.ExecuteNonQuery();
}
// Save the new bindings
foreach (var (id, chord) in bindings)
{
var value = chord?.ToString() ?? "none";
UpsertSetting($"Hotkey.{id}", value);
}
}
public (double Left, double Top, double Width, double Height, string State)? LoadWindowState()
{
var left = GetSetting("Window.Left");
+12 -9
View File
@@ -960,7 +960,7 @@ The tasks below are ordered by dependency and risk. Each task builds on the prev
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. 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.
5. **TASK 20** — Hotkeys — global keyboard shortcuts. Steps 1-2 shipped 2026-08-26: F1-F9 defaults + config UI with modifier chords, persistence, conflict detection, unbinding.
5b. ✅ **Broadcast metadata pull-out + launch geometry** — shipped 2026-08-24 (row 29 above). Live-screen "Text" tab → broadcast form, persistence + remote update; window default/minimums bumped (1920×1040 / 1366×768) with size+position restore.
6. **TASK 17** — Web source (WebView2) — enables alert ecosystem.
7. **TASK 18** — Local recording — independent, but pairs with stream.
@@ -989,21 +989,24 @@ 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: ◐ In progress — step 1 shipped 2026-08-24
### Status: ◐ In progress — step 2 shipped 2026-08-26
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
2. ✅ Scene switching hotkeys — F1-F5 stage/transition the five canonical scenes.
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 — `HotkeyConfigDialog` (XAML Window + `HotkeyConfigViewModel`). Click-to-capture, "Unbind" per row, Reset to Defaults. Conflict detection shows a warning; user must unbind the conflicting action first. Modifier chords supported from day 1 (Ctrl+F10, Alt+Shift+F12, etc.).
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. ☑ 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.
6. ✅ Conflict detection — warns when a chord is already bound to another action; user must unbind first.
7. ✅ Persistence — `LoadHotkeyBindings()` / `SaveHotkeyBindings()` in `LayoutStore`, stored as `"modifiers+vk"` in Settings table. No schema migration needed.
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.
9. ✅ Test: `HotkeyConfigTests` — round-trip persistence, display string formatting, storage serialization.
### Design decisions
- **Global hotkeys are mandatory** — streamers are in-game and cannot alt-tab. F1-F5 must work from anywhere.
- **Simple config** — one row per action: [Action] [Current Key] [Change]. No complex macro system.
- **Modifier chords from day 1** — Ctrl+F10, Alt+F1, etc. for Stream Deck support and power users.
- **Conflict = warning, not swap** — user must unbind the conflicting action before reassigning. No surprise reassignments.
- **Unbinding** — every action can be set to "None" (no hotkey). Explicitly unbound actions are absent from the DB.
- **No Stream Deck yet** — bare keyboard first. Stream Deck support (physical devices) is v1.1+.
---
+217
View File
@@ -0,0 +1,217 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Windows.Input;
using ytLive.Helpers;
using ytLive.Services;
namespace ytLive.ViewModels;
/// <summary>One row in the hotkey config dialog — an action and its current chord.</summary>
public sealed class HotkeyBindingRow : ViewModelBase
{
public HotkeyId Id { get; }
public string ActionName { get; }
private HotkeyChord? _chord;
public HotkeyChord? Chord
{
get => _chord;
set
{
if (SetProperty(ref _chord, value))
OnPropertyChanged(nameof(DisplayText));
}
}
private bool _isCapturing;
public bool IsCapturing
{
get => _isCapturing;
set
{
if (SetProperty(ref _isCapturing, value))
OnPropertyChanged(nameof(DisplayText));
}
}
public string DisplayText => IsCapturing
? "▶ Press a key combination..."
: HotkeyChord.DisplayString(Chord);
public HotkeyBindingRow(HotkeyId id, HotkeyChord? chord)
{
Id = id;
ActionName = GetActionName(id);
Chord = chord;
}
private static string GetActionName(HotkeyId id) => id switch
{
HotkeyId.SceneStarting => "Scene: Starting",
HotkeyId.SceneLive => "Scene: Live",
HotkeyId.SceneBrb => "Scene: BRB",
HotkeyId.SceneChat => "Scene: Chat",
HotkeyId.SceneEnding => "Scene: Ending",
HotkeyId.StartStopStream => "Start / Stop Stream",
HotkeyId.ToggleMicMute => "Toggle Mic Mute",
HotkeyId.ToggleGameMute => "Toggle Desktop Audio Mute",
HotkeyId.TraxPlayPause => "TRAX Play / Pause",
_ => id.ToString(),
};
}
/// <summary>
/// ViewModel for the hotkey config dialog. Manages the working copy of
/// bindings, capture mode, conflict detection, and save/cancel/reset.
/// </summary>
public sealed class HotkeyConfigViewModel : ViewModelBase
{
private readonly LayoutStore _store;
private readonly Dictionary<HotkeyId, HotkeyChord?> _original;
public ObservableCollection<HotkeyBindingRow> Rows { get; }
public event Action? SaveRequested;
public event Action? CancelRequested;
private string? _conflictMessage;
public string? ConflictMessage
{
get => _conflictMessage;
set => SetProperty(ref _conflictMessage, value);
}
private HotkeyBindingRow? _capturingRow;
public HotkeyBindingRow? CapturingRow
{
get => _capturingRow;
private set => SetProperty(ref _capturingRow, value);
}
public ICommand StartCaptureCommand { get; }
public ICommand UnbindCommand { get; }
public ICommand ResetToDefaultsCommand { get; }
public ICommand SaveCommand { get; }
public ICommand CancelCommand { get; }
public HotkeyConfigViewModel(LayoutStore store)
{
_store = store;
ConflictMessage = null;
// Load saved bindings — keys present in DB are either custom or "none" (unbound).
var saved = store.LoadHotkeyBindings();
// Build working copy: start from defaults, overlay saved, fill gaps.
_original = new Dictionary<HotkeyId, HotkeyChord?>();
foreach (HotkeyId id in Enum.GetValues(typeof(HotkeyId)))
{
if (saved.TryGetValue(id, out var chord))
_original[id] = chord; // custom or null (explicitly unbound)
else
_original[id] = GlobalHotkeyManager.Defaults.TryGetValue(id, out var def)
? def
: null;
}
Rows = new ObservableCollection<HotkeyBindingRow>(
_original.Select(kv => new HotkeyBindingRow(kv.Key, kv.Value)));
StartCaptureCommand = new RelayCommand(row =>
{
if (row is HotkeyBindingRow r) StartCapture(r);
});
UnbindCommand = new RelayCommand(row =>
{
if (row is HotkeyBindingRow r) Unbind(r);
});
ResetToDefaultsCommand = new RelayCommand(_ => ResetToDefaults());
SaveCommand = new RelayCommand(_ => Save());
CancelCommand = new RelayCommand(_ => CancelRequested?.Invoke());
}
/// <summary>
/// Called by the dialog after it captures a key combo from the user.
/// Assigns the chord if free; shows a conflict warning otherwise.
/// </summary>
public void AssignCapturedChord(HotkeyChord chord)
{
if (CapturingRow == null) return;
// Same chord as current — no-op
if (CapturingRow.Chord != null && CapturingRow.Chord.Equals(chord))
{
CapturingRow.IsCapturing = false;
CapturingRow = null;
return;
}
// Conflict: another action already uses this chord
var conflict = Rows.FirstOrDefault(r =>
r.Id != CapturingRow.Id && r.Chord != null && r.Chord.Equals(chord));
if (conflict != null)
{
ConflictMessage = $"{chord.ToDisplayString()} is already bound to {conflict.ActionName}. Unbind it first.";
CapturingRow.IsCapturing = false;
CapturingRow = null;
return;
}
// Free — assign it
ConflictMessage = null;
CapturingRow.Chord = chord;
CapturingRow.IsCapturing = false;
CapturingRow = null;
}
/// <summary>Cancel capture mode without changing the binding.</summary>
public void CancelCapture()
{
if (CapturingRow == null) return;
CapturingRow.IsCapturing = false;
CapturingRow = null;
ConflictMessage = null;
}
private void StartCapture(HotkeyBindingRow row)
{
CancelCapture(); // cancel any in-progress capture first
ConflictMessage = null;
row.IsCapturing = true;
CapturingRow = row;
}
private void Unbind(HotkeyBindingRow row)
{
CancelCapture();
ConflictMessage = null;
row.Chord = null;
}
private void ResetToDefaults()
{
CancelCapture();
ConflictMessage = null;
foreach (var row in Rows)
{
row.Chord = GlobalHotkeyManager.Defaults.TryGetValue(row.Id, out var def)
? def
: null;
}
}
private void Save()
{
CancelCapture();
ConflictMessage = null;
var bindings = new Dictionary<HotkeyId, HotkeyChord?>();
foreach (var row in Rows)
bindings[row.Id] = row.Chord;
_store.SaveHotkeyBindings(bindings);
SaveRequested?.Invoke();
}
}
+29
View File
@@ -158,6 +158,9 @@ public class MainViewModel : ViewModelBase
private bool _brandFlashEnabled = true;
private bool _brandFlashActive;
// TASK 20 step 2 — hotkey bindings (custom chord per action)
private Dictionary<HotkeyId, HotkeyChord> _hotkeyBindings = new();
// TASK 10 — Polar license validation
private string _licenseKey = string.Empty;
private string _licenseMessage = string.Empty;
@@ -352,6 +355,15 @@ public class MainViewModel : ViewModelBase
// ─── Global hotkeys (TASK 20) — dispatch a registered hotkey to its action ───
/// <summary>
/// Callback set by MainWindow so the VM can trigger hotkey re-registration
/// after the user saves custom bindings from the config dialog.
/// </summary>
public Action<Dictionary<HotkeyId, HotkeyChord>>? HotkeyBindingsChanged { get; set; }
/// <summary>The current hotkey bindings (loaded from DB, updated on dialog save).</summary>
public Dictionary<HotkeyId, HotkeyChord> CurrentHotkeyBindings => _hotkeyBindings;
public void HandleHotkey(Services.HotkeyId id)
{
switch (id)
@@ -383,6 +395,18 @@ public class MainViewModel : ViewModelBase
if (scene != null) TransitionToScene(scene);
}
private void OpenHotkeyConfig()
{
var vm = new HotkeyConfigViewModel(_layoutStore);
var dialog = new HotkeyConfigDialog(vm) { Owner = Application.Current.MainWindow };
if (dialog.ShowDialog() == true)
{
// Reload bindings from DB (the dialog already saved them).
_hotkeyBindings = _layoutStore.LoadHotkeyBindings();
HotkeyBindingsChanged?.Invoke(_hotkeyBindings);
}
}
/// <summary>
/// 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).
@@ -1485,6 +1509,7 @@ public class MainViewModel : ViewModelBase
public ICommand OpenLayoutCommand { get; }
public ICommand ActivateLicenseCommand { get; }
public ICommand OpenLicenseEntryCommand { get; }
public ICommand OpenHotkeyConfigCommand { get; }
public MainViewModel()
{
@@ -1558,10 +1583,14 @@ public class MainViewModel : ViewModelBase
OpenLayoutCommand = new RelayCommand(_ => OpenLayoutFile());
ActivateLicenseCommand = new RelayCommand(_ => _ = ActivateLicenseAsync(), _ => !IsLicenseValidating);
OpenLicenseEntryCommand = new RelayCommand(_ => IsLicenseEntryOpen = !IsLicenseEntryOpen);
OpenHotkeyConfigCommand = new RelayCommand(_ => OpenHotkeyConfig());
_layoutStore = new LayoutStore(LayoutPathOverride ?? DefaultLayoutPath);
_activeLayoutPath = _layoutStore.ActivePath;
// Load saved hotkey bindings (filled with defaults for any unbound actions).
_hotkeyBindings = _layoutStore.LoadHotkeyBindings();
BroadcastForm = new Services.LiveBroadcastFormViewModel(
() => _layoutStore,
() => _youtubeStream,
+1 -1
View File
@@ -31,7 +31,7 @@ public sealed class GlobalHotkeyTests : IDisposable
public readonly List<HotkeyId> Registered = new();
public readonly List<HotkeyId> Unregistered = new();
public bool Register(IntPtr hwnd, HotkeyId id, uint virtualKey)
public bool Register(IntPtr hwnd, HotkeyId id, uint modifiers, uint virtualKey)
{
Registered.Add(id);
return true;
+100
View File
@@ -0,0 +1,100 @@
using System;
using System.Collections.Generic;
using Xunit;
using ytLive.Services;
namespace ytLive.Tests;
/// <summary>
/// TASK 20 step 2 integration: custom hotkey bindings survive a DB round-trip.
/// Save a mix of modifier chords and unbound actions to a temp LayoutStore,
/// load them back, and verify that (a) custom chords match, (b) unbound actions
/// are absent, and (c) actions the user didn't touch retain their defaults.
/// </summary>
public sealed class HotkeyConfigTests : IDisposable
{
private readonly string _tempDb;
public HotkeyConfigTests()
{
_tempDb = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
$"ytLlive-hotkeycfg-{Guid.NewGuid():N}.db");
}
[Fact]
public void HotkeyBindings_Persist_RoundTrip()
{
// Arrange: create a fresh DB
using (new LayoutStore(_tempDb)) { }
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
var bindings = new Dictionary<HotkeyId, HotkeyChord?>
{
[HotkeyId.SceneStarting] = new HotkeyChord(HotkeyChord.MOD_CONTROL, 0x79), // Ctrl+F10
[HotkeyId.ToggleMicMute] = null, // explicitly unbound
[HotkeyId.StartStopStream] = new HotkeyChord(HotkeyChord.MOD_ALT | HotkeyChord.MOD_SHIFT, 0x7B), // Alt+Shift+F12
};
// Act: save then load
using (var store = new LayoutStore(_tempDb))
{
store.SaveHotkeyBindings(bindings);
}
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
Dictionary<HotkeyId, HotkeyChord> loaded;
using (var store = new LayoutStore(_tempDb))
{
loaded = store.LoadHotkeyBindings();
}
// Assert: custom modifier chord round-trips correctly
Assert.True(loaded.TryGetValue(HotkeyId.SceneStarting, out var sceneStarting));
Assert.Equal(HotkeyChord.MOD_CONTROL, sceneStarting!.Modifiers);
Assert.Equal(0x79u, sceneStarting.VirtualKey);
// Assert: explicitly unbound action is absent
Assert.False(loaded.ContainsKey(HotkeyId.ToggleMicMute));
// Assert: multi-modifier chord round-trips correctly
Assert.True(loaded.TryGetValue(HotkeyId.StartStopStream, out var startStop));
Assert.Equal(HotkeyChord.MOD_ALT | HotkeyChord.MOD_SHIFT, startStop!.Modifiers);
Assert.Equal(0x7Bu, startStop.VirtualKey);
// Assert: untouched actions are absent (caller fills defaults)
Assert.False(loaded.ContainsKey(HotkeyId.SceneBrb));
Assert.False(loaded.ContainsKey(HotkeyId.TraxPlayPause));
}
[Fact]
public void HotkeyChord_DisplayString_FormatsModifiers()
{
var ctrlF10 = new HotkeyChord(HotkeyChord.MOD_CONTROL, 0x79);
Assert.Equal("Ctrl+F10", ctrlF10.ToDisplayString());
var altShiftF12 = new HotkeyChord(HotkeyChord.MOD_ALT | HotkeyChord.MOD_SHIFT, 0x7B);
Assert.Equal("Alt+Shift+F12", altShiftF12.ToDisplayString());
var bareF5 = new HotkeyChord(0, 0x74);
Assert.Equal("F5", bareF5.ToDisplayString());
var none = HotkeyChord.DisplayString(null);
Assert.Equal("None", none);
}
[Fact]
public void HotkeyChord_StorageRoundTrip()
{
var original = new HotkeyChord(HotkeyChord.MOD_CONTROL | HotkeyChord.MOD_WIN, 0x7B);
var stored = original.ToString();
var parsed = HotkeyChord.ParseStorage(stored);
Assert.Equal(original, parsed);
}
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { System.IO.File.Delete(_tempDb); } catch { }
}
}