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

Earhorn Music 0.5.0

· The first release hosted here.

Download earhorn-0.5.0.zipAll releases

NameLast changedSize
android/0.5.0 The first release hosted here.174 files
desktop/0.5.0 The first release hosted here.119 files
LICENSE0.5.0 The first release hosted here.35.1 KB
README.md0.5.0 The first release hosted here.14.0 KB

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

FolderWhat 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.
LICENSEThe 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 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.

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

TargetFlavorNotes
Google Play (TV, phone, tablet, ChromeOS)playBundles Google Cast
F-Droid / Fire TV / GrapheneOSlibreZero GMS dependencies
Android AutobothCar App Library CarAppService with full browse tree
GNU/Linuxdesktop/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.

LibraryVersionPurpose
Compose BOM2024.11.00UI framework
androidx.tvfoundation 1.0.0-alpha11, material 1.0.0TV-native Compose
Media31.5.0ExoPlayer + MediaLibraryService
jUPnP3.0.3UPnP/DLNA discovery + ContentDirectory
Jetty9.4.48HTTP server for jUPnP
Coil3.0.2Image loading (compose, okhttp, gif)
OkHttp4.12.0HTTP
Hilt2.52Dependency injection
Room2.6.1Saved servers
DataStore1.1.1Preferences persistence
Car App Library1.7.0-rc01Android Auto
Cast framework22.1.0Google Cast sender (play only)
MediaRouter1.7.0Cast device picker (play only)
Kotlin2.0.21Language
AGP8.7.3Build system
Gradle8.11.1Build 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.

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.