# Earhorn Music — a MinimServer-aware UPnP control point for Android TV, phones, and Android Auto > **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. > **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. **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.) **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. --- ## 1. What this app is, and what it is not ### It is A **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. ### It is not - 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. - A streaming service UI. No TIDAL/Qobuz login flow in v1. The user's library is their library. - 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. ### Success criteria 1. Every primary task is reachable in ≤3 D-pad presses from the home screen with a stock Android TV remote (no touchpad, no voice). 2. Focus is never ambiguous. At every frame, exactly one item is obviously focused and obviously actionable. 3. Library browsing feels as fast as scrolling a list on a phone. No spinners between every tag narrowing. 4. MinimServer's index semantics — Work/Group, Composer, AlbumArtist, Recently Added, alphabetical grouping — render natively. We do not flatten them. 5. A user who has never read the manual can queue an album within 60 seconds. --- ## 2. The three pillars, examined ### 2.1 MinimServer — what we're actually integrating with MinimServer 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: - **`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. - **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. - **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. - **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. - **Recently Added / Recently Played.** Enabled via the `*RecentAdded` and `*RecentPlayed` pseudo-tags. Earhorn surfaces these as first-class home-screen shelves when present. - **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. - **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. - **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. - **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. ### 2.2 BubbleUPnP — the baseline, with honesty about what breaks on TV BubbleUPnP'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: - Three primary destinations: Library, Queue, Now Playing. - First-class OpenHome renderer support (multi-room continuity without the app running). - Resilience to mixed-capability renderers: we query `GetProtocolInfo` and fall back gracefully. - Gapless flag propagation where the renderer supports it. - Sleep timer, shuffle/repeat modes, scrobbling hooks. - Cover-art dynamic accent tinting on Now Playing. What we reject for TV: - **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. - **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. - **Small album grid tiles at phone density.** TV tiles are 240–280 dp wide, 2–2.5× the phone density. - **Modal drawers that trap focus.** Everything is a full-screen destination or a pane that the D-pad can leave. - **Confirmation dialogs mid-playback.** Queue "Replace / Append / Play Next" choices live as focusable actions on the item row, not as a popup after selection. ### 2.3 Quoyle — the aesthetic, applied Quoyle 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: - **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*. - **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). - **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. - **Generous whitespace.** Quoyle treats every quotation as worth a full card. Earhorn treats every album the same. Density is low by choice. - **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. - **No glassmorphism, no gradients-for-their-own-sake.** Surfaces are flat, layered by tone. Depth comes from shadow-free elevation tokens. --- ## 3. Architecture ``` ┌─────────────────────────────────────────────────┐ │ Earhorn Music │ │ ┌──────────────┐ ┌──────────────────────────┐ │ │ │ UI layer │ │ Domain layer │ │ │ │ (Compose │←→│ - BrowseSession │ │ │ │ for TV / │ │ - PlaybackCoordinator │ │ │ │ Compose │ │ - RendererRegistry │ │ │ │ Material) │ │ - ServerRegistry │ │ │ └──────────────┘ └──────────────────────────┘ │ │ ↓ │ │ ┌───────────────────────────────────────────┐ │ │ │ UPnP/AV core │ │ │ │ - ContentDirectory client │ │ │ │ - AVTransport / RenderingControl client │ │ │ │ - OpenHome {Playlist, Product, Info} │ │ │ │ - SSDP discovery (+ static hosts) │ │ │ │ - DIDL-Lite + upnp:userAnnotation parser │ │ │ └───────────────────────────────────────────┘ │ │ ↓ │ │ ┌───────────────────────────────────────────┐ │ │ │ Local DMR (Media3) · Local DMS (opt.) │ │ │ └───────────────────────────────────────────┘ │ └─────────────────────────────────────────────────┘ ``` ### Stack choices | Layer | Choice | Why | |---|---|---| | UI (TV) | Compose for TV 1.0+ | Current official Android TV toolkit. Leanback is deprecated. | | UI (mobile) | Compose Material 3 | Shared ViewModels with TV; divergent composables. | | UPnP | Cling 2.1.2 (4thline) | Battle-tested, the same library BubbleUPnP uses. | | 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. | | Local playback (DMR) | Media3 ExoPlayer | Gapless, FLAC 24/192, DSD via passthrough where supported. | | Discovery | Android's `NsdManager` + Cling SSDP | Dual-stack because SSDP on Android Wi-Fi varies by OEM. | | Images | Coil 3 | Cover art with per-tag fallback logic. | | DI | Hilt | Standard. | | Navigation | Compose Navigation (single NavHost, form-factor routes) | One graph, platform-specific start destinations. | | Persistence | Room + DataStore | Queue, playback history, server/renderer preferences. | ### Single-codebase form-factor handling - `app/src/main/` — shared domain, UPnP core, ViewModels. - `app/src/mobile/` — phone/tablet composables, `AndroidManifest` without ``. - `app/src/tv/` — TV composables, Leanback manifest feature + `isLaunchable` intent filter on `android.intent.category.LEANBACK_LAUNCHER`. - 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. ### UPnP lifecycle on Android - Service discovery runs in a foreground service while the app is in foreground; SSDP M-SEARCH every 30 s, plus passive NOTIFY listening. - Subscriptions to `AVTransport`, `RenderingControl`, and OpenHome `Playlist`/`Product`/`Info` renew at 50% of their timeout. - 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. - 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. --- ## 4. Android TV — the hero experience ### 4.1 Navigation model Android 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: - **Left–right traversal** moves between siblings within a row (e.g., across albums in a shelf). - **Up–down traversal** moves between rows, or into/out of the nav rail. - **Select** enters / plays / opens. - **Back** climbs one level in the browse stack, never exits mid-session. - **Long-press select** opens the action sheet for the focused item (play, append to queue, play next, add to favourites, view info). - **Media keys** always control the currently active renderer, regardless of screen. - **Fast-jump** (hold right on an alphabetical list) advances to the next letter group; this is MinimServer's `alphaGroup` made tangible. The 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. ### 4.2 Focus model This is the thing BubbleUPnP gets wrong on TV and we get right. - Every focusable item has three visual states: unfocused, focused, and focused-pressed. Pressed is the 80 ms confirmation flash on select. - 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. - Focus memory: returning to a previous screen restores the previously focused item. `rememberSaveable` on `FocusRequester`. - 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. - No focus-stealing: modals do not grab focus until their enter animation completes, and they release to the triggering item on dismiss. ### 4.3 Screen inventory | Screen | Purpose | D-pad grammar | |---|---|---| | 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. | | Browse | MinimServer's `indexTags` root + drill-down. | Left column shows current breadcrumb of selections; right panel shows the next narrowing choices or items. | | Detail (album/group/work) | Full metadata, track list, artwork, info-file link, action rail. | Left column: artwork + meta + actions. Right column: track list. | | Queue | Current playback queue. | Up/down tracks; left/right edits (remove, move up, move down). | | Now Playing | Transport controls, artwork, lyrics if present. | Left/right across transport; up/down to reveal queue peek / info pane. | | Renderers | Pick the device that plays. | Vertical list. | | Servers | Pick/switch MinimServer instance; trigger rescan. | Vertical list, actions inline. | | Search | Remote-friendly search. | Adaptive: on-screen QWERTY with D-pad, plus voice button. | | Settings | Preferences. | Leanback-style settings list, two-pane. | | Info overlay | MinimServer info-file HTML. | Focusable scroll, back to dismiss. | ### 4.4 The Browse screen — MinimServer's narrowing, done well This is where BubbleUPnP's Android TV experience falls apart and where we win. ``` ┌───────────────────────────────────────────────────────────────────────────┐ │ ≡ Earhorn Study @ Living Room · ● Now Playing │ ├─────┬─────────────────────────────────┬───────────────────────────────────┤ │ │ │ │ │ 🏠 │ Browse │ │ │ │ │ │ │ 🎼 │ Composer ▸ Bach, J.S. ▸ Work │ Matthäus-Passion, BWV 244 │ │ │ │ Goldberg Variations, BWV 988 │ │ ♪ │ │ Mass in B minor, BWV 232 │ │ │ │ Sonatas & Partitas for Violin │ │ ▶ │ │ Suites for Solo Cello, BWV 1007… │ │ │ │ Well-Tempered Clavier, Book I │ │ ⚙ │ │ Well-Tempered Clavier, Book II │ │ │ │ … │ │ │ │ │ │ │ │ [ >> Show All works ] │ │ │ │ │ ├─────┴─────────────────────────────────┴───────────────────────────────────┤ │ ♪ Gould · Goldberg Variations · Aria 02:43 / 04:11 ▮▮ │ └───────────────────────────────────────────────────────────────────────────┘ ``` Design decisions: - **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). - **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. - **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". - **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). - **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). ### 4.5 The Album Detail screen ``` ┌───────────────────────────────────────────────────────────────────────────┐ │ ◂ Back │ │ │ │ ┌─────────────┐ Goldberg Variations, BWV 988 │ │ │ │ Glenn Gould · 1981 │ │ │ [artwork] │ Sony Classical · FLAC 24/96 · 77:03 │ │ │ │ │ │ │ │ ▶ Play + Queue ⤵ Play Next ♡ Favourite │ │ │ │ │ │ └─────────────┘ Work: Goldberg Variations │ │ Performer: Glenn Gould, piano │ │ Info → │ │ │ ├────────────────────────────────────────────────────────────────────────────┤ │ 1. Aria 3:02 │ │ 2. Variatio 1 a 1 Clav. 0:52 │ │ 3. Variatio 2 a 1 Clav. 1:45 │ │ … │ ├────────────────────────────────────────────────────────────────────────────┤ │ ♪ Gould · Goldberg Variations · Aria 02:43 / 04:11 ▮▮ │ └───────────────────────────────────────────────────────────────────────────┘ ``` - 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). - MinimServer's Work and Performer tags appear inline with the album metadata — not behind a "details" disclosure. Classical listeners need this at a glance. - If MinimServer returned an info-file URL, the "Info →" affordance is focusable and opens the HTML overlay. - 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. ### 4.6 The Queue screen The queue is **the** feature that separates a real control point from a cast-and-forget remote. Ours: - 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. - 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). - 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). - "Clear" and "Save as playlist" are focusable actions in a right-hand action rail, never a hidden menu. ### 4.7 Now Playing ``` ┌───────────────────────────────────────────────────────────────────────────┐ │ │ │ ┌──────────────────────────┐ │ │ │ │ │ │ │ │ │ │ │ [artwork] │ Variatio 15 a 1 Clav., │ │ │ │ Canone alla Quinta │ │ │ │ in moto contrario │ │ │ │ │ │ └──────────────────────────┘ J.S. Bach │ │ Goldberg Variations │ │ Glenn Gould │ │ │ │ 02:43 ─────●──────────── 04:11 │ │ │ │ ⇄ ⏮ ⏯ ⏭ ⟳ │ │ │ │ Living Room · FLAC 24/96 · Gapless │ │ │ └───────────────────────────────────────────────────────────────────────────┘ ``` - Transport controls are a horizontal row, center-anchored. Initial focus is on play/pause. - 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. - 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). - 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. ### 4.8 Search - 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). - Live results while typing (debounced 200 ms) via UPnP `Search()` against MinimServer's SearchCapabilities. - Three result sections: Artists/Composers, Albums/Works, Tracks. Each collapsible; the D-pad treats them as nested horizontal rows within a vertical stack. - Voice: if `android.hardware.microphone` is present and the remote has a voice button, a focusable voice chip launches `RecognizerIntent` and fills the query. ### 4.9 Settings Two-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. Notable settings: - **Appearance → Typography scale.** 90% / 100% / 110% / 125%. TV viewing distances vary; this is accessibility, not vanity. - **Appearance → Dynamic cover tint.** Off / Subtle / Prominent. Default Subtle. - **Playback → OpenHome preferred.** When both UPnP AV and OpenHome are advertised by the same device, prefer OpenHome. Default on. - **Playback → Gapless.** On / Off / Auto (auto = on only if renderer advertises support). - **Advanced → MinimServer Web API.** URL, token. Enables "Rescan" and "Reload properties" in the server actions menu. - **Advanced → Persistent control mode.** The foreground-service variant for power users. --- ## 5. Android phone / tablet Everything above has a phone adaptation. In brief: - The left nav rail becomes a bottom nav bar on phone, a navigation rail on tablet in landscape. - The two-pane Browse layout collapses to a single pane with back navigation on phone. - The Now Playing screen becomes a full-screen sheet on phone, persistent on tablet. - Long-press opens the action sheet as a bottom sheet on phone (same actions, touch targets at 48 dp). - Focus states are not rendered on touch-only devices; ripple and state-layer opacity handle feedback. This is a single codebase with form-factor composables. ViewModels and domain code are shared. --- ## 6. Design system ### 6.1 Colour (dark mode tokens) All 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. | Token | Hex | Usage | |---|---|---| | `surface.base` | `#17130F` | Root background | | `surface.raised` | `#1F1A14` | Cards, sheets | | `surface.hover` | `#2A2319` | Focus backplate, row hover | | `surface.divider` | `#3A3024` | Hairline dividers | | `on.primary` | `#EFE7D8` | Body text | | `on.muted` | `#B9AE98` | Secondary text, meta | | `on.quiet` | `#7F7665` | Tertiary text, captions | | `accent.amber` | `#E8A23B` | Focus, now-playing indicator, primary action | | `accent.amber.soft` | `#3A2A12` | Focus backplate tint | | `accent.sage` | `#8FA68A` | Section headers, synthetic entries (`>>`, `[]`) | | `accent.rose` | `#C97A74` | Favourites, destructive actions | | `accent.rose.soft` | `#3A1C1A` | Destructive backplate | The focus glow is `accent.amber` at 8% opacity, 16 dp blur, not a standard Material shadow. ### 6.2 Typography | Role | Family | Size | Weight | Tracking | |---|---|---|---|---| | Display (album title, Now Playing title) | Fraunces 72pt optical | 48 sp TV / 28 sp phone | 500 | 0 | | Headline (shelf titles, screen titles) | Fraunces 72pt optical | 32 sp TV / 22 sp phone | 500 | 0 | | Title (card titles, composer names) | Fraunces 144pt optical | 22 sp TV / 16 sp phone | 500 | 0 | | Body (metadata, description) | Inter | 18 sp TV / 14 sp phone | 400 | 0 | | Label (button, chip) | Inter | 16 sp TV / 13 sp phone | 500 | 0.4 sp | | Caption (duration, kbps, captions) | Inter | 14 sp TV / 11 sp phone | 400 | 0.2 sp | | Synthetic / sage (`>>`, `[folder]`) | Inter italic | same as body | 400 | 0 | Fraunces 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. ### 6.3 Spacing, shape, motion - **Spacing scale**: 4, 8, 12, 16, 20, 24, 32, 40, 48, 64. No odd values. - **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). - **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. ### 6.4 Empty states Empty states use the stave-and-minims lettermark described earlier, in `on.quiet`, at 160 dp on TV. Copy is a single serif line. Examples: - Empty queue: *"A silent stave, awaiting its minim."* - No servers found: *"No MinimServer announced itself. Check the network, or add one by address."* - No favourites: *"The catalogue of things you love begins with one."* This is Quoyle's tonal register — literary, quiet, not cute. --- ## 7. Implementation notes & landmines ### 7.1 UPnP on Android's Wi-Fi, honestly - 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. - 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. - Some OEMs (Xiaomi, Huawei) kill background services aggressively. Our foreground service uses `FOREGROUND_SERVICE_MEDIA_PLAYBACK` and persists only while a MediaSession is active. ### 7.2 Renderer compatibility BubbleUPnP'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: - Capability probing on first connection, cached per-UDN. - A known-quirks registry (JSON, shipped with app, updatable via manifest URL): Naim, Linn, Lumin, foobar2000, Kodi, specific Samsung TV models, etc. - Gapless attempted if and only if the renderer advertises it in its capabilities or is in the known-good list. - 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. ### 7.3 DIDL-Lite and MinimServer specifics - 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` so a future version of the UI can surface them without parser changes. - 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. - Sorting respects MinimServer's server-side sort (it uses alphanumeric-aware sort, "Symphony 2" before "Symphony 10"). We do not re-sort client-side. ### 7.4 Accessibility - 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). - 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. - High-contrast mode: amber accent becomes pure `#FFB000`, focus border doubles to 4 dp. ### 7.5 What we do not build in v1 - 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. - No video. Audio-first, with cover art. MinimServer is an audio server; a video UI is a different product. - No cloud sources. No TIDAL, no Qobuz, no Dropbox. If it's in the user's MinimServer library, we play it. - No remote-over-internet access. That's BubbleUPnP Server territory and outside our security model. --- ## 8. Roadmap **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. **v0.2 — Renderer breadth.** OpenHome support, quirks registry, multi-room via BubbleUPnP Server integration. **v0.3 — Classical polish.** Work-aware timeline dividers, Performer/Conductor/Ensemble in detail views, info-file overlay, MinimServer Web API for rescan. **v0.4 — Phone parity.** Full phone adaptation, tablet landscape. Widget on Android 12+. Media3 lockscreen controls polished. **v0.5 — Favourites, history, playlists.** Local library of favourites, recently-played-by-me (distinct from server-side), playlist save/load, scrobbling. **v1.0 — Stable.** Settings polish, localization (EN, DE, FR, IT, ES, NL, JA, ZH), Play Store + F-Droid + Amazon. --- ## 9. Risks - **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. - **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. - **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. - **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. - **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. --- ## 10. Why this will actually work BubbleUPnP 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). MinimServer 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. That's the pitch.