README.md
Each line, with the release that last changed it.
| 0.5.0 | 1 | # 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 | ||
| 7 | License: 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 | ||
| 17 | The 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 | ||
| 21 | Every 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 | ||
| 33 | Needs 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 | |
| 36 | cd android | |
| 37 | ./gradlew :app:assembleLibreRelease # without Google Play Services | |
| 38 | ./gradlew :app:assemblePlayRelease # with Google Cast | |
| 39 | ``` | |
| 40 | ||
| 41 | APKs 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 | ||
| 43 | Sideload to an Android TV device: | |
| 44 | ||
| 45 | ```bash | |
| 46 | adb connect <tv-ip>:5555 | |
| 47 | adb install -r app/build/outputs/apk/libre/release/app-libre-release.apk | |
| 48 | ``` | |
| 49 | ||
| 50 | ### Your own build values (optional) | |
| 51 | ||
| 52 | Two 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 | |
| 55 | earhorn.musicnowStatusUrl=http://<host>:8642/status | |
| 56 | earhorn.domEthaDefaultUrl=http://<host>/mediacontrol.html | |
| 57 | ``` | |
| 58 | ||
| 59 | Never 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 | ||
| 63 | Needs 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 | |
| 66 | cd desktop | |
| 67 | ./gradlew :desktop:run # run from source | |
| 68 | ./gradlew :desktop:packageDeb :desktop:packageAppImage # build the .deb and AppImage | |
| 69 | ``` | |
| 70 | ||
| 71 | See [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 | ||
| 82 | Package 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 | ||
| 104 | One 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 | ``` | |
| 107 | dev/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 | ||
| 150 | Screens 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 | ||
| 154 | Fraunces (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 | ||
| 162 | Versions 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 | ||
| 183 | compileSdk 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 | ||
| 205 | GNU General Public License, version 3 or later (GPLv3+). See [LICENSE](LICENSE). | |
| 206 | ||
| 207 | Copyright (C) 2026 Patrick T. Lafferty. | |
| 208 | ||
| 209 | A [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 | ||
| 211 | If Earhorn Music is useful to you, [say thanks on Ko-fi](https://ko-fi.com/faveat). |


