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

README.md

Each line, with the release that last changed it.

0.5.01# Earhorn Music 0.5.0
2
3**A UPnP/DLNA music player for Android TV, phones, Android Auto and the GNU/Linux desktop.** Point it at the server already running on your NAS, browse and queue from the remote, your phone or your desk, and play through the speakers you already own: no cloud account, no second library to maintain, no telemetry.
4
5[**Website**](https://faveat.software/earhorn/) · [**Download**](https://faveat.software/earhorn/#download) · [**Browse the code**](https://faveat.software/earhorn/browse/) · [**All releases**](https://faveat.software/earhorn/browse/history.html) · [**Tip jar**](https://ko-fi.com/faveat)
6
7License: GPLv3+ · Android 8.0 (API 26) and newer · Kotlin 2.0 · Screenshots are on the [website](https://faveat.software/earhorn/).
8
9## What's in this zip
10
11| Folder | What it is |
12|---|---|
13| `android/` | The Android app (TV, phones, tablets, ChromeOS, Android Auto): a complete Gradle project with its wrapper. The app module is `android/app/`. |
14| `desktop/` | The GNU/Linux desktop app: a Kotlin Multiplatform project with a `shared/` module and the `desktop/` UI module. It has its own [README](desktop/README.md). |
15| `LICENSE` | The GNU General Public License, version 3. |
16
17The ready-built packages (two Android APKs, an AppImage and a .deb) are separate downloads on the [website](https://faveat.software/earhorn/#download).
18
19## Why Earhorn?
20
21Every UPnP server already tags your music properly: Composer, Work, Performer, Ensemble, Conductor. Most players flatten that into "Artist / Album / Track" and throw the rest away. Earhorn renders what the server sends, faithfully.
22
23- **One APK, three surfaces.** Android TV (D-pad, Leanback launcher), phone (compact touch layout) and Android Auto (Car App Library). Same codebase, same queue, same MediaSession. The desktop app brings the same library to GNU/Linux.
24- **No cloud, no account, no sync.** UPnP/DLNA discovery on your local network. Your server, your speakers, your files.
25- **Cast to speaker groups.** The `play` flavor bundles Google Cast: send audio to a Chromecast, Nest Audio or a speaker group. The queue loads on the receiver, so tracks advance without the phone. You can also follow a Cast device another app is playing to, and control it.
26- **Device-local audio.** No server? Browse and play the files on the device itself via MediaStore. Same UI, same queue, same transport.
27- **Classical-listener defaults.** 28 configurable metadata fields. The defaults surface Composer, Conductor, Work, Performer, Ensemble, sample rate and bit depth. Toggle what you care about.
28- **Gapless playback, ReplayGain.** Media3 `setMediaItems()` queues the whole album for gapless transitions. ReplayGain modes: off, track, album, with adjustable pre-amp.
29- **Zero telemetry.** No analytics, no crash reporting. What goes over the network: UPnP/DLNA discovery on your LAN and your server's streams and album art; Chromecast discovery and the Cast devices you play to or follow; and time-synced lyrics lookups to lrclib.net (the track's title, artist, album and length), which you can switch off in Settings.
30
31## Building the Android app
32
33Needs JDK 17 and the Android SDK with platform 35 (Android Studio Ladybug or newer installs both). Tell Gradle where the SDK is, either with `ANDROID_HOME` or a `local.properties` file in `android/` containing `sdk.dir=` and the SDK's path.
34
35```bash
36cd android
37./gradlew :app:assembleLibreRelease # without Google Play Services
38./gradlew :app:assemblePlayRelease # with Google Cast
39```
40
41APKs land in `android/app/build/outputs/apk/<flavor>/release/`. Release builds are signed with your machine's debug key (`~/.android/debug.keystore`), so they install for testing; sign with your own key before distributing. For debug builds, use `assembleLibreDebug` or `assemblePlayDebug`.
42
43Sideload to an Android TV device:
44
45```bash
46adb connect <tv-ip>:5555
47adb install -r app/build/outputs/apk/libre/release/app-libre-release.apk
48```
49
50### Your own build values (optional)
51
52Two features take an address on your own network: the station overlay (a musicnow `/status` address) and the default address of the web control panel in Settings. Public builds leave both empty, which turns those features off. For your own builds, put them in `android/app/earhorn-private.properties`:
53
54```properties
55earhorn.musicnowStatusUrl=http://<host>:8642/status
56earhorn.domEthaDefaultUrl=http://<host>/mediacontrol.html
57```
58
59Never commit or publish that file. To build without it even when it's there, add `-Pearhorn.public` to the Gradle command.
60
61## Building the desktop app
62
63Needs JDK 17 (newer JDKs aren't supported by this Gradle and Kotlin) and libvlc. On Debian and Ubuntu: `sudo apt install openjdk-17-jdk vlc dpkg-dev`.
64
65```bash
66cd desktop
67./gradlew :desktop:run # run from source
68./gradlew :desktop:packageDeb :desktop:packageAppImage # build the .deb and AppImage
69```
70
71See [desktop/README.md](desktop/README.md) for the desktop app's features and settings.
72
73## Distribution
74
75| Target | Flavor | Notes |
76|--------|--------|-------|
77| Google Play (TV, phone, tablet, ChromeOS) | `play` | Bundles Google Cast |
78| F-Droid / Fire TV / GrapheneOS | `libre` | Zero GMS dependencies |
79| Android Auto | both | Car App Library `CarAppService` with full browse tree |
80| GNU/Linux | `desktop/` | Compose Multiplatform, .deb and AppImage |
81
82Package namespace: `dev.stave`. Application IDs: `dev.stave` (play), `dev.stave.libre` (libre).
83
84## What works (Android)
85
86- **UPnP/DLNA discovery.** SSDP via jUPnP 3.0.3. `NetworkInterfaceResolver` binds to the real LAN interface so VPN clients (NetGuard, ProtonVPN, Tailscale) don't break discovery.
87- **Browse.** ContentDirectory `Browse(DIRECT_CHILDREN)` for any container. MinimServer's `indexTags` structure renders faithfully; synthetic entries (`>> Show All`, `[folder view]`, `[untagged]`) render in distinct italic sage-green typography.
88- **Playback.** Media3 ExoPlayer hosted by a `MediaLibraryService`. Hardware media keys, Android Auto transport and lock-screen controls all bind to the same session. FLAC, MP3, WAV, AAC, ALAC.
89- **Cast sender (play flavor).** `CastIntegration` with queue loading, a stream proxy (`CastStreamProxy`), device volume control and disconnect handling. Queue navigation via `queueNext`/`queuePrev`/`queueJumpTo`, so receiver-side track advancement works.
90- **Cast follow.** Observe a Cast device that another app is playing to: its track, art and queue, with transport and volume control.
91- **Android Auto.** Five-file `cartemplate` module: `EarhornCarAppService`, `EarhornCarSession`, `CarSessionMediaBridge`, `NowPlayingCarScreen`, `AlbumDetailCarScreen`.
92- **Album art.** Coil 3 with animated GIF/WebP support, fetched from `upnp:albumArtURI`. 256 MB disk cache. Now Playing renders art full-bleed edge-to-edge at 1:1 aspect ratio with no cropping.
93- **Device-local audio.** `LocalMediaScanner` indexes on-device audio via MediaStore. Browsable by artist, album, genre, song.
94- **Queue.** Persistent save/restore via DataStore. Load/save as M3U playlists.
95- **Search.** Cross-server ContentDirectory search with history persistence. Voice search with system IME fallback.
96- **Lyrics.** Time-synced lyrics fetched from lrclib.net; falls back to server-side sibling `.lrc` files. Rendered in Inter (sans) with line-by-line scroll.
97- **Album notes.** `AlbumInfoLoader` reads ID3 COMMENT / Vorbis DESCRIPTION from the first track, falls back to sibling `.txt` / `review.txt` / `info.txt` files in the same UPnP directory.
98- **Random picker.** Shuffle by Albums or Tracks from the active server or local library.
99- **Sleep timer.** Standard intervals.
100- **Album disambiguation.** "More from this album" filters by both album title and artist, so "Greatest Hits" by Queen doesn't pull in "Greatest Hits" by Fleetwood Mac.
101
102## Architecture (Android)
103
104One app module (`android/app/`), two product flavors (`play`, `libre`) sharing one `main` source set. 101 Kotlin source files in `main`, 3 in `play` (Cast), 1 in `libre` (Cast stub).
105
106```
107dev/stave/
108 MainActivity.kt — single activity
109 EarhornApp.kt — Application class; Hilt entry point
110 ui/
111 theme/ — Color / Type / Shape / Theme + WindowSize helpers
112 nav/ — StaveNavHost — top-level navigation graph
113 servers/ — Server list + "On this device" (compact only)
114 browse/ — Container browsing, album grid, synthetic entries
115 detail/ — Album view: art + metadata + track list
116 nowplaying/ — Full player + persistent bottom bar
117 queue/ — Queue management
118 search/ — Cross-server + local search
119 local/ — Device-local audio hub, albums, artists, songs
120 playlist/ — M3U playlist read/write
121 lyrics/ — LRC parsing + synchronized display
122 settings/ — Metadata field toggles, playback settings
123 about/ — About screen
124 welcome/ — First-run onboarding
125 guide/ — In-app help
126 more/ — "More tracks from..." overflow
127 random/ — Shuffle-by-tag picker
128 metadata/ — Per-track metadata sheet
129 cast/ — Cast device picker
130 dometha/ — Web control panel
131 components/ — StaveFocusable, Marquee, ActionSheet, AlbumArt,
132 OnScreenKeyboard, dialogs, TvLongPress
133 cartemplate/ — Android Auto integration
134 domain/model/ — MediaServer, DidlItem, MetadataField
135 domain/renderer/ — Renderer interface + PlaybackState
136 upnp/ — jUPnP wrappers, ContentDirectoryClient, DidlParser
137 playback/ — PlaybackService, LocalRenderer, CoilBitmapLoader
138 data/ — DataStore settings, queue persistence, search history
139 local/ — LocalMediaScanner
140 lyrics/ — LrcParser, LyricsData, LyricsRepository
141 playlist/ — M3uPlaylistStore
142 di/ — Hilt modules
143 cast/ — Raw Cast protocol (follow mode) and the station overlay;
144 flavor-specific: play (CastIntegration, CastStreamProxy,
145 EarhornCastOptionsProvider) vs libre (stub)
146```
147
148### Layout dispatch
149
150Screens use `isCompactWidth()` from `WindowSize.kt` to branch between TV and phone layouts within the same composable. All code paths resolve typography through `staveTypography()`: Fraunces for display/headline/title, Inter for body/label.
151
152### Font architecture
153
154Fraunces (serif, variable, opsz 144) for display, headline and title scales. Inter (sans) for body and label scales. The on-screen keyboard renders all characters in Fraunces except numerals, which use Inter. `withInterDigits()` in `Type.kt` wraps any string so that digit characters (0–9) render in Inter regardless of the parent typography scale, applied at every call site where dynamic text touches a serif scale.
155
156### Theme
157
158"Quoyle": amber/sage/rose on warm charcoal. 2dp amber focus border with soft amber backplate on TV. Serif display (Fraunces) + sans chrome (Inter). Numerals always Inter.
159
160## Dependencies (Android)
161
162Versions are set in `android/gradle/libs.versions.toml`.
163
164| Library | Version | Purpose |
165|---------|---------|---------|
166| Compose BOM | 2024.11.00 | UI framework |
167| androidx.tv | foundation 1.0.0-alpha11, material 1.0.0 | TV-native Compose |
168| Media3 | 1.5.0 | ExoPlayer + MediaLibraryService |
169| jUPnP | 3.0.3 | UPnP/DLNA discovery + ContentDirectory |
170| Jetty | 9.4.48 | HTTP server for jUPnP |
171| Coil | 3.0.2 | Image loading (compose, okhttp, gif) |
172| OkHttp | 4.12.0 | HTTP |
173| Hilt | 2.52 | Dependency injection |
174| Room | 2.6.1 | Saved servers |
175| DataStore | 1.1.1 | Preferences persistence |
176| Car App Library | 1.7.0-rc01 | Android Auto |
177| Cast framework | 22.1.0 | Google Cast sender (play only) |
178| MediaRouter | 1.7.0 | Cast device picker (play only) |
179| Kotlin | 2.0.21 | Language |
180| AGP | 8.7.3 | Build system |
181| Gradle | 8.11.1 | Build tool (wrapper, checksum-pinned) |
182
183compileSdk 35, minSdk 26, targetSdk 35.
184
185## Known debugging territory
186
187**Multicast.** Some routers block SSDP multicast (IGMP snooping, guest VLANs, mesh systems). Confirm server and device are on the same subnet. `WAKE_LOCK` is required by jUPnP's `AndroidRouter` to hold a `WifiLock`.
188
189**VPN interference.** `NetworkInterfaceResolver` pins jUPnP's multicast to the real LAN interface so a VPN `tun` device doesn't capture SSDP.
190
191**ExoPlayer + 24/192 FLAC.** Software decoder works; hardware offload varies by TV chipset. Check `logcat | grep ExoPlayer`.
192
193**Resource shrinking.** Disabled (`isShrinkResources = false`). AGP's resource shrinker has historically replaced the launcher icon with a surviving drawable when references are manifest-only.
194
195**ChromeOS.** Same Play Store APK installs via ARC. Multicast and SSDP work through ARC's bridged network.
196
197## Documentation
198
199- `android/DESIGN.md`: product spec, UX, screen-by-screen treatment, MinimServer integration model
200- `android/FONTS.md`: Fraunces / Inter installation notes
201- `desktop/README.md` and the other documents in `desktop/`: the desktop app
202
203## License
204
205GNU General Public License, version 3 or later (GPLv3+). See [LICENSE](LICENSE).
206
207Copyright (C) 2026 Patrick T. Lafferty.
208
209A [Faveat Software](https://faveat.software) project. Faveat Software deploys exclusively under GPLv3+ to ensure that technical labour remains a public utility, immune to proprietary enclosure.
210
211If Earhorn Music is useful to you, [say thanks on Ko-fi](https://ko-fi.com/faveat).