Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
32fbca1
feat: ✨ strip redundant artist prefix from now playing titles
hoobio Jun 6, 2026
12a0abf
perf: snappier startup, relocate data to %APPDATA%, lighter Mica
hoobio Jun 6, 2026
93b971e
feat: ✨ strip '- Topic' suffix from now playing artist
hoobio Jun 6, 2026
cc09160
fix: 🐛 quick controls scrollbar gutter, disconnected dimming, scroll …
hoobio Jun 6, 2026
3b72879
feat: ✨ now-playing leniency grace window and artwork cross-fade
hoobio Jun 6, 2026
5e3a19e
feat: ✨ slide-in animation for quick controls windows
hoobio Jun 6, 2026
0132ac8
feat: ✨ theme-aware card surfaces, Mica uniformity, and tighter page …
hoobio Jun 6, 2026
58095cf
feat: ✨ collapse nav pane on backdrop tap
hoobio Jun 6, 2026
f19cdc2
feat: ✨ refresh rule summaries during in-place session reconcile
hoobio Jun 6, 2026
1024fbb
docs: 📝 two-window consistency, card/divider theming, and Mica unifor…
hoobio Jun 6, 2026
f3346da
fix: 🐛 mask black flash on quick controls open with backdrop-matched …
hoobio Jun 6, 2026
04d9a16
feat: adopt WinUI TitleBar control with working back navigation
hoobio Jun 6, 2026
34587f1
feat: add taskbar thumbnail media controls and playback badge
hoobio Jun 6, 2026
99ff8de
refactor: stop persisting and restoring window size
hoobio Jun 6, 2026
92739ec
refactor: ♻️ unify card section dividers and modernise now-playing sl…
hoobio Jun 6, 2026
4916603
fix: 🐛 keep card at row height as a floor when rules expand
hoobio Jun 6, 2026
596f7cc
fix: 🐛 animate page transitions on every nav, not just first load
hoobio Jun 6, 2026
82c7284
fix: 🐛 lower audible threshold so faint audio keeps app chips lit
hoobio Jun 6, 2026
8c38b3a
feat: ✨ resolve session icons from the IconPath fallback
hoobio Jun 6, 2026
b66533f
refactor: render now-playing backdrop with in-app acrylic
hoobio Jun 6, 2026
7953518
build: drop Win2D, render taskbar icons via System.Drawing
hoobio Jun 6, 2026
d36ca72
refactor: remove unused volume-controls toggle from DeviceCard
hoobio Jun 6, 2026
a262c0f
fix: refine now-playing band layout in strip and fill modes
hoobio Jun 6, 2026
80031b0
docs: update app screenshots
hoobio Jun 6, 2026
d84612c
feat: ✨ hide taskbar playback indicator after 30s paused
hoobio Jun 6, 2026
dcef88b
feat: add per-device display overrides
hoobio Jun 6, 2026
d9a63ca
fix: 🐛 show device card menu on right-click, not backdrop menu
hoobio Jun 6, 2026
302d59d
fix: strip Official tags from now-playing title and artist
hoobio Jun 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ jobs:
# the installed build can compare itself against newer pre-release tags. Everything else
# CI builds is "Release".
$channel = if ('${{ github.event_name }}' -eq 'pull_request' -and '${{ github.head_ref }}' -eq 'release-please--branches--main') { 'Prerelease' } else { 'Release' }
# Expose the channel to later steps (the WiX build reads $(env.EARMARK_CHANNEL) to set the
# per-channel ARP name, install folder, and UpgradeCode).
"EARMARK_CHANNEL=$channel" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
$extra = @()
if ($channel -eq 'Prerelease') {
$extra += "-p:InformationalVersion=$($env:APP_VERSION)-pre.$($env:GITHUB_RUN_NUMBER)"
Expand Down
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,9 @@ Follows [Fluent 2](https://fluent2.microsoft.design); benchmark against Windows
- **Spacing:** 4px grid via `Spacing*` `x:Double` resources in `App.xaml` (`SpacingXXSmall`=2 ... `SpacingXXLarge`=32). No ad-hoc numbers.
- **Corner radii:** `{ThemeResource ControlCornerRadius}` (4) for inset controls/chips, `{StaticResource CardCornerRadius}` (8) for cards. No one-off radii.
- **Theme:** `AppSettings.Theme` drives `RootGrid.RequestedTheme`, caption colours, backdrop tint. Theme-dependent brushes MUST be `{ThemeResource}` (code-resolved brushes snapshot one theme). Absolute brand colours (Wave Link accents, white mix tile) are deliberately theme-independent.
- **Backdrop:** `AppSettings.Backdrop` (Mica default / Acrylic / Solid) picks material. `MainWindow.ApplyBackdrop` attaches a `MicaController` (`BaseAlt`) or `DesktopAcrylicController` so tint follows the Theme setting. Solid shows the opaque `SolidBackdrop` border.
- **Backdrop:** `AppSettings.Backdrop` (Mica default / Acrylic / Solid) picks material. `MainWindow.ApplyBackdrop` (and `QuickControlsWindow`) attach a `MicaController` (`Base`, matching Windows Settings - it bleeds the wallpaper through; `BaseAlt` reads much darker) or `DesktopAcrylicController` so tint follows the Theme setting. Solid shows the opaque `SolidBackdrop` border. Mica fills the whole window uniformly like Settings: `NavigationViewContentBackground` is overridden to `Transparent` in `App.xaml` so the content region doesn't sit on a lighter layer than the pane.
- **Two windows, one look:** the main window and the Quick Controls flyout share `DeviceCardView` + `SectionCardStyle`, but have **separate** backdrop controllers and chrome. Any chrome/visual change (backdrop kind, card surface, dividers, scrim) MUST be applied to **both** `MainWindow` and `QuickControlsWindow` so they stay consistent.
- **Cards & dividers:** card surface is theme-aware via `SectionCardBackgroundBrush` / `SectionCardBorderBrush` (App.xaml theme dictionaries): light gets the standard `CardBackgroundFillColorDefault` + `CardStrokeColorDefault` hairline, dark keeps the subtle borderless `LayerFillColorDefault` (a hard fill + stroke reads too light/boxy over dark Mica). Intra-card dividers use `CardStrokeColorDefaultBrush` (`DividerStrokeColorDefault` is lighter in dark, not more defined).
- **Content width:** Devices/Rules/Sessions stretch full-width; Settings uses a ~720 column. Don't cap the list/grid pages.

## Reactivity
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
See [AGENTS.md](./AGENTS.md) for the full project instructions (layout, build/run pattern, rule schema, commit/PR conventions). Treat AGENTS.md as the authoritative source - this file exists only so Claude Code's auto-context loader picks it up.
See [AGENTS.md](./AGENTS.md) for the full project instructions (layout, build/run pattern, rule schema, commit/PR conventions). Treat AGENTS.md as the authoritative source.
ALWAYS READ THIS INSTANTLY.
4 changes: 3 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
-->
<PackageVersion Include="Microsoft.WindowsAppSDK.Runtime" Version="2.1.3" />
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1839" />
<PackageVersion Include="Microsoft.Graphics.Win2D" Version="1.4.0" />
<!-- GDI+ glyph -> HICON rendering for the taskbar transport buttons (TaskbarMediaControlsManager).
Also pulled transitively by H.NotifyIcon.WinUI; pin to its required 10.0.0 to avoid a conflict. -->
<PackageVersion Include="System.Drawing.Common" Version="10.0.0" />

<!-- MVVM -->
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
Expand Down
53 changes: 53 additions & 0 deletions docs/adr/navigation-singleton-pages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Decision record: singleton DI pages swapped via `Content=` instead of `Frame.Navigate`

**Status:** Accepted (2026-06-06). Implemented in `perf/startup-improvements` (`NavigationService`).

Records why the shell navigates by assigning `Frame.Content` to long-lived, DI-resolved page singletons instead of calling `Frame.Navigate(typeof(Page))`, and what we gave up to do it.

## Context

The shell is a `NavigationView` hosting a single `ContentFrame` (`MainWindow.xaml`). Four top-level pages (`HomePage`, `RulesPage`, `SessionsPage`, `SettingsPage`) are registered as **singletons** in DI (`HostBuilderExtensions.cs:90-93`) and constructor-inject their view models. `NavigationService.Navigate` resolves the page from the container and sets `_frame.Content = page` (`INavigationService.cs`).

This is deliberately *not* the idiomatic WinUI pattern. The textbook approach is `Frame.Navigate(typeof(HomePage))`: the Frame owns a navigation stack, instantiates each page via its parameterless constructor on every visit, and animates the swap with the `NavigationThemeTransition` declared in `Frame.ContentTransitions`.

Two things make that default a poor fit here:

1. **Constructor injection.** `Frame.Navigate` instantiates pages itself with `Activator`, so it can only call a parameterless constructor. Our pages take their view models (and the VMs take audio services) through DI. Routing every page through `Frame.Navigate` would mean either a service-locator anti-pattern in each page's parameterless ctor or a custom navigation hook that resolves from the container anyway, at which point we are most of the way to our own navigator.

2. **Pages are expensive to build, and the cost is COM-shaped.** `HomePage` binds an `ItemsRepeater` of device cards; each card hosts `ChannelPeakMeter`s, a `CrossfadeImage`, and now-playing strips driven by live audio data. Building a page means re-enumerating endpoints and sessions and re-establishing meter polling against the Core Audio COM services (`AudioEndpointService`, `AudioSessionService`, `AudioSessionMeterService`, all marshalled WASAPI/`IMMDeviceEnumerator` interop). The marshalling, the per-card visual tree, and the first layout pass dominate page-open cost. With `Frame.Navigate` that whole cost is paid **on every tab switch**, because the Frame discards the old page and constructs a fresh one. It also throws away transient UI state (scroll offset, expanded cards, in-flight meter animations) each time.

Singletons turn the first build into a one-time cost. After a page is built once, re-navigating to it re-attaches the **same** fully-realised element tree, with its COM subscriptions and visual state intact, in roughly the cost of a property set.

## Decision

Keep pages as DI singletons and navigate by `Content=` assignment. Accept that this means owning the parts of `Frame` we bypass.

### What `NavigationService` reimplements

`Frame.Navigate` gives you a navigation stack, `CanGoBack`/`CanGoForward`, and the page-transition animation for free. Bypassing it means we build those ourselves:

- **Back/forward history.** Two `Stack<Type>` collections (`_backStack` / `_forwardStack`) with standard browser semantics: a forward `Navigate` pushes the current page and clears the forward stack; `GoBack`/`GoForward` move entries between the two. We store `Type`, not page instances, because the instance is always recoverable from the container. A `HistoryChanged` event lets the title-bar back button track `CanGoBack`.
- **Page transitions.** `NavigationThemeTransition` only fires on a real `Frame.Navigate`, so it never ran for us (the `Frame.ContentTransitions` block we initially copied in was dead, and the `NavigationTransitionInfo` we passed into `Navigate` was silently ignored). Element `Transitions` (e.g. `EntranceThemeTransition`) don't fill the gap either: they only play on an element's **first** realisation into the tree, so a re-shown cached singleton wouldn't animate (only never-seen pages would). `SwapTo` instead drives an explicit fade + slide-up `Storyboard` (`PlayEntrance`) on every swap, which animates whether the page is freshly built or a cached re-show.
- **De-duping.** `Navigate` no-ops when the target page is already current, so re-clicking the active `NavigationViewItem` does nothing.

## Tradeoffs

| Concern | `Frame.Navigate` (default) | Singleton + `Content=` (chosen) |
|---|---|---|
| Page construction cost | Paid on **every** visit | Paid **once**, then reused |
| COM interop churn | Re-enumerate endpoints/sessions + re-subscribe meters per visit | Subscriptions stay live for the app's lifetime |
| UI state across visits | Lost (scroll, expansion, animations reset) | Preserved |
| DI / constructor injection | Awkward (parameterless ctor only) | Natural (container resolves the page) |
| Back/forward stack | Built in | We maintain it (`_backStack`/`_forwardStack`) |
| Transitions | `NavigationThemeTransition` for free | We run a `Storyboard` per swap (`PlayEntrance`) |
| Memory | One page alive at a time | All four pages + their trees resident for the session |
| Per-page lifecycle hooks | `OnNavigatedTo`/`OnNavigatedFrom` fire | Don't fire; page must react to its VM, not nav events |

The deliberate cost we accept: **all four pages stay resident** for the session, and pages don't get `OnNavigatedTo`/`OnNavigatedFrom` callbacks. The resident-memory cost is bounded (four pages) and cheap next to the COM/layout savings. The missing lifecycle hooks are a non-issue because our pages are driven by their view models and the live audio services, not by navigation events: there is no per-visit "load" step to hang off `OnNavigatedTo`.

## Consequences

- New top-level pages must be registered as singletons in `HostBuilderExtensions` and added to the `tag -> Type` maps in `MainWindow.xaml.cs`.
- Anything that previously would have lived in `OnNavigatedTo` (refresh-on-show) must instead be reactive: subscribe to the view model / audio service so the page updates whether or not it is the visible page.
- A page that must *not* hold resources while hidden (none today) would be the trigger to revisit this: at that point we would add an explicit show/hide signal rather than reverting to `Frame.Navigate`.
- `INavigationService.Navigate` no longer accepts a `NavigationTransitionInfo` (it was dead); transition tuning happens in `NavigationService.PlayEntrance`.
165 changes: 0 additions & 165 deletions docs/adr/now-playing-banner-research.md

This file was deleted.

Loading
Loading