Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 1 addition & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
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.
@agents.md
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

An audio companion for Windows, built around a regex-driven rules engine. Routing rules pin each app to the device you want (a browser to your media DAC, Discord onto your headset mic, the system default to a specific endpoint) and keep it there across reboots, app updates, and driver reinstalls. Around that sits a live per-device mixer, now-playing with media controls, and quick access from the tray or a global shortcut. Like Volume Mixer's per-app device picker, but pattern-driven and persistent. A full control centre.

[![Download from GitHub Releases](https://img.shields.io/github/v/release/hoobio/earmark?label=Download&logo=github&style=for-the-badge&color=181717)](https://github.com/hoobio/earmark/releases/latest)
[![Get it from Microsoft Store](https://img.shields.io/badge/Microsoft%20Store-Install-0078D4?style=for-the-badge&logo=data:image/png;base64,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)](https://apps.microsoft.com/detail/9N27KG5M9W9B)
    
[![Download from GitHub Releases](https://img.shields.io/github/v/release/hoobio/earmark?label=Download%20from%20GitHub&logo=github&style=for-the-badge&color=181717)](https://github.com/hoobio/earmark/releases/latest)
    
[![Donate with PayPal](https://img.shields.io/badge/Donate-PayPal-003087?style=for-the-badge&logo=paypal)](https://www.paypal.com/donate/?hosted_button_id=SWG4G7VH3ZFQN)

[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![GitHub Stars](https://img.shields.io/github/stars/hoobio/earmark?style=social)](https://github.com/hoobio/earmark/stargazers)
Expand Down Expand Up @@ -51,6 +55,12 @@ No external CLIs, no service install, no admin rights.

## Installation

### Microsoft Store

**[Get it from the Microsoft Store](https://apps.microsoft.com/detail/9N27KG5M9W9B)** for automatic updates.

> The Store version may lag behind GitHub Releases due to Microsoft's certification process. Install from GitHub if you need the latest version immediately.

### GitHub Releases

1. Download the latest `.msi` for your architecture (x64 or ARM64) from [Releases](https://github.com/hoobio/earmark/releases/latest).
Expand Down
73 changes: 73 additions & 0 deletions docs/adr/compact-card-density.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Decision record: per-view card display options (compact density + Quick Controls overrides)

**Status:** Accepted (2026-06-07). Implemented in `feat/compact-cards` (`PeakMeterOptions`, `CardPresentation`, `DeviceCardView`, `HomeViewModel`, `AppSettings`).

Records how a device card renders at a different density and feature set in each window without duplicating the card, why compact mode is one input among the per-view options, and the slider-centring gotcha the compact row hit.

## Context

Device cards (`DeviceCardView`) render in two windows from the **same** view-models: the Devices page (`HomePage`) and the Quick Controls overlay (`QuickControlsWindow`). There is one `DeviceCard` per device and one `NowPlayingStrip` per playing app; both windows bind those same instances. This single-instance design is why volume, mute, the 20 Hz peak meters, and now-playing state are automatically identical in both windows (same object, nothing to sync).

Two needs pulled against that:

1. **Compact density** had to tighten many independent measurements at once (card padding, section spacing, icon tile, volume + meter height, now-playing strip layout, rule chips) and update live on toggle.
2. **Quick Controls had to diverge from the Devices page**: render compact, drop the rules section, drop the header badges, drop the section dividers, keep now-playing - while the main window kept the user's roomy Devices layout. Driving the one shared options object compacts the main window too whenever it's visible behind the overlay (a visible glitch the user rejected).

## Decision

**Card display is resolved per view through a lightweight `CardPresentation(card, options)` projection. The card and strip instances stay shared (live state identical); only a `PeakMeterOptions` instance diverges per window.**

- `DeviceCardView` takes an `Options` dependency property. Unset (the main window) falls back to `card.MeterOptions`, the global options object - so the Devices page is byte-for-byte unchanged. Quick Controls sets `Options="{x:Bind QuickMeterOptions}"`.
- From `Card` + `Options` the view builds a `CardPresentation`. The XAML binds it for two things:
- **display geometry** (`Presentation.Options.X`: padding, spacing, icon tile, volume/meter heights, the now-playing strip layout, rule-chip metrics, and the `CompactCards` flag), and
- **combine-with-data visibility** (`Presentation.ShowRulesSection`, `ShowNowPlaying`, the `ShowNormalBadges`/`ShowCompactBadges` pair, the section dividers) which fold one option (`ShowRules` / `ShowNowPlaying` / `ShowDeviceBadges` / `ShowCardDividers` / `CompactCards`) with card data.
- The **data-template gap** is closed by `CardPresentation.NowPlayingStrips`: it mirrors `DeviceCard.NowPlayingStrips` as `NowPlayingStripView(Strip, Options)` records (in place, via `CollectionChanged`). Each strip's data template binds `Strip.X` for live state and `Options.X` for geometry, so the strip renders at the host window's density even though the template can't reach the host control's DP. The expanded rule chips use the same trick (`RuleSummary.Options`).

This is a refinement of the per-view option the first draft of this ADR rejected (then "approach 2"). What made it viable is that `CardPresentation` holds **no live state** - only a reference to the shared card/strip plus the view's options, recomputing pure styling flags on `PropertyChanged`. So it never becomes "approach 3" (separate card/strip instances + a mirroring layer): live state stays single-instance and identical across windows; only styling diverges.

### Compact density

`CompactCards` on `PeakMeterOptions` drives every compact geometry getter (`CardContentPadding`, `CardSectionSpacing`, `IconTileSize`, `VolumeRowHeight`, `MeterTotalHeight`, `VolumeSliderMargin`, `NowPlayingStripPadding`, the transport sizes, `RuleChipPadding`, the `ShowNormalBadges`/`ShowCompactBadges` pair, etc.). Every getter returns the **roomy** value when `CompactCards` is false, so the non-compact layout never shifts. `OnCompactCardsChanged` re-raises them all, so a toggle re-flows live with no card rebuild.

- The Devices page is fed by `AppSettings.CompactCards` (default false), toggled from Settings and the Devices backdrop menu, mirrored onto the global options by `HomeViewModel.SyncMeterOptions`.
- Quick Controls is fed by its own `QuickControlsCompact` (default true), independent of the page.

### Quick Controls overrides

Quick Controls is its own configurable view. `HomeViewModel.SyncQuickMeterOptions` builds `QuickMeterOptions` by mirroring the global options, then applying five overrides that win for the overlay only (the main window binds the global object, QC binds its own, so the page is never touched):

| Setting | Default | Effect in QC |
|---|---|---|
| `QuickControlsCompact` | true | Compact density |
| `QuickControlsShowNowPlaying` | true | Keep the now-playing strip |
| `QuickControlsShowRules` | false | Drop the rules section (overlay is for control, not rule editing) |
| `QuickControlsShowDeviceBadges` | false | Drop the flow / Default / Communications pills |
| `QuickControlsShowDividers` | false | Drop the hairline section dividers |

Defaults suit a dense, glanceable overlay. A Quick Controls card's only context menu is a single **Settings** item: it restores the main window, navigates to Settings, and reveals (expands + scrolls to) the Quick Controls section. Configuration lives in the app, not the overlay.

## Tradeoffs

| Concern | This design | Alternative |
|---|---|---|
| Per-window divergence | `CardPresentation` projection over shared instances | Separate card/strip instances + a live-state mirroring layer |
| Live state across windows | Single instance, identical, nothing to sync | Mirroring layer (volume/mute/meters/now-playing/seek/connection) |
| Live update on toggle | `OnPropertyChanged` re-raise, no rebuild | Card rebuild on toggle (loses transient UI state) |
| Data-templated scopes (strip, rule chips) | `NowPlayingStripView` / `RuleSummary.Options` carry the view's options | Unreachable from a host-view DP (half-compact card) |
| Number of compact knobs | One `CompactCards` flag per options object | Per-card flags (more state, more sync) |

## Consequences

- A new display-dependent measurement is added as a getter on `PeakMeterOptions` and re-raised in `OnCompactCardsChanged` (or the relevant `On…Changed`). Keep the roomy branch returning the pre-compact value so normal layout never shifts.
- A new combine-with-data flag is added to `CardPresentation` (option folded with card data) and re-raised in `RaiseAll`, then bound as `Presentation.X`.
- A new per-window QC override is a setting on `AppSettings` (mirrored in `SyncQuickMeterOptions`) plus a card under the Quick Controls settings expander. It must read from `QuickMeterOptions`, never the global, so the main window is untouched.
- Live state stays single-instance. If a future need can't be expressed as a styling option (it needs genuinely different live data per window), that is the trigger to give QC its own projected card instances with a sync layer - and this ADR is where that cost is documented.

### Slider height gotcha (volume + now-playing seek)

The WinUI `Slider` template forces a ~32 px min body (`SliderHorizontalHeight`) with the thumb sitting below the control's geometric centre. Two non-fixes we tried and rejected:

- Overriding the `SliderHorizontalHeight` resource in `Slider.Resources` does **nothing** (the template doesn't pick up the instance override).
- A `MaxHeight` clamp shrinks the control but does **not** re-centre the thumb, so it clips.

What works: keep the slider its natural size and place it (centre-aligned) inside a **fixed-height, non-clipping host**; the slim row is the host's height, the slider overflows it invisibly (transparent track), and a small DIP-based negative top margin lifts the low-sitting thumb back onto the host centre. The compact volume row and the now-playing seek host both use this.
14 changes: 14 additions & 0 deletions src/Earmark.App/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,20 @@ public void RestoreFromBackground()
chrome?.RestoreWindow();
}

/// <summary>Restores the main window, navigates to Settings, and reveals the Quick Controls section.
/// Invoked from the Quick Controls overlay's "Settings" context-menu item (cards and group titles).</summary>
public void OpenQuickControlsSettings()
{
if (_host is null)
{
return;
}

RestoreFromBackground();
_host.Services.GetRequiredService<MainWindow>().NavigateByTag("Settings");
_host.Services.GetRequiredService<SettingsPage>().RevealQuickControls();
}

public void DisposeHost()
{
if (_host is null)
Expand Down
4 changes: 3 additions & 1 deletion src/Earmark.App/Controls/BlockWrapLayout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,9 @@ protected override Size ArrangeOverride(VirtualizingLayoutContext context, Size

for (var slot = 0; slot < display.Length; slot++)
{
context.GetOrCreateElementAt(display[slot]).Arrange(slotRects[slot]);
var element = context.GetOrCreateElementAt(display[slot]);
element.Arrange(slotRects[slot]);
ReorderElevation.TrackAndElevate(element, slotRects[slot]);
}

return new Size(finalSize.Width, totalHeight);
Expand Down
15 changes: 14 additions & 1 deletion src/Earmark.App/Controls/ChannelPeakMeter.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,18 @@ public double BarHeightOverride
set => SetValue(BarHeightOverrideProperty, value);
}

/// <summary>When &gt; 0, overrides the total stacked-meter height (default 20) that the channel bars
/// divide between them - used by compact cards to slim the meter. Ignored if
/// <see cref="BarHeightOverride"/> is set (the app-chip underbar path).</summary>
public static readonly DependencyProperty TotalHeightOverrideProperty = DependencyProperty.Register(
nameof(TotalHeightOverride), typeof(double), typeof(ChannelPeakMeter), new PropertyMetadata(0.0, OnShapeChanged));

public double TotalHeightOverride
{
get => (double)GetValue(TotalHeightOverrideProperty);
set => SetValue(TotalHeightOverrideProperty, value);
}

public static readonly DependencyProperty ShowHoldProperty = DependencyProperty.Register(
nameof(ShowHold), typeof(bool), typeof(ChannelPeakMeter), new PropertyMetadata(true, OnShapeChanged));

Expand Down Expand Up @@ -187,9 +199,10 @@ private void RebuildBars()
}
}

var totalHeight = TotalHeightOverride > 0 ? TotalHeightOverride : TotalMeterHeight;
var barHeight = BarHeightOverride > 0
? BarHeightOverride
: TotalMeterHeight / count;
: totalHeight / count;

// Rounded ends on the outer corners only so the stack reads as one block; radius =
// half the stack height for pill ends matching the old single bar.
Expand Down
Loading
Loading