README.md
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 · Download · Browse the code · All releases · Tip jar
License: GPLv3+ · Android 8.0 (API 26) and newer · Kotlin 2.0 · Screenshots are on the website.
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. |
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.
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
playflavor 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.
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:
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:
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.
cd desktop
./gradlew :desktop:run # run from source
./gradlew :desktop:packageDeb :desktop:packageAppImage # build the .deb and AppImage
See 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.
NetworkInterfaceResolverbinds to the real LAN interface so VPN clients (NetGuard, ProtonVPN, Tailscale) don't break discovery. - Browse. ContentDirectory
Browse(DIRECT_CHILDREN)for any container. MinimServer'sindexTagsstructure 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).
CastIntegrationwith queue loading, a stream proxy (CastStreamProxy), device volume control and disconnect handling. Queue navigation viaqueueNext/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
cartemplatemodule: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.
LocalMediaScannerindexes 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
.lrcfiles. Rendered in Inter (sans) with line-by-line scroll. - Album notes.
AlbumInfoLoaderreads ID3 COMMENT / Vorbis DESCRIPTION from the first track, falls back to sibling.txt/review.txt/info.txtfiles 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 modelandroid/FONTS.md: Fraunces / Inter installation notesdesktop/README.mdand the other documents indesktop/: the desktop app
License
GNU General Public License, version 3 or later (GPLv3+). See LICENSE.
Copyright (C) 2026 Patrick T. Lafferty.
A 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.


