NavidromeApp/CLAUDE.md
Dallas Groot 97f2f9ab76 Watch: large volume buttons, remove Digital Crown volume
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.
2026-04-30 23:13:40 -07:00

104 lines
5.4 KiB
Markdown

# 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`