A native iOS + watchOS music player for [Navidrome](https://www.navidrome.org/) servers, built with SwiftUI and AVFoundation. Features a Mitsuha-style audio visualizer, Apple Watch companion app, radio streaming with Shazam identification, and an optional Companion API for advanced server-side features.
- **Offline playback** — download songs for offline listening with per-track and per-album downloads; all song rows grey out and disable when network or server is unavailable
- **Mitsuha visualizer** — real-time FFT waveform visualization with Catmull-Rom spline rendering, multiple styles (wave, bar, line), color modes (dynamic, album art, Siri, custom), and configurable presets; compact wave morphs between mini player and Dynamic Island using `matchedGeometryEffect`
- **Dynamic Island** — pill morphs from mini player with spring animation; album art and visualizer fly between contexts using matched geometry
- **Radio streaming** — live radio with HLS/PLS/M3U playlist resolution, timeshift buffering, recording, and Shazam identification; stations grey out and show offline banner when server is unreachable
- **Smart DJ** — crossfade engine with silence skipping, loudness normalization (LUFS), predictive transitions, and full queue reactivity (requires Companion API)
- **Playlist reorder** — drag-and-drop song reorder within playlist detail, synced to server via `updatePlaylist`
- **Dynamic status bar** — status bar icon colour (light/dark) adapts to album art brightness via UIKit `preferredStatusBarStyle` override; decoupled from SwiftUI `preferredColorScheme` so the rest of the UI always stays dark
- **Download progress** — circular arc + thin progress bar on song cover art during downloads across all song-list views (Albums, Songs, Playlists, Search)
All song-list views use `LibraryCache.shared.isServerAvailable` to decide whether to grey out and disable songs. This property combines two signals:
1.**NWPathMonitor** — detects network loss (`isOffline`)
1.**ServerManager.connectionState** — detects server unreachability even when the network is up
`LibraryCache` subscribes to `ServerManager.$connectionState` via Combine and fires `objectWillChange`, so all observing views update reactively when the server connects or drops. A `hasEverConnected` guard prevents songs flashing grey on first launch while the initial ping is in-flight.
### Status Bar Dynamic Coloring
`preferredStatusBarStyle` is decoupled from SwiftUI’s `preferredColorScheme`. Architecture:
-`NavidromeSceneDelegate` (in `iOS/App/NavidromeSceneDelegate.swift`) creates the window manually with `NavidromeHostingController` as root
-`NavidromeHostingController: UIHostingController<AnyView>` overrides `preferredStatusBarStyle` to read from `AlbumColorExtractor.shared.preferredStatusBarStyle`
-`AlbumColorExtractor` derives UIKit style from album art HSB brightness (threshold: 0.88) and publishes it — the scene delegate subscribes and calls `setNeedsStatusBarAppearanceUpdate()` on change
-`NowPlayingView` keeps `.preferredColorScheme(.dark)` so all SwiftUI views stay dark; only the status bar icons flip
### Smart DJ / Crossfade Queue Reactivity
`SmartCrossfadeManager` (dual-AVPlayer A/B engine) stays in sync with the queue through:
- All queue mutations (`playNext`, `playLater`, `moveQueueItem`, `removeFromQueue`) call `notifyQueueChanged()`
- If not crossfading: immediately calls `prepareNextForCrossfade()` to load the new `queue[queueIndex+1]` into standby
- If mid-crossfade: sets `queueChangedDuringFade = true` to avoid interrupting audio; `finalizeCrossfade()` calls `needsNextTrack()` → `prepareNextForCrossfade()` which reads current queue state
-`needsNextTrack` syncs `queueIndex` by searching for `pendingNextSong.id` in the current queue (handles reorder and removal); falls back to sequential advancement if the song was removed
- Scrobble fires in `needsNextTrack` (not `playerDidFinish`) to avoid double-counting on the crossfade path
- Safety-net `AVPlayerItemDidPlayToEndTime` observer is re-registered on every crossfade slot swap
### Companion Push → Library Sync
`SyncEngine` subscribes to `.companionMetadataUpdated` and `.companionTrackUploaded` notifications (posted by `CompanionPushClient`) via Combine. Events are debounced 2 seconds and trigger a delta sync, so uploads and tag edits appear in the library immediately rather than waiting for the 15-minute background refresh.
### WaveStateCache (Visualizer Morph Continuity)
`WaveStateCache.shared` stores the most recent compact visualizer `displayLevels` array. When the wave flies between `MiniPlayerBar` and `DynamicIslandView` via `matchedGeometryEffect`, the incoming view seeds from the cache so the wave shape is continuous rather than resetting to flat idle.
The Companion API is optional. Without it, the app works as a standard Navidrome player. With it, you get Smart DJ, tag editing, uploads, and server-side visualizer pre-computation.
### Server Directory Structure
```
/home/pi/docker/navidrome/
├── docker-compose.yml # Both Navidrome + Companion
> **Volume mount note:** The companion mounts `/home/pi/navidrome/music` directly (not the parent `/home/pi/navidrome`). This ensures `MUSIC_DIR=/music` maps to your actual music files without path prefix issues.
The compact visualizer (mini player, Dynamic Island) uses `WaveStateCache` to preserve wave shape across the `matchedGeometryEffect` flight animation, and `matchedGeometryEffect(id: "visWave")` to physically squish/morph the wave between positions rather than cutting. Settings include per-view configuration for Now Playing and Mini Player independently. The mini player visualizer pauses when the full-screen Now Playing view is open (battery optimisation).
> **Do not refactor `WavePhysics` or the `Canvas` rendering.** It is heavily optimised with Catmull-Rom splines, touch ripples, temporal smoothing via viscosity, and a phase-shifted depth shadow layer for liquid parallax. Breaking any part of this will degrade visual quality significantly.
### Smart DJ / Crossfade
- **Silence detection** — `silence_start` is where *trailing* silence begins (crossfade trigger); `silence_end` is where *leading* silence ends (seek-to point). These fields are intentionally backwards from what you’d expect.
- **LUFS normalization** — volume scaled as `pow(10, gainDB/20)` clamped to 0.05–1.0
- **Dual-AVPlayer A/B** — standby player pre-loaded and seeked past leading silence while active player fades out
- **Queue reactivity** — see *Smart DJ / Crossfade Queue Reactivity* above
- **Scrobble** — fires in `needsNextTrack` on the crossfade path; fires in `playerDidFinish` on the normal AVPlayer path (guarded against double-fire)
- **Visualizer handoff** — at 50% crossfade midpoint, `visualizerHandoff` callback triggers loading the incoming song’s offline vis frames so they’re ready by the time the fade completes
- **Offline** — stations dim and show an orange banner when server is unreachable; taps are blocked
### Playlist Reorder
Playlist detail view has a **Reorder** toolbar button that toggles a `List` with `.onMove` + `.environment(\.editMode, .constant(.active))`. Drag to reorder locally (optimistic update), synced to server via `SubsonicClient.reorderPlaylist()` which removes all indices then re-adds in new order atomically. Reverts to server state on error.
- **Select Albums** — enter multi-select mode (nav bar transforms: Cancel / count / Edit button), tap albums to select, “Select All” toggle, then apply changes across all selected albums
The “Set same artist on all tracks” toggle fixes compilation albums that split into separate per-artist entries in Navidrome.
### Zip Import
`ZipImportManager` uses [ZIPFoundation](https://github.com/weichsel/ZIPFoundation) (SPM, iOS target only) to extract archives. The previous `NSFileCoordinator(.forUploading)` approach was incorrect — it re-compressed the source URL rather than extracting it. After extraction, audio files are staged and uploaded via background `URLSession` with per-file progress tracking.
Download progress shows as a circular arc overlay on cover art plus a thin capsule progress bar under the song title — consistent across Albums, Songs, Playlists, and Search views.
- **LibraryCache** — albums, artists, playlists, genres, album/artist details as JSON; also owns `isServerAvailable` combining NWPathMonitor + ServerManager state
- **ImageCache** — two-tier NSCache + disk JPEG with 200 MB limit and LRU eviction
1.**DO NOT** refactor `WavePhysics` or `Canvas` rendering in `MitsuhaVisualizerView`
1.**DO NOT** make `AudioPlayer.currentTime` / `duration``@Published` — causes 10 Hz full-hierarchy rerenders; leaf views poll via local `Timer.publish`
1.**DO NOT** use `Menu` in `NowPlayingView` — causes `_UIReparentingView` errors; use `Button + .sheet`
1.**DO NOT** use `.contextMenu` on watchOS — deprecated; use `.swipeActions`
1.**DO NOT** use `UIScreen.main` — deprecated iOS 26; use `GeometryReader`
1.**DO NOT** use `sheet(isPresented:)` with `if let` — causes timing bugs; use `sheet(item:)`
1.**DO NOT** use `.preferredColorScheme` to control status bar style — it cascades to the entire view hierarchy and flips all system colors; use the `NavidromeHostingController` UIKit bridge instead
1.`silence_start` from the Companion API is **trailing** silence; `silence_end` is **leading** silence — this is backwards from what you’d expect
1.`song.path` from Navidrome is a virtual ID3-derived path — never treat it as a real filesystem path
1.`album_artist` is the field Navidrome groups albums by — not `artist`