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

README.md

296 lines · 17.6 KB

Changed in: 0.5.0

1# Earhorn Music — GNU/Linux
2
3**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.
4
5[![License: GPL v3+](https://img.shields.io/badge/License-GPLv3+-blue.svg)](LICENSE)
6[![Kotlin](https://img.shields.io/badge/Kotlin-2.0.21-7F52FF.svg)](https://kotlinlang.org)
7[![Compose Multiplatform](https://img.shields.io/badge/Compose_MP-1.7-4285F4.svg)](https://www.jetbrains.com/lp/compose-multiplatform/)
8
9[**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)
10
11---
12
13## Why Earhorn on the desktop?
14
15Earhorn 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.
16
17- **Plays through your computer.** libvlc via VLCJ — FLAC, MP3, WAV, AAC, ALAC, DSD, Opus, anything libvlc handles, which is essentially everything.
18- **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)).
19- **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.
20- **Classical-listener defaults.** 28 configurable metadata fields. Defaults surface Composer, Conductor, Work, Performer, Ensemble, sample rate, bit depth. Toggle what you care about.
21- **Drag-to-reorder queue.** Real-time visual reorder; the renderer's playback never restarts when the currently-playing track stays put.
22- **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.
23- **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.
24- **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.
25
26## Screenshots
27
28[![Main view](docs/screenshots/main.png)](docs/screenshots/main.png)
29[![Search](docs/screenshots/search.png)](docs/screenshots/search.png)
30[![Random](docs/screenshots/random.png)](docs/screenshots/random.png)
31[![Browse view](docs/screenshots/about.png)](docs/screenshots/about.png)
32[![Now Playing](docs/screenshots/user-guide.png)](docs/screenshots/user-guide.png)
33[![Settings 1](docs/screenshots/settings1.png)](docs/screenshots/settings1.png)
34[![Settings 2](docs/screenshots/settings2.png)](docs/screenshots/settings2.png)
35
36## Status
37
38Feature-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.
39
40## What this is
41
42- A native desktop UPnP/DLNA music player for GNU/Linux.
43- A drop-in alternative to Android Earhorn for users who want the library on their computer's speakers.
44- 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.
45
46## What this is not
47
48- **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.
49- **Not an Android Auto / TV / phone app.** Use the Android app (`android/`, next to this folder) for those surfaces.
50- **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.
51
52## Install
53
54Three options, easiest first.
55
56### `.AppImage` (any GNU/Linux distro)
57
58Download `earhorn-<version>-x86_64.AppImage` from the [download page](https://faveat.software/earhorn/#download). Mark executable, run.
59
60```bash
61chmod +x earhorn-<version>-x86_64.AppImage
62./earhorn-<version>-x86_64.AppImage
63```
64
65Verify the download: save `SHA256SUMS` from the same page next to it, then:
66
67```bash
68sha256sum -c SHA256SUMS --ignore-missing
69```
70
71The AppImage bundles a JDK runtime. The host needs only libvlc (`sudo apt install vlc` on Debian/Ubuntu; equivalent elsewhere).
72
73### `.deb` (Debian, Ubuntu, Linux Mint, Pop!_OS)
74
75```bash
76sudo apt install ./earhorn_<version>_amd64.deb
77```
78
79Installs 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:
80
81```bash
82sudo appstreamcli refresh-cache --force
83```
84
85### From source
86
87```bash
88sudo apt install openjdk-17-jdk vlc dpkg-dev
89# download earhorn-<version>.zip from https://faveat.software/earhorn/#download and unzip it, then:
90cd earhorn-<version>/desktop
91./gradlew :desktop:run # run from source
92./gradlew :desktop:packageDeb # build .deb
93./gradlew :desktop:packageAppImage # build .AppImage (requires appimagetool on PATH)
94```
95
96Outputs land under `desktop/build/compose/binaries/main/`.
97
98Requirements: 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`).
99
100## Usage
101
102### First launch
103
104Earhorn 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.
105
106### Browsing
107
108- **Toolbar dropdowns** drive a hierarchical browse: Albums / Artists / Composers / Genres / Tracks / etc. — whatever your server advertises.
109- **Click a row** to drill in. The breadcrumb above the list collapses when it gets too long; click the collapsed entries to jump.
110- **Right-click a track or container** for "Play now", "Play next", "Add to queue".
111- **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.
112
113### Now Playing
114
115- **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.
116- **Queue** — drag the handle on a row to reorder. The currently-playing track keeps playing through the reorder; only an explicit "skip" stops audio.
117- **Metadata sidebar** — Composer, Conductor, Work, Performer, etc. Defaults to classical-listener tags; configurable in Settings.
118
119### Keyboard shortcuts
120
121| Key | Action |
122|-----|--------|
123| `Space` | Play / Pause (suppressed while typing in any text field) |
124| `Ctrl+←` / `Ctrl+→` | Previous / next track |
125| `Ctrl+K` | Search |
126| `Ctrl+1` / `Ctrl+2` / `Ctrl+3` | Library / Random / Playlists |
127| `Ctrl+L` | Lyrics for the current track |
128| `Ctrl+,` | Settings |
129| `Ctrl+R` | Refresh devices |
130| `Ctrl+Q` | Quit |
131| `Esc` / `Alt+←` | Back: closes an open menu or dialog first, then overlays, then the browse trail |
132
133Media keys work through the desktop's media controls (MPRIS).
134
135## Features
136
137| Feature | Status |
138|---------|--------|
139| UPnP/DLNA discovery (SSDP) | ✓ |
140| ContentDirectory browse | ✓ |
141| Search (with history) | ✓ |
142| Local playback via libvlc | ✓ |
143| Drag-to-reorder queue | ✓ |
144| Time-synced lyrics (lrclib.net + server-side `.lrc`) | ✓ |
145| Album notes (ID3 / Vorbis / sibling `.txt`) | ✓ |
146| Random picker (Albums / Tracks) | ✓ |
147| Party mode (42 random tracks, instant play) | ✓ |
148| ReplayGain | ✓ local playback — track or album gain from the file's tags, with a pre-amp |
149| Gapless playback | ✓ (libvlc native) |
150| Save / load M3U playlists | ✓ |
151| Sleep timer | ✓ |
152| Google Cast sender (Chromecast, Chromecast Audio, speaker groups) | ✓ |
153| Follow a Cast session another app is driving | ✓ |
154| Control a UPnP renderer (AVTransport) | ✓ |
155| Act as a UPnP renderer for other apps (LAN broadcast) | ✓ — off by default |
156| Local device-side library | n/a — use a UPnP server |
157| Android Auto | n/a |
158| TV / leanback layout | n/a — use the Android build |
159
160## Privacy
161
162Earhorn makes the minimum network requests required for its features. There are no analytics, no crash reporting, no telemetry.
163
164- **SSDP multicast** to UDP 1900 and **mDNS** to UDP 5353 — server, renderer and Chromecast discovery on your LAN.
165- **HTTP to your UPnP server** — track streams, browse responses, album art, sibling `.lrc` / `.txt` files.
166- **Devices you pick** — UPnP renderer commands, and TLS to Cast devices (port 8009) when you cast to or follow one.
167- **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).
168- **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.
169- **Cover art published by others** — when following a Cast device, the artwork address the sending app chose (often an internet server) is fetched.
170- **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.
171
172Auditable: 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.
173
174## Limitations
175
176- **GNU/Linux only.** The build pipeline produces `.deb` and `.AppImage`. macOS / Windows are out of scope.
177- **x86_64 only.** ARM64 builds are technically possible (libvlc supports it, Compose Multiplatform supports it) but not packaged today.
178- **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.
179- **Single-server browse.** Switching servers takes one tap; cross-server search/browse is not a feature.
180
181## Compatibility
182
183| Tested | Notes |
184|--------|-------|
185| Ubuntu 24.04 LTS (Plasma, GNOME) | Primary target |
186| Debian 12 | `.deb` install path |
187| Pop!_OS 22.04 | `.deb` install path |
188| Fedora 40 (KDE) | `.AppImage` path |
189| Arch (KDE) | `.AppImage` path |
190| openSUSE Tumbleweed | `.AppImage` path |
191
192JDK requirement: 17 or newer. The bundled JDK in the packaged `.deb` and `.AppImage` is 21. Source builds run on either.
193
194Tested servers: MinimServer 2.x, Plex Media Server (DLNA mode), Universal Media Server, Jellyfin (DLNA plugin).
195
196## Tech stack
197
198| Layer | What |
199|-------|------|
200| Build | Gradle 8.x, JDK 17+, Kotlin 2.0.21 |
201| UI | Compose Multiplatform 1.7 |
202| Architecture | Kotlin Multiplatform (`shared/` common across Android + Desktop) |
203| Media | libvlc via VLCJ |
204| UPnP / DLNA | jUPnP 3.0.3 |
205| Image loading | Coil 3 |
206| DI | Koin |
207| Drag-to-reorder | sh.calvin.reorderable 2.4.3 |
208| Packaging | Compose `jpackage` + custom Gradle tasks for AppStream / hicolor / .AppImage post-processing |
209
210## Project layout
211
212```
213earhorn-gnu-linux/
214├── shared/ ← KMP library shared with the Android sibling
215│ └── src/
216│ ├── commonMain/ ← domain model, theme tokens
217│ ├── jvmCommonMain/ ← UPnP, ViewModels, lyrics, playlist store
218│ └── desktopMain/ ← VLCJ renderer, CastStreamProxy, desktop entry points
219│
220├── desktop/ ← Compose Multiplatform desktop application
221│ └── src/jvmMain/
222│ ├── kotlin/dev/stave/desktop/
223│ │ ├── Main.kt ← application entry, Compose window setup,
224│ │ │ WindowExceptionHandlerFactory installation
225│ │ ├── screens/ ← AboutScreen, NowPlayingPane, RandomPickerScreen,
226│ │ │ ServerSwitcher, SettingsScreen, AlbumGrid, …
227│ │ ├── ui/StaveApp.kt ← top-level app shell, navigation stack
228│ │ ├── window/StaveMenuBar.kt ← AWT menu bar wiring
229│ │ ├── shortcuts/Keybindings.kt ← keyboard accelerator handling
230│ │ └── coil/ ← Coil image loader integration
231│ └── resources/
232│ ├── icon.png ← steampunk-ear dock icon
233│ ├── earhorn.png ← in-app brand badge
234│ ├── earhorn_gramophone_glow.webp
235│ └── icons/earhorn-{48,64,128,256,512}.png
236│
237├── build.gradle.kts ← plugin aliases
238├── settings.gradle.kts ← module declarations
239├── gradle/libs.versions.toml ← version catalog
240├── README.md ← this file
241├── DESIGN.md ← internal architecture / port notes
242├── CHANGELOG.md
243├── CONTRIBUTING.md
244├── PRIVACY.md
245├── SECURITY.md
246└── LICENSE
247```
248
249## Network notes
250
251UPnP discovery uses SSDP multicast on UDP 1900. Most issues are network-level rather than code-level:
252
253- **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.
254- **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()`.
255- **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`.
256- **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.
257
258## Building from source
259
260See [Install → From source](#from-source) for the basic invocation. For ongoing development:
261
262```bash
263./gradlew :desktop:run # iterative run
264./gradlew :shared:compileKotlinDesktop # type-check shared
265./gradlew clean :desktop:packageDeb :desktop:packageAppImage # full release build
266```
267
268Configuration cache is enabled. If you change a `build.gradle.kts` and Gradle complains about cache invalidation, that's expected — let it rebuild the cache.
269
270The `.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.
271
272See [`DESIGN.md`](DESIGN.md) for the deeper architecture / port write-up.
273
274## Reporting bugs
275
276Email [`foundry@faveat.software`](mailto:foundry@faveat.software). Include:
277
278- Distro + version (`lsb_release -a`)
279- Java version (`java -version`)
280- UPnP server name + version
281- How to reproduce, and what you expected to happen instead
282- 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)
283
284The 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.
285
286## Contributing
287
288See [`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/`.
289
290## License
291
292GPLv3 or later. See [`LICENSE`](LICENSE).
293
294A [Faveat Software](https://faveat.software) project.
295
296If Earhorn GNU-Linux is useful to you, consider [gifting a nice, frosty beverage](https://ko-fi.com/faveat).

As a page

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+ Kotlin Compose Multiplatform

Website · Download · Browse the code · All releases · Tip jar

---

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).
  • 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 Search Random Browse view Now Playing Settings 1 Settings 2

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 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, Plex, 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. Mark executable, run.

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:

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)

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:

sudo appstreamcli refresh-cache --force

From source

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 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 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

KeyAction
SpacePlay / Pause (suppressed while typing in any text field)
Ctrl+← / Ctrl+→Previous / next track
Ctrl+KSearch
Ctrl+1 / Ctrl+2 / Ctrl+3Library / Random / Playlists
Ctrl+LLyrics for the current track
Ctrl+,Settings
Ctrl+RRefresh devices
Ctrl+QQuit
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

FeatureStatus
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 libraryn/a — use a UPnP server
Android Auton/a
TV / leanback layoutn/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, 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

TestedNotes
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

LayerWhat
BuildGradle 8.x, JDK 17+, Kotlin 2.0.21
UICompose Multiplatform 1.7
ArchitectureKotlin Multiplatform (shared/ common across Android + Desktop)
Medialibvlc via VLCJ
UPnP / DLNAjUPnP 3.0.3
Image loadingCoil 3
DIKoin
Drag-to-reordersh.calvin.reorderable 2.4.3
PackagingCompose 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 for the basic invocation. For ongoing development:

./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 project.

If Earhorn GNU-Linux is useful to you, consider gifting a nice, frosty beverage.