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

DESIGN.md

Each line, with the release that last changed it.

0.5.01# Design notes — Android → KMP port
2
3These are the developer-oriented architecture notes from the initial port. They explain why the codebase is split the way it is, what runs where, and which decisions were made for which reasons. New contributors: read this after [README.md](README.md) and before opening a PR that crosses module boundaries.
4
5The notes describe the state of the project as of the initial port. Where the current code differs from what's described here, the current code wins — this is a snapshot of intent, not a live specification.
6
7---
8
9
10This project restructures Earhorn Music Player from a single Android app module into a Kotlin
11Multiplatform project so the Linux desktop build can share everything worth
12sharing with Android: the DidlItem domain, the UPnP / MinimServer logic, the
13Renderer interface, theme tokens, and the ViewModels.
14
15The Android TV / mobile product keeps its current features. The desktop
16product is a **control point + local renderer** — it drives other UPnP
17renderers on the LAN *and* plays audio through the Ubuntu machine's own
18speakers via libvlc (VLCJ).
19
20---
21
22## 0. Prerequisites
23
24```bash
25sudo apt install vlc openjdk-17-jdk gradle
26```
27
28VLCJ is a thin binding over the system libvlc — the `vlc` package supplies
29both the `.so` and the codec plugin set needed for FLAC / ALAC / DSD / Opus.
30
31---
32
33## 1. Project layout
34
35```
36earhorn/
37├── settings.gradle.kts ← declares :shared, :desktop (+ :android later)
38├── build.gradle.kts ← plugin aliases only
39├── gradle.properties
40├── gradle/
41│ ├── libs.versions.toml ← version catalog: Kotlin, Compose MP, jUPnP, VLCJ, Koin…
42│ └── wrapper/
43│ ├── gradle-wrapper.properties
44│ └── gradle-wrapper.jar ← generate via `gradle wrapper` (see §2)
45│
46├── shared/ ← KMP library: android + desktop (jvm) targets
47│ ├── build.gradle.kts
48│ └── src/
49│ ├── commonMain/kotlin/dev/earhorn/
50│ │ ├── domain/model/ ← DidlItem (sealed), MediaServer, MetadataField
51│ │ ├── domain/renderer/ ← Renderer interface, PlaybackState
52│ │ ├── ui/theme/ ← Color, Type (Typography), StaveTheme (compose.material3)
53│ │ ├── ui/browse/ ← BrowseViewModel
54│ │ ├── ui/nowplaying/ ← NowPlayingViewModel
55│ │ └── ui/servers/ ← ServersViewModel
56│ ├── jvmCommonMain/kotlin/dev/earhorn/
57│ │ ├── upnp/ ← UpnpServiceHolder (iface), BrowseResult, DidlParser,
58│ │ │ ContentDirectoryClient, MediaCollector
59│ │ └── di/SharedModule.kt ← Koin module binding UPnP + VMs
60│ ├── androidMain/ ← (AndroidManifest.xml for now; you move the Android
61│ │ UpnpServiceHolder and ExoPlayer renderer here)
62│ └── desktopMain/kotlin/dev/earhorn/
63│ ├── upnp/DesktopUpnpServiceHolder.kt ← plain jUPnP UpnpServiceImpl
64│ └── playback/VlcjLocalRenderer.kt ← libvlc, implements Renderer fully
65│
66└── desktop/ ← Compose-for-Desktop app
67 ├── build.gradle.kts
68 └── src/jvmMain/
69 ├── kotlin/dev/earhorn/desktop/
70 │ ├── Main.kt ← application() entry, KoinApplication, window
71 │ ├── DesktopModule.kt ← binds UpnpServiceHolder→Desktop*, Renderer→Vlcj*
72 │ ├── window/StaveMenuBar.kt
73 │ ├── shortcuts/Keybindings.kt
74 │ ├── screens/
75 │ │ ├── BrowseScreen.kt ← consumes BrowseViewModel
76 │ │ ├── NowPlayingPane.kt ← consumes NowPlayingViewModel
77 │ │ └── Sidebar.kt ← consumes ServersViewModel
78 │ └── ui/StaveApp.kt ← three-pane shell + breadcrumb nav
79 └── resources/ ← drop an icon.png here for jpackage
80```
81
82---
83
84## 2. Generate the Gradle wrapper
85
86From the repo root, once:
87
88```bash
89gradle wrapper --gradle-version 8.10.2
90```
91
92Thereafter `./gradlew` works without the system Gradle.
93
94---
95
96## 3. What actually carries over from the upstream Android project
97
98The upstream Earhorn Music Player Android project uses **Hilt (Dagger)** for DI and
99**jUPnP 3.x** as the UPnP stack (not Cling). For KMP the DI switches to
100**Koin** — Hilt is Android-only — so each ported file that had
101`@HiltViewModel` / `@Inject constructor()` / `@Singleton` is now a plain
102class wired by the Koin modules in `di/SharedModule.kt` and
103`desktop/DesktopModule.kt`.
104
105### Files copied near-verbatim (annotations stripped)
106
107| Upstream Android path | New path |
108|-------------------------------------------------------|----------------------------------------------------------------------|
109| `domain/model/DidlItem.kt` | `shared/commonMain/.../domain/model/DidlItem.kt` |
110| `domain/model/MediaServer.kt` | `shared/commonMain/.../domain/model/MediaServer.kt` |
111| `domain/model/MetadataField.kt` | `shared/commonMain/.../domain/model/MetadataField.kt` |
112| `domain/renderer/Renderer.kt` | `shared/commonMain/.../domain/renderer/Renderer.kt` |
113| `upnp/BrowseResult.kt` | `shared/jvmCommonMain/.../upnp/BrowseResult.kt` |
114| `upnp/DidlParser.kt` | `shared/jvmCommonMain/.../upnp/DidlParser.kt` |
115| `upnp/ContentDirectoryClient.kt` | `shared/jvmCommonMain/.../upnp/ContentDirectoryClient.kt` |
116| `upnp/MediaCollector.kt` | `shared/jvmCommonMain/.../upnp/MediaCollector.kt` |
117| `ui/theme/Color.kt` | `shared/commonMain/.../ui/theme/Color.kt` |
118| `ui/theme/Type.kt` | `shared/commonMain/.../ui/theme/Type.kt` |
119| `ui/browse/BrowseViewModel.kt` | `shared/commonMain/.../ui/browse/BrowseViewModel.kt` |
120| `ui/nowplaying/NowPlayingViewModel.kt` | `shared/commonMain/.../ui/nowplaying/NowPlayingViewModel.kt` |
121
122### Files rewritten (platform-incompatible originals)
123
124- **`UpnpServiceHolder`** — split into an interface (jvmCommonMain) + two implementations.
125 - Desktop: `DesktopUpnpServiceHolder` around a plain `org.jupnp.UpnpServiceImpl`, no
126 Android Service, no `WifiManager.MulticastLock`. Preserves the critical `svc.startup()`
127 call that the Android holder's in-code comment flags as the jUPnP-3.x gotcha.
128 - Android: port the existing `UpnpServiceHolder` into `shared/androidMain/.../upnp/` as
129 `AndroidUpnpServiceHolder`, adjusting it to implement the common interface.
130
131- **`LocalRenderer`** — the Android `LocalRenderer` is Media3-based and stays on Android.
132 Desktop's `VlcjLocalRenderer` is a from-scratch implementation of the same `Renderer`
133 interface, backed by VLCJ's `MediaListPlayer` for gapless. Matches the Android
134 semantics exactly: `isStopped` vs paused-mid-track, "enqueue into empty queue does not
135 auto-play", "playNext into empty queue auto-plays", insert-after-current indexing,
136 stop() pauses+rewinds (doesn't destroy prepared state).
137
138- **`ui/theme/Theme.kt`** — Android used `androidx.tv.material3.MaterialTheme`; the new
139 shared wrapper uses `androidx.compose.material3.MaterialTheme` which works on Desktop
140 and Android (non-TV). The Android TV flavor will need its own `StaveTheme` wrapper that
141 reuses `StaveTypography` / the colors but wraps them in `androidx.tv.material3`.
142
143- **`di/RendererModule.kt`** (Hilt) — replaced by the two Koin modules.
144
145### Files that stay in `:android` unchanged
146
147Screens still Android-only (they consume the shared VMs):
148- `ui/browse/BrowseScreen.kt`, `ui/nowplaying/NowPlayingScreen.kt` — both import
149 `androidx.tv.material3.*`, which is Android-only. Desktop has its own parallel screens
150 in `desktop/.../screens/`.
151- Everything under `ui/nav/`, `ui/search/`, `ui/servers/` (Android), `ui/settings/`,
152 `ui/queue/`, `ui/detail/`, `ui/more/`, `ui/about/`, `ui/guide/`, `ui/random/`.
153- `ui/components/Marquee.kt`, `ui/components/ActionSheet.kt`, `ui/components/StaveFocusable.kt`,
154 `ui/components/TvLongPress.kt`.
155- `MainActivity.kt`, `StaveApp.kt` (the Application subclass), `PlaybackService.kt`,
156 Coil image loader setup, Android permissions.
157- `data/SettingsStore.kt` (uses Android DataStore).
158- The `play` / `libre` flavor dirs (Chromecast is Android-only).
159
160---
161
162## 4. What's runnable right now (given a populated Gradle wrapper)
163
164```bash
165./gradlew :desktop:run # launches the app
166./gradlew :desktop:packageDistributionForCurrentOS # builds .deb + AppImage
167```
168
169On first launch you should see: an empty sidebar, a "Select a server in the sidebar"
170message in the center pane, and an empty Now Playing pane on the right. Once your
171MinimServer advertises itself via SSDP, it appears in the sidebar with the amber icon
172(the `isMinimServer` detection). Click it → root containers render in the center pane.
173Double-click a track → plays on the Ubuntu box's speakers.
174
175Keybindings: `Space` play/pause, media keys → transport, `/` to focus search (search
176itself not yet wired).
177
178---
179
180## 5. Still TODO
181
182Functional gaps (the app runs without these but they're obvious rough edges):
183
184- **Breadcrumb navigation** — `StaveApp.kt` has a stack but the non-terminal crumbs
185 don't yet have a real clickable Modifier chain. A small fix once you settle on the
186 nav-click UX.
187- **Titles of navigated-into containers** — `onNavigateInto(childId)` pushes the
188 raw container ID as the crumb title. `BrowseScreen`'s ContainerRow knows the title;
189 it should pass `(id, title)` up.
190- **Search screen** — the keybinding is wired but no search UI exists on desktop yet.
191 `ContentDirectoryClient.searchByTitle` is already ported, so it's a screen + VM away.
192- **Queue screen** — the Android app has `QueueScreen` + long-press reorder. Desktop
193 should get an equivalent; `VlcjLocalRenderer.moveInQueue` already supports it.
194- **Action menu on track right-click** — in the Android app this is long-press (a TV
195 idiom); on desktop, right-click → context menu (Play next / Enqueue / View album) is
196 the natural mapping.
197- **Detail / More screens** — "View album" from context menu + the Android `DetailScreen`
198 port. Reuses `ContentDirectoryClient.getContainerParent` and the existing VMs.
199- **`icon.png`** — drop a 512×512 in `desktop/src/jvmMain/resources/` for jpackage.
200
201Architectural TODOs (one-time, do as you need them):
202
203- **Migrate the Android app module into `:android`** so it depends on `:shared` for all
204 the hoisted code. Move `AndroidUpnpServiceHolder` under `shared/src/androidMain/` so
205 it satisfies the common interface. Keep Media3 deps in `:android`, not `:shared`.
206- **Android TV theme wrapper** — a second `StaveTheme` in the TV flavor that uses
207 `androidx.tv.material3` but reads the same `Color.kt` tokens + a small Typography
208 rewrapper. Trivial once touched.
209- **Koin on Android** — replace the Hilt bootstrap in `StaveApp.onCreate` with
210 `startKoin { modules(sharedModule, androidModule) }`, where `androidModule` binds
211 `UpnpServiceHolder` → `AndroidUpnpServiceHolder` and `Renderer` → the Android
212 `LocalRenderer`. Existing Android `@HiltViewModel` annotations disappear.
213
214---
215
216## 6. Gotchas learned from the upstream Android code
217
218Worth preserving verbatim because they will bite on desktop if forgotten:
219
220- **jUPnP 3.x does not auto-start.** The Android `UpnpServiceHolder.attach()` explicitly
221 calls `service.get().startup()` — without this, the `ControlPoint` exists but
222 `.search()` is a no-op and no SSDP NOTIFYs arrive. `DesktopUpnpServiceHolder.start()`
223 preserves the same call on the plain `UpnpServiceImpl`.
224- **SSDP multicast on dual-stack Linux boxes**: if discovery is silent on a machine with
225 an active IPv6 interface, set `-Dorg.jupnp.network.useIPv6=false` or configure a
226 `NetworkAddressFactory` that filters out IPv6.
227- **`upnp:artist` vs `upnp:albumArtist`** — the existing `ContentDirectoryClient` uses
228 `upnp:artist` because MinimServer silently ignores `upnp:albumArtist`. Do not "fix"
229 this.
230- **Recursive "play genre" is sequential, not parallel** — `MediaCollector`'s comment
231 explains why: a single-threaded MinimServer on an older NAS will interleave output
232 and corrupt track order within an album. Preserve this.
233- **Track title-search must include a class filter** — bare property searches are
234 rejected by MinimServer as malformed. `searchByTitle` already encodes this.
235
236---
237
238## 7. When in doubt
239
240Move code **up** into `:shared/commonMain` first. If the compiler complains about a
241JVM-only symbol, drop it down to `:shared/jvmCommonMain`. If it complains about an
242Android-only symbol, it belongs in `androidMain` or the `:android` app module.