Volume controls moved to dedicated row with large tap targets (36pt height, full-width, dark background, percentage readout). Digital Crown binding removed — buttons are the primary control. Applied to both local playback and iPhone remote views.
5.4 KiB
5.4 KiB
NavidromePlayer
iOS/watchOS native Subsonic music player connecting to a Navidrome server.
XcodeGen-based project (project.yml + ./generate.sh).
Build
./generate.shto 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_VERSIONinproject.ymlbefore 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.ymliOS target sources:iOS/+Shared/project.ymlwatchOS target sources:watchOS/+Shared/- Widget target sources:
Widget/only
Critical Rules
Swift / Xcode
AudioPlayer.swiftis inShared/— ALL iOS-only code MUST be inside#if os(iOS)/#endifAudioTapProcessor,VisualizerSettings,PlayQueueSyncManagerare iOS-only files (iniOS/)- Never reference iOS-only types from
Shared/without#if os(iOS)guards CompanionAPIServiceis a Swiftactor— model structs MUST be declared at TOP LEVEL outside the actorFlowLayoutis defined inLyricsOverlayView.swift— never redeclare it- New Swift types should go in EXISTING registered files when possible
- Never use placeholders like
// existing codeor// rest unchanged— always output complete file contents PlaybackStateStore.swiftlives inShared/Storage/(was moved fromiOS/Data/)
Threading & Concurrency (from crash audit)
SmartDJCacheusesNSLockfor allmemoryCacheaccess — NEVER access without lock- When swapping
self.playertocrossfade.activePlayer, ALWAYS removetimeObserverfrom the OLD player first — removing from wrong player throws NSException → SIGABRT stopAVPlayer()removes time observer BEFOREreplaceCurrentItem(with: nil)- All Combine
.sinkhandlers on NotificationCenter publishers MUST use.receive(on: DispatchQueue.main) - KVO
item.observe(\.status)callbacks fire on AVFoundation's internal thread — wrap body inDispatchQueue.main.async loadOfflineVisualizerTask completion blocks MUST guardself.currentSong?.id == songIdto prevent stale vis data after track skip
Visualizer / Audio Tap
- Radio streams use
MTAudioProcessingTapviaAudioTapProcessor.sharedfor real-time FFT - Tap MUST install on
.readyToPlaystatus, NOT immediately afterplayer.play() startRadioSimulation()runs as visual placeholder until tap installsShazamRecognizersubscribes to shared tap viashazamHandler— does NOT create its own tap- Background: tap removed in
suspendVisTimers(), reinstalled inresumeVisTimers() - Every
player?.replaceCurrentItem(with: item)MUST also setself.playerItem = item
watchOS Audio
- NO HealthKit — uses
.longFormAudiopolicy for both speaker and Bluetooth .longFormAudioprovides background runtime automatically- On watchOS 11+,
.longFormAudiofalls through to built-in speaker when no Bluetooth connected - Activation MUST use
activate(options:completionHandler:)— NOTsetActive(true)(throws OSStatus 561145203) useSpeakerModetoggle is a user preference only — underlying session policy is always.longFormAudio- Watch remote control: iPhone pushes state via
sendNowPlayingToWatch()inupdateNowPlayingInfo() - All WCSession
sendMessagecalls MUST usereplyHandler: { _ in }(notnil) —nilroutes to wrong delegate method - iPhone command handlers (
playCommand,nextCommand, etc.) dispatch toDispatchQueue.main.async
Companion API
- Tag writes MUST call
backup_tags()first and abort if it returnsNone - See
companion-api/CLAUDE.mdfor server-specific rules
Key Patterns
Subsonic API
SubsonicClient.swift— all server communication- Response types in
Shared/Models/Models.swiftviaSubsonicResponseBody
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:
removeTimeObserveron wrong AVPlayer after crossfade swap (SIGABRT) - Build 11:
SmartDJCache.bulkImportconcurrent Dictionary mutation (EXC_BAD_ACCESS + doesNotRecognizeSelector) - Build 12: Same
removeTimeObservercrash 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