EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

DESIGN.md

444 lines · 39.7 KB

Changed in: 0.5.0

1# Earhorn Music — a MinimServer-aware UPnP control point for Android TV, phones, and Android Auto
2
3> **v2 addendum (supersedes section 7.5 and the original roadmap):** Google Cast sender, including speaker-group playback, moves into **v0.1** scope. MinimServer-served audio URLs are handed directly to the Cast Default Media Receiver (no transcoding middleman for Cast-native formats). Format capability is gated up front via `res@protocolInfo`; unsupported formats (e.g. 24/192 FLAC, DSD) show a clear disabled state with an explanatory message until transcoding fallback arrives in v0.3. OpenHome is deferred to v0.2. A `libre` build flavor compiles Cast out entirely for Fire TV, GrapheneOS, and F-Droid.
4
5> **v3 addendum (form-factor):** The original spec framed Earhorn as a TV-first app where phone was "graceful adaptation." That has shifted. The same APK targets Android TV, phones, tablets, the Android Auto host, and — via the Android subsystem — ChromeOS devices including the HP Chromebase. A separate Compose Multiplatform module delivers GNU/Linux desktop builds. Each surface gets its own UI shell on top of a shared playback / UPnP / data core, dispatched via the `LocalFormFactor` CompositionLocal. The TV experience remains the design anchor; the touch experiences are designed *as siblings*, not as fallbacks. See `MIGRATION.md` for the per-screen conversion status.
6
7**Name:** Earhorn Music. (The earhorn — a Victorian-era ear trumpet — sits at the meeting point of focused listening and unfussy materials. The empty-state illustration uses a five-line musical stave with two minims as its lettermark, echoing MinimServer's own lineage.)
8
9**One-line pitch:** a UPnP/DLNA control point with BubbleUPnP's functional completeness, tuned first for the D-pad and the 10-foot screen, and ported in parallel to every surface a listener might already own.
10
11---
12
13## 1. What this app is, and what it is not
14
15### It is
16A **DMC** (Digital Media Controller) in UPnP/AV parlance — the thing that talks to a media server (MinimServer) and a renderer (a streamer, AVR, OpenHome device, Chromecast, or the TV itself) and coordinates playback between them. Secondary role: an optional **DMR** (renderer) so the Android TV box itself can be the playback target, and an optional **DMS** (server) so local Android files can be exposed to other control points.
17
18### It is not
19- A transcoding server. That is a separate process; if the user runs BubbleUPnP Server, we integrate with it, but Earhorn itself does not re-encode video on-device.
20- A streaming service UI. No TIDAL/Qobuz login flow in v1. The user's library is their library.
21- A single-surface app pretending to be cross-platform. Each form factor — TV, phone, tablet, Chromebase, Auto, GNU/Linux desktop — gets a UI shell designed for its input model. The TV shell remains the design anchor and the most polished surface; the others are siblings, not fallbacks. Shared core: playback service, UPnP/DLNA stack, settings, metadata model, theme tokens.
22
23### Success criteria
241. Every primary task is reachable in ≤3 D-pad presses from the home screen with a stock Android TV remote (no touchpad, no voice).
252. Focus is never ambiguous. At every frame, exactly one item is obviously focused and obviously actionable.
263. Library browsing feels as fast as scrolling a list on a phone. No spinners between every tag narrowing.
274. MinimServer's index semantics — Work/Group, Composer, AlbumArtist, Recently Added, alphabetical grouping — render natively. We do not flatten them.
285. A user who has never read the manual can queue an album within 60 seconds.
29
30---
31
32## 2. The three pillars, examined
33
34### 2.1 MinimServer — what we're actually integrating with
35
36MinimServer is a UPnP/AV ContentDirectory server with some opinionated behaviours we must handle natively rather than through a generic DLNA browser. What matters for a control point:
37
38- **`indexTags` is the top level.** The server owner configures which tags appear as top-level selection menus — typically some combination of `AlbumArtist`, `Artist`, `Composer`, `Genre`, `Date`, `Work`, `Conductor`, `Orchestra`, `Label`. Earhorn must display these in the order MinimServer returns them and preserve the owner's configured names (including translations). We do **not** impose our own top-level categories.
39- **Intelligent Browsing narrows.** After the first selection, MinimServer returns only the tags that still discriminate. If the user picks composer = Bach, there is no "composer" option on the next screen. This is a core feature of the server, not a bug in our UI. Earhorn must render whatever selection menu MinimServer returns, in order.
40- **Special entries.** MinimServer injects synthetic children: `[folder view]`, `[untagged]`, `>> Tag View`, `>> Complete Album`, `>> Show All`, and selectable disc headings inside multidisc albums. These have conventions (the `>>` prefix signals a meta-action, `[]` signals a view switch) and must be visually distinguished from real tag values, not shown as ordinary rows.
41- **Alphabetical grouping.** When `alphaGroup` is set for a tag, MinimServer returns A, B, C… headers. Treat these as section dividers with a D-pad fast-jump affordance.
42- **Recently Added / Recently Played.** Enabled via the `*RecentAdded` and `*RecentPlayed` pseudo-tags. Earhorn surfaces these as first-class home-screen shelves when present.
43- **Work and Group differ.** `Group` is a container (like an album). `Work` is a browsable tag but does not create containment. Our queue and Now Playing must expose `Work` as a displayed field (via `upnp:userAnnotation[tag.Work]` filter when the user has a full license); classical users depend on this.
44- **Artwork resolution.** MinimServer serves artwork per tag via tag-specific subfolders (an `Artist/` image for the Artist index, a `Label/` image for the Label index, etc.). We request artwork via the URIs MinimServer returns in DIDL-Lite; we do not second-guess its resolution rules.
45- **Per-file information pages.** If a tag value has an associated info file, MinimServer includes an HTML link in the description. BubbleUPnP renders this as "click to view". Earhorn mirrors that: a focused "info" action on detail screens opens the HTML in a TV-sized WebView overlay.
46- **The Web API.** MinimServer has a scripting/Web API for control. We do not rely on it for browsing (we use standard UPnP ContentDirectory), but we expose server-level actions — rescan library, reload properties, switch profile — via the Web API when the server is configured to allow them. This is a power-user feature, behind a "Server tools" menu.
47
48### 2.2 BubbleUPnP — the baseline, with honesty about what breaks on TV
49
50BubbleUPnP's three-pane model (Library / Playlist / Now Playing), OpenHome support, Chromecast proxy, DIDL-Lite handling, and transcoding via BubbleUPnP Server are the functional bar we clear. Its v4.0 Material Design 3 refresh and cover-art tinting are good references. What we keep:
51
52- Three primary destinations: Library, Queue, Now Playing.
53- First-class OpenHome renderer support (multi-room continuity without the app running).
54- Resilience to mixed-capability renderers: we query `GetProtocolInfo` and fall back gracefully.
55- Gapless flag propagation where the renderer supports it.
56- Sleep timer, shuffle/repeat modes, scrobbling hooks.
57- Cover-art dynamic accent tinting on Now Playing.
58
59What we reject for TV:
60- **Dense phone toolbars.** A three-dot menu on TV is an anti-pattern; it nests actions behind a hidden affordance. All actions surface as focusable rows on a side sheet.
61- **Tabs and bottom navigation.** D-pad users cannot reach a bottom tab without traversing the content above it. We use a left navigation rail that collapses out of the way.
62- **Small album grid tiles at phone density.** TV tiles are 240–280 dp wide, 2–2.5× the phone density.
63- **Modal drawers that trap focus.** Everything is a full-screen destination or a pane that the D-pad can leave.
64- **Confirmation dialogs mid-playback.** Queue "Replace / Append / Play Next" choices live as focusable actions on the item row, not as a popup after selection.
65
66### 2.3 Quoyle — the aesthetic, applied
67
68Quoyle is a quotation library. It looks the way it does because it respects the text it holds. Earhorn must respect the music the same way. Practical translation:
69
70- **Serif display, sans chrome.** Fraunces (variable, 72pt optical size for titles, 9pt for captions) carries album titles, composer names, Work titles. Inter (or similar humanist sans) carries UI chrome, counts, metadata rows, button labels. The serif is what you *read*; the sans is what you *operate*.
71- **Amber / sage / rose.** The Quoyle palette is warm and earthy, not neon. Amber is the primary accent (focus, "now playing" indicator). Sage is the secondary (section headers, quiet metadata). Rose is the tertiary (favourites, destructive confirmations).
72- **Dark first, warm dark.** Not `#000` OLED pure black — a deep warm charcoal, around `#17130F`, so Fraunces's old-style strokes don't fight the background. Text at `#EFE7D8`, a warm off-white, not cool grey.
73- **Generous whitespace.** Quoyle treats every quotation as worth a full card. Earhorn treats every album the same. Density is low by choice.
74- **Rope-textured 66/99.** The Quoyle lettermark uses typographic quotation marks as a brand element. Earhorn's empty-state illustration is a stave with a pair of minims resting on it — same idiom (glyphs as ornament), different discipline.
75- **No glassmorphism, no gradients-for-their-own-sake.** Surfaces are flat, layered by tone. Depth comes from shadow-free elevation tokens.
76
77---
78
79## 3. Architecture
80
81```
82┌─────────────────────────────────────────────────┐
83│ Earhorn Music │
84│ ┌──────────────┐ ┌──────────────────────────┐ │
85│ │ UI layer │ │ Domain layer │ │
86│ │ (Compose │←→│ - BrowseSession │ │
87│ │ for TV / │ │ - PlaybackCoordinator │ │
88│ │ Compose │ │ - RendererRegistry │ │
89│ │ Material) │ │ - ServerRegistry │ │
90│ └──────────────┘ └──────────────────────────┘ │
91│ ↓ │
92│ ┌───────────────────────────────────────────┐ │
93│ │ UPnP/AV core │ │
94│ │ - ContentDirectory client │ │
95│ │ - AVTransport / RenderingControl client │ │
96│ │ - OpenHome {Playlist, Product, Info} │ │
97│ │ - SSDP discovery (+ static hosts) │ │
98│ │ - DIDL-Lite + upnp:userAnnotation parser │ │
99│ └───────────────────────────────────────────┘ │
100│ ↓ │
101│ ┌───────────────────────────────────────────┐ │
102│ │ Local DMR (Media3) · Local DMS (opt.) │ │
103│ └───────────────────────────────────────────┘ │
104└─────────────────────────────────────────────────┘
105```
106
107### Stack choices
108
109| Layer | Choice | Why |
110|---|---|---|
111| UI (TV) | Compose for TV 1.0+ | Current official Android TV toolkit. Leanback is deprecated. |
112| UI (mobile) | Compose Material 3 | Shared ViewModels with TV; divergent composables. |
113| UPnP | Cling 2.1.2 (4thline) | Battle-tested, the same library BubbleUPnP uses. |
114| OpenHome | Linn ohNet JNI wrapper **or** pure-Kotlin Playlist/Product service client | ohNet is authoritative but LGPL+JNI; Kotlin client is lighter. Start pure-Kotlin. |
115| Local playback (DMR) | Media3 ExoPlayer | Gapless, FLAC 24/192, DSD via passthrough where supported. |
116| Discovery | Android's `NsdManager` + Cling SSDP | Dual-stack because SSDP on Android Wi-Fi varies by OEM. |
117| Images | Coil 3 | Cover art with per-tag fallback logic. |
118| DI | Hilt | Standard. |
119| Navigation | Compose Navigation (single NavHost, form-factor routes) | One graph, platform-specific start destinations. |
120| Persistence | Room + DataStore | Queue, playback history, server/renderer preferences. |
121
122### Single-codebase form-factor handling
123
124- `app/src/main/` — shared domain, UPnP core, ViewModels.
125- `app/src/mobile/` — phone/tablet composables, `AndroidManifest` without `<uses-feature android:name="android.software.leanback">`.
126- `app/src/tv/` — TV composables, Leanback manifest feature + `isLaunchable` intent filter on `android.intent.category.LEANBACK_LAUNCHER`.
127- Build flavours `mobile` and `tv` produce two APKs with shared 90% of code but form-factor-appropriate entry activities and themes. A single `unified` flavour is also produced for the Play Store TV listing; it uses `UI_MODE_TYPE_TELEVISION` at runtime to pick the root composable.
128
129### UPnP lifecycle on Android
130
131- Service discovery runs in a foreground service while the app is in foreground; SSDP M-SEARCH every 30 s, plus passive NOTIFY listening.
132- Subscriptions to `AVTransport`, `RenderingControl`, and OpenHome `Playlist`/`Product`/`Info` renew at 50% of their timeout.
133- When the user selects a renderer and starts playback, a foreground media session (Media3 `MediaSession`) is created so the TV can show lock-screen / ambient-mode controls and respond to hardware media keys.
134- Background: the app does **not** keep SSDP alive when backgrounded unless the user enables "Persistent control" (for a living-room use case where the TV box is idle but the phone drives playback). This is the BubbleUPnP Server pattern and is gated behind that setting.
135
136---
137
138## 4. Android TV — the hero experience
139
140### 4.1 Navigation model
141
142Android TV users operate a D-pad: up, down, left, right, center (select), back, home, and (on many remotes) a dedicated play/pause and microphone. Our entire UI is designed so that:
143
144- **Left–right traversal** moves between siblings within a row (e.g., across albums in a shelf).
145- **Up–down traversal** moves between rows, or into/out of the nav rail.
146- **Select** enters / plays / opens.
147- **Back** climbs one level in the browse stack, never exits mid-session.
148- **Long-press select** opens the action sheet for the focused item (play, append to queue, play next, add to favourites, view info).
149- **Media keys** always control the currently active renderer, regardless of screen.
150- **Fast-jump** (hold right on an alphabetical list) advances to the next letter group; this is MinimServer's `alphaGroup` made tangible.
151
152The root layout is a **collapsible left navigation rail** (56 dp collapsed, 240 dp expanded on focus) with five destinations: Home, Browse, Queue, Now Playing, Settings. The rail auto-collapses 600 ms after losing focus. This is the standard Compose for TV `NavigationDrawer` with `DrawerValue.Closed` as default.
153
154### 4.2 Focus model
155
156This is the thing BubbleUPnP gets wrong on TV and we get right.
157
158- Every focusable item has three visual states: unfocused, focused, and focused-pressed. Pressed is the 80 ms confirmation flash on select.
159- Focused state lifts the item with a **1.05× scale**, a **2 dp amber border**, and a **warm glow shadow** (not a Material elevation shadow — a soft outer amber bloom at 8% opacity, 16 dp radius). This is consistent whether the item is an album card, a button, or a row in a list.
160- Focus memory: returning to a previous screen restores the previously focused item. `rememberSaveable` on `FocusRequester`.
161- Focus never lands on nothing. If the currently focused item disappears (e.g., a row's data refreshes), focus moves to the nearest sibling in reading order.
162- No focus-stealing: modals do not grab focus until their enter animation completes, and they release to the triggering item on dismiss.
163
164### 4.3 Screen inventory
165
166| Screen | Purpose | D-pad grammar |
167|---|---|---|
168| Home | Entry point. Shelves: Recently Added, Recently Played, Favourites, Continue Listening, Top-level `indexTags`. | Up/down across shelves; left/right across cards; select opens detail; long-press shows actions. |
169| Browse | MinimServer's `indexTags` root + drill-down. | Left column shows current breadcrumb of selections; right panel shows the next narrowing choices or items. |
170| Detail (album/group/work) | Full metadata, track list, artwork, info-file link, action rail. | Left column: artwork + meta + actions. Right column: track list. |
171| Queue | Current playback queue. | Up/down tracks; left/right edits (remove, move up, move down). |
172| Now Playing | Transport controls, artwork, lyrics if present. | Left/right across transport; up/down to reveal queue peek / info pane. |
173| Renderers | Pick the device that plays. | Vertical list. |
174| Servers | Pick/switch MinimServer instance; trigger rescan. | Vertical list, actions inline. |
175| Search | Remote-friendly search. | Adaptive: on-screen QWERTY with D-pad, plus voice button. |
176| Settings | Preferences. | Leanback-style settings list, two-pane. |
177| Info overlay | MinimServer info-file HTML. | Focusable scroll, back to dismiss. |
178
179### 4.4 The Browse screen — MinimServer's narrowing, done well
180
181This is where BubbleUPnP's Android TV experience falls apart and where we win.
182
183```
184 ┌───────────────────────────────────────────────────────────────────────────┐
185 │ ≡ Earhorn Study @ Living Room · ● Now Playing │
186 ├─────┬─────────────────────────────────┬───────────────────────────────────┤
187 │ │ │ │
188 │ 🏠 │ Browse │ │
189 │ │ │ │
190 │ 🎼 │ Composer ▸ Bach, J.S. ▸ Work │ Matthäus-Passion, BWV 244 │
191 │ │ │ Goldberg Variations, BWV 988 │
192 │ ♪ │ │ Mass in B minor, BWV 232 │
193 │ │ │ Sonatas & Partitas for Violin │
194 │ ▶ │ │ Suites for Solo Cello, BWV 1007… │
195 │ │ │ Well-Tempered Clavier, Book I │
196 │ ⚙ │ │ Well-Tempered Clavier, Book II │
197 │ │ │ … │
198 │ │ │ │
199 │ │ │ [ >> Show All works ] │
200 │ │ │ │
201 ├─────┴─────────────────────────────────┴───────────────────────────────────┤
202 │ ♪ Gould · Goldberg Variations · Aria 02:43 / 04:11 ▮▮ │
203 └───────────────────────────────────────────────────────────────────────────┘
204```
205
206Design decisions:
207
208- **Breadcrumb-left, content-right.** The breadcrumb is not a toolbar; it's a focusable vertical stack showing the narrowing choices the user has made. D-pad left from the content column returns focus to the breadcrumb. Pressing select on a breadcrumb crumb pops back to that level (equivalent to multi-level back).
209- **Items render as lines, not grids, when the selection is a list of tag values.** Grids are for terminal albums only. Tag lists are information-dense and meant to be scanned vertically — a grid of "Composer names" is harder to scan than a list.
210- **Synthetic entries are typographically distinct.** `>> Show All` renders in sage, italic, indented; `[folder view]` in sage with a folder glyph prefix. They never look like an artist named "Show All".
211- **Alphabetical grouping shows sticky section headers.** Holding right on the D-pad jumps to the next letter group; a letter-jump overlay appears briefly (like iOS's contacts strip but in the overlay — one-shot, dismisses on its own).
212- **Albums shown as grid.** When the selection terminates at albums, we switch to a 5-column grid (1920-wide TV: 240 dp cards with 24 dp gutters, ~3-row viewport at 1080p before "more below" indicator).
213
214### 4.5 The Album Detail screen
215
216```
217 ┌───────────────────────────────────────────────────────────────────────────┐
218 │ ◂ Back │
219 │ │
220 │ ┌─────────────┐ Goldberg Variations, BWV 988 │
221 │ │ │ Glenn Gould · 1981 │
222 │ │ [artwork] │ Sony Classical · FLAC 24/96 · 77:03 │
223 │ │ │ │
224 │ │ │ ▶ Play + Queue ⤵ Play Next ♡ Favourite │
225 │ │ │ │
226 │ └─────────────┘ Work: Goldberg Variations │
227 │ Performer: Glenn Gould, piano │
228 │ Info → │
229 │ │
230 ├────────────────────────────────────────────────────────────────────────────┤
231 │ 1. Aria 3:02 │
232 │ 2. Variatio 1 a 1 Clav. 0:52 │
233 │ 3. Variatio 2 a 1 Clav. 1:45 │
234 │ … │
235 ├────────────────────────────────────────────────────────────────────────────┤
236 │ ♪ Gould · Goldberg Variations · Aria 02:43 / 04:11 ▮▮ │
237 └───────────────────────────────────────────────────────────────────────────┘
238```
239
240- The action rail is horizontal: Play / Queue (append) / Play Next / Favourite / Info. On D-pad left from a track, focus lands on Play by default (the most common action).
241- MinimServer's Work and Performer tags appear inline with the album metadata — not behind a "details" disclosure. Classical listeners need this at a glance.
242- If MinimServer returned an info-file URL, the "Info →" affordance is focusable and opens the HTML overlay.
243- Multi-disc albums render disc headings as sticky section dividers inside the track list. Pressing select on a disc heading (which MinimServer makes selectable when `showExtras=true`) plays that disc.
244
245### 4.6 The Queue screen
246
247The queue is **the** feature that separates a real control point from a cast-and-forget remote. Ours:
248
249- Reorder via D-pad: focus a track, press select to enter "move mode" (the track rises 4 dp and pulses amber), then up/down to reposition, select again to commit.
250- Bulk ops: focus the header row to select-all; shift into multi-select via a dedicated remote button (Play/Pause doubles as "enter select mode" when held on the Queue screen — long press is distinct from single press).
251- Queue source persistence: the queue survives app restarts and renderer switches. When the user switches from Chromecast to an OpenHome renderer, we migrate the queue across (OpenHome has a persistent playlist; UPnP AV does not — we maintain ours and re-push on renderer change).
252- "Clear" and "Save as playlist" are focusable actions in a right-hand action rail, never a hidden menu.
253
254### 4.7 Now Playing
255
256```
257 ┌───────────────────────────────────────────────────────────────────────────┐
258 │ │
259 │ ┌──────────────────────────┐ │
260 │ │ │ │
261 │ │ │ │
262 │ │ [artwork] │ Variatio 15 a 1 Clav., │
263 │ │ │ Canone alla Quinta │
264 │ │ │ in moto contrario │
265 │ │ │ │
266 │ └──────────────────────────┘ J.S. Bach │
267 │ Goldberg Variations │
268 │ Glenn Gould │
269 │ │
270 │ 02:43 ─────●──────────── 04:11 │
271 │ │
272 │ ⇄ ⏮ ⏯ ⏭ ⟳ │
273 │ │
274 │ Living Room · FLAC 24/96 · Gapless │
275 │ │
276 └───────────────────────────────────────────────────────────────────────────┘
277```
278
279- Transport controls are a horizontal row, center-anchored. Initial focus is on play/pause.
280- Cover-art dynamic tinting (BubbleUPnP 4.0 lineage) subtly shifts the background tone toward a hue extracted from the artwork — but capped at 8% mix with the base charcoal so the serif remains readable.
281- Pressing up reveals the queue peek (next 5 tracks, horizontally scrolling). Pressing down reveals the info pane (all metadata, including Work, Performer, Conductor, Ensemble — whatever MinimServer returned).
282- For classical: if the current track's Work differs from the previous track's Work, a thin sage divider line appears in the progress timeline at the Work boundary. This is a tiny detail classical listeners will notice and love.
283
284### 4.8 Search
285
286- On-screen QWERTY rendered as a D-pad grid, 10 columns × 5 rows, with a 40 dp key size. Not the stock Android TV IME (which is inconsistent across OEMs).
287- Live results while typing (debounced 200 ms) via UPnP `Search()` against MinimServer's SearchCapabilities.
288- Three result sections: Artists/Composers, Albums/Works, Tracks. Each collapsible; the D-pad treats them as nested horizontal rows within a vertical stack.
289- Voice: if `android.hardware.microphone` is present and the remote has a voice button, a focusable voice chip launches `RecognizerIntent` and fills the query.
290
291### 4.9 Settings
292
293Two-pane Leanback pattern: categories on the left, controls on the right. Categories: Servers, Renderers, Playback, Appearance, Advanced, About. Every setting is a focusable row; no nested dialogs for binary toggles.
294
295Notable settings:
296- **Appearance → Typography scale.** 90% / 100% / 110% / 125%. TV viewing distances vary; this is accessibility, not vanity.
297- **Appearance → Dynamic cover tint.** Off / Subtle / Prominent. Default Subtle.
298- **Playback → OpenHome preferred.** When both UPnP AV and OpenHome are advertised by the same device, prefer OpenHome. Default on.
299- **Playback → Gapless.** On / Off / Auto (auto = on only if renderer advertises support).
300- **Advanced → MinimServer Web API.** URL, token. Enables "Rescan" and "Reload properties" in the server actions menu.
301- **Advanced → Persistent control mode.** The foreground-service variant for power users.
302
303---
304
305## 5. Android phone / tablet
306
307Everything above has a phone adaptation. In brief:
308
309- The left nav rail becomes a bottom nav bar on phone, a navigation rail on tablet in landscape.
310- The two-pane Browse layout collapses to a single pane with back navigation on phone.
311- The Now Playing screen becomes a full-screen sheet on phone, persistent on tablet.
312- Long-press opens the action sheet as a bottom sheet on phone (same actions, touch targets at 48 dp).
313- Focus states are not rendered on touch-only devices; ripple and state-layer opacity handle feedback.
314
315This is a single codebase with form-factor composables. ViewModels and domain code are shared.
316
317---
318
319## 6. Design system
320
321### 6.1 Colour (dark mode tokens)
322
323All tokens given as hex for the dark scheme. A light scheme is derived by inversion for daylight / accessibility, but dark is the default and the designed-first experience.
324
325| Token | Hex | Usage |
326|---|---|---|
327| `surface.base` | `#17130F` | Root background |
328| `surface.raised` | `#1F1A14` | Cards, sheets |
329| `surface.hover` | `#2A2319` | Focus backplate, row hover |
330| `surface.divider` | `#3A3024` | Hairline dividers |
331| `on.primary` | `#EFE7D8` | Body text |
332| `on.muted` | `#B9AE98` | Secondary text, meta |
333| `on.quiet` | `#7F7665` | Tertiary text, captions |
334| `accent.amber` | `#E8A23B` | Focus, now-playing indicator, primary action |
335| `accent.amber.soft` | `#3A2A12` | Focus backplate tint |
336| `accent.sage` | `#8FA68A` | Section headers, synthetic entries (`>>`, `[]`) |
337| `accent.rose` | `#C97A74` | Favourites, destructive actions |
338| `accent.rose.soft` | `#3A1C1A` | Destructive backplate |
339
340The focus glow is `accent.amber` at 8% opacity, 16 dp blur, not a standard Material shadow.
341
342### 6.2 Typography
343
344| Role | Family | Size | Weight | Tracking |
345|---|---|---|---|---|
346| Display (album title, Now Playing title) | Fraunces 72pt optical | 48 sp TV / 28 sp phone | 500 | 0 |
347| Headline (shelf titles, screen titles) | Fraunces 72pt optical | 32 sp TV / 22 sp phone | 500 | 0 |
348| Title (card titles, composer names) | Fraunces 144pt optical | 22 sp TV / 16 sp phone | 500 | 0 |
349| Body (metadata, description) | Inter | 18 sp TV / 14 sp phone | 400 | 0 |
350| Label (button, chip) | Inter | 16 sp TV / 13 sp phone | 500 | 0.4 sp |
351| Caption (duration, kbps, captions) | Inter | 14 sp TV / 11 sp phone | 400 | 0.2 sp |
352| Synthetic / sage (`>>`, `[folder]`) | Inter italic | same as body | 400 | 0 |
353
354Fraunces is loaded as a variable font; we interpolate weight/softness based on size for optical correctness. Inter is the sans chrome. We do not ship a third typeface.
355
356### 6.3 Spacing, shape, motion
357
358- **Spacing scale**: 4, 8, 12, 16, 20, 24, 32, 40, 48, 64. No odd values.
359- **Corner radius**: 4 dp (chips), 8 dp (cards), 12 dp (sheets), 16 dp (dialogs). Album artwork is 2 dp (nearly square — preserves the rectilinear feel of record covers).
360- **Motion**: 120 ms focus transitions with standard easing; 240 ms content enter; 400 ms hero (only on Now Playing transitions). No bouncy overshoot springs — unbecoming on a 10-foot screen.
361
362### 6.4 Empty states
363
364Empty states use the stave-and-minims lettermark described earlier, in `on.quiet`, at 160 dp on TV. Copy is a single serif line. Examples:
365
366- Empty queue: *"A silent stave, awaiting its minim."*
367- No servers found: *"No MinimServer announced itself. Check the network, or add one by address."*
368- No favourites: *"The catalogue of things you love begins with one."*
369
370This is Quoyle's tonal register — literary, quiet, not cute.
371
372---
373
374## 7. Implementation notes & landmines
375
376### 7.1 UPnP on Android's Wi-Fi, honestly
377
378- Multicast SSDP is blocked on some carriers' hotspot modes and on some Wi-Fi routers with IGMP snooping misconfigured. We must support **manual server addition by URL** (MinimServer's device description URL, typically `http://host:port/description.xml`). This is in Settings → Servers → Add manually.
379- On Android 10+, we need `ACCESS_WIFI_STATE` and `CHANGE_WIFI_MULTICAST_STATE`, plus `MulticastLock` acquired for the duration of discovery. We release the lock when the app backgrounds.
380- Some OEMs (Xiaomi, Huawei) kill background services aggressively. Our foreground service uses `FOREGROUND_SERVICE_MEDIA_PLAYBACK` and persists only while a MediaSession is active.
381
382### 7.2 Renderer compatibility
383
384BubbleUPnP's forum history shows the failure modes clearly: renderers that lie about `GetProtocolInfo`, renderers that implement `AVTransport` incompletely, renderers whose SetNextAVTransportURI only works at certain phases. We handle these by:
385
386- Capability probing on first connection, cached per-UDN.
387- A known-quirks registry (JSON, shipped with app, updatable via manifest URL): Naim, Linn, Lumin, foobar2000, Kodi, specific Samsung TV models, etc.
388- Gapless attempted if and only if the renderer advertises it in its capabilities or is in the known-good list.
389- If a renderer drops off and returns, the OpenHome `Playlist` service makes recovery trivial; UPnP AV requires us to keep an authoritative queue ourselves and replay it.
390
391### 7.3 DIDL-Lite and MinimServer specifics
392
393- We request `upnp:userAnnotation` via the `Filter` argument of Browse/Search so MinimServer returns `tag.Work`, `tag.Group`, `tag.DiscSubtitle` and whatever else the owner configured. Parsing is defensive — unexpected tags are preserved in an `extras: Map<String, String>` so a future version of the UI can surface them without parser changes.
394- Album art is fetched at its native URL; we do not proxy. If MinimServer is behind a restrictive firewall, the user configures the proxy at the server, not the app.
395- Sorting respects MinimServer's server-side sort (it uses alphanumeric-aware sort, "Symphony 2" before "Symphony 10"). We do not re-sort client-side.
396
397### 7.4 Accessibility
398
399- TalkBack / Android TV's accessibility suite: every focusable item has `contentDescription` with the item role. The focus-highlight affordance is also communicated (not just visual).
400- Keyboard: the full UI works with a physical keyboard (some users drive Android TV via Bluetooth keyboard). Tab = down-row, Shift-Tab = up-row, arrow keys = D-pad, Enter = select, Esc = back.
401- High-contrast mode: amber accent becomes pure `#FFB000`, focus border doubles to 4 dp.
402
403### 7.5 What we do not build in v1
404
405- No Chromecast Google Cast protocol support (BubbleUPnP's strength). UPnP-to-Cast bridging requires a server-side transcoder; out of scope for v1. Users with Chromecasts use a BubbleUPnP Server + OpenHome renderer pattern, which we support natively.
406- No video. Audio-first, with cover art. MinimServer is an audio server; a video UI is a different product.
407- No cloud sources. No TIDAL, no Qobuz, no Dropbox. If it's in the user's MinimServer library, we play it.
408- No remote-over-internet access. That's BubbleUPnP Server territory and outside our security model.
409
410---
411
412## 8. Roadmap
413
414**v0.1 — TV bones.** Discovery, browse, queue, Now Playing, Media3 local DMR. MinimServer tested as the only server. Chromecast explicitly unsupported. Phone UI ships but is minimal.
415
416**v0.2 — Renderer breadth.** OpenHome support, quirks registry, multi-room via BubbleUPnP Server integration.
417
418**v0.3 — Classical polish.** Work-aware timeline dividers, Performer/Conductor/Ensemble in detail views, info-file overlay, MinimServer Web API for rescan.
419
420**v0.4 — Phone parity.** Full phone adaptation, tablet landscape. Widget on Android 12+. Media3 lockscreen controls polished.
421
422**v0.5 — Favourites, history, playlists.** Local library of favourites, recently-played-by-me (distinct from server-side), playlist save/load, scrobbling.
423
424**v1.0 — Stable.** Settings polish, localization (EN, DE, FR, IT, ES, NL, JA, ZH), Play Store + F-Droid + Amazon.
425
426---
427
428## 9. Risks
429
430- **Cling is abandoned.** Upstream development stopped around 2018. We pin to 2.1.2 and self-host a mirror of the JAR if 4thline's repo goes offline. A future migration to DM-UPnP (an actively maintained fork) is possible but changes package names project-wide, so we defer unless forced.
431- **OpenHome without Linn's ohNet.** Writing a pure-Kotlin `Playlist` / `Product` / `Info` client is two to three engineer-weeks. Risk is reduced by starting with UPnP AV only in v0.1.
432- **Compose for TV churn.** 1.0 stable is recent; focus behaviour occasionally regresses between minor versions. We own a thin focus abstraction so we can reach under the hood when a release breaks something.
433- **MinimServer owner configuration variance.** Libraries tagged for classical look very different from pop/rock libraries. Our UI must not assume a schema. We render what the server returns.
434- **Testing on many TVs.** Android TV has real device fragmentation (Sony, Nvidia Shield, Chromecast with Google TV, Xiaomi Mi Box, Amazon Fire TV with sideload). We target the top 5 and document others.
435
436---
437
438## 10. Why this will actually work
439
440BubbleUPnP is the best UPnP control point on Android and it is genuinely unusable on TV. That gap is a product opportunity, not a criticism of Bubbleguuum's work. His app is maximal; ours is focused. He supports every source, every codec, every protocol; we support one server (well), one remote (perfectly), one aesthetic (consistently).
441
442MinimServer users are a self-selected group: they already care enough about their music to run a dedicated server, tag their files carefully, and consider the difference between a Work tag and a Group tag. They deserve a control point that matches that care. In the living room, where they already sit with a remote in one hand and a glass in the other, they deserve Earhorn Music on the TV. On their phones, at their desks, in their cars, on the Chromebase in the kitchen — they deserve the same library, served by the same software, sized for the device in front of them.
443
444That's the pitch.