Navidrome
Find a file
2026-04-09 23:11:40 -07:00
companion-api Added companion_api 2026-04-06 11:57:16 -07:00
iOS Imrpved Nowplaying/Visualizer 2026-04-09 23:11:40 -07:00
Shared Imrpved Nowplaying/Visualizer 2026-04-09 23:11:40 -07:00
watchOS fixed Testflight Submission 2026-04-08 15:59:11 -07:00
.gitignore NavidromePlayer: iOS + watchOS Navidrome/Subsonic music player 2026-03-28 20:49:47 +00:00
AppIcon.png quick fix 2026-03-28 18:24:48 -07:00
generate.sh NavidromePlayer: iOS + watchOS Navidrome/Subsonic music player 2026-03-28 20:49:47 +00:00
project.yml fixed Testflight Submission 2026-04-08 15:59:11 -07:00
README.md uodared readme 2026-04-04 23:19:58 -07:00
update.sh quick fix 2026-03-28 18:24:48 -07:00

NavidromePlayer

A native iOS + watchOS music player for Navidrome 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.

Features

iOS App

  • Full Navidrome/Subsonic library — browse by albums, artists, songs, genres, playlists
  • 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
  • Now Playing — full-screen player with album art color extraction, drag-to-dismiss, AirPlay, seek bar, queue management
  • Mini player — persistent bottom bar with scrubbable progress, visualizer overlay, transport controls
  • 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
  • Batch metadata editing — edit album/artist tags across multiple albums simultaneously (requires Companion API)
  • Zip import — import audio files via ZIPFoundation; extraction and batch upload with background URLSession
  • Custom album covers — long-press album art to replace with photos from your library
  • Multi-server support — automatic failover between servers with the same credentials
  • Background audio — Lock Screen / Control Center controls, Dynamic Island support
  • 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)

watchOS App

  • Offline playback — transfer songs from iPhone to Apple Watch for standalone listening
  • Bluetooth + Speaker mode — Apple Watch Ultra speaker support via HKWorkoutSession
  • Crown control — Digital Crown volume control with haptic feedback
  • Compact visualizer — waveform visualization on watch face

Companion API (Optional)

A Python FastAPI server that runs alongside Navidrome on your server (e.g., Raspberry Pi) providing:

  • Smart DJ analysis — BPM detection, silence boundary mapping, LUFS loudness measurement
  • Mitsuha visualizer pre-computation — generate FFT frames on the server instead of on-device
  • Remote ID3 tag editing — edit metadata on server files via mutagen
  • File uploads — upload and auto-tag new music via the app; ZIP archives extracted via ZIPFoundation
  • WebSocket push — real-time notifications to the iOS app; metadata updates and track uploads trigger an immediate library delta-sync in the app
  • Navidrome scan trigger — automatically rescan after tag edits

Architecture

NavidromePlayer/
├── Shared/                          # Code shared between iOS and watchOS
│   ├── API/SubsonicClient.swift     # Full Subsonic/Navidrome REST client
│   ├── Audio/AudioPlayer.swift      # AVPlayer + AVAudioEngine, FFT, visualizer levels
│   ├── Models/Models.swift          # Codable models for all API responses
│   └── Storage/
│       ├── LibraryCache.swift       # Disk cache + NWPathMonitor + isServerAvailable
│       ├── OfflineManager.swift     # Download manager with progress tracking
│       ├── ServerManager.swift      # Multi-server with auto-failover
│       └── WatchConnectivityManager.swift  # WCSession file transfers
│
├── iOS/
│   ├── App/
│   │   ├── NavidromePlayerApp.swift        # Entry point, BGTask registration
│   │   └── NavidromeSceneDelegate.swift    # UIWindowSceneDelegate, NavidromeHostingController
│   └── Views/
│       ├── Common/                  # MainTabView, MiniPlayer, DynamicIslandView, AsyncCoverArt, DebugConsole
│       ├── Companion/               # Companion API integration
│       │   ├── CompanionAPIService.swift    # API client + WebSocket push client
│       │   ├── CompanionSettingsView.swift  # Config, Smart DJ toggles, analysis triggers
│       │   ├── SmartCrossfadeManager.swift  # Dual AVPlayer A/B crossfade engine
│       │   ├── TrackEditorView.swift        # Single-track metadata editor
│       │   ├── BatchAlbumEditorSheet.swift  # Edit tags for one album
│       │   ├── MultiAlbumEditorSheet.swift  # Batch edit across multiple albums
│       │   ├── BatchUploadView.swift        # Zip import + batch upload
│       │   └── ZipImportManager.swift       # ZIPFoundation extraction + background URLSession uploads
│       ├── Library/                 # MyMusic, Albums, Artists, Playlists, Search, Radio, Downloads
│       ├── Login/                   # Server configuration
│       ├── NowPlaying/              # Full player, SiriSeekBar, RadioStreamBuffer, Shazam
│       └── Visualizer/              # MitsuhaVisualizerView, OfflineAudioAnalyzer, storage
│
├── watchOS/
│   ├── App/                        # Watch app entry, offline store, session manager
│   ├── Audio/WatchAudioPlayer.swift # Dual mode: Bluetooth + Ultra speaker
│   └── Views/                      # Library, NowPlaying, Setup
│
├── project.yml                     # XcodeGen project definition (includes ZIPFoundation SPM)
└── generate.sh                     # Regenerate .xcodeproj

Requirements

  • iOS 26.0+ / watchOS 26.0+
  • Xcode 26+
  • XcodeGenbrew install xcodegen
  • Navidrome server — any version with Subsonic API support

Building

./generate.sh
# Opens Xcode automatically. Select device and build.

Set your Apple Developer Team ID in project.yml under DEVELOPMENT_TEAM.

ZIPFoundation is declared as an SPM dependency in project.yml. Xcode resolves it automatically after running ./generate.sh.


Key Architecture Notes

Offline Availability (LibraryCache.isServerAvailable)

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)
  2. 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 SwiftUIs 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.


Companion API

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
├── navidrome_data/                  # Navidrome database (auto-created)
├── companion_data/                  # Persistent data (auto-created)
│   ├── smart_dj.db                  # BPM, silence, loudness profiles
│   └── vis_cache/                   # Pre-computed Mitsuha FFT frames
└── companion_api/                   # Companion API source code
    ├── Dockerfile
    ├── main.py
    └── pre_analyze.py

/home/pi/navidrome/music/            # Your music library
├── Artist/Album/song.flac
└── ...

docker-compose.yml

services:
  navidrome:
    image: deluan/navidrome:latest
    container_name: navidrome
    restart: unless-stopped
    ports:
      - "4533:4533"
    environment:
      - ND_SCANSCHEDULE=1h
      - ND_BASEURL=/navidrome
    volumes:
      - /home/pi/navidrome:/music:ro
      - ./navidrome_data:/data

  music-companion:
    build: ./companion_api
    container_name: music-companion
    restart: unless-stopped
    ports:
      - "8000:8000"
    volumes:
      - /home/pi/navidrome/music:/music:rw
      - ./companion_data:/app/data
    environment:
      - MUSIC_DIR=/music
      - DB_PATH=/app/data/smart_dj.db
      - VIS_CACHE_DIR=/app/data/vis_cache
      - NAVIDROME_URL=http://navidrome:4533/navidrome
      - SUBSONIC_USER=your_username
      - SUBSONIC_TOKEN=your_token
      - SUBSONIC_SALT=your_salt
    depends_on:
      - navidrome

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.

Commands

cd /home/pi/docker/navidrome

# ── Startup ──────────────────────────────────────────────
docker compose up -d                                        # Start everything
docker compose up -d --build music-companion                # Rebuild after code changes
docker compose logs -f music-companion                      # View logs

# ── Health Check ─────────────────────────────────────────
curl http://localhost:8000/health
# Returns: profiles count, vis cache count, connected clients

# ── Pre-Analysis (run after adding new music) ────────────
docker compose exec music-companion python pre_analyze.py           # Analyze missing (DJ + vis)
docker compose exec music-companion python pre_analyze.py --dj      # DJ profiles only (faster)
docker compose exec music-companion python pre_analyze.py --vis     # Visualizer frames only
docker compose exec music-companion python pre_analyze.py --force   # Re-analyze everything

# ── Reset Analysis Data ──────────────────────────────────
rm companion_data/smart_dj.db                               # Delete DJ profiles
rm -rf companion_data/vis_cache/*                           # Delete vis frame cache
docker compose exec music-companion python pre_analyze.py   # Regenerate from scratch

# ── Maintenance ──────────────────────────────────────────
docker compose restart music-companion                      # Restart API
docker compose down                                         # Stop everything
docker compose down -v                                      # Stop + remove volumes

API Endpoints

Method Endpoint Description
GET /health Server status, profile count, vis cache count, connected clients
PATCH /edit-metadata Edit ID3 tags (JSON body: relative_path, title, artist, album, etc.)
POST /upload-track Upload audio file with metadata (multipart: file, title, artist, album)
GET /smart-dj/profile?relative_path=... BPM, silence start/end, LUFS for a track
GET /smart-dj/bulk-profiles?paths=... Batch fetch profiles (comma-separated paths)
GET /visualizer/frames?relative_path=... Pre-computed Mitsuha FFT frames (JSON array)
POST /visualizer/precompute Trigger background vis frame generation for all tracks
POST /bulk-fix Trigger Navidrome library rescan
WS /ws/push Real-time push events to iOS app

Path Resolution

Navidromes song.path field can differ from the actual filesystem path. The Companion API uses a four-strategy resolver:

  1. Direct joinMUSIC_DIR + relative_path
  2. Strip prefix — removes leading components one at a time (handles Navidromes library folder prefix)
  3. Filename search — walks the music directory for an exact filename match
  4. Fuzzy DB match — searches file_index SQLite table with 50% word-match threshold

If a 404 still occurs, the error response includes the exact paths that were tried for debugging.

iOS App Configuration

  1. SettingsCompanion API
  2. Enter your servers IP address and port (default: 8000)
  3. Toggle Enable Companion API
  4. Tap Test Connection to verify
  5. Toggle Smart DJ for crossfade and analysis features

Feature Details

Mitsuha Visualizer

The visualizer renders smooth liquid waveforms using Catmull-Rom splines with configurable tension. FFT data comes from one of three sources:

  • Real-time engine FFT — AVAudioEngine tap on local files
  • Offline pre-analyzed frames — cached FFT data synced to playback position
  • Server-computed frames — downloaded from Companion API (saves device battery)

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 detectionsilence_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 youd expect.
  • LUFS normalization — volume scaled as pow(10, gainDB/20) clamped to 0.051.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 songs offline vis frames so theyre ready by the time the fade completes

Radio

  • Playlist resolution — resolves .pls, .m3u, .asx playlist URLs to direct stream URLs
  • HLS detection — identifies HLS streams from URL extension or content-type, disables raw buffering
  • Timeshift — buffer live radio and scrub back through recent audio
  • Recording — capture radio segments to local files
  • Shazam — identify currently playing content via SHSession + dedicated AVAudioEngine mic tap
  • Recorded playback — recorded radio files play with ±5s skip buttons instead of prev/next
  • 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.

Batch Tag Editing

Long-press any album to:

  • Edit Album — edit tags for all tracks in that album
  • 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 (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.

Downloads

Split into two sub-tabs:

  • Offline — storage usage, downloaded songs with playback, swipe to delete
  • Watch — Apple Watch connection status, pending transfers with progress, songs on watch, send all / delete all

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.

Debug Console

Toggle in Settings → Developer. Two display modes:

  • Docked — panel above the tab bar, drag handle to resize (120500pt)
  • PiP — floating draggable/resizable window with collapse, dock-back, and close buttons

All iOS-path log calls route through DebugLogger via alog() / wlog() helpers. watchOS falls back to print() since DebugLogger is iOS-only.

Caching

All views use cache-first loading to eliminate spinners on warm launches:

  • 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
  • SmartDJCache — Smart DJ profiles cached locally per song path
  • WaveStateCache — compact visualizer levels for morph continuity across DI transitions
  • AlbumCoverStore — user-set custom album covers in Documents
  • VisualizerStorageManager — pre-analyzed FFT frames per song

Detail views load from cache instantly, refresh from server silently, and show a Retry button if both cache and server are unavailable.


Critical Rules for Future Developers

  1. DO NOT refactor WavePhysics or Canvas rendering in MitsuhaVisualizerView
  2. DO NOT make AudioPlayer.currentTime / duration @Published — causes 10 Hz full-hierarchy rerenders; leaf views poll via local Timer.publish
  3. DO NOT use Menu in NowPlayingView — causes _UIReparentingView errors; use Button + .sheet
  4. DO NOT use .contextMenu on watchOS — deprecated; use .swipeActions
  5. DO NOT use UIScreen.main — deprecated iOS 26; use GeometryReader
  6. DO NOT use sheet(isPresented:) with if let — causes timing bugs; use sheet(item:)
  7. 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
  8. silence_start from the Companion API is trailing silence; silence_end is leading silence — this is backwards from what youd expect
  9. song.path from Navidrome is a virtual ID3-derived path — never treat it as a real filesystem path
  10. album_artist is the field Navidrome groups albums by — not artist

License

Personal project. Not affiliated with Navidrome.