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

README.md

# Earhorn Music — GNU/Linux

**A UPnP/DLNA music player for GNU/Linux desktops.** Point it at the server already running on your NAS, browse and queue with mouse and keyboard, and play through your computer's own speakers via libvlc — or send the queue to a Chromecast or a UPnP renderer. Same library you stream to on the TV, same MinimServer-aware browse tree, no cloud account, no telemetry.

[![License: GPL v3+](https://img.shields.io/badge/License-GPLv3+-blue.svg)](LICENSE)
[![Kotlin](https://img.shields.io/badge/Kotlin-2.0.21-7F52FF.svg)](https://kotlinlang.org)
[![Compose Multiplatform](https://img.shields.io/badge/Compose_MP-1.7-4285F4.svg)](https://www.jetbrains.com/lp/compose-multiplatform/)

[**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)

---

## Why Earhorn on the desktop?

Earhorn already exists for Android TV, phone, and Android Auto in the `android/` folder next to this one. This is the same product on the surface where you already keep your headphones plugged in — a laptop running Debian, an old workstation wired into a stereo, a homelab box doing double duty. Same UPnP discovery, same library, same classical-listener metadata defaults, rendered in a Compose Multiplatform window.

- **Plays through your computer.** libvlc via VLCJ — FLAC, MP3, WAV, AAC, ALAC, DSD, Opus, anything libvlc handles, which is essentially everything.
- **No cloud, no account.** SSDP discovery on your local network. Your server, your speakers, your files. The only routine internet traffic is the lyrics lookup, and one switch turns it off (see [Privacy](#privacy)).
- **Same browse tree as the Android build.** MinimServer's `indexTags` structure renders faithfully; synthetic entries (`>> Show All`, `[folder view]`, `[untagged]`) get distinct sage-green italic typography.
- **Classical-listener defaults.** 28 configurable metadata fields. Defaults surface Composer, Conductor, Work, Performer, Ensemble, sample rate, bit depth. Toggle what you care about.
- **Drag-to-reorder queue.** Real-time visual reorder; the renderer's playback never restarts when the currently-playing track stays put.
- **Lyrics, album art, album notes.** Time-synced lyrics from lrclib.net with server-side `.lrc` fallback. Coil-loaded album art. `AlbumInfoLoader` reads ID3 / Vorbis comments and sibling `.txt` notes.
- **Cast and UPnP renderers.** Cast the queue to a Chromecast, Chromecast Audio or speaker group, drive a UPnP renderer on your network, or *follow* what another app is playing on a Cast device without taking it over.
- **Zero telemetry.** No analytics, no crash reporting. Network traffic is your LAN (discovery, your server, the devices you pick) plus lrclib.net for lyrics (on by default, one switch to turn off). [`PRIVACY.md`](PRIVACY.md) lists every connection.

## Screenshots

[![Main view](docs/screenshots/main.png)](docs/screenshots/main.png)
[![Search](docs/screenshots/search.png)](docs/screenshots/search.png)
[![Random](docs/screenshots/random.png)](docs/screenshots/random.png)
[![Browse view](docs/screenshots/about.png)](docs/screenshots/about.png)
[![Now Playing](docs/screenshots/user-guide.png)](docs/screenshots/user-guide.png)
[![Settings 1](docs/screenshots/settings1.png)](docs/screenshots/settings1.png)
[![Settings 2](docs/screenshots/settings2.png)](docs/screenshots/settings2.png)

## Status

Feature-complete enough for daily use as a music player; some features of the Android sibling (Android Auto, local MediaStore browse) are out of scope by design. See the [Limitations](#limitations) section.

## What this is

- A native desktop UPnP/DLNA music player for GNU/Linux.
- A drop-in alternative to Android Earhorn for users who want the library on their computer's speakers.
- The result of a Kotlin Multiplatform port: the entire `shared/` module (domain model, UPnP layer, ViewModels, theme tokens) is the same code that powers the Android build. Only the renderer (VLCJ vs Media3) and the screens (Compose Multiplatform vs Compose Android) differ.

## What this is not

- **Not a music ripper, transcoder, library manager, or tagger.** Earhorn renders what the server sends. Run [MinimServer](https://minimserver.com/), [Plex](https://www.plex.tv/), or any other DLNA-compliant server and let it own the library.
- **Not an Android Auto / TV / phone app.** Use the Android app (`android/`, next to this folder) for those surfaces.
- **Not a Windows or macOS app today.** The build pipeline produces `.deb` and `.AppImage` only. The codebase is Compose Multiplatform; cross-compiling to other JVM platforms is technically possible but not packaged here.

## Install

Three options, easiest first.

### `.AppImage` (any GNU/Linux distro)

Download `earhorn-<version>-x86_64.AppImage` from the [download page](https://faveat.software/earhorn/#download). Mark executable, run.

```bash
chmod +x earhorn-<version>-x86_64.AppImage
./earhorn-<version>-x86_64.AppImage
```

Verify the download: save `SHA256SUMS` from the same page next to it, then:

```bash
sha256sum -c SHA256SUMS --ignore-missing
```

The AppImage bundles a JDK runtime. The host needs only libvlc (`sudo apt install vlc` on Debian/Ubuntu; equivalent elsewhere).

### `.deb` (Debian, Ubuntu, Linux Mint, Pop!_OS)

```bash
sudo apt install ./earhorn_<version>_amd64.deb
```

Installs to `/opt/earhorn`, registers a `.desktop` entry, drops hicolor icons at 48 / 64 / 128 / 256 / 512, and writes AppStream metainfo so software centers list it properly. The first launch after install may need an AppStream cache refresh:

```bash
sudo appstreamcli refresh-cache --force
```

### From source

```bash
sudo apt install openjdk-17-jdk vlc dpkg-dev
# download earhorn-<version>.zip from https://faveat.software/earhorn/#download and unzip it, then:
cd earhorn-<version>/desktop
./gradlew :desktop:run                 # run from source
./gradlew :desktop:packageDeb          # build .deb
./gradlew :desktop:packageAppImage     # build .AppImage (requires appimagetool on PATH)
```

Outputs land under `desktop/build/compose/binaries/main/`.

Requirements: JDK 17 or newer, libvlc available on the system (`sudo apt install vlc`), and for AppImage builds, `appimagetool` on `PATH` (download from the [AppImageKit continuous build](https://github.com/AppImage/appimagetool/releases) and put it somewhere on your `PATH`).

## Usage

### First launch

Earhorn scans the LAN with SSDP and lists the UPnP/DLNA servers it finds. Pick one. If no server appears, see [Network notes](#network-notes) below.

### Browsing

- **Toolbar dropdowns** drive a hierarchical browse: Albums / Artists / Composers / Genres / Tracks / etc. — whatever your server advertises.
- **Click a row** to drill in. The breadcrumb above the list collapses when it gets too long; click the collapsed entries to jump.
- **Right-click a track or container** for "Play now", "Play next", "Add to queue".
- **Dice button** picks 42 random albums or tracks. The **Party** button in the breadcrumb row is "Party mode" — load 42 random tracks and start playing immediately.

### Now Playing

- **Album art** — click for a full-screen lightbox. The drag handle below the hero row resizes the art square; pull it up to give the queue more vertical room on short windows.
- **Queue** — drag the handle on a row to reorder. The currently-playing track keeps playing through the reorder; only an explicit "skip" stops audio.
- **Metadata sidebar** — Composer, Conductor, Work, Performer, etc. Defaults to classical-listener tags; configurable in Settings.

### Keyboard shortcuts

| Key | Action |
|-----|--------|
| `Space` | Play / Pause (suppressed while typing in any text field) |
| `Ctrl+←` / `Ctrl+→` | Previous / next track |
| `Ctrl+K` | Search |
| `Ctrl+1` / `Ctrl+2` / `Ctrl+3` | Library / Random / Playlists |
| `Ctrl+L` | Lyrics for the current track |
| `Ctrl+,` | Settings |
| `Ctrl+R` | Refresh devices |
| `Ctrl+Q` | Quit |
| `Esc` / `Alt+←` | Back: closes an open menu or dialog first, then overlays, then the browse trail |

Media keys work through the desktop's media controls (MPRIS).

## Features

| Feature | Status |
|---------|--------|
| UPnP/DLNA discovery (SSDP) | ✓ |
| ContentDirectory browse | ✓ |
| Search (with history) | ✓ |
| Local playback via libvlc | ✓ |
| Drag-to-reorder queue | ✓ |
| Time-synced lyrics (lrclib.net + server-side `.lrc`) | ✓ |
| Album notes (ID3 / Vorbis / sibling `.txt`) | ✓ |
| Random picker (Albums / Tracks) | ✓ |
| Party mode (42 random tracks, instant play) | ✓ |
| ReplayGain | ✓ local playback — track or album gain from the file's tags, with a pre-amp |
| Gapless playback | ✓ (libvlc native) |
| Save / load M3U playlists | ✓ |
| Sleep timer | ✓ |
| Google Cast sender (Chromecast, Chromecast Audio, speaker groups) | ✓ |
| Follow a Cast session another app is driving | ✓ |
| Control a UPnP renderer (AVTransport) | ✓ |
| Act as a UPnP renderer for other apps (LAN broadcast) | ✓ — off by default |
| Local device-side library | n/a — use a UPnP server |
| Android Auto | n/a |
| TV / leanback layout | n/a — use the Android build |

## Privacy

Earhorn makes the minimum network requests required for its features. There are no analytics, no crash reporting, no telemetry.

- **SSDP multicast** to UDP 1900 and **mDNS** to UDP 5353 — server, renderer and Chromecast discovery on your LAN.
- **HTTP to your UPnP server** — track streams, browse responses, album art, sibling `.lrc` / `.txt` files.
- **Devices you pick** — UPnP renderer commands, and TLS to Cast devices (port 8009) when you cast to or follow one.
- **musicnow** — while following a Cast device, a status request about every 12 s to the musicnow address in Settings, only if you enter one (empty by default).
- **HTTPS to `lrclib.net`** — time-synced lyrics for the track in the lyrics view. **On by default**; Settings → Lyrics → *Allow external service calls* turns it off. Sends the track title, artist, album and duration, plus a User-Agent naming Earhorn. No identifiers, no account.
- **Cover art published by others** — when following a Cast device, the artwork address the sending app chose (often an internet server) is fetched.
- **Listening** — a UPnP event port (always), a stream proxy while casting to a speaker group, and, only if you turn on *LAN broadcast*, a renderer any device on your network can control.

Auditable: the source is published at [faveat.software/earhorn](https://faveat.software/earhorn/browse/), GPLv3+. See [`PRIVACY.md`](PRIVACY.md) for the full list, including what is stored on disk.

## Limitations

- **GNU/Linux only.** The build pipeline produces `.deb` and `.AppImage`. macOS / Windows are out of scope.
- **x86_64 only.** ARM64 builds are technically possible (libvlc supports it, Compose Multiplatform supports it) but not packaged today.
- **No device-side library indexer.** Earhorn doesn't scan local files into a library — point it at a UPnP server and let the server own that.
- **Single-server browse.** Switching servers takes one tap; cross-server search/browse is not a feature.

## Compatibility

| Tested | Notes |
|--------|-------|
| Ubuntu 24.04 LTS (Plasma, GNOME) | Primary target |
| Debian 12 | `.deb` install path |
| Pop!_OS 22.04 | `.deb` install path |
| Fedora 40 (KDE) | `.AppImage` path |
| Arch (KDE) | `.AppImage` path |
| openSUSE Tumbleweed | `.AppImage` path |

JDK requirement: 17 or newer. The bundled JDK in the packaged `.deb` and `.AppImage` is 21. Source builds run on either.

Tested servers: MinimServer 2.x, Plex Media Server (DLNA mode), Universal Media Server, Jellyfin (DLNA plugin).

## Tech stack

| Layer | What |
|-------|------|
| Build | Gradle 8.x, JDK 17+, Kotlin 2.0.21 |
| UI | Compose Multiplatform 1.7 |
| Architecture | Kotlin Multiplatform (`shared/` common across Android + Desktop) |
| Media | libvlc via VLCJ |
| UPnP / DLNA | jUPnP 3.0.3 |
| Image loading | Coil 3 |
| DI | Koin |
| Drag-to-reorder | sh.calvin.reorderable 2.4.3 |
| Packaging | Compose `jpackage` + custom Gradle tasks for AppStream / hicolor / .AppImage post-processing |

## Project layout

```
earhorn-gnu-linux/
├── shared/                              ← KMP library shared with the Android sibling
│   └── src/
│       ├── commonMain/                  ← domain model, theme tokens
│       ├── jvmCommonMain/               ← UPnP, ViewModels, lyrics, playlist store
│       └── desktopMain/                 ← VLCJ renderer, CastStreamProxy, desktop entry points
│
├── desktop/                             ← Compose Multiplatform desktop application
│   └── src/jvmMain/
│       ├── kotlin/dev/stave/desktop/
│       │   ├── Main.kt                  ← application entry, Compose window setup,
│       │   │                              WindowExceptionHandlerFactory installation
│       │   ├── screens/                 ← AboutScreen, NowPlayingPane, RandomPickerScreen,
│       │   │                              ServerSwitcher, SettingsScreen, AlbumGrid, …
│       │   ├── ui/StaveApp.kt           ← top-level app shell, navigation stack
│       │   ├── window/StaveMenuBar.kt   ← AWT menu bar wiring
│       │   ├── shortcuts/Keybindings.kt ← keyboard accelerator handling
│       │   └── coil/                    ← Coil image loader integration
│       └── resources/
│           ├── icon.png                 ← steampunk-ear dock icon
│           ├── earhorn.png              ← in-app brand badge
│           ├── earhorn_gramophone_glow.webp
│           └── icons/earhorn-{48,64,128,256,512}.png
│
├── build.gradle.kts                     ← plugin aliases
├── settings.gradle.kts                  ← module declarations
├── gradle/libs.versions.toml            ← version catalog
├── README.md                            ← this file
├── DESIGN.md                            ← internal architecture / port notes
├── CHANGELOG.md
├── CONTRIBUTING.md
├── PRIVACY.md
├── SECURITY.md
└── LICENSE
```

## Network notes

UPnP discovery uses SSDP multicast on UDP 1900. Most issues are network-level rather than code-level:

- **VPN clients** (NetGuard, ProtonVPN, Tailscale, …) often route all traffic through a tunnel that hides the LAN. Add a split-tunnel exclusion for your LAN subnet or temporarily disable the VPN.
- **Docker / KVM / libvirt bridges** appear as separate interfaces. Earhorn's local-IP picker filters obvious virtuals (`docker0`, `br-*`, `veth*`, `virbr*`, `vmnet*`, `tun*`, `tap*`, `zt*`) — if your setup has differently-named virtuals, extend the filter in `CastStreamProxy.getLocalIp()`.
- **Firewalls** — multicast UDP 1900 inbound, plus the ephemeral UDP ports the server uses for unicast responses, must be allowed. UFW: `sudo ufw allow from 192.168.0.0/16 to any port 1900 proto udp`.
- **Wi-Fi multicast** — some access points drop multicast on Wi-Fi to save airtime. Look for "IGMP Snooping" or "Multicast Forwarding" in the AP settings.

## Building from source

See [Install → From source](#from-source) for the basic invocation. For ongoing development:

```bash
./gradlew :desktop:run                                          # iterative run
./gradlew :shared:compileKotlinDesktop                          # type-check shared
./gradlew clean :desktop:packageDeb :desktop:packageAppImage    # full release build
```

Configuration cache is enabled. If you change a `build.gradle.kts` and Gradle complains about cache invalidation, that's expected — let it rebuild the cache.

The `.deb` build runs an `enhanceDeb` finalizer that patches `.desktop` files, installs hicolor icons, writes AppStream metainfo, and rewrites the `DEBIAN/control` Description block. The `.AppImage` build similarly post-processes the `jpackage` app-image directory into a real AppImage via `appimagetool`. Both flows are in `desktop/build.gradle.kts` and are documented inline.

See [`DESIGN.md`](DESIGN.md) for the deeper architecture / port write-up.

## Reporting bugs

Email [`foundry@faveat.software`](mailto:foundry@faveat.software). Include:

- Distro + version (`lsb_release -a`)
- Java version (`java -version`)
- UPnP server name + version
- How to reproduce, and what you expected to happen instead
- For crashes, the stack trace from stderr (run `./gradlew :desktop:run` from a terminal to capture it, or run the AppImage / `/opt/earhorn/bin/Earhorn` from a terminal)

The app installs a `WindowExceptionHandlerFactory` and a `CoroutineExceptionHandler` on all ViewModels, so most unexpected exceptions are caught, logged to stderr with full stack trace, and the app keeps running. If you see a "[Earhorn]" tagged stack trace in your terminal during a reproduction, that's the gold — include it.

## Contributing

See [`CONTRIBUTING.md`](CONTRIBUTING.md). The short version: small focused PRs, comments explaining *why* not *what*, no force pushes to long-lived branches, KMP shared code goes in `shared/`.

## License

GPLv3 or later. See [`LICENSE`](LICENSE).

A [Faveat Software](https://faveat.software) project.

If Earhorn GNU-Linux is useful to you, consider [gifting a nice, frosty beverage](https://ko-fi.com/faveat).