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
+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,