NavidromeApp/CLAUDE.md
Dallas Groot 99bf17ec1a Fix 3 crashes from crash logs: SmartDJCache race, AVPlayer observer, KVO threading
🔴 SmartDJCache concurrent Dictionary crash (April 27+28 crash logs):
- EXC_BAD_ACCESS + doesNotRecognizeSelector in bulkImport()
- memoryCache dictionary mutated from main thread (loadBulkCache)
  and background Task.detached (bulkImport) simultaneously
- Fix: NSLock serializes all memoryCache reads and writes

🔴 stopAVPlayer removeTimeObserver crash (April 30 crash log):
- SIGABRT in -[AVPlayer removeTimeObserver:] from Previous button
- timeObserver registered on old player, self.player swapped to
  crossfade's active player by finalizeCrossfade
- Fix: remove observer from OLD player at both swap sites before
  assignment + reorder stopAVPlayer (observer before replaceCurrentItem)

🟡 Audit fixes (no crash logs, preventative):
- KVO in radioSeekBack wrapped in DispatchQueue.main.async
- Stale vis Task guarded by songId in all MainActor.run blocks
- CHANGELOG.md with full findings documentation
2026-04-30 17:27:54 -07:00

5.2 KiB

NavidromePlayer

iOS/watchOS native Subsonic music player connecting to a Navidrome server. XcodeGen-based project (project.yml + ./generate.sh).

Build

  • ./generate.sh to regenerate Xcode project after adding/removing files
  • Bundle prefix: ca.dallasgroot, Team ID: E9C9AGS9K6
  • Targets: iOS 26+, watchOS 26+
  • App Group: group.com.navidromeplayer.shared
  • TestFlight build: currently at 13 (version 1.0.0)
  • Always increment CURRENT_PROJECT_VERSION in project.yml before any release zip
  • Git repo: https://repo.dallasgroot.ca/dallasgroot/NavidromeApp.git
  • Push via SSH (Forgejo hooks disabled — push works, Forgejo reads repo directly)

Architecture

Shared/          — Models, SubsonicClient, AudioPlayer (compiles for iOS + watchOS)
iOS/             — Views, data managers, visualizer, audio tap (iOS only)
watchOS/         — Watch app, WatchAudioPlayer, WatchSessionManager
Widget/          — Now Playing widget extension
companion-api/   — Python FastAPI server on Raspberry Pi (Docker)
  • project.yml iOS target sources: iOS/ + Shared/
  • project.yml watchOS target sources: watchOS/ + Shared/
  • Widget target sources: Widget/ only

Critical Rules

Swift / Xcode

  • AudioPlayer.swift is in Shared/ — ALL iOS-only code MUST be inside #if os(iOS) / #endif
  • AudioTapProcessor, VisualizerSettings, PlayQueueSyncManager are iOS-only files (in iOS/)
  • Never reference iOS-only types from Shared/ without #if os(iOS) guards
  • CompanionAPIService is a Swift actor — model structs MUST be declared at TOP LEVEL outside the actor
  • FlowLayout is defined in LyricsOverlayView.swift — never redeclare it
  • New Swift types should go in EXISTING registered files when possible
  • Never use placeholders like // existing code or // rest unchanged — always output complete file contents
  • PlaybackStateStore.swift lives in Shared/Storage/ (was moved from iOS/Data/)

Threading & Concurrency (from crash audit)

  • SmartDJCache uses NSLock for all memoryCache access — NEVER access without lock
  • When swapping self.player to crossfade.activePlayer, ALWAYS remove timeObserver from the OLD player first — removing from wrong player throws NSException → SIGABRT
  • stopAVPlayer() removes time observer BEFORE replaceCurrentItem(with: nil)
  • All Combine .sink handlers on NotificationCenter publishers MUST use .receive(on: DispatchQueue.main)
  • KVO item.observe(\.status) callbacks fire on AVFoundation's internal thread — wrap body in DispatchQueue.main.async
  • loadOfflineVisualizer Task completion blocks MUST guard self.currentSong?.id == songId to prevent stale vis data after track skip

Visualizer / Audio Tap

  • Radio streams use MTAudioProcessingTap via AudioTapProcessor.shared for real-time FFT
  • Tap MUST install on .readyToPlay status, NOT immediately after player.play()
  • startRadioSimulation() runs as visual placeholder until tap installs
  • ShazamRecognizer subscribes to shared tap via shazamHandler — does NOT create its own tap
  • Background: tap removed in suspendVisTimers(), reinstalled in resumeVisTimers()
  • Every player?.replaceCurrentItem(with: item) MUST also set self.playerItem = item

watchOS Audio

  • NO HealthKit — speaker background runtime uses standard audio WKBackgroundModes
  • Speaker mode: .playback category + .default policy + setActive(true)
  • Bluetooth mode: .playback category + .longFormAudio policy + activate(options:completionHandler:)
  • Watch remote control: iPhone pushes state via sendNowPlayingToWatch() in updateNowPlayingInfo()
  • All WCSession sendMessage calls MUST use replyHandler: { _ in } (not nil) — nil routes to wrong delegate method
  • iPhone command handlers (playCommand, nextCommand, etc.) dispatch to DispatchQueue.main.async

Companion API

  • Tag writes MUST call backup_tags() first and abort if it returns None
  • See companion-api/CLAUDE.md for server-specific rules

Key Patterns

Subsonic API

  • SubsonicClient.swift — all server communication
  • Response types in Shared/Models/Models.swift via SubsonicResponseBody

Data flow for visualizer

AVPlayerItem → MTAudioProcessingTap (C callback, render thread)
             → PCMRingBuffer (lock-free, 8192 samples)
             → Timer at 30fps (main thread)
             → vDSP FFT (1024 samples, Hann window, log-frequency bands)
             → setLevels([Float]) → _audioLevels
             → MitsuhaVisualizerView reads currentLevels()

Watch remote data flow

iPhone: updateNowPlayingInfo() → sendNowPlayingToWatch() → WCSession message
Watch:  didReceiveMessage → phoneNowPlaying → WatchNowPlayingView
Watch → iPhone: sendPlayCommand/nextCommand/etc → didReceiveMessage:replyHandler:

Known Crash History (all fixed)

  • Build 11: removeTimeObserver on wrong AVPlayer after crossfade swap (SIGABRT)
  • Build 11: SmartDJCache.bulkImport concurrent Dictionary mutation (EXC_BAD_ACCESS + doesNotRecognizeSelector)
  • Build 12: Same removeTimeObserver crash reproduced, confirmed fix in build 13

Testing

  • Build and run on device via Xcode
  • TestFlight for distribution testing
  • Companion API: docker compose build music-companion && docker compose up -d