# Earhorn Music 0.5.0
**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.
[**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)
License: GPLv3+ · Android 8.0 (API 26) and newer · Kotlin 2.0 · Screenshots are on the [website](https://faveat.software/earhorn/).
## What's in this zip
| Folder | What it is |
|---|---|
| `android/` | The Android app (TV, phones, tablets, ChromeOS, Android Auto): a complete Gradle project with its wrapper. The app module is `android/app/`. |
| `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). |
| `LICENSE` | The GNU General Public License, version 3. |
The ready-built packages (two Android APKs, an AppImage and a .deb) are separate downloads on the [website](https://faveat.software/earhorn/#download).
## Why Earhorn?
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.
- **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.
- **No cloud, no account, no sync.** UPnP/DLNA discovery on your local network. Your server, your speakers, your files.
- **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.
- **Device-local audio.** No server? Browse and play the files on the device itself via MediaStore. Same UI, same queue, same transport.
- **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.
- **Gapless playback, ReplayGain.** Media3 `setMediaItems()` queues the whole album for gapless transitions. ReplayGain modes: off, track, album, with adjustable pre-amp.
- **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.
## Building the Android app
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.
```bash
cd android
./gradlew :app:assembleLibreRelease # without Google Play Services
./gradlew :app:assemblePlayRelease # with Google Cast
```
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`.
Sideload to an Android TV device:
```bash
adb connect <tv-ip>:5555
adb install -r app/build/outputs/apk/libre/release/app-libre-release.apk
```
### Your own build values (optional)
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`:
```properties
earhorn.musicnowStatusUrl=http://<host>:8642/status
earhorn.domEthaDefaultUrl=http://<host>/mediacontrol.html
```
Never commit or publish that file. To build without it even when it's there, add `-Pearhorn.public` to the Gradle command.
## Building the desktop app
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`.
```bash
cd desktop
./gradlew :desktop:run # run from source
./gradlew :desktop:packageDeb :desktop:packageAppImage # build the .deb and AppImage
```
See [desktop/README.md](desktop/README.md) for the desktop app's features and settings.
## Distribution
| Target | Flavor | Notes |
|--------|--------|-------|
| Google Play (TV, phone, tablet, ChromeOS) | `play` | Bundles Google Cast |
| F-Droid / Fire TV / GrapheneOS | `libre` | Zero GMS dependencies |
| Android Auto | both | Car App Library `CarAppService` with full browse tree |
| GNU/Linux | `desktop/` | Compose Multiplatform, .deb and AppImage |
Package namespace: `dev.stave`. Application IDs: `dev.stave` (play), `dev.stave.libre` (libre).
## What works (Android)
- **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.
- **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.
- **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.
- **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.
- **Cast follow.** Observe a Cast device that another app is playing to: its track, art and queue, with transport and volume control.
- **Android Auto.** Five-file `cartemplate` module: `EarhornCarAppService`, `EarhornCarSession`, `CarSessionMediaBridge`, `NowPlayingCarScreen`, `AlbumDetailCarScreen`.
- **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.
- **Device-local audio.** `LocalMediaScanner` indexes on-device audio via MediaStore. Browsable by artist, album, genre, song.
- **Queue.** Persistent save/restore via DataStore. Load/save as M3U playlists.
- **Search.** Cross-server ContentDirectory search with history persistence. Voice search with system IME fallback.
- **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.
- **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.
- **Random picker.** Shuffle by Albums or Tracks from the active server or local library.
- **Sleep timer.** Standard intervals.
- **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.
## Architecture (Android)
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).
```
dev/stave/
MainActivity.kt — single activity
EarhornApp.kt — Application class; Hilt entry point
ui/
theme/ — Color / Type / Shape / Theme + WindowSize helpers
nav/ — StaveNavHost — top-level navigation graph
servers/ — Server list + "On this device" (compact only)
browse/ — Container browsing, album grid, synthetic entries
detail/ — Album view: art + metadata + track list
nowplaying/ — Full player + persistent bottom bar
queue/ — Queue management
search/ — Cross-server + local search
local/ — Device-local audio hub, albums, artists, songs
playlist/ — M3U playlist read/write
lyrics/ — LRC parsing + synchronized display
settings/ — Metadata field toggles, playback settings
about/ — About screen
welcome/ — First-run onboarding
guide/ — In-app help
more/ — "More tracks from..." overflow
random/ — Shuffle-by-tag picker
metadata/ — Per-track metadata sheet
cast/ — Cast device picker
dometha/ — Web control panel
components/ — StaveFocusable, Marquee, ActionSheet, AlbumArt,
OnScreenKeyboard, dialogs, TvLongPress
cartemplate/ — Android Auto integration
domain/model/ — MediaServer, DidlItem, MetadataField
domain/renderer/ — Renderer interface + PlaybackState
upnp/ — jUPnP wrappers, ContentDirectoryClient, DidlParser
playback/ — PlaybackService, LocalRenderer, CoilBitmapLoader
data/ — DataStore settings, queue persistence, search history
local/ — LocalMediaScanner
lyrics/ — LrcParser, LyricsData, LyricsRepository
playlist/ — M3uPlaylistStore
di/ — Hilt modules
cast/ — Raw Cast protocol (follow mode) and the station overlay;
flavor-specific: play (CastIntegration, CastStreamProxy,
EarhornCastOptionsProvider) vs libre (stub)
```
### Layout dispatch
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.
### Font architecture
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.
### Theme
"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.
## Dependencies (Android)
Versions are set in `android/gradle/libs.versions.toml`.
| Library | Version | Purpose |
|---------|---------|---------|
| Compose BOM | 2024.11.00 | UI framework |
| androidx.tv | foundation 1.0.0-alpha11, material 1.0.0 | TV-native Compose |
| Media3 | 1.5.0 | ExoPlayer + MediaLibraryService |
| jUPnP | 3.0.3 | UPnP/DLNA discovery + ContentDirectory |
| Jetty | 9.4.48 | HTTP server for jUPnP |
| Coil | 3.0.2 | Image loading (compose, okhttp, gif) |
| OkHttp | 4.12.0 | HTTP |
| Hilt | 2.52 | Dependency injection |
| Room | 2.6.1 | Saved servers |
| DataStore | 1.1.1 | Preferences persistence |
| Car App Library | 1.7.0-rc01 | Android Auto |
| Cast framework | 22.1.0 | Google Cast sender (play only) |
| MediaRouter | 1.7.0 | Cast device picker (play only) |
| Kotlin | 2.0.21 | Language |
| AGP | 8.7.3 | Build system |
| Gradle | 8.11.1 | Build tool (wrapper, checksum-pinned) |
compileSdk 35, minSdk 26, targetSdk 35.
## Known debugging territory
**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`.
**VPN interference.** `NetworkInterfaceResolver` pins jUPnP's multicast to the real LAN interface so a VPN `tun` device doesn't capture SSDP.
**ExoPlayer + 24/192 FLAC.** Software decoder works; hardware offload varies by TV chipset. Check `logcat | grep ExoPlayer`.
**Resource shrinking.** Disabled (`isShrinkResources = false`). AGP's resource shrinker has historically replaced the launcher icon with a surviving drawable when references are manifest-only.
**ChromeOS.** Same Play Store APK installs via ARC. Multicast and SSDP work through ARC's bridged network.
## Documentation
- `android/DESIGN.md`: product spec, UX, screen-by-screen treatment, MinimServer integration model
- `android/FONTS.md`: Fraunces / Inter installation notes
- `desktop/README.md` and the other documents in `desktop/`: the desktop app
## License
GNU General Public License, version 3 or later (GPLv3+). See [LICENSE](LICENSE).
Copyright (C) 2026 Patrick T. Lafferty.
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.
If Earhorn Music is useful to you, [say thanks on Ko-fi](https://ko-fi.com/faveat).