# Design notes — Android → KMP port
These 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.
The 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.
---
This project restructures Earhorn Music Player from a single Android app module into a Kotlin
Multiplatform project so the Linux desktop build can share everything worth
sharing with Android: the DidlItem domain, the UPnP / MinimServer logic, the
Renderer interface, theme tokens, and the ViewModels.
The Android TV / mobile product keeps its current features. The desktop
product is a **control point + local renderer** — it drives other UPnP
renderers on the LAN *and* plays audio through the Ubuntu machine's own
speakers via libvlc (VLCJ).
---
## 0. Prerequisites
```bash
sudo apt install vlc openjdk-17-jdk gradle
```
VLCJ is a thin binding over the system libvlc — the `vlc` package supplies
both the `.so` and the codec plugin set needed for FLAC / ALAC / DSD / Opus.
---
## 1. Project layout
```
earhorn/
├── settings.gradle.kts ← declares :shared, :desktop (+ :android later)
├── build.gradle.kts ← plugin aliases only
├── gradle.properties
├── gradle/
│ ├── libs.versions.toml ← version catalog: Kotlin, Compose MP, jUPnP, VLCJ, Koin…
│ └── wrapper/
│ ├── gradle-wrapper.properties
│ └── gradle-wrapper.jar ← generate via `gradle wrapper` (see §2)
│
├── shared/ ← KMP library: android + desktop (jvm) targets
│ ├── build.gradle.kts
│ └── src/
│ ├── commonMain/kotlin/dev/earhorn/
│ │ ├── domain/model/ ← DidlItem (sealed), MediaServer, MetadataField
│ │ ├── domain/renderer/ ← Renderer interface, PlaybackState
│ │ ├── ui/theme/ ← Color, Type (Typography), StaveTheme (compose.material3)
│ │ ├── ui/browse/ ← BrowseViewModel
│ │ ├── ui/nowplaying/ ← NowPlayingViewModel
│ │ └── ui/servers/ ← ServersViewModel
│ ├── jvmCommonMain/kotlin/dev/earhorn/
│ │ ├── upnp/ ← UpnpServiceHolder (iface), BrowseResult, DidlParser,
│ │ │ ContentDirectoryClient, MediaCollector
│ │ └── di/SharedModule.kt ← Koin module binding UPnP + VMs
│ ├── androidMain/ ← (AndroidManifest.xml for now; you move the Android
│ │ UpnpServiceHolder and ExoPlayer renderer here)
│ └── desktopMain/kotlin/dev/earhorn/
│ ├── upnp/DesktopUpnpServiceHolder.kt ← plain jUPnP UpnpServiceImpl
│ └── playback/VlcjLocalRenderer.kt ← libvlc, implements Renderer fully
│
└── desktop/ ← Compose-for-Desktop app
├── build.gradle.kts
└── src/jvmMain/
├── kotlin/dev/earhorn/desktop/
│ ├── Main.kt ← application() entry, KoinApplication, window
│ ├── DesktopModule.kt ← binds UpnpServiceHolder→Desktop*, Renderer→Vlcj*
│ ├── window/StaveMenuBar.kt
│ ├── shortcuts/Keybindings.kt
│ ├── screens/
│ │ ├── BrowseScreen.kt ← consumes BrowseViewModel
│ │ ├── NowPlayingPane.kt ← consumes NowPlayingViewModel
│ │ └── Sidebar.kt ← consumes ServersViewModel
│ └── ui/StaveApp.kt ← three-pane shell + breadcrumb nav
└── resources/ ← drop an icon.png here for jpackage
```
---
## 2. Generate the Gradle wrapper
From the repo root, once:
```bash
gradle wrapper --gradle-version 8.10.2
```
Thereafter `./gradlew` works without the system Gradle.
---
## 3. What actually carries over from the upstream Android project
The upstream Earhorn Music Player Android project uses **Hilt (Dagger)** for DI and
**jUPnP 3.x** as the UPnP stack (not Cling). For KMP the DI switches to
**Koin** — Hilt is Android-only — so each ported file that had
`@HiltViewModel` / `@Inject constructor()` / `@Singleton` is now a plain
class wired by the Koin modules in `di/SharedModule.kt` and
`desktop/DesktopModule.kt`.
### Files copied near-verbatim (annotations stripped)
| Upstream Android path | New path |
|-------------------------------------------------------|----------------------------------------------------------------------|
| `domain/model/DidlItem.kt` | `shared/commonMain/.../domain/model/DidlItem.kt` |
| `domain/model/MediaServer.kt` | `shared/commonMain/.../domain/model/MediaServer.kt` |
| `domain/model/MetadataField.kt` | `shared/commonMain/.../domain/model/MetadataField.kt` |
| `domain/renderer/Renderer.kt` | `shared/commonMain/.../domain/renderer/Renderer.kt` |
| `upnp/BrowseResult.kt` | `shared/jvmCommonMain/.../upnp/BrowseResult.kt` |
| `upnp/DidlParser.kt` | `shared/jvmCommonMain/.../upnp/DidlParser.kt` |
| `upnp/ContentDirectoryClient.kt` | `shared/jvmCommonMain/.../upnp/ContentDirectoryClient.kt` |
| `upnp/MediaCollector.kt` | `shared/jvmCommonMain/.../upnp/MediaCollector.kt` |
| `ui/theme/Color.kt` | `shared/commonMain/.../ui/theme/Color.kt` |
| `ui/theme/Type.kt` | `shared/commonMain/.../ui/theme/Type.kt` |
| `ui/browse/BrowseViewModel.kt` | `shared/commonMain/.../ui/browse/BrowseViewModel.kt` |
| `ui/nowplaying/NowPlayingViewModel.kt` | `shared/commonMain/.../ui/nowplaying/NowPlayingViewModel.kt` |
### Files rewritten (platform-incompatible originals)
- **`UpnpServiceHolder`** — split into an interface (jvmCommonMain) + two implementations.
- Desktop: `DesktopUpnpServiceHolder` around a plain `org.jupnp.UpnpServiceImpl`, no
Android Service, no `WifiManager.MulticastLock`. Preserves the critical `svc.startup()`
call that the Android holder's in-code comment flags as the jUPnP-3.x gotcha.
- Android: port the existing `UpnpServiceHolder` into `shared/androidMain/.../upnp/` as
`AndroidUpnpServiceHolder`, adjusting it to implement the common interface.
- **`LocalRenderer`** — the Android `LocalRenderer` is Media3-based and stays on Android.
Desktop's `VlcjLocalRenderer` is a from-scratch implementation of the same `Renderer`
interface, backed by VLCJ's `MediaListPlayer` for gapless. Matches the Android
semantics exactly: `isStopped` vs paused-mid-track, "enqueue into empty queue does not
auto-play", "playNext into empty queue auto-plays", insert-after-current indexing,
stop() pauses+rewinds (doesn't destroy prepared state).
- **`ui/theme/Theme.kt`** — Android used `androidx.tv.material3.MaterialTheme`; the new
shared wrapper uses `androidx.compose.material3.MaterialTheme` which works on Desktop
and Android (non-TV). The Android TV flavor will need its own `StaveTheme` wrapper that
reuses `StaveTypography` / the colors but wraps them in `androidx.tv.material3`.
- **`di/RendererModule.kt`** (Hilt) — replaced by the two Koin modules.
### Files that stay in `:android` unchanged
Screens still Android-only (they consume the shared VMs):
- `ui/browse/BrowseScreen.kt`, `ui/nowplaying/NowPlayingScreen.kt` — both import
`androidx.tv.material3.*`, which is Android-only. Desktop has its own parallel screens
in `desktop/.../screens/`.
- Everything under `ui/nav/`, `ui/search/`, `ui/servers/` (Android), `ui/settings/`,
`ui/queue/`, `ui/detail/`, `ui/more/`, `ui/about/`, `ui/guide/`, `ui/random/`.
- `ui/components/Marquee.kt`, `ui/components/ActionSheet.kt`, `ui/components/StaveFocusable.kt`,
`ui/components/TvLongPress.kt`.
- `MainActivity.kt`, `StaveApp.kt` (the Application subclass), `PlaybackService.kt`,
Coil image loader setup, Android permissions.
- `data/SettingsStore.kt` (uses Android DataStore).
- The `play` / `libre` flavor dirs (Chromecast is Android-only).
---
## 4. What's runnable right now (given a populated Gradle wrapper)
```bash
./gradlew :desktop:run # launches the app
./gradlew :desktop:packageDistributionForCurrentOS # builds .deb + AppImage
```
On first launch you should see: an empty sidebar, a "Select a server in the sidebar"
message in the center pane, and an empty Now Playing pane on the right. Once your
MinimServer advertises itself via SSDP, it appears in the sidebar with the amber icon
(the `isMinimServer` detection). Click it → root containers render in the center pane.
Double-click a track → plays on the Ubuntu box's speakers.
Keybindings: `Space` play/pause, media keys → transport, `/` to focus search (search
itself not yet wired).
---
## 5. Still TODO
Functional gaps (the app runs without these but they're obvious rough edges):
- **Breadcrumb navigation** — `StaveApp.kt` has a stack but the non-terminal crumbs
don't yet have a real clickable Modifier chain. A small fix once you settle on the
nav-click UX.
- **Titles of navigated-into containers** — `onNavigateInto(childId)` pushes the
raw container ID as the crumb title. `BrowseScreen`'s ContainerRow knows the title;
it should pass `(id, title)` up.
- **Search screen** — the keybinding is wired but no search UI exists on desktop yet.
`ContentDirectoryClient.searchByTitle` is already ported, so it's a screen + VM away.
- **Queue screen** — the Android app has `QueueScreen` + long-press reorder. Desktop
should get an equivalent; `VlcjLocalRenderer.moveInQueue` already supports it.
- **Action menu on track right-click** — in the Android app this is long-press (a TV
idiom); on desktop, right-click → context menu (Play next / Enqueue / View album) is
the natural mapping.
- **Detail / More screens** — "View album" from context menu + the Android `DetailScreen`
port. Reuses `ContentDirectoryClient.getContainerParent` and the existing VMs.
- **`icon.png`** — drop a 512×512 in `desktop/src/jvmMain/resources/` for jpackage.
Architectural TODOs (one-time, do as you need them):
- **Migrate the Android app module into `:android`** so it depends on `:shared` for all
the hoisted code. Move `AndroidUpnpServiceHolder` under `shared/src/androidMain/` so
it satisfies the common interface. Keep Media3 deps in `:android`, not `:shared`.
- **Android TV theme wrapper** — a second `StaveTheme` in the TV flavor that uses
`androidx.tv.material3` but reads the same `Color.kt` tokens + a small Typography
rewrapper. Trivial once touched.
- **Koin on Android** — replace the Hilt bootstrap in `StaveApp.onCreate` with
`startKoin { modules(sharedModule, androidModule) }`, where `androidModule` binds
`UpnpServiceHolder` → `AndroidUpnpServiceHolder` and `Renderer` → the Android
`LocalRenderer`. Existing Android `@HiltViewModel` annotations disappear.
---
## 6. Gotchas learned from the upstream Android code
Worth preserving verbatim because they will bite on desktop if forgotten:
- **jUPnP 3.x does not auto-start.** The Android `UpnpServiceHolder.attach()` explicitly
calls `service.get().startup()` — without this, the `ControlPoint` exists but
`.search()` is a no-op and no SSDP NOTIFYs arrive. `DesktopUpnpServiceHolder.start()`
preserves the same call on the plain `UpnpServiceImpl`.
- **SSDP multicast on dual-stack Linux boxes**: if discovery is silent on a machine with
an active IPv6 interface, set `-Dorg.jupnp.network.useIPv6=false` or configure a
`NetworkAddressFactory` that filters out IPv6.
- **`upnp:artist` vs `upnp:albumArtist`** — the existing `ContentDirectoryClient` uses
`upnp:artist` because MinimServer silently ignores `upnp:albumArtist`. Do not "fix"
this.
- **Recursive "play genre" is sequential, not parallel** — `MediaCollector`'s comment
explains why: a single-threaded MinimServer on an older NAS will interleave output
and corrupt track order within an album. Preserve this.
- **Track title-search must include a class filter** — bare property searches are
rejected by MinimServer as malformed. `searchByTitle` already encodes this.
---
## 7. When in doubt
Move code **up** into `:shared/commonMain` first. If the compiler complains about a
JVM-only symbol, drop it down to `:shared/jvmCommonMain`. If it complains about an
Android-only symbol, it belongs in `androidMain` or the `:android` app module.