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.
104 lines
5.4 KiB
Markdown
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`
|