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

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**](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).