diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b1d3296 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,13 @@ +# RigSwitch Workspace Guidelines + +## Verification & Visual Screenshot Testing +- Run `dotnet test --nologo`, `npm run lint`, and `npx tsc --noEmit` after code changes. +- When modifying WPF views (`MainSettingsWindow.xaml`, `FluentTheme.xaml`) or layout ViewModels: + 1. Regenerate the documentation screenshots via `dotnet test --filter "FullyQualifiedName~UiScreenshotCaptureTests"`. + 2. Visually inspect all 5 images in `docs/screenshots/*.png` using `view_file` to confirm zero unwanted vertical scrollbars and zero badge/text clipping at `1240x840`. +- For headless WPF `RenderTargetBitmap` capture in STA unit tests, render `(FrameworkElement)window.Content` (ensuring the root `Grid` has an explicit `Background` brush and calling `Measure`, `Arrange`, and `UpdateLayout`) rather than rendering the `Window` instance directly, which produces a black bitmap without an active HWND. + +## Domain & Testing Invariants +- `new UserSettings()` pre-populates 3 default `DeskPresets` and 3 default `RigPresets`. Tests that construct custom preset lists on a `UserSettings` instance must call `.Clear()` on `DeskPresets` / `RigPresets` before adding test presets. +- Windows CoreAudio COM calls (`IMMDeviceEnumerator`, `IPolicyConfig`) invoked from threadpool threads must call `CoInitializeEx(IntPtr.Zero, COINIT_MULTITHREADED)`. +- `AudioEndpointWatchdogService` must suppress automatic re-assertion while a profile switch is in progress (`BeginProfileSwitch` / `EndProfileSwitch`) and must never re-assert a target endpoint whose state is `NotPresent` (unplugged). diff --git a/Directory.Build.props b/Directory.Build.props index ef5220f..b3c7f8d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -6,5 +6,6 @@ true 13.0 latest-recommended + 1.5.0 diff --git a/docs/screenshots/01_presets_view.png b/docs/screenshots/01_presets_view.png index 91c2202..2635cc2 100644 Binary files a/docs/screenshots/01_presets_view.png and b/docs/screenshots/01_presets_view.png differ diff --git a/docs/screenshots/02_displays_topology_view.png b/docs/screenshots/02_displays_topology_view.png index 91c2202..0994b3d 100644 Binary files a/docs/screenshots/02_displays_topology_view.png and b/docs/screenshots/02_displays_topology_view.png differ diff --git a/docs/screenshots/03_audio_dac_lockout_view.png b/docs/screenshots/03_audio_dac_lockout_view.png index 91c2202..45dece0 100644 Binary files a/docs/screenshots/03_audio_dac_lockout_view.png and b/docs/screenshots/03_audio_dac_lockout_view.png differ diff --git a/docs/screenshots/04_device_nicknames_view.png b/docs/screenshots/04_device_nicknames_view.png index 91c2202..cec2c8b 100644 Binary files a/docs/screenshots/04_device_nicknames_view.png and b/docs/screenshots/04_device_nicknames_view.png differ diff --git a/docs/screenshots/05_hotkeys_and_system_view.png b/docs/screenshots/05_hotkeys_and_system_view.png index 91c2202..8b38d06 100644 Binary files a/docs/screenshots/05_hotkeys_and_system_view.png and b/docs/screenshots/05_hotkeys_and_system_view.png differ diff --git a/docs/superpowers/plans/2026-10-13-ui-topology-audio-redesign.md b/docs/superpowers/plans/2026-10-13-ui-topology-audio-redesign.md new file mode 100644 index 0000000..9b05f54 --- /dev/null +++ b/docs/superpowers/plans/2026-10-13-ui-topology-audio-redesign.md @@ -0,0 +1,192 @@ +# UI, Topology & Ranked Audio Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Per-preset sidebar pages with a 2D monitor topology canvas, ranked audio/mic priority lists, a "Relevant Devices" audio filter, a `+ Add Nickname` workflow, a larger default window and a visible version. + +**Architecture:** Extend `WorkstationPreset` with backward-compatible `MonitorPlacements`, `AudioPriorityIds`, `MicrophonePriorityIds`. Core services (`ProfileSwitchCoordinator`, `AudioEndpointWatchdogService`) resolve the first `Active` entry of the ranked list. The WPF layer gains per-preset page ViewModels; the monolithic XAML is split into per-page UserControls where new. + +**Tech Stack:** .NET / WPF (Fluent), xUnit, Windows CCD + CoreAudio P/Invoke. + +## Global Constraints + +- Spec: `docs/superpowers/specs/2026-10-13-ui-topology-audio-redesign-design.md` (source of truth). +- Default window `1240×840`, `MinWidth=1040`, `MinHeight=680`. +- Version shown as `vX.Y.Z` in header and sidebar footer. +- Existing configs (`PrimaryAudioId/SecondaryAudioId/FallbackAudioId`, mic equivalents, `TargetMonitorIds`) must load and keep working (migration, no data loss). +- Audio page keeps both Fluent toggles per row: `Win Menu` (left) and `Exclude / Bass Shaker Lockout` (right). +- After every code change run: `dotnet build`, `dotnet test`, `npm run lint`, `npx tsc --noEmit`. +- Validate UI with `MainSettingsWindow.CaptureScreenshotAsync` + `view_file` before claiming done. +- Preserve unrelated comments/docstrings. + +--- + +### Task 1: Commit audio stutter fix (DONE) + +Committed as `733a987` on `fix/audio-stutter-watchdog`. + +--- + +### Task 2: Preset model — placements and ranked lists + +**Files:** +- Modify: `src/RigSwitch.Core/Models/WorkstationPreset.cs` +- Create: `src/RigSwitch.Core/Models/MonitorPlacement.cs` +- Modify: `src/RigSwitch.Core/Models/DisplayDeviceInfo.cs` (add `PositionX`, `PositionY`, `Width`, `Height`) +- Test: `tests/RigSwitch.Tests.Unit/WorkstationPresetRankingTests.cs` + +**Interfaces:** +- Produces: + - `public sealed record MonitorPlacement(string MonitorId, int X, int Y, bool IsPrimary, PresetHdrMode HdrMode)` + - `WorkstationPreset.MonitorPlacements : List` + - `WorkstationPreset.AudioPriorityIds : List`, `MicrophonePriorityIds : List` + - `IReadOnlyList WorkstationPreset.GetEffectiveAudioPriorityList()` / `GetEffectiveMicrophonePriorityList()` — returns the explicit list if non-empty, else `[Primary, Secondary, Fallback]` with blanks removed and case-insensitive de-dup. + - `DisplayDeviceInfo.PositionX/PositionY/Width/Height : int { get; init; }` + +- [ ] **Step 1: Write failing tests** + +```csharp +using RigSwitch.Core.Models; +using Xunit; + +namespace RigSwitch.Tests.Unit; + +public class WorkstationPresetRankingTests +{ + [Fact] + public void EffectiveAudioList_FallsBackToLegacyFields() + { + var p = new WorkstationPreset { PrimaryAudioId = "a", SecondaryAudioId = "", FallbackAudioId = "A" }; + Assert.Equal(new[] { "a" }, p.GetEffectiveAudioPriorityList()); + } + + [Fact] + public void EffectiveAudioList_PrefersExplicitList() + { + var p = new WorkstationPreset { PrimaryAudioId = "legacy", AudioPriorityIds = ["x", "y"] }; + Assert.Equal(new[] { "x", "y" }, p.GetEffectiveAudioPriorityList()); + } + + [Fact] + public void EffectiveMicList_FallsBackToLegacyFields() + { + var p = new WorkstationPreset { PrimaryMicrophoneId = "m1", FallbackMicrophoneId = "m2" }; + Assert.Equal(new[] { "m1", "m2" }, p.GetEffectiveMicrophonePriorityList()); + } +} +``` + +- [ ] **Step 2:** Run `dotnet test --filter WorkstationPresetRankingTests` — expect compile FAIL. +- [ ] **Step 3: Implement.** In `WorkstationPreset` add: + +```csharp +public List MonitorPlacements { get; set; } = []; +public List AudioPriorityIds { get; set; } = []; +public List MicrophonePriorityIds { get; set; } = []; + +public IReadOnlyList GetEffectiveAudioPriorityList() => + ResolveRanking(AudioPriorityIds, PrimaryAudioId, SecondaryAudioId, FallbackAudioId); + +public IReadOnlyList GetEffectiveMicrophonePriorityList() => + ResolveRanking(MicrophonePriorityIds, PrimaryMicrophoneId, SecondaryMicrophoneId, FallbackMicrophoneId); + +private static IReadOnlyList ResolveRanking(List explicitList, params string[] legacy) +{ + IEnumerable source = explicitList is { Count: > 0 } ? explicitList : legacy; + return source.Where(s => !string.IsNullOrWhiteSpace(s)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); +} +``` + +`MonitorPlacement.cs`: the record above in namespace `RigSwitch.Core.Models` (`using RigSwitch.Core.Enums;`). Add the four int props to `DisplayDeviceInfo`. +- [ ] **Step 4:** Re-run tests — PASS. Also verify existing config JSON deserialization tests still pass (`dotnet test`). +- [ ] **Step 5:** `git add -A; git commit -m "feat: add ranked audio lists and monitor placements to presets"` + +--- + +### Task 3: CCD positions — read, capture and apply placements + +**Files:** +- Modify: `src/RigSwitch.Infrastructure/Windows/Ccd/WindowsDisplayConfigurationService.cs` (populate `PositionX/Y/Width/Height` in `EnumerateDisplaysAsync`; apply `MonitorPlacements` positions in `ApplyDisplayTopologyAsync`, falling back to current behaviour when empty) +- Modify: `src/RigSwitch.Core/Services/ProfileSwitchCoordinator.cs` (pass preset placements to topology apply) +- Modify: `IDisplayConfigurationService` (add optional `IReadOnlyList? placements = null` parameter; update fakes in tests) +- Test: `tests/RigSwitch.Tests.Unit/ProfileSwitchCoordinatorPlacementTests.cs` + +**Interfaces:** +- Consumes: Task 2 `MonitorPlacements`. +- Produces: `ApplyDisplayTopologyAsync(IReadOnlyList monitorIds, string primaryId, IReadOnlyList? placements, CancellationToken ct)` (match the existing signature's naming when editing; only append `placements`). + +- [ ] **Step 1:** Write a test with the existing fake display service asserting that switching to a preset with two `MonitorPlacements` forwards them (record the argument on the fake) and that a legacy preset forwards `null`/empty. +- [ ] **Step 2:** Run it, expect FAIL (compile). +- [ ] **Step 3:** Implement parameter plumbing in interface, Windows service, coordinator and all test fakes. In the Windows service, after cloning modes, for each placement matching a target `monitorId` set `sourceMode.position = new POINTL { x = p.X, y = p.Y }`; the primary placement must be normalized to (0,0) by shifting all others by its offset. +- [ ] **Step 4:** `dotnet test` PASS. +- [ ] **Step 5:** Commit `feat: apply per-preset monitor placements via CCD`. + +--- + +### Task 4: Ranked audio routing in coordinator and watchdog + +**Files:** +- Modify: `src/RigSwitch.Core/Services/ProfileSwitchCoordinator.cs` (resolve route target and `ToggleAudioOutputModeAsync` cycle) +- Modify: `src/RigSwitch.Infrastructure/Windows/CoreAudio/AudioEndpointWatchdogService.cs` (`ResolveEffectiveTargetEndpoint` uses ranked list) +- Test: extend `tests/RigSwitch.Tests.Unit/AudioEndpointWatchdogServiceTests.cs`, `ProfileSwitchCoordinatorAudioToggleTests.cs` + +**Interfaces:** +- Consumes: `GetEffectiveAudioPriorityList()`, `GetEffectiveMicrophonePriorityList()`. +- Produces: behaviour — first `Active`, non-blacklisted endpoint in rank wins; toggle selects the next `Active` endpoint after the current default (wrapping); if ≤1 active, no-op. + +- [ ] **Step 1:** Add failing tests: (a) `AudioPriorityIds = [A(NotPresent), B(Active), C(Active)]` → watchdog and switch target B; (b) toggle with default=B moves to C, then back to B; (c) blacklisted top entry skipped. +- [ ] **Step 2:** Run filtered tests — expect FAIL. +- [ ] **Step 3:** Replace the Primary/Secondary/Fallback walk in both services with iteration over the effective list (reuse existing `IsEndpointAvailable`). Keep `onIntentionalAudioSwitch` invocation before every set-default call. Apply the same logic to microphones. +- [ ] **Step 4:** `dotnet test` PASS (all existing tests included). +- [ ] **Step 5:** Commit `feat: route audio and mic by ranked priority list`. + +--- + +### Task 5: ViewModel layer + +**Files:** +- Modify: `src/RigSwitch.App/ViewModels/MainSettingsViewModel.cs`, `PresetConfigurationItemViewModel.cs`, `DeviceOptionResolver` (in `src/RigSwitch.App/ViewModels/`) +- Create: `src/RigSwitch.App/ViewModels/PresetMonitorCanvasItemViewModel.cs`, `RankedAudioItemViewModel.cs`, `NicknameItemViewModel.cs` +- Test: `tests/RigSwitch.Tests.Unit/MainSettingsViewModelRedesignTests.cs` + +**Interfaces (Produces):** +- `MainSettingsViewModel`: `string AppVersionText` (`"v" + assembly InformationalVersion` trimmed of `+hash`), `PresetConfigurationItemViewModel? SelectedPreset`, `bool IsPresetViewSelected`, `NavigateToPresetCommand(PresetConfigurationItemViewModel)`, `AudioDeviceFilter` enum `{ Relevant, All }` + `ShowAllRegistryGhosts` toggle, `ObservableCollection ConfiguredNicknames`, `ObservableCollection UnnicknamedDeviceOptions`, `AddNicknameCommand`, `RemoveNicknameCommand`. +- `PresetConfigurationItemViewModel`: `ObservableCollection StageMonitors`, `ObservableCollection UnassignedMonitors`, commands `AddMonitorToStage`, `RemoveMonitorFromStage`, `SetPrimary`, `CycleHdr`, `CaptureWindowsLayoutCommand`, `MoveMonitor(double x, double y)` (snaps edges within 24 px); `ObservableCollection AudioRanking` and `MicrophoneRanking` with `MoveUp/MoveDown/Remove`, `AddAudioCommand(DeviceOption)`, and write-back to `AudioPriorityIds` / `MicrophonePriorityIds` / `MonitorPlacements` / `TargetMonitorIds` on save. +- `RankedAudioItemViewModel`: `Rank`, `DisplayName`, `Status` (`Active Now`, `Ready`, `Offline`). +- Relevant filter rule: show `Active`, `Disabled`, `Unplugged`, plus any `NotPresent` referenced by a preset ranking, blacklist or nickname. + +- [ ] **Step 1:** Write failing tests, one per rule: relevant filter hides unreferenced `NotPresent` but keeps one referenced by a nickname; `UnnicknamedDeviceOptions` excludes already-nicknamed devices; `MoveUp` on rank 2 swaps and persists to `AudioPriorityIds`; `RemoveMonitorFromStage` moves the monitor to `UnassignedMonitors` and drops its placement; `SetPrimary` normalizes the primary to (0,0); `MoveMonitor` snaps to a neighbour's edge; `AppVersionText` matches `^v\d+\.\d+\.\d+`. +- [ ] **Step 2:** Run — expect FAIL. +- [ ] **Step 3:** Implement the ViewModels (use the existing `RelayCommand` / observable base the project already uses in `MainSettingsViewModel`). Replace legacy per-preset Primary/Secondary/Fallback dropdown properties on the UI side only; the model keeps legacy fields for compatibility, and on save write `Primary/Secondary/Fallback` from the first three ranking entries. +- [ ] **Step 4:** `dotnet test` PASS. +- [ ] **Step 5:** Commit `feat: viewmodels for preset pages, ranking, topology canvas and nicknames`. + +--- + +### Task 6: XAML — window, sidebar, preset page, audio and nickname pages + +**Files:** +- Modify: `src/RigSwitch.App/Views/MainSettingsWindow.xaml` and `.xaml.cs` +- Create: `src/RigSwitch.App/Views/PresetPageView.xaml/.cs` (monitor stage + tray + rankings), optionally `MonitorStageControl.cs` for drag/snap hit-testing +- Test: `tests/RigSwitch.Tests.Unit/UiScreenshotCaptureTests.cs` (add per-view captures) + +- [ ] **Step 1:** Add failing screenshot test assertions: window `Width==1240`, `Height==840`, `MinWidth==1040`, `MinHeight==680`; version text element present (`AutomationId="AppVersionBadge"`) in header. +- [ ] **Step 2:** Run — expect FAIL. +- [ ] **Step 3:** Implement: + - Window sizes and version badge (header and sidebar footer bound to `AppVersionText`). + - Remove `Presets`, `Displays & Topology` and `Hardware Telemetry Summary` tabs; add sidebar groups `Desk 1..3` and `Sim Rig 1..3` bound to `NavigateToPresetCommand`. + - `PresetPageView`: Canvas stage (monitors as draggable `Border` with Thumb drag → `MoveMonitor`), per-monitor buttons `★ Primary (0,0)`, HDR cycle, `✕`; slim "Off in Preset" tray with click/drag-to-stage; `🔍 Identify`, `📸 Capture Windows Layout`; ranked audio/mic cards with `#n`, status pill, `↑ ↓ ✕`, `+ Add device` dropdown. + - Audio page: `Relevant Devices` / `All Registry Ghosts` filter bar; keep both Fluent toggles per row. + - Nicknames page: `+ Add New Device Nickname` bar (dropdown of `UnnicknamedDeviceOptions`), list of configured nicknames with inline edit and `✕`. +- [ ] **Step 4:** Build, run tests — PASS. +- [ ] **Step 5:** Commit `feat: redesigned settings window with per-preset pages`. + +--- + +### Task 7: Visual validation, checks and PR + +- [ ] **Step 1:** Run screenshot capture for views: `Desk 1`, `Sim Rig 1`, `Audio & DAC Lockout`, `Device Nicknames`, at default size and at `MinWidth×MinHeight`. `view_file` each PNG; fix clipping or cramped layout, re-capture. +- [ ] **Step 2:** `dotnet build`, `dotnet test`, `npm run lint`, `npx tsc --noEmit` — all clean. +- [ ] **Step 3:** `git push -u origin fix/audio-stutter-watchdog`; open PR via `gh pr create` summarising stutter fix + redesign, attaching screenshots. diff --git a/docs/superpowers/specs/2026-10-13-ui-topology-audio-redesign-design.md b/docs/superpowers/specs/2026-10-13-ui-topology-audio-redesign-design.md new file mode 100644 index 0000000..83bf469 --- /dev/null +++ b/docs/superpowers/specs/2026-10-13-ui-topology-audio-redesign-design.md @@ -0,0 +1,135 @@ +# RigSwitch Audio Stutter Fix & UI / Topology / Audio Priority Redesign Specification + +**Date:** 2026-10-13 +**Branch:** `fix/audio-stutter-watchdog` + +--- + +## 1. Overview & Goals + +This specification covers the root-cause fix for background CoreAudio stuttering as well as a cohesive UI and configuration redesign for RigSwitch: + +1. **Eliminate Background CoreAudio Stuttering:** Prevent `AudioEndpointWatchdogService` from fighting Windows CoreAudio (`Audiosrv`) when a configured primary audio device is offline (e.g., USB-powered speakers in AUX/`NotPresent` mode), fix `IMMNotificationClient` COM apartment marshaling, and lazy-initialize `MainSettingsWindow` when started minimized to the system tray. +2. **Spacious Default Window & Visible Version Badge:** Increase the default `MainSettingsWindow` dimensions from `960×700` to `1240×840` (`MinWidth="1040"`, `MinHeight="680"`) so controls are never scrunched on startup, and display the application version (`vX.Y.Z`) in the header bar and sidebar status footer. +3. **Direct Sidebar Sub-Page Routing per Preset:** Replace the long stacked `Presets` page and redundant `Displays & Topology` / `Hardware Telemetry Summary` tab with direct sidebar navigation routes for each Desk preset (`Desk 1..3`) and Sim Rig preset (`Sim Rig 1..3`). +4. **Per-Preset 2D Monitor Topology Canvas + Slim "Off in Preset" Tray:** Give each preset its own interactive 2D spatial monitor stage where placing a monitor on the stage enables it in that preset, dragging positions it in 2D space (with edge snapping), clicking `★` sets Primary `(0,0)`, clicking the HDR pill cycles HDR state, and a **"📸 Capture Windows Layout"** button snapshots exact `(X, Y)` coordinates from Windows CCD. Disabled monitors sit in a compact pill tray below the stage. +5. **Ranked Audio & Microphone Priority Lists:** Replace separate `Primary` / `Secondary` / `Fallback` dropdowns with an ordered priority list per preset. RigSwitch and the CoreAudio Watchdog automatically route to the highest-ranked `Active` (non-blacklisted) endpoint, and the `Toggle Audio` hotkey (`Ctrl+Alt+A`) cycles through the currently `Active` endpoints in the preset's ranked list. +6. **Smart "Relevant Devices" Audio Filter:** Filter out unconnected Windows `MMDevice` registry ghosts (`NotPresent`) by default unless a device is referenced in a Preset, Blacklist, or Nickname, while preserving the two per-row Fluent toggle switches (`Win Menu Visibility` and `Exclude / Bass Shaker Lockout`) and offering filter tabs for `Relevant Devices`, `Active Only`, `Disabled / Unplugged`, and `All Registry Ghosts`. +7. **Streamlined `+ Add Nickname` Workflow:** Show only devices that currently have a custom nickname configured (with inline editing and `✕` removal), paired with an **"+ Add New Device Nickname"** bar whose dropdown only lists relevant devices that do not yet have a nickname. + +--- + +## 2. Architecture & Data Model Changes + +### 2.1 Backward-Compatible `WorkstationPreset` Models ([`WorkstationPreset.cs`](file:///C:/Users/Alias/repos/RigSwitch/src/RigSwitch.Core/Models/WorkstationPreset.cs)) + +To support per-preset 2D spatial monitor layouts and ranked audio/microphone priority lists without breaking existing `settings.json` files: + +- **`MonitorPlacement` Model (`RigSwitch.Core.Models.MonitorPlacement`):** + ```csharp + public sealed class MonitorPlacement + { + public string MonitorId { get; set; } = string.Empty; + public int PositionX { get; set; } + public int PositionY { get; set; } + public int Width { get; set; } = 1920; + public int Height { get; set; } = 1080; + public bool IsPrimary { get; set; } + public PresetHdrMode HdrMode { get; set; } = PresetHdrMode.Retain; + } + ``` +- **`WorkstationPreset` Additions:** + - `List MonitorPlacements { get; set; } = [];` + - Synchronized with `TargetMonitorIds` (where the primary monitor is first in `TargetMonitorIds` for backward compatibility). + - `List AudioPriorityIds { get; set; } = [];` + - `List MicrophonePriorityIds { get; set; } = [];` + - **Automatic Migration Helper (`GetEffectiveAudioPriorityList()` / `GetEffectiveMicrophonePriorityList()`):** + - If `AudioPriorityIds` is empty but legacy `PrimaryAudioId`, `SecondaryAudioId`, or `FallbackAudioId` are populated, automatically seeds the ordered list `[PrimaryAudioId, FallbackAudioId, SecondaryAudioId]` (deduplicated, non-empty) and keeps `PrimaryAudioId` / `FallbackAudioId` / `SecondaryAudioId` synchronized on save. + +### 2.2 CCD 2D Spatial Positioning ([`WindowsDisplayConfigurationService.cs`](file:///C:/Users/Alias/repos/RigSwitch/src/RigSwitch.Infrastructure/Windows/Ccd/WindowsDisplayConfigurationService.cs)) + +- **Reading Live Coordinates & Resolution:** + - Extend [`DisplayDeviceInfo`](file:///C:/Users/Alias/repos/RigSwitch/src/RigSwitch.Core/Models/DisplayDeviceInfo.cs) with optional `PositionX`, `PositionY`, `Width`, and `Height` (defaulting to `0, 0, 1920, 1080`) populated from `mode.modeInfo.sourceMode.position` and `sourceMode.width` / `sourceMode.height` during `EnumerateDisplaysAsync`. +- **Applying 2D Coordinates:** + - Add an overload or optional `IReadOnlyList? placements = null` parameter on `ApplyDisplayTopologyAsync`. + - When explicit `MonitorPlacement` coordinates are provided for a preset, set the primary monitor at `(0, 0)` (shifting relative coordinates if needed so primary is `(0, 0)`) and apply each enabled monitor's `(PositionX, PositionY)` to `clonedModes[modeIdx].modeInfo.sourceMode.position`. When no explicit placements exist, retain the linear left-to-right arrangement. + +### 2.3 Ranked Audio Routing, Cycling Toggle & Watchdog Enforcement + +- **Profile Activation ([`ProfileSwitchCoordinator.SwitchCoreAsync`](file:///C:/Users/Alias/repos/RigSwitch/src/RigSwitch.Core/Services/ProfileSwitchCoordinator.cs)):** + - Evaluates the preset's `GetEffectiveAudioPriorityList()` in order (`#1`, `#2`, `#3`, ...) and selects the first endpoint that is `DevicePresenceState.Active` and not in `BlacklistedAudioEndpointIds`. + - Invokes `_onIntentionalAudioSwitch?.Invoke(resolvedAudioId)` **before** calling `_audioDirector.SetDefaultPlaybackEndpointAsync(resolvedAudioId)`. +- **Cycling Audio Toggle ([`ProfileSwitchCoordinator.ToggleAudioOutputModeAsync`](file:///C:/Users/Alias/repos/RigSwitch/src/RigSwitch.Core/Services/ProfileSwitchCoordinator.cs)):** + - Finds all endpoints in the active preset's `GetEffectiveAudioPriorityList()` that are currently `DevicePresenceState.Active` and not blacklisted. + - Advances from the current default playback endpoint to the next active endpoint in that ranked list (wrapping around to index `0`), notifies the watchdog via `_onIntentionalAudioSwitch`, and routes default playback (and corresponding ranked microphone if configured) to that endpoint. +- **Watchdog Enforcement ([`AudioEndpointWatchdogService.cs`](file:///C:/Users/Alias/repos/RigSwitch/src/RigSwitch.Infrastructure/Windows/CoreAudio/AudioEndpointWatchdogService.cs)):** + - When `EnforcePrimaryAudio` is enabled, resolves the highest-ranked `DevicePresenceState.Active` non-blacklisted endpoint from the active preset's `GetEffectiveAudioPriorityList()`. + - Never attempts to force a `NotPresent` or `Unplugged` endpoint (such as USB speakers in AUX mode), and recognizes intentional switches from `SwitchProfileAsync` and `ToggleAudioOutputModeAsync`. + +--- + +## 3. UI & Navigation Specification ([`MainSettingsWindow.xaml`](file:///C:/Users/Alias/repos/RigSwitch/src/RigSwitch.App/Views/MainSettingsWindow.xaml)) + +### 3.1 Window Dimensions, Header & Sidebar Navigation +- **Window Dimensions:** `Width="1240"`, `Height="840"`, `MinWidth="1040"`, `MinHeight="680"`. +- **Version Display:** Expose `AppVersionText` (e.g., `v1.4.0`) on [`MainSettingsViewModel`](file:///C:/Users/Alias/repos/RigSwitch/src/RigSwitch.App/ViewModels/MainSettingsViewModel.cs) and render it as a subtle pill badge next to `⚡ RigSwitch` in the top header and in the bottom watchdog card of the left navigation sidebar. +- **Left Sidebar Navigation Rail:** + - **🖥️ Desk Presets** section header + 3 preset route items bound to `DeskPresets[0..2]` (showing preset name and a dot badge on the default desk preset). + - **🏎️ Sim Rig Presets** section header + 3 preset route items bound to `RigPresets[0..2]` (showing preset name and a dot badge on the default rig preset). + - **System & Hardware** section header: + - `🔊 Audio & DAC Lockout` + - `🏷️ Device Nicknames` + - `⌨️ Hotkeys & System` + - *(The redundant `Displays & Topology` tab and `Hardware Telemetry Summary` card are removed, as monitor topology and live audio status badges now live directly inside each preset view.)* + +### 3.2 Selected Preset Page Layout +When any Desk or Sim Rig preset is selected in the left sidebar, the main content pane displays that single preset's configuration without requiring nested tab bars: +1. **Top Identity & Shortcut Bar:** + - Preset Name `TextBox`, **Default Preset for Profile** toggle/radio, **Direct Hotkey** recorder (`HotkeyRecorderControl`), and a **"⚡ Activate Preset Now"** button. +2. **Middle 2-Column Hardware Grid:** + - **Left Card — 2D Monitor Topology Stage & Slim "Off in Preset" Tray:** + - Header actions: `🔍 Identify Monitors` and `📸 Capture Windows Layout`. + - **2D Stage Canvas:** Displays rectangles for each monitor enabled in this preset, positioned proportionally by `(PositionX, PositionY)` and `(Width, Height)`. Users can drag monitor rectangles to reposition (with edge snapping), click `★` to make a monitor Primary `(0,0)`, click the `HDR` badge on a monitor to cycle `Retain` → `Enable HDR` → `Disable HDR`, or click `✕` to disable the monitor for this preset. + - **Slim "Off in Preset" Dock:** Compact horizontal wrap strip underneath the canvas showing monitors not enabled in this preset. Clicking `+` (or dragging onto the stage) enables the monitor on the canvas. + - **Right Card — Playback Audio Priority Order (Ranked List):** + - Header action: Dropdown + `+ Add Device` button (listing relevant un-added playback devices). + - Ordered list of ranked playback endpoints (`#1`, `#2`, `#3`, ...) with live status pills (`● Active Now`, `● Ready`, `● Offline`), move `↑` / `↓` buttons, and `✕` remove button. + - Compact footer row inside the card for **Playback Volume Behavior** (`Retain System Volume` vs `Custom Volume % + Mute`). +3. **Bottom 2-Column Auxiliary Grid:** + - **Left Card — Microphone Priority Order & Input Level:** + - Ranked microphone priority list (`#1`, `#2`, ...) with `+ Add Mic`, `↑` / `↓` reorder, `✕` remove, and custom mic volume slider. + - **Right Card — Lifecycle Application Hooks:** + - `+ Add Application (.exe)` button and list of launch/terminate hooks for the preset. + +### 3.3 Audio & DAC Lockout Page +- **Filter Segmented Control:** + - `Relevant Devices` *(Default)*: Shows any endpoint where `State == Active`, `State == Disabled`, `State == Unplugged`, OR the endpoint ID is referenced in any Preset, Blacklist, or Nickname. Hides unconnected `NotPresent` Windows registry ghosts. + - `Active Only`: Shows only `DevicePresenceState.Active` endpoints. + - `Disabled / Unplugged`: Shows `Disabled` and `Unplugged` endpoints. + - `All Registry Ghosts`: Shows all enumerated endpoints including unconnected `NotPresent` registry entries. +- **Per-Row Controls:** + - Left Fluent toggle switch (`IsVisible`): toggles visibility in the Windows Sound menu. + - Device Friendly Name, Adapter Name, and Endpoint ID. + - Right Fluent toggle switch (`IsBlacklisted`, Amber style): `Exclude / Bass Shaker DAC` lockout toggle. + - Presence state badge (`Active`, `Disabled`, `Unplugged`, `Not Present`). + +### 3.4 Device Nicknames Page (`+ Add Nickname` Workflow) +- **Top `+ Add New Device Nickname` Card:** + - `ComboBox` bound to `UnnicknamedDeviceOptions` (containing only **relevant** displays, playback devices, and microphones that do **not** currently have a nickname in `CustomDeviceNicknames`). + - `TextBox` for entering the new nickname + **`+ Add Nickname`** button. +- **Configured Nicknames List:** + - Displays only devices that currently have a non-empty nickname. + - Each row shows the device type badge (`Display`, `Audio`, `Mic`), original hardware name/ID, an editable `TextBox` for updating the nickname inline, and a **`✕`** button to remove the nickname (which returns the device to `UnnicknamedDeviceOptions`). + +--- + +## 4. Verification & Testing Plan + +1. **Unit Tests (`RigSwitch.Tests.Unit`):** + - `AudioEndpointWatchdogServiceTests`: Verify offline primary (`NotPresent`) falls back to active ranked device without stutter loops. + - `ProfileSwitchCoordinatorAudioToggleTests`: Verify ranked audio priority resolution on profile switch and cycling across active ranked devices on `ToggleAudioOutputModeAsync`. + - `MainSettingsViewModel` tests: Verify `Relevant` audio filter hides unreferenced `NotPresent` ghosts while keeping preset-referenced offline devices, verify `+ Add Nickname` only offers un-nicknamed devices and removes/edits existing nicknames, and verify per-preset selection and monitor canvas inclusion/exclusion. +2. **Visual Verification (`UiScreenshotCaptureTests` & Live Screenshot Inspection):** + - Render `MainSettingsWindow` at `1240×840` across `presets`, `audio`, `nicknames`, and `hotkeys` routes, capture PNGs via `CaptureScreenshotAsync`, and inspect them with `view_file` to confirm spacing, typography, version badges, 2D monitor canvas, and toggle switches. +3. **Build, Lint & Typecheck:** + - Run `dotnet test`, `dotnet build --warnaserror`, `npm run lint`, and `npx tsc --noEmit`. diff --git a/src/RigSwitch.App/App.xaml.cs b/src/RigSwitch.App/App.xaml.cs index 25dcd88..07282c4 100644 --- a/src/RigSwitch.App/App.xaml.cs +++ b/src/RigSwitch.App/App.xaml.cs @@ -86,7 +86,8 @@ protected override async void OnStartup(StartupEventArgs e) sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - appHookService: sp.GetRequiredService())); + appHookService: sp.GetRequiredService(), + onIntentionalAudioSwitch: sp.GetRequiredService().NotifyIntentionalSwitch)); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -101,7 +102,11 @@ protected override async void OnStartup(StartupEventArgs e) sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - (view, path) => sp.GetRequiredService().CaptureScreenshotAsync(view, path))); + (view, path) => + { + var window = Dispatcher.Invoke(() => _mainWindow ??= sp.GetRequiredService()); + return window.CaptureScreenshotAsync(view, path); + })); builder.Services.AddSingleton(); _host = builder.Build(); @@ -118,7 +123,6 @@ protected override async void OnStartup(StartupEventArgs e) var settingsStorage = _host.Services.GetRequiredService(); var coordinator = _host.Services.GetRequiredService(); var viewModel = _host.Services.GetRequiredService(); - _mainWindow = _host.Services.GetRequiredService(); _trayIconService = _host.Services.GetRequiredService(); var watchdog = _host.Services.GetRequiredService(); @@ -183,15 +187,6 @@ protected override async void OnStartup(StartupEventArgs e) { if (args.Success) { - var activePreset = args.ActivePreset; - var audioTarget = coordinator.CurrentAudioOutputMode == AudioOutputMode.Secondary && !string.IsNullOrWhiteSpace(activePreset?.SecondaryAudioId) - ? activePreset?.SecondaryAudioId - : activePreset?.PrimaryAudioId; - if (!string.IsNullOrWhiteSpace(audioTarget)) - { - watchdog.NotifyIntentionalSwitch(audioTarget); - } - _trayIconService.UpdateTrayState(args.NewProfile, args.ActivePreset); _trayIconService.ShowNotification("RigSwitch", $"Switched to {args.NewProfile} setup [{args.ActivePreset?.Name ?? "Default"}]."); } @@ -237,13 +232,14 @@ protected override async void OnStartup(StartupEventArgs e) private void ShowSettingsWindow() { - if (_mainWindow == null) + if (_host == null) { return; } Dispatcher.Invoke(() => { + _mainWindow ??= _host.Services.GetRequiredService(); MainWindow = _mainWindow; _mainWindow.Show(); if (_mainWindow.WindowState == WindowState.Minimized) diff --git a/src/RigSwitch.App/Styles/FluentTheme.xaml b/src/RigSwitch.App/Styles/FluentTheme.xaml index ca48542..98d27c1 100644 --- a/src/RigSwitch.App/Styles/FluentTheme.xaml +++ b/src/RigSwitch.App/Styles/FluentTheme.xaml @@ -557,6 +557,32 @@ + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + - - + + + - + + + + + + + + + + - + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + +