# 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//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 :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://:8642/status earhorn.domEthaDefaultUrl=http:///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).