Navidrome
Find a file
Dallas Groot 842cb6353b Widget v2 glassmorphism, lyrics, backup, crossfade fixes
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
2026-04-14 00:44:38 -07:00
companion-api Performance audit, Now Playing widget, crossfade stability, cover art embedding, DJ profile bulk cache 2026-04-12 19:24:22 -07:00
iOS Seek bar glitch — The time observer was reporting from the outgoing player (near 100%) for the first half, then jumping to the incoming player (near 0%) at 50%. Now it reports from the incoming player as soon as crossfade begins. The user hears the new song fading in — the seek bar matches. 2026-04-14 00:27:10 -07:00
Shared Widget v2 glassmorphism, lyrics, backup, crossfade fixes 2026-04-14 00:44:38 -07:00
watchOS quick fix 2026-04-11 16:31:24 -07:00
Widget Seek bar glitch — The time observer was reporting from the outgoing player (near 100%) for the first half, then jumping to the incoming player (near 0%) at 50%. Now it reports from the incoming player as soon as crossfade begins. The user hears the new song fading in — the seek bar matches. 2026-04-14 00:27:10 -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 Performance audit, Now Playing widget, crossfade stability, cover art embedding, DJ profile bulk cache 2026-04-12 19:24:22 -07:00
README.md updated readme 2026-04-10 20:05:45 -07:00
update.sh quick fix 2026-03-28 18:24:48 -07:00

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 smoothingdisplayLevel += (target - displayLevel) * smoothFactor applied per-bar every frame. The viscosity setting 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 (864), 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 search3 API 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
  • AudioPreFetcher silently 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 (120500pt)
  • 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+
  • XcodeGenbrew 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

  1. Settings → Companion API
  2. Enter your server IP and port (default: 8000)
  3. Toggle Enable → tap Test Connection
  4. 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.