ADR-016: Ambient dominant color — Rust-side extraction, DB-cached
ADR-016: Ambient dominant color — Rust-side extraction, DB-cached
Status
Accepted
Context
The now-playing surfaces (Music Room NowPlayingView, MusicPlayerExpansion —
ui-design-system.md §8.8) are where users spend the most time. A static
background behind gray artwork feels dead. Ambient color pulls the dominant
color from the current track’s cover art and applies it as a subtle tint —
felt, not noticed (~8% over the card surface, blended in oklch) — on the
now-playing surfaces only; browse/detail views keep the static theme
background.
data-models.md gives Track a cover_art_url but no color field. Playback
events deliver TrackStub (tauri-events.md), which carries neither cover art
nor color. The question is where the dominant color is computed and how the
frontend receives it.
Decision
Rust-side extraction, cached in the library service. Image decoding and dominant-color computation are added to the library service (it owns content resolution) as part of this change:
- Computed when a cover is first indexed/resolved: 1×1 resize + oklch average — cheap, and run once per unique cover.
- Cached with the resolved metadata; invalidated only when the cover URL
changes.
nullwhen there is no cover art — the surface then uses the static theme background (no tint). - Served to the frontend as a nullable
cover_dominant_colorfield (a string like"oklch(0.55 0.12 45)") on bothTrackandTrackStub— events deliverTrackStub, so the field must exist there, not only on the fullTrack.
The UI applies it through the token channel: NowPlayingView and
MusicPlayerExpansion set --ambient-color on their own root and the base
layer builds the gradient (ui-design-system.md §6.3). No client-side
extraction as a fallback in v1.
Alternatives considered
- JS canvas extraction in the frontend. Rejected. It runs on the render thread and causes a visible layout shift when the cover resolves after the view mounts; also duplicates computation across every display of the same cover.
- No ambient treatment. Rejected — the now-playing surface reads as dead without it, and the tint is the main “audio identity” cue the design system calls for (ui-design-system.md §1).
- Frontend-resolved full
Track(events stay stub-only; the view callsget_track/library for the color). Works, but adds a lookup and a loading state on the primary surface; carrying the small nullable field on the stub avoids it.
Consequences
Positive:
- Dominant color is computed once per unique cover, then served from cache for every subsequent display — near-zero runtime cost.
- Off the render thread; no layout shift from late-arriving cover art.
- The field is nullable and tolerant — the UI degrades to the static theme background when there is no cover, no color, or an unprocessed track.
- Rides the existing
--ambient-colortoken channel, so the design system owns the blend and components stay token-clean.
Negative:
- A data-model change (
cover_dominant_coloronTrackandTrackStub) and a Rust/TS contract update (contracts/types.ts, serde structs) with the associated migrations — tracked in ui-design-system.md §14 open items. - The library service gains an image-decode capability (downscale + oklch average) it did not have; adds a dependency or code for image decoding.
- Tint is limited to now-playing surfaces in v1; extending it is a per-room decision later.
References
ui-design-system.md§6 (ambient color), §6.2 (extraction), §6.3 (application), §14 (open items —cover_dominant_color)api-specs/data-models.md(Track,TrackStub)api-specs/tauri-events.md(events deliverTrackStub)services/library-service.md(resolution/cache owner)- ADR-015 (token architecture —
--ambient-colorchannel) - FR-MUSIC-2 (player expansion surface)