# 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 — uses `.longFormAudio` policy for both speaker and Bluetooth - `.longFormAudio` provides background runtime automatically - On watchOS 11+, `.longFormAudio` falls through to built-in speaker when no Bluetooth connected - Activation MUST use `activate(options:completionHandler:)` — NOT `setActive(true)` (throws OSStatus 561145203) - `useSpeakerMode` toggle is a user preference only — underlying session policy is always `.longFormAudio` - 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`