Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine. |
||
|---|---|---|
| companion-api | ||
| iOS | ||
| Shared | ||
| watchOS | ||
| .gitignore | ||
| AppIcon.png | ||
| generate.sh | ||
| project.yml | ||
| README.md | ||
| update.sh | ||
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
- Mitsuha visualizer — real-time FFT waveform visualization with Catmull-Rom spline rendering, multiple styles (wave, bar, line), color modes (dynamic, album art, custom), and configurable presets
- 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
- Radio streaming — live radio with HLS/PLS/M3U playlist resolution, timeshift buffering, recording, and Shazam identification
- Smart DJ — crossfade engine with silence skipping, loudness normalization (LUFS), and predictive transitions (requires Companion API)
- Batch metadata editing — edit album/artist tags across multiple albums simultaneously (requires Companion API)
- 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
- Keyboard dismiss — tap anywhere outside text fields or scroll to dismiss
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
- WebSocket push — real-time notifications to the iOS app when metadata changes or uploads complete
- 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 for instant offline browsing
│ ├── 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, background upload delegate
│ └── Views/
│ ├── Common/ # MainTabView, MiniPlayer, 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 # 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, Visualizer
│
├── project.yml # XcodeGen project definition
└── generate.sh # Regenerate .xcodeproj
Requirements
- iOS 17.0+ / watchOS 10.0+
- Xcode 15+
- XcodeGen —
brew 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.
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/musicdirectly (not the parent/home/pi/navidrome). This ensuresMUSIC_DIR=/musicmaps 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
Navidrome's song.path field can differ from the actual filesystem path. The Companion API uses a three-strategy resolver:
- Direct join —
MUSIC_DIR + relative_path - Strip prefix — removes leading components one at a time (handles Navidrome's library folder prefix)
- Filename search — walks the music directory looking for an exact filename match
If a 404 still occurs, the error response includes the exact paths that were tried for debugging.
iOS App Configuration
- Settings → Companion API
- Enter your server's IP address and port (default: 8000)
- Toggle Enable Companion API
- Tap Test Connection to verify
- 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)
Settings include per-view configuration for Now Playing and Mini Player independently, with four built-in presets. The mini player visualizer automatically pauses when the full-screen Now Playing view is open (battery optimization).
Radio
- Playlist resolution — resolves
.pls,.m3u,.asxplaylist 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
- Live toggle — start/stop buffering, snap to live edge
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.
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
Debug Console
Toggle in Settings → Developer. Two display modes:
- Docked — panel above the tab bar, drag handle to resize (120–500pt)
- PiP — floating draggable/resizable window with collapse, dock-back, and close buttons
Caching
All views use cache-first loading to eliminate spinners on warm launches:
- LibraryCache — albums, artists, playlists, genres, album/artist details as JSON
- ImageCache — two-tier NSCache + disk JPEG with 200MB limit and LRU eviction
- SmartDJCache — Smart DJ profiles cached locally per song path
- 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.
License
Personal project. Not affiliated with Navidrome.