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
This commit is contained in:
Dallas Groot 2026-04-30 17:27:54 -07:00
parent 5205d708c3
commit 99bf17ec1a
10 changed files with 323 additions and 148 deletions

67
CHANGELOG.md Normal file
View file

@ -0,0 +1,67 @@
# CHANGELOG — Race Condition & Crash Audit
## Build 13 (1.0.0) — 2026-04-30
### 🟢 ARCHITECTURE — Remove HealthKit, use standard audio background mode for speaker
**Files:** `watchOS/Audio/WatchAudioPlayer.swift`, `watchOS/Resources/Info.plist`, `watchOS/Resources/NavidromeWatch.entitlements`
**What changed:**
1. Removed `import HealthKit`, `HKHealthStore`, `HKWorkoutSession`, `HKLiveWorkoutBuilder`, and both delegate extensions
2. Removed `startWorkoutSession()` / `stopWorkoutSession()` and all call sites (play, resume, stop, reconfigure)
3. Info.plist: `WKBackgroundModes` changed from `workout-processing` to `audio`; removed `NSHealthShareUsageDescription` and `NSHealthUpdateUsageDescription`
4. Entitlements: removed `com.apple.developer.healthkit` and `com.apple.developer.healthkit.access`
5. Speaker mode now uses `setActive(true)` in `configureAudioSession` instead of deferring to workout session
**Why:** The HKWorkoutSession workaround was a watchOS 3 technique that Apple closed in watchOS 4. The standard `audio` background mode (available since watchOS 5/WWDC 2018) keeps the app alive while audio is actively playing through AVAudioSession — no HealthKit needed. The workout session was activating the heart rate sensor (green LEDs), showing the green workout indicator, contributing to Activity Rings, and draining 40%+ extra battery. Speaker routing is controlled entirely by AVAudioSession policy: `.default` = speaker, `.longFormAudio` = Bluetooth.
---
### 🔴 NEW — `SmartDJCache.bulkImport` concurrent Dictionary crash
**File:** `iOS/Views/Companion/CompanionAPIService.swift`
**Function:** `SmartDJCache``get()`, `store()`, `bulkImport()`, `loadBulkCache()`, `clearAll()`, `cachedCount`
**What changed:** Added `NSLock` to serialize all `memoryCache` reads and writes.
**Why:** Two crash logs (April 27 `EXC_BAD_ACCESS` + April 28 `SIGABRT doesNotRecognizeSelector`) both crash inside `SmartDJCache.bulkImport()`. Root cause: `memoryCache` is a plain `[String: SmartDJProfile]` dictionary mutated from the main thread (`loadBulkCache` at app launch) and a background `Task.detached` (`bulkImport` from server fetch) simultaneously. Swift Dictionary is not thread-safe — concurrent mutation corrupts the hash table, causing either a segfault on the corrupted bucket chain or a garbage pointer that fails `objc_msgSend`.
**Crash logs:** `NavidromePlayer-2026-04-27-180336.ips`, `NavidromePlayer-2026-04-28-211506.ips`
---
### 🔴 NEW — `stopAVPlayer` crashes on `removeTimeObserver` after crossfade player swap
**File:** `Shared/Audio/AudioPlayer.swift`
**Functions:** `stopAVPlayer()`, `play(song:)` crossfade path, `prepareNextForCrossfade()` needsNextTrack callback
**What changed:**
1. Both player swap sites (`player = crossfade.activePlayer`) now remove `timeObserver` from the OLD player first
2. `stopAVPlayer()` reordered: observer removal now happens BEFORE `replaceCurrentItem(with: nil)`
**Why:** Crash log shows `SIGABRT` in `-[AVPlayer removeTimeObserver:]` called from `stopAVPlayer()``stopAll()``play(song:)``previous()`. Root cause: SmartCrossfadeManager's `finalizeCrossfade()` swaps `self.player` to a different AVPlayer instance, but `timeObserver` was registered on the OLD player. When `stopAVPlayer()` later tries `player?.removeTimeObserver(observer)`, it removes from the wrong AVPlayer → NSException. The fix ensures the observer is always removed from the correct player before the swap.
**Crash log:** `NavidromePlayer-2026-04-30-141820.ips`
---
### 🔴 Finding 2 — KVO in `radioSeekBack` fires on non-main thread
**File:** `Shared/Audio/AudioPlayer.swift`
**Function:** `radioSeekBack(to:)`
**What changed:** Wrapped the entire `item.observe(\.status)` KVO callback body in `DispatchQueue.main.async { }`.
**Why:** `NSKeyValueObservation` fires on whatever thread changes the observed property. `AVPlayerItem.status` is changed on AVFoundation's internal media processing thread, NOT the main thread. The callback was accessing `self.player?.seek()`, `self.snapshotStatusObservation?.invalidate()`, `self.radioGoLive()`, and other main-thread-only state from a background thread — a data race that could corrupt AudioPlayer state or crash under the right timing (rapid seek-back → go-live during radio timeshift).
---
### 🟡 Finding 1 — Stale `analysisTask` overwrites visualizer buffer after track skip
**File:** `Shared/Audio/AudioPlayer.swift`
**Function:** `loadOfflineVisualizer(songId:url:)`
**What changed:** Added `guard self.currentSong?.id == songId else { return }` to all three `MainActor.run` blocks (cache hit, server fetch, local analysis) and to the progress callback.
**Why:** When the user skips tracks quickly, `stopAll()` cancels `analysisTask` and clears `offlineVisBuffer`. But if the previous Task's async work had already completed, its `MainActor.run` block was already queued and would fire AFTER `stopAll()`, overwriting the new song's cleared buffer with the old song's stale vis data. This caused the visualizer to either show the wrong song's waveform or fail to start entirely for the new song. The songId guard ensures stale completions are silently discarded.
---
### Findings NOT fixed (documented, acceptable risk)
| # | Sev | Finding | Rationale |
|---|---|---|---|
| 5 | 🟡 | `shazamHandler` heap alloc on render thread | Pre-existing pattern, 15s max duration, no crash risk |
| 6 | 🟡 | `debugWriteSamples` I/O on render thread | Diagnostic tool only, 5s capture window, acceptable |
| 3 | 🟢 | `processFFT` dead code data race | `playLocalWithEngine` is never called — unreachable |
| 4 | 🟢 | `timeUpdate` closure fragile without `@MainActor` | All callers use `.main` queue today — future risk only |
| 7 | 🟢 | `sourceFormat` data race | ARM64 naturally-atomic pointer writes — safe in practice |
| 8 | 🟢 | Stale status observer memory spike | Guard `self.playerItem === itemToObserve` handles it |

102
CLAUDE.md Normal file
View file

@ -0,0 +1,102 @@
# 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`

View file

@ -486,7 +486,13 @@ class AudioPlayer: NSObject, ObservableObject {
isPlaying = true
// Set AudioPlayer.player to the active crossfade player
// so Lock Screen controls and visualizer still work
// so Lock Screen controls and visualizer still work.
// Remove any stale time observer first it was registered on
// the old player and would crash if removed from the new one.
if let obs = timeObserver {
player?.removeTimeObserver(obs)
timeObserver = nil
}
player = crossfade.activePlayer
// Safety: if boundary observer doesn't fire (short track, bad profile),
@ -700,8 +706,12 @@ class AudioPlayer: NSObject, ObservableObject {
installAudioTapIfNeeded(on: item)
#endif
// KVO on status more reliable than polling; fires immediately on failure too
// KVO on status more reliable than polling; fires immediately on failure too.
// IMPORTANT: NSKeyValueObservation fires on whatever thread changes the property.
// AVPlayerItem.status is changed on an internal AVFoundation thread, NOT main.
// All state access must be dispatched to main to prevent data races.
snapshotStatusObservation = item.observe(\.status, options: [.new, .initial]) { [weak self] observedItem, _ in
DispatchQueue.main.async {
guard let self else { return }
switch observedItem.status {
case .readyToPlay:
@ -726,6 +736,7 @@ class AudioPlayer: NSObject, ObservableObject {
}
}
}
}
/// Return to the live radio stream, discarding any snapshot
func radioGoLive() {
@ -1244,7 +1255,13 @@ class AudioPlayer: NSObject, ObservableObject {
currentTime: 0, currentSongId: self.currentSong?.id
)
// Swap AudioPlayer.player to the new active crossfade player
// Swap AudioPlayer.player to the new active crossfade player.
// Remove stale time observer first it was registered on the old
// player instance and removing it from the new one throws NSException.
if let obs = self.timeObserver {
self.player?.removeTimeObserver(obs)
self.timeObserver = nil
}
self.player = crossfade.activePlayer
// Re-register the safety-net AVPlayerItemDidPlayToEndTime observer
@ -1619,6 +1636,9 @@ class AudioPlayer: NSObject, ObservableObject {
if let buffer = try? await storage.loadCache(for: songId) {
let normalized = Self.normalizeVisBuffer(buffer)
await MainActor.run {
// Guard: if user skipped tracks while cache was loading,
// don't apply stale vis data from the previous song.
guard self.currentSong?.id == songId else { return }
self.offlineVisBuffer = normalized
self.isUsingOfflineVis = true
self.isUsingRealFFT = true
@ -1642,6 +1662,7 @@ class AudioPlayer: NSObject, ObservableObject {
let normalized = Self.normalizeVisBuffer(rawBuf)
alog("Offline vis: fetched \(serverFrames.count) frames from server")
await MainActor.run {
guard self.currentSong?.id == songId else { return }
self.offlineVisBuffer = normalized
self.isUsingOfflineVis = true
self.isUsingRealFFT = true
@ -1674,7 +1695,10 @@ class AudioPlayer: NSObject, ObservableObject {
cutoff: cutoff,
extractSmartDJ: needsSmartDJ
) { [weak self] pct in
Task { @MainActor in self?.offlineVisProgress = pct }
Task { @MainActor in
guard self?.currentSong?.id == songId else { return }
self?.offlineVisProgress = pct
}
}
// Seed SmartDJ cache from on-device analysis if we extracted it
@ -1704,6 +1728,7 @@ class AudioPlayer: NSObject, ObservableObject {
alog("Offline vis: cached \(frames.count) frames for \(songId)")
await MainActor.run {
guard self.currentSong?.id == songId else { return }
self.offlineVisBuffer = normalized
self.isUsingOfflineVis = true
self.isUsingRealFFT = true
@ -2082,12 +2107,15 @@ class AudioPlayer: NSObject, ObservableObject {
// sourceFormat from confusing Shazam into skipping tap installation
AudioTapProcessor.shared.removeTap(from: playerItem)
#endif
player?.pause()
player?.replaceCurrentItem(with: nil)
// Remove time observer FIRST before pause/replaceCurrentItem.
// replaceCurrentItem(with: nil) can tear down internal AVPlayer state
// that the observer depends on, causing removeTimeObserver to throw.
if let observer = timeObserver {
player?.removeTimeObserver(observer)
timeObserver = nil
}
player?.pause()
player?.replaceCurrentItem(with: nil)
playerItem = nil
}

55
companion-api/CLAUDE.md Normal file
View file

@ -0,0 +1,55 @@
# Companion API
Python FastAPI server running on Raspberry Pi 5 inside Docker.
Provides metadata editing, Smart DJ analysis, visualizer precomputation, lyrics, and library management.
## Deployment
```bash
# On the Pi, from ~/docker/navidrome/
docker compose build music-companion && docker compose up -d music-companion
```
- Port: 8000
- Music dir inside container: `/music` (mapped from `/home/pi/navidrome/music`)
- Navidrome DB: `/navidrome_data/navidrome.db` (read-only)
- Companion data: `/app/data/` (smart_dj.db, vis_cache, cover_art, tag backups)
## Architecture
- `main.py` is the entire server (~3800 lines, single file)
- `Dockerfile` + `docker-compose.yml` for containerization
- `diagnose.py` — standalone diagnostic script
- `pre_analyze.py` — bulk pre-analysis for Smart DJ profiles
## Critical Rules
### Tag Safety
- ALL tag writes go through `apply_tags()` (single track) or `apply_tags_dict()` (batch)
- Both functions call `backup_tags()` FIRST and abort with `RuntimeError` if backup fails
- Files are NEVER modified without a backup safety net
- `enforce_tag_whitelist()` has separate paths:
- FLAC/OGG/Opus: checks against `NAVIDROME_TAGS` (Vorbis Comment names)
- MP3: checks against `ID3_FRAME_WHITELIST` (ID3v2 frame IDs like TPE1, TALB)
- AIFF: uses `mutagen.aiff.AIFF` with raw ID3 frames (easy=True returns None for AIFF)
- NEVER use `audio.delete()` in restore — it destroys APIC (album art) and USLT (lyrics)
### Backup System
- Dual-key backups: `_backup_key(full_path)` + `_backup_key_rel(relative_path)`
- `_find_backup()` tries 4 strategies (current path, current rel, original path, original rel)
- Survives file moves from `/bulk-fix` restructure
- `save_batch_manifest()` stores: batch_id, paths, tags_changed, affected_albums/artists, edit_type, is_reverted
- Single-track edits (`PATCH /edit-metadata`) also create manifests with `edit_type: "single"`
- Double undo prevented: `is_reverted` flag checked, HTTP 409 on repeat
### Path Resolution
- `resolve_path()` has 5 fallback strategies for finding files
- Handles URL encoding, NFC unicode normalization, library folder prefixes
- Falls back to Companion songs table and fuzzy title matching
## Common Pitfalls
- `MutagenFile(path, easy=True)` returns `None` for AIFF — always check
- `datetime.utcnow().isoformat()` produces timestamps WITHOUT timezone suffix — Swift must parse with fallback formats
- `ffmpeg` subprocess can hang on corrupt files — 120s timeout with process kill
- Pi has limited CPU (4 cores) — `deploy.resources.limits.cpus: '2.0'` in docker-compose

View file

@ -216,3 +216,4 @@ struct RootView: View {
}
}
}

View file

@ -65,6 +65,10 @@ class SmartDJCache {
private let cacheDir: URL
private let bulkCacheURL: URL
private var memoryCache: [String: SmartDJProfile] = [:]
/// Serializes all memoryCache reads/writes prevents concurrent Dictionary
/// mutation crashes (EXC_BAD_ACCESS) when bulkImport runs on a background
/// Task while get/store fire from other threads.
private let lock = NSLock()
private init() {
let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!
@ -81,16 +85,23 @@ class SmartDJCache {
}
func get(_ relativePath: String) -> SmartDJProfile? {
if let cached = memoryCache[relativePath] { return cached }
lock.lock()
if let cached = memoryCache[relativePath] { lock.unlock(); return cached }
lock.unlock()
let url = cacheURL(for: relativePath)
guard let data = try? Data(contentsOf: url),
let profile = try? JSONDecoder().decode(SmartDJProfile.self, from: data) else { return nil }
lock.lock()
memoryCache[relativePath] = profile
lock.unlock()
return profile
}
func store(_ profile: SmartDJProfile, for relativePath: String) {
lock.lock()
memoryCache[relativePath] = profile
lock.unlock()
let url = cacheURL(for: relativePath)
if let data = try? JSONEncoder().encode(profile) {
try? data.write(to: url, options: .atomic)
@ -106,9 +117,11 @@ class SmartDJCache {
let profiles = try? JSONDecoder().decode([String: SmartDJProfile].self, from: data)
else { return }
// Merge: individual stores (from cache misses) take priority over bulk
lock.lock()
for (path, profile) in profiles where memoryCache[path] == nil {
memoryCache[path] = profile
}
lock.unlock()
DebugLogger.shared.log(
"DJ bulk cache loaded: \(profiles.count) profiles from disk",
category: "SmartDJ"
@ -118,28 +131,37 @@ class SmartDJCache {
/// Import all profiles from the server export. Updates memory immediately,
/// writes a single bulk JSON file to disk for next launch.
func bulkImport(_ profiles: [String: SmartDJProfile]) {
lock.lock()
for (path, profile) in profiles {
memoryCache[path] = profile
}
lock.unlock()
// Write single bulk file one atomic write, no per-profile I/O
if let data = try? JSONEncoder().encode(profiles) {
try? data.write(to: bulkCacheURL, options: .atomic)
}
DebugLogger.shared.log(
"DJ bulk import: \(profiles.count) profiles cached (\(memoryCache.count) total in memory)",
"DJ bulk import: \(profiles.count) profiles cached",
category: "SmartDJ"
)
}
func clearAll() {
lock.lock()
memoryCache.removeAll()
lock.unlock()
try? fileManager.removeItem(at: bulkCacheURL)
if let files = try? fileManager.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil) {
for file in files { try? fileManager.removeItem(at: file) }
}
}
var cachedCount: Int { memoryCache.count }
var cachedCount: Int {
lock.lock()
let count = memoryCache.count
lock.unlock()
return count
}
}
// MARK: - Metadata Edit Request (matches Python MetadataUpdate model)

View file

@ -20,7 +20,7 @@ settings:
base:
SWIFT_VERSION: "5.9"
MARKETING_VERSION: "1.0.0"
CURRENT_PROJECT_VERSION: "10"
CURRENT_PROJECT_VERSION: "13"
DEAD_CODE_STRIPPING: true
ENABLE_USER_SCRIPT_SANDBOXING: true
DEVELOPMENT_TEAM: E9C9AGS9K6

View file

@ -3,18 +3,18 @@ import AVFoundation
import Combine
import WatchKit
import MediaPlayer
import HealthKit
/// watchOS audio player with dual-mode output:
///
/// **Bluetooth Mode** (default):
/// Uses `.longFormAudio` policy. Requires Bluetooth/AirPlay. Background playback is
/// handled by the audio session itself.
/// Uses `.longFormAudio` policy. Requires Bluetooth/AirPlay. Session must be
/// activated via `activate(options:completionHandler:)`.
///
/// **Speaker Mode** (Watch Ultra):
/// Uses `.default` policy which routes to the built-in speaker. Background runtime is
/// maintained by a silent `HKWorkoutSession`. watchOS keeps workout apps alive indefinitely
/// even when the screen sleeps. A green workout indicator appears on the watch face.
/// Uses `.default` policy which routes to the built-in speaker. Background
/// runtime is provided by the `audio` UIBackgroundModes entitlement the
/// system keeps the app alive while audio is actively playing. No HealthKit
/// or HKWorkoutSession required.
///
/// Toggle between modes in the Now Playing route indicator.
class WatchAudioPlayer: NSObject, ObservableObject {
@ -61,11 +61,6 @@ class WatchAudioPlayer: NSObject, ObservableObject {
private var pendingPlayURL: URL?
private var pendingSong: Song?
// HealthKit workout session for speaker background runtime
private let healthStore = HKHealthStore()
private var workoutSession: HKWorkoutSession?
private var workoutBuilder: HKLiveWorkoutBuilder?
private override init() {
super.init()
useSpeakerMode = UserDefaults.standard.bool(forKey: "watch_speaker_mode")
@ -83,7 +78,6 @@ class WatchAudioPlayer: NSObject, ObservableObject {
let hasBuiltIn = route.outputs.contains { $0.portType == .builtInSpeaker }
if hasBuiltIn { return true }
// Watch Ultra always has a speaker even if not currently routed
// Check model name heuristic
let model = WKInterfaceDevice.current().model.lowercased()
return model.contains("ultra")
}
@ -94,8 +88,12 @@ class WatchAudioPlayer: NSObject, ObservableObject {
let session = AVAudioSession.sharedInstance()
do {
if useSpeakerMode {
// Speaker mode: .default policy allows speaker output
// Speaker mode: .default policy allows speaker output.
// No .longFormAudio that policy requires Bluetooth and blocks
// speaker routing on pre-watchOS 11. The `audio` background mode
// in Info.plist keeps the app alive while audio plays.
try session.setCategory(.playback, mode: .default, policy: .default)
try session.setActive(true)
print("[WatchAudio] Session: .playback / .default (speaker mode)")
} else {
// Bluetooth mode: .longFormAudio requires BT/AirPlay
@ -112,77 +110,25 @@ class WatchAudioPlayer: NSObject, ObservableObject {
let wasPlaying = isPlaying
if wasPlaying { player?.pause() }
configureAudioSession()
if useSpeakerMode {
startWorkoutSession()
} else {
stopWorkoutSession()
}
if wasPlaying { player?.play() }
updateRouteInfo()
}
// MARK: - HKWorkoutSession (keeps app alive for speaker mode)
private func startWorkoutSession() {
guard workoutSession == nil else { return }
guard HKHealthStore.isHealthDataAvailable() else {
print("[WatchAudio] HealthKit not available")
return
}
let config = HKWorkoutConfiguration()
config.activityType = .mindAndBody // Least intrusive workout type
config.locationType = .indoor
do {
let session = try HKWorkoutSession(healthStore: healthStore, configuration: config)
let builder = session.associatedWorkoutBuilder()
builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: config)
session.delegate = self
builder.delegate = self
session.startActivity(with: Date())
builder.beginCollection(withStart: Date()) { success, error in
if let error = error {
print("[WatchAudio] Workout builder start failed: \(error.localizedDescription)")
} else {
print("[WatchAudio] Workout session started (speaker background mode)")
}
}
workoutSession = session
workoutBuilder = builder
} catch {
print("[WatchAudio] Failed to start workout session: \(error.localizedDescription)")
}
}
private func stopWorkoutSession() {
guard let session = workoutSession else { return }
session.end()
workoutBuilder?.endCollection(withEnd: Date()) { [weak self] success, error in
self?.workoutBuilder?.finishWorkout { workout, error in
print("[WatchAudio] Workout session ended")
}
}
workoutSession = nil
workoutBuilder = nil
}
// MARK: - Session Activation (Bluetooth mode only)
// MARK: - Session Activation
func activateSession() async -> Bool {
if useSpeakerMode {
// Speaker mode doesn't need activation just start workout
// Speaker mode: session is already active from configureAudioSession.
// No Bluetooth route picker needed.
await MainActor.run {
startWorkoutSession()
isSessionActive = true
updateRouteInfo()
}
return true
}
// Bluetooth mode: must use activate(options:completionHandler:) NOT setActive(true).
// setActive(true) throws OSStatus 561145203 with .longFormAudio policy.
let session = AVAudioSession.sharedInstance()
let result = await withCheckedContinuation { (continuation: CheckedContinuation<(Bool, String?), Never>) in
@ -293,8 +239,8 @@ class WatchAudioPlayer: NSObject, ObservableObject {
}
if useSpeakerMode {
// Speaker mode: start workout if needed, then play
startWorkoutSession()
// Speaker mode: session already configured with .default policy.
// setActive(true) was called in configureAudioSession.
isSessionActive = true
playFromURL(playURL)
return
@ -374,7 +320,6 @@ class WatchAudioPlayer: NSObject, ObservableObject {
}
func resume() {
if useSpeakerMode { startWorkoutSession() }
player?.play()
isPlaying = true
updateNowPlayingInfo()
@ -429,7 +374,6 @@ class WatchAudioPlayer: NSObject, ObservableObject {
levelTimer?.invalidate(); levelTimer = nil
audioLevels = Array(repeating: 0, count: 8)
if let observer = timeObserver { player?.removeTimeObserver(observer); timeObserver = nil }
if useSpeakerMode { stopWorkoutSession() }
}
// MARK: - Now Playing
@ -469,28 +413,3 @@ class WatchAudioPlayer: NSObject, ObservableObject {
let s = Int(max(0, seconds)); return String(format: "%d:%02d", s / 60, s % 60)
}
}
// MARK: - HKWorkoutSession Delegate
extension WatchAudioPlayer: HKWorkoutSessionDelegate {
func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date) {
print("[WatchAudio] Workout state: \(fromState.rawValue)\(toState.rawValue)")
if toState == .ended {
DispatchQueue.main.async {
self.workoutSession = nil
self.workoutBuilder = nil
}
}
}
func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {
print("[WatchAudio] Workout session failed: \(error.localizedDescription)")
}
}
// MARK: - HKLiveWorkoutBuilder Delegate
extension WatchAudioPlayer: HKLiveWorkoutBuilderDelegate {
func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}
func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set<HKSampleType>) {}
}

View file

@ -30,21 +30,6 @@
<key>WKRunsIndependentlyOfCompanionApp</key>
<true/>
<!-- workout-processing keeps the HKWorkoutSession alive so audio
continues playing through the speaker when the screen turns off.
'audio' is not a valid WKBackgroundModes value on watchOS. -->
<key>WKBackgroundModes</key>
<array>
<string>workout-processing</string>
</array>
<!-- Required because WatchAudioPlayer uses HKWorkoutSession to
maintain background speaker audio. No fitness data is stored. -->
<key>NSHealthShareUsageDescription</key>
<string>Navidrome uses a workout session to keep audio playing through the speaker when the screen is off. No health or fitness data is read or stored.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>Navidrome uses a workout session to keep audio playing through the speaker when the screen is off. No health or fitness data is written or stored.</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>

View file

@ -6,9 +6,5 @@
<array>
<string>group.com.navidromeplayer.shared</string>
</array>
<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.developer.healthkit.access</key>
<array/>
</dict>
</plist>