Widget v2: - Glassmorphism glass panel, 40-bar waveform Canvas with SeekToIntent - CIAreaAverage color extraction + secondary color for adaptive theming - Small: inset glass. Medium/Large: flush edge-to-edge (contentMarginsDisabled) - Large: 3-item queue with real cover art thumbnails, crossfade countdown - Waveform sampled from offline vis buffer, seeded PRNG fallback Live Lyrics: - LyricsService: direct LRCLIB from iOS, no companion dependency - Embedded lyrics read via AVAsset.load(.metadata) from downloads - Karaoke word-by-word gradient fill, auto-scroll, fade edges - Tap-to-sync timing editor with +/-0.1s offset adjust - Companion API fallback only for server-side embedded tags Backup System: - .nvdbackup export/import via ZIPFoundation - UTI registered for AirDrop, passwords stripped on export - PendingOperationsQueue with retry + disk persistence Crossfade fixes: - Seek bar: reports from incoming player immediately, not at 50% - songHandoff at midpoint: art/colors/text/lyrics transition mid-fade - Scrobble fires before metadata swap (correct outgoing song) Visualizer fixes: - stopAll() zeros _audioLevels + clears offlineVisBuffer - All 5 simulation start sites gated behind realAudioAnalysis check - Bars stay flat between skips, only rise when real vis data loads Smart DJ bulk prefetch, appendingPathComponent slash fix |
||
|---|---|---|
| companion-api | ||
| iOS | ||
| Shared | ||
| watchOS | ||
| Widget | ||
| .gitignore | ||
| AppIcon.png | ||
| generate.sh | ||
| project.yml | ||
| README.md | ||
| update.sh | ||
NavidromePlayer
Your music library. Your hardware. No subscriptions. No ads. No cloud.
A native iOS + watchOS music player handcrafted in Swift for Navidrome servers. Built because every streaming app charges rent for music you already own — and none of them have a waveform that looks like this.
The Visualizer
This is the part worth talking about first.
NavidromePlayer renders a Mitsuha-style liquid waveform — named after the classic anime visualization that made smooth, flowing audio waves famous. Every frame is drawn with Catmull-Rom splines, a curve interpolation algorithm that eliminates the jagged polygon edges of typical FFT displays and replaces them with fluid, continuous shapes that genuinely move with the music.
Three-source pipeline
The visualizer automatically selects the best available data for the current track:
1. Server-precomputed frames (best — requires Companion API)
The Companion API runs a full 1024-point FFT pass over your audio files ahead of time, storing the results as compact frame arrays. When you play a song, the app downloads these frames once and caches them locally. Playback syncs the frame index to currentTime / duration — frame-perfect visualization with zero CPU cost during playback. A 4-minute song generates ~7,200 frames.
2. On-device offline analysis (great — for downloaded songs)
For locally stored files, OfflineAudioAnalyzer runs a custom Swift/Accelerate FFT pipeline using vDSP_fft with a Hann window, a hop size calculated from your target FPS, and a ring buffer that processes the file faster than real-time. The result is identical frame-perfect sync without requiring the Companion API. Analysis runs in a background Task and cancels immediately if you exit the app — it never interferes with battery life.
3. Live engine FFT (good — for streaming)
When no cached data exists, an MTAudioProcessingTap is installed directly on the AVPlayerItem.audioMix, tapping the audio stream at the hardware level. FFT results feed the visualizer at 30fps with no perceptible latency.
The rendering chain
Each frame goes through several stages before anything appears on screen:
- Log-frequency binning — raw FFT bins are mapped logarithmically so bass frequencies get appropriate visual weight relative to treble. Linear FFT looks wrong; your ears are logarithmic.
- Dynamic gain normalization — a peak follower tracks the loudest frame seen so far and scales the entire output accordingly. Quiet passages still look interesting. Loud passages don't clip.
- Exponential smoothing —
displayLevel += (target - displayLevel) * smoothFactorapplied per-bar every frame. Theviscositysetting controls how snappy or liquid the response feels. - Depth shadow — a second wave drawn behind the main wave at reduced opacity, phase-shifted using a 60/40 blend of current and next Y values to create a parallax drag effect. This gives the wave a sense of depth without any GPU compositing.
- Wobble phase — an independent time-based phase offset drives a slow undulation in the wave baseline, keeping the idle state alive and giving the playing state a subtle organic quality.
- Catmull-Rom splines — final bar heights are connected with Catmull-Rom interpolation rather than straight lines. The curve passes smoothly through every data point.
Four styles
| Style | Description |
|---|---|
| Wave | Classic Mitsuha liquid waveform with depth shadow and Catmull-Rom curves |
| Siri Wave | Symmetric wave mirrored above and below center |
| Bar | Vertical frequency bars with rounded tops |
| Line | Minimal single-line trace through the frequency data |
Color modes
- Dynamic — colors shift continuously through a hue cycle at a configurable speed
- Album art — dominant color extracted from the current track's cover art using a custom pixel sampler, changes on every track
- Custom — a fixed color you pick, applied consistently
Per-view configuration
Now Playing and Mini Player are configured independently. Each has its own point count (8–64), sensitivity, color mode, and style. The Mini Player visualizer suspends automatically when the full Now Playing view is open.
The architecture rule that makes it work
The entire visualizer lives inside a single TimelineView(.periodic) that never unmounts while enabled. Rendering mode (live vs. idle) is decided inside the Canvas body, not by swapping views. This eliminates a SwiftUI compositor bug where the old idle Canvas persists in the render pipeline while the new live view composites its first frame — manifesting as a frozen wave after pause/resume. One stable view identity. No stale frames.
Features
iOS
Library
- Browse albums, artists, songs, genres, playlists — all cache-first for instant loads on warm launch
- Alphabetical all-songs list loaded via
search3API pagination (no N+1 album fetches) - Long-press any song for a context menu: Play Now, Play Next, Add to Queue, Download, Send to Watch, Add to Playlist
- Album and artist detail views with full discography
Playback
- AVPlayer for streaming, AVAudioEngine for local FFT-enabled playback
- Smart DJ crossfade — dual AVPlayer A/B engine with configurable crossfade duration
- Skip silence — jumps leading and trailing silence using pre-analyzed boundaries
- LUFS loudness normalization — every song plays at the same perceived volume
- AirPlay output selection
- Background audio with Lock Screen / Control Center / CarPlay controls
- Dynamic Island now playing indicator
Now Playing
- Full-screen player with album art color extraction
- Drag-to-dismiss gesture
- Scrubbable seek bar with SiriKit-style scrubbing speed reduction on lateral movement
- Queue view and management
- Repeat and shuffle modes
- Shazam identification — identify the currently playing track or live radio
Mini Player
- Persistent bottom bar with scrubbable progress, visualizer overlay, transport controls
- Tap to expand, swipe up to open Now Playing
Offline
- Per-track and per-album downloads with inline progress indicators
- Download All on songs list
AudioPreFetchersilently pre-caches the next 3 tracks in the foreground for instant gapless starts
Radio
- Live streaming with HLS/PLS/M3U/ASX playlist resolution
- Timeshift buffer — scrub back through recent live audio
- Recording — capture radio segments to local files
- Shazam identification for live streams
- Recorded playback mode with ±5s skip buttons
Metadata editing (requires Companion API)
- Single track editor with title, artist, album, album artist, track number, disc number, year
- Per-album batch editor — edit all tracks in an album simultaneously
- Multi-album batch editor — select multiple albums and apply changes across all of them
- "Set same artist on all tracks" — fixes compilation albums split into per-artist entries in Navidrome
- Custom album art — long-press any album art to replace it from your photo library
Uploads (requires Companion API)
- Zip import from Files app with background URLSession uploading
- Auto-tag on upload, Navidrome library rescan on completion
watchOS
- Transfer songs from iPhone for standalone offline playback
- Bluetooth headphone output
- Apple Watch Ultra speaker mode via HKWorkoutSession
- Digital Crown volume control with haptic feedback
- Compact Mitsuha visualizer on the now playing screen
- Tag edits sync automatically from iPhone via WatchConnectivity
Debug Console
A built-in developer console toggleable from Settings:
- Docked panel — appears above the tab bar with drag-to-resize (120–500pt)
- Group filter toggles — iPhone / Watch / Companion / Audio, directly in the panel header, persisted to UserDefaults
- PiP mode — floating draggable + resizable window, collapsible
- Full console sheet — expand button opens level filters (ERROR / WARN / INFO / DEBUG), category pills, text search, delta timestamps, pause mode, and auto background/foreground separator lines
- Export filtered logs via the share sheet
Architecture
NavidromePlayer/
├── Shared/
│ ├── API/SubsonicClient.swift # Full Subsonic/Navidrome REST client
│ ├── Audio/AudioPlayer.swift # AVPlayer + AVAudioEngine, FFT, vis pipeline
│ ├── Models/Models.swift # Codable models for all API responses
│ └── Storage/
│ ├── LibraryCache.swift # JSON 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 + tag sync
│
├── iOS/
│ ├── App/
│ │ ├── NavidromePlayerApp.swift # Entry point, audio session, background upload
│ │ └── NavidromeSceneDelegate.swift # UIWindow owner
│ ├── Data/
│ │ ├── AudioPreFetcher.swift # Pre-caches next 3 tracks (foreground only)
│ │ ├── OptimisticActionQueue.swift # Offline action queue (stars, ratings)
│ │ └── SyncEngine.swift # Library sync engine
│ └── Views/
│ ├── Common/
│ │ ├── MainTabView.swift # Root + mini player + docked debug panel
│ │ ├── AsyncCoverArt.swift # Two-tier NSCache/disk image cache
│ │ └── DebugConsoleView.swift # Developer console with PiP
│ ├── Companion/
│ │ ├── CompanionAPIService.swift # REST + WebSocket client
│ │ ├── SmartCrossfadeManager.swift # Dual AVPlayer A/B crossfade engine
│ │ ├── TrackEditorView.swift
│ │ ├── BatchAlbumEditorSheet.swift
│ │ ├── MultiAlbumEditorSheet.swift
│ │ ├── BatchUploadView.swift
│ │ └── ZipImportManager.swift
│ ├── Library/ # Albums, Artists, Songs, Playlists, Radio, Downloads
│ ├── NowPlaying/
│ │ ├── NowPlayingView.swift
│ │ ├── SiriSeekBar.swift
│ │ └── RadioStreamBuffer.swift
│ └── Visualizer/
│ ├── MitsuhaVisualizerView.swift # All rendering — wave, bar, line, siriwave
│ ├── OfflineAudioAnalyzer.swift # Device-side FFT analysis pipeline
│ └── VisualizerStorageManager.swift # VisFrameBuffer flat storage + memory-mapped I/O
│
├── watchOS/
│ ├── App/ # Watch entry, offline store, WCSession manager
│ ├── Audio/WatchAudioPlayer.swift # Bluetooth + Ultra speaker dual mode
│ └── Views/ # Library, NowPlaying, Setup
│
├── project.yml # XcodeGen definition
└── generate.sh # Regenerate .xcodeproj
Key design decisions
VisualizerLevelBox is a fileprivate final class ObservableObject with zero @Published properties, held by @StateObject. All mutations happen inside the SwiftUI Canvas closure — never through Combine. The visualizer updates at 60fps without triggering SwiftUI view diffing anywhere in the tree.
VisFrameBuffer stores pre-analyzed FFT frames as a flat ContiguousArray<Float> with memory-mapped I/O (alwaysMapped). copyFrame(at:into:) does a single contiguous copy into a pre-allocated destination — zero allocations per frame in the hot path.
Background CPU — four sources of background CPU were identified and eliminated: vis timers (30/60fps), the periodic time observer (10Hz SwiftUI re-evaluation), the offline FFT analysis Task (ran to completion with no cancellation), and SmartCrossfadeManager's independent 10Hz time observer. All four suspend on didEnterBackground and resume conditionally on willEnterForeground.
Requirements
- iOS 26.0+ / watchOS 26.0+
- Xcode 26+
- XcodeGen —
brew install xcodegen - Navidrome server — any version with Subsonic API support
Building
git clone <repo>
cd NavidromePlayer
./generate.sh
# Xcode opens. Select your device. Build.
Set your Apple Developer Team ID in project.yml under DEVELOPMENT_TEAM. Change the bundle IDs if needed (ca.dallasgroot.navidromeplayer.app).
Companion API
Completely optional. The app is fully functional without it. With it, you get Smart DJ crossfade, silence skipping, loudness normalization, tag editing, batch uploads, and server-precomputed visualizer frames.
Runs as a Docker container alongside Navidrome — typically on the same Raspberry Pi or home server that already runs Navidrome.
Server layout
/home/pi/docker/navidrome/
├── docker-compose.yml
├── navidrome_data/ # Navidrome database
├── companion_data/
│ ├── smart_dj.db # BPM, silence boundaries, LUFS profiles
│ └── vis_cache/ # Pre-computed FFT frame files per song
└── companion_api/
├── Dockerfile
├── main.py
└── pre_analyze.py
/home/pi/navidrome/music/ # Your actual music files
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
Commands
cd /home/pi/docker/navidrome
docker compose up -d # Start everything
docker compose up -d --build music-companion # Rebuild after changes
docker compose logs -f music-companion # Tail logs
curl http://localhost:8000/health # Check status
# Pre-analyze your library — run after adding new music
docker compose exec music-companion python pre_analyze.py # All missing tracks
docker compose exec music-companion python pre_analyze.py --dj # DJ profiles only
docker compose exec music-companion python pre_analyze.py --vis # Vis frames only
docker compose exec music-companion python pre_analyze.py --force # Re-analyze everything
API endpoints
| Method | Endpoint | Description |
|---|---|---|
GET |
/health |
Status, profile count, vis cache size, connected clients |
PATCH |
/edit-metadata |
Edit ID3 tags for one file |
PATCH |
/batch-edit-metadata |
Edit tags across multiple files |
POST |
/bulk-fix |
Apply fixes + trigger Navidrome rescan |
POST |
/upload-track |
Upload audio file with metadata (multipart) |
GET |
/smart-dj/profile?relative_path= |
BPM, silence start/end, LUFS for one track |
GET |
/smart-dj/bulk-profiles?paths= |
Batch fetch profiles |
GET |
/visualizer/frames?relative_path= |
Pre-computed FFT frame array |
POST |
/visualizer/precompute |
Queue background vis frame generation |
WS |
/ws/push |
Real-time push events to the iOS app |
iOS setup
- Settings → Companion API
- Enter your server IP and port (default:
8000) - Toggle Enable → tap Test Connection
- Toggle Smart DJ for crossfade and analysis features
Caching
Every view loads from cache first. The server updates the display silently in the background.
| Cache | Storage | Contents |
|---|---|---|
LibraryCache |
JSON on disk | Albums, artists, playlists, genres, all-songs list, album/artist detail |
ImageCache |
NSCache + JPEG on disk (200MB LRU) | Album art at multiple sizes |
SmartDJCache |
In-memory | Smart DJ profiles per song path |
AlbumCoverStore |
Documents directory | User-set custom album covers |
VisualizerStorageManager |
Memory-mapped flat binary | Pre-analyzed FFT frames per song |
License
Personal project. Not affiliated with Navidrome.