Skip to content

feat: add Devices page with live cards, peak meter and Ctrl+Z undo#32

Merged
hoobio merged 7 commits into
mainfrom
feat/devices-page
May 28, 2026
Merged

feat: add Devices page with live cards, peak meter and Ctrl+Z undo#32
hoobio merged 7 commits into
mainfrom
feat/devices-page

Conversation

@hoobio
Copy link
Copy Markdown
Owner

@hoobio hoobio commented May 28, 2026

Summary

Replaces the Home placeholder in the nav rail with a full Devices page that lists every active audio endpoint as a live card. Each card surfaces the device's current peak level on a dB-accurate sectioned meter, a master volume slider that doubles as a mute control, a hide/show toggle, and a compact summary of every rule that touches the device. Clicking a rule chip jumps to the Rules page with that rule expanded and scrolled into view. Plus rule-condition support for "Application running" / "Application not running", external mute reconciliation with Windows toast notifications naming the locking rule, Ctrl+Z undo across hide/show, volume drags, and mute toggles, and persistent window sizing.

Changes

  • New Devices page (HomePage + HomeViewModel + DeviceCard + DeviceRulesSummary) with custom WrapByRowLayout so chevron-expanded cards grow alone without dragging their row mates with them.
  • Peak meter (PingPlayer swapped to Windows system WAV; sectioned bar at -12 / -6 / 0 dBFS thresholds with gradient transitions; peak-hold marker with 1.5 s latch + decay; log-scaled across -60 to 0 dBFS).
  • Auto-mute on 0 % volume, auto-unmute on drag with rule lock honoured.
  • Rule conditions: ApplicationRunning / ApplicationNotRunning types in RuleCondition; IRuleMatcher.ConditionsMet now takes sessions; RuleEvaluator updated.
  • External mute reconciliation: per-device AudioEndpointVolume.OnVolumeNotification subscriptions in AudioEndpointService raise ExternalMuteChanged; HomeViewModel snaps the device back to the rule''s target and fires a Microsoft.Windows.AppNotifications toast naming the locking rule (rate-limited per device). 250 ms poll kept as fallback.
  • Ctrl+Z undo for VisibilityUndo (hide/show) and VolumeMuteUndo (slider drag and mute icon click).
  • Window size persisted to AppSettings.WindowWidth / WindowHeight, restored via AppWindow.Resize on Attach.
  • WaveLink snapshot equality fixed (ReferenceEquals -> structural compare) so SnapshotChanged no longer fires every 5 s poll.
  • Title bar renamed to "Audio rules engine"; per-page subtext lines collapsed into info-icon tooltips.
  • CLAUDE.md -> AGENTS.md rename plus a new "Reactivity preferences" section documenting event-driven-first, polling-as-fallback.

Follow-up commits (review pass)

  • fix(rules): volume / mute rules targeting a capture device (microphone) were being classified Idle by the evaluator because EffectiveFlow defaults to Render for those action types. The applier was correctly muting the mic, but DeviceRulesSummary then cleared IsMuteLockedByRule, the card''s mute icon stayed clickable, the user could unmute, and ApplyExternalMute had no rule target to reconcile against. Added MatchEndpointAnyFlow and used it for SetDeviceVolume / MuteDevice / UnmuteDevice.
  • perf(audio): GetPeakLevel now reuses the long-lived MMDevice already held by the mute subscription, instead of allocating a fresh COM wrapper on every 50 ms poll.
  • refactor(home): pulled the capped undo LIFO out of HomeViewModel into its own DeviceUndoStack. The previous in-line trim re-stacked the whole stack to drop the oldest entry; LinkedList makes that an O(1) RemoveFirst. Also renamed MasonryLayout.cs to match its only class, WrapByRowLayout.
  • ci(sbom): the SBOM job was still using v1''s single channel: ci/<branch> input after the pipeline-tools v1 -> v2 bump in build(deps): Bump hoobio/pipeline-tools from 1.5.0 to 2.2.1 #31. v2 requires channel: ci + sub-channel: <branch> (or channel: <default-branch> for main), and the bootstrap migration was deleting the just-created upload parent on every run. Switched to the v2 channel + sub-channel pattern.
  • docs(readme): replaced the single hero image with separate Devices / Rules screenshots.

Testing

  • Manual testing on Windows 11 host (build 26200)
  • Tail of the latest log file shows expected Applied rule ... lines after launch
  • Unit tests added/updated under tests/Earmark.Core.Tests
  • Existing tests pass (dotnet test -p:Platform=x64)

Checklist

  • PR title follows Conventional Commits
  • Code builds without warnings (dotnet build src/Earmark.App/Earmark.App.csproj -c Debug -p:Platform=x64)
  • No emoji / gitmoji in commit messages or PR title
  • Architecture boundary respected: Earmark.Core stays UI-free; Windows / NAudio interop in Earmark.Audio; UI / VMs / settings in Earmark.App
  • AGENTS.md updated (rename + reactivity section)

feat(rules): add ApplicationRunning and ApplicationNotRunning conditions
feat(audio): reconcile external mute changes via AudioEndpointVolume notifications and fire a Windows toast naming the locking rule
feat(home): Ctrl+Z undo for hide / show, volume drags and mute toggles
feat(home): persist window size across launches
feat(ui): replace per-page subtext lines with info-icon tooltips
fix(rules): mark volume / mute rules targeting capture devices as Active
perf(audio): reuse cached MMDevice for peak-level reads to avoid per-tick COM activations
chore: rename CLAUDE.md to AGENTS.md and document event-driven reactivity preference

hoobio added 7 commits May 28, 2026 16:22
The Devices page (replacing the old Home placeholder in the nav rail) lists
every active audio endpoint as a card with:

- A log-scaled, colour-sectioned peak meter (green / amber / red bands at
  -12 / -6 / 0 dBFS, with narrow gradient blends straddling each threshold)
  and a 1.5 s peak-hold marker.
- A volume slider that auto-mutes at 0 % and auto-unmutes the moment the user
  drags off 0; a Windows system WAV ping plays on each commit.
- A mute toggle that doubles as the device icon. The icon greys + drops its
  chip background when a rule pins the mute state.
- A hide / show eye toggle. Cards sort defaults first (output before input),
  then Wave Link mix targets, then Wave Link virtual channels, then
  alphabetical. Non-default devices with no rules auto-hide; user-pinned
  devices override.
- A summary of every enabled rule that targets the device (name + evaluator
  status + match count), collapsed to one chip by default with a chevron to
  expand. Clicking a chip jumps to the Rules page with that rule expanded
  and scrolled into view, all other rules collapsed.

Layout is a custom WrapByRowLayout: cards in a row align to a shared baseline
height except when a card is in chevron-expanded mode, where it grows alone.

Audio rules engine title replaces "Per-app audio routing" on the title bar.

feat(rules): add ApplicationRunning and ApplicationNotRunning conditions
feat(audio): reconcile external mute changes via AudioEndpointVolume notifications and fire a Windows toast naming the locking rule
feat(home): Ctrl+Z undo for hide / show, volume drags and mute toggles
feat(home): persist window size across launches
feat(ui): replace per-page subtext lines with info-icon tooltips
chore: rename CLAUDE.md to AGENTS.md and document event-driven preference
SetDeviceVolume / MuteDevice / UnmuteDevice are flow-agnostic - the applier
matches them against render and capture endpoints alike. The evaluator was
passing action.EffectiveFlow (which falls back to Render) to MatchEndpoint,
so a MuteDevice rule on a microphone was classified Idle even while the
applier kept it muted. Cascading effect: DeviceRulesSummary cleared the
mute lock, the card's mute icon became clickable, the user could unmute,
and ExternalMuteChanged had no rule target to reconcile against - so the
next applier pass silently re-muted with no toast.

Add MatchEndpointAnyFlow and use it for volume/mute actions.
GetPeakLevel was allocating a fresh MMDevice via the enumerator on every
call. The Devices page polls at ~20Hz per visible card, so a typical six-
device layout was burning ~120 COM activations per second. Route the read
through the long-lived MMDevice held by the mute subscription (added in
this PR for OnVolumeNotification); fall back to the enumerator path only
when no subscription exists.
…class

Pull the capped LIFO of reversible Devices-page actions out of
HomeViewModel into its own class. The previous in-line trim re-stacked
the whole undo stack to drop the oldest entry on overflow; LinkedList
makes that an O(1) RemoveFirst. HomeViewModel now just calls
PushVisibility / PushVolumeMute / TryPop, and the apply-on-pop switch
stays where it belongs - on the view-model that knows the cards.

Also rename Controls/MasonryLayout.cs to WrapByRowLayout.cs so the file
matches the class it defines.
PR #31 bumped hoobio/pipeline-tools from v1.5.0 to v2.2.1 without updating
the SBOM job to v2's API. v1 took a single `channel: ci/<branch>` string;
v2 splits it into `channel: ci` + `sub-channel: <branch>`, and the bootstrap
now treats any `<component>@ci/<X>` project it finds as a legacy v1 entry
that needs migrating out. With the v1-style channel still in place, the
bootstrap was creating `earmark-app@ci/<branch>`, then the migration sweep
immediately rewrote and deleted it, then the upload step 404'd on the
missing parent.

Switch the resolver to emit channel + sub-channel separately, promote the
default branch (main) to top-level (`earmark-app@main`, sibling of release)
instead of nesting it under ci, and pass the new sub-channel + default-branch
inputs to the upload action. Effective parent name (sub-channel if set, else
channel) is exposed as a separate output so the project-tag value stays
aligned with the new v2 hierarchy.
@hoobio hoobio merged commit 7ea82f9 into main May 28, 2026
13 checks passed
hoobio added a commit that referenced this pull request May 28, 2026
## Summary

Replaces the Home placeholder in the nav rail with a full Devices page
that lists every active audio endpoint as a live card. Each card
surfaces the device's current peak level on a dB-accurate sectioned
meter, a master volume slider that doubles as a mute control, a
hide/show toggle, and a compact summary of every rule that touches the
device. Clicking a rule chip jumps to the Rules page with that rule
expanded and scrolled into view. Plus rule-condition support for
"Application running" / "Application not running", external mute
reconciliation with Windows toast notifications naming the locking rule,
Ctrl+Z undo across hide/show, volume drags, and mute toggles, and
persistent window sizing.

## Changes

- New **Devices page** (`HomePage` + `HomeViewModel` + `DeviceCard` +
`DeviceRulesSummary`) with custom `WrapByRowLayout` so chevron-expanded
cards grow alone without dragging their row mates with them.
- **Peak meter** (`PingPlayer` swapped to Windows system WAV; sectioned
bar at -12 / -6 / 0 dBFS thresholds with gradient transitions; peak-hold
marker with 1.5 s latch + decay; log-scaled across -60 to 0 dBFS).
- **Auto-mute on 0 % volume, auto-unmute on drag** with rule lock
honoured.
- **Rule conditions**: `ApplicationRunning` / `ApplicationNotRunning`
types in `RuleCondition`; `IRuleMatcher.ConditionsMet` now takes
sessions; `RuleEvaluator` updated.
- **External mute reconciliation**: per-device
`AudioEndpointVolume.OnVolumeNotification` subscriptions in
`AudioEndpointService` raise `ExternalMuteChanged`; `HomeViewModel`
snaps the device back to the rule''s target and fires a
`Microsoft.Windows.AppNotifications` toast naming the locking rule
(rate-limited per device). 250 ms poll kept as fallback.
- **Ctrl+Z undo** for `VisibilityUndo` (hide/show) and `VolumeMuteUndo`
(slider drag and mute icon click).
- **Window size persisted** to `AppSettings.WindowWidth` /
`WindowHeight`, restored via `AppWindow.Resize` on `Attach`.
- **WaveLink snapshot equality** fixed (`ReferenceEquals` -> structural
compare) so `SnapshotChanged` no longer fires every 5 s poll.
- **Title bar** renamed to "Audio rules engine"; per-page subtext lines
collapsed into info-icon tooltips.
- **CLAUDE.md -> AGENTS.md** rename plus a new "Reactivity preferences"
section documenting event-driven-first, polling-as-fallback.

## Follow-up commits (review pass)

- **`fix(rules)`**: volume / mute rules targeting a capture device
(microphone) were being classified `Idle` by the evaluator because
`EffectiveFlow` defaults to `Render` for those action types. The applier
was correctly muting the mic, but `DeviceRulesSummary` then cleared
`IsMuteLockedByRule`, the card''s mute icon stayed clickable, the user
could unmute, and `ApplyExternalMute` had no rule target to reconcile
against. Added `MatchEndpointAnyFlow` and used it for `SetDeviceVolume`
/ `MuteDevice` / `UnmuteDevice`.
- **`perf(audio)`**: `GetPeakLevel` now reuses the long-lived `MMDevice`
already held by the mute subscription, instead of allocating a fresh COM
wrapper on every 50 ms poll.
- **`refactor(home)`**: pulled the capped undo LIFO out of
`HomeViewModel` into its own `DeviceUndoStack`. The previous in-line
trim re-stacked the whole stack to drop the oldest entry; `LinkedList`
makes that an O(1) `RemoveFirst`. Also renamed `MasonryLayout.cs` to
match its only class, `WrapByRowLayout`.
- **`ci(sbom)`**: the SBOM job was still using v1''s single `channel:
ci/<branch>` input after the `pipeline-tools` v1 -> v2 bump in #31. v2
requires `channel: ci` + `sub-channel: <branch>` (or `channel:
<default-branch>` for main), and the bootstrap migration was deleting
the just-created upload parent on every run. Switched to the v2 channel
+ sub-channel pattern.
- **`docs(readme)`**: replaced the single hero image with separate
Devices / Rules screenshots.

## Testing

- [x] Manual testing on Windows 11 host (build 26200)
- [x] Tail of the latest log file shows expected `Applied rule ...`
lines after launch
- [ ] Unit tests added/updated under `tests/Earmark.Core.Tests`
- [x] Existing tests pass (`dotnet test -p:Platform=x64`)

## Checklist

- [x] PR title follows Conventional Commits
- [x] Code builds without warnings (`dotnet build
src/Earmark.App/Earmark.App.csproj -c Debug -p:Platform=x64`)
- [x] No emoji / gitmoji in commit messages or PR title
- [x] Architecture boundary respected: `Earmark.Core` stays UI-free;
Windows / NAudio interop in `Earmark.Audio`; UI / VMs / settings in
`Earmark.App`
- [x] AGENTS.md updated (rename + reactivity section)

---

<!-- release-please footers: one bare conventional-commit line per
extra changelog entry, separated by blank lines. -->

feat(rules): add ApplicationRunning and ApplicationNotRunning conditions

feat(audio): reconcile external mute changes via AudioEndpointVolume
notifications and fire a Windows toast naming the locking rule

feat(home): Ctrl+Z undo for hide / show, volume drags and mute toggles

feat(home): persist window size across launches

feat(ui): replace per-page subtext lines with info-icon tooltips

fix(rules): mark volume / mute rules targeting capture devices as Active

perf(audio): reuse cached MMDevice for peak-level reads to avoid
per-tick COM activations

chore: rename CLAUDE.md to AGENTS.md and document event-driven
reactivity preference
hoobio added a commit that referenced this pull request May 28, 2026
## Summary

Updates AGENTS.md "Multi-change PRs" section and
`.github/pull_request_template.md` to specify that conventional-commit
footers in a squash-merge PR description body must be
**blank-line-separated**. This is what was missing on PR #32 (`7ea82f9`)
which only produced one changelog entry out of the eight intended.

## Why

release-please's parser groups consecutive non-blank lines into a single
paragraph and only treats the first conventional-commit line per
paragraph as a changelog entry. The old guidance and PR template hint
both showed footers stacked without blank lines, which is why PR #32's
`feat(audio):`, `feat(home)` x2, `feat(ui):`, `fix(rules):`,
`perf(audio):`, and `chore:` footers were silently dropped.
Cross-checked against command-palette-bitwarden squash commits (e.g.
`60e48c7`) which work because each footer is blank-line-separated.

## Changes

- **AGENTS.md** "Multi-change PRs":
- Example uses blank-line-separated footers (matching the layout
release-please actually parses).
  - Explicit rule and reason for the blank line requirement.
  - Note that soft-wrapping within a single footer is fine.
- Correct the old "only feat/fix/perf/revert produce changelog entries"
bullet: every type produces an entry because
`release-please-config.json` sets `hidden: false` on all sections; only
the version-bump effect differs.
- **`.github/pull_request_template.md`**: hint updated with the same
convention, pointer changed from CLAUDE.md to AGENTS.md.

## Already done

The bad PR #32 squash commit message has been amended on main and
force-pushed (with the `main` ruleset briefly disabled and re-enabled)
so release-please will pick up the missed entries on its next run. See
open PR #29 once it refreshes.

## Testing

- [x] Local diff review against the working command-palette-bitwarden
footer format
- [ ] Wait for release-please workflow to re-run after this merges and
confirm PR #29 lists the additional `feat(audio)`, `feat(home)`,
`feat(ui)`, `perf(audio)`, `chore` entries
hoobio added a commit that referenced this pull request May 29, 2026
Resolve the #32 add/add conflicts in favour of this branch (a strict superset of the Devices-page work main has as f7951e8). Combine AGENTS.md: keep this branch's ExpanderPill / Fluent 2 sections and adopt main's #33 blank-line footer rule. Take main's updated PR template. Keep CLAUDE.md (main dropped it; this branch needs it as the Claude Code context pointer).
hoobio pushed a commit that referenced this pull request May 29, 2026
This PR was generated automatically by
[release-please](https://github.com/googleapis/release-please).
---


## [0.1.7](v0.1.6...v0.1.7)
(2026-05-29)


### Features

* add a setting to reconcile Wave Link device names
([0f7d12c](0f7d12c))
* add Devices page with live cards, peak meter and Ctrl+Z undo
([#32](#32))
([f7951e8](f7951e8))
* **audio:** reconcile external mute changes via AudioEndpointVolume
([f7951e8](f7951e8))
* **home:** Ctrl+Z undo for hide / show, volume drags and mute toggles
([f7951e8](f7951e8))
* **home:** match device-card icons to Wave Link mixes
([0f7d12c](0f7d12c))
* **home:** persist window size across launches
([f7951e8](f7951e8))
* **rules:** add ApplicationRunning and ApplicationNotRunning conditions
([f7951e8](f7951e8))
* **ui:** replace per-page subtext lines with info-icon tooltips
([f7951e8](f7951e8))
* **ui:** right-click menus to hide a device and enable or delete a rule
([0f7d12c](0f7d12c))
* Wave Link card theming, app theme and per-device apps row
([#34](#34))
([0f7d12c](0f7d12c))
* **wavelink:** route Wave Link input mute and volume over the WebSocket
([0f7d12c](0f7d12c))


### Bug Fixes

* **audio:** keep CoreAudio COM off the UI thread to stop hangs
([0f7d12c](0f7d12c))
* **audio:** release COM wrappers and adopt singleton view-model
lifetime ([#28](#28))
([694819e](694819e))
* **audio:** release MMDevice / AudioSessionControl wrappers and
([694819e](694819e))
* **rules:** mark volume / mute rules targeting capture devices as
Active
([f7951e8](f7951e8))


### Performance Improvements

* **audio:** reuse cached MMDevice for peak-level reads to avoid
([f7951e8](f7951e8))
* **audio:** reuse the cached MMDevice and skip no-op mute notifications
([0f7d12c](0f7d12c))


### Documentation

* **release-please:** require blank lines between footer entries
([#33](#33))
([4d21efc](4d21efc))


### Miscellaneous Chores

* rename CLAUDE.md to AGENTS.md and document event-driven
([f7951e8](f7951e8))


### Build System

* **deps:** Bump hoobio/pipeline-tools from 1.5.0 to 2.2.1
([#31](#31))
([afe3e45](afe3e45))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: release-please-hoobi[bot] <279189756+release-please-hoobi[bot]@users.noreply.github.com>
@hoobio hoobio deleted the feat/devices-page branch May 30, 2026 11:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant