diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5c6234b --- /dev/null +++ b/CHANGELOG.md @@ -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 | diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b9d67bb --- /dev/null +++ b/CLAUDE.md @@ -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` diff --git a/Shared/Audio/AudioPlayer.swift b/Shared/Audio/AudioPlayer.swift index 7114447..5dfd3d7 100644 --- a/Shared/Audio/AudioPlayer.swift +++ b/Shared/Audio/AudioPlayer.swift @@ -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,29 +706,34 @@ 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 - guard let self else { return } - switch observedItem.status { - case .readyToPlay: - self.snapshotStatusObservation?.invalidate() - self.snapshotStatusObservation = nil - // Generous tolerance for raw audio without index tables - let cmTime = CMTime(seconds: seekTime, preferredTimescale: 600) - let tol = CMTime(seconds: 1, preferredTimescale: 600) - self.player?.seek(to: cmTime, toleranceBefore: tol, toleranceAfter: tol) { [weak self] _ in - self?.player?.play() - self?.alog("Radio seek: ▶ playing from \(String(format: "%.1f", seekTime))s — \(String(format: "%.0f", snapshotBufSec - seekTime))s behind live") + DispatchQueue.main.async { + guard let self else { return } + switch observedItem.status { + case .readyToPlay: + self.snapshotStatusObservation?.invalidate() + self.snapshotStatusObservation = nil + // Generous tolerance for raw audio without index tables + let cmTime = CMTime(seconds: seekTime, preferredTimescale: 600) + let tol = CMTime(seconds: 1, preferredTimescale: 600) + self.player?.seek(to: cmTime, toleranceBefore: tol, toleranceAfter: tol) { [weak self] _ in + self?.player?.play() + self?.alog("Radio seek: ▶ playing from \(String(format: "%.1f", seekTime))s — \(String(format: "%.0f", snapshotBufSec - seekTime))s behind live") + } + case .failed: + self.snapshotStatusObservation?.invalidate() + self.snapshotStatusObservation = nil + self.alog("Radio seek FAILED: \(observedItem.error?.localizedDescription ?? "unknown error")") + try? FileManager.default.removeItem(at: snapURL) + self.currentSnapshotURL = nil + self.radioGoLive() // fall back to live + default: + break } - case .failed: - self.snapshotStatusObservation?.invalidate() - self.snapshotStatusObservation = nil - self.alog("Radio seek FAILED: \(observedItem.error?.localizedDescription ?? "unknown error")") - try? FileManager.default.removeItem(at: snapURL) - self.currentSnapshotURL = nil - self.radioGoLive() // fall back to live - default: - break } } } @@ -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 } diff --git a/companion-api/CLAUDE.md b/companion-api/CLAUDE.md new file mode 100644 index 0000000..9ce5f3b --- /dev/null +++ b/companion-api/CLAUDE.md @@ -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 diff --git a/iOS/App/NavidromePlayerApp.swift b/iOS/App/NavidromePlayerApp.swift index ab5278e..b828f0d 100644 --- a/iOS/App/NavidromePlayerApp.swift +++ b/iOS/App/NavidromePlayerApp.swift @@ -216,3 +216,4 @@ struct RootView: View { } } } + \ No newline at end of file diff --git a/iOS/Views/Companion/CompanionAPIService.swift b/iOS/Views/Companion/CompanionAPIService.swift index 6496419..b6a1c6c 100644 --- a/iOS/Views/Companion/CompanionAPIService.swift +++ b/iOS/Views/Companion/CompanionAPIService.swift @@ -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) diff --git a/project.yml b/project.yml index bb2f38e..cf9602b 100644 --- a/project.yml +++ b/project.yml @@ -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 diff --git a/watchOS/Audio/WatchAudioPlayer.swift b/watchOS/Audio/WatchAudioPlayer.swift index eed4d87..aeef898 100644 --- a/watchOS/Audio/WatchAudioPlayer.swift +++ b/watchOS/Audio/WatchAudioPlayer.swift @@ -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) {} -} diff --git a/watchOS/Resources/Info.plist b/watchOS/Resources/Info.plist index e073921..8c3dae8 100644 --- a/watchOS/Resources/Info.plist +++ b/watchOS/Resources/Info.plist @@ -30,21 +30,6 @@ WKRunsIndependentlyOfCompanionApp - - WKBackgroundModes - - workout-processing - - - - NSHealthShareUsageDescription - 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. - NSHealthUpdateUsageDescription - 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. - NSAppTransportSecurity NSAllowsArbitraryLoads diff --git a/watchOS/Resources/NavidromeWatch.entitlements b/watchOS/Resources/NavidromeWatch.entitlements index f00eb96..34da8c1 100644 --- a/watchOS/Resources/NavidromeWatch.entitlements +++ b/watchOS/Resources/NavidromeWatch.entitlements @@ -6,9 +6,5 @@ group.com.navidromeplayer.shared - com.apple.developer.healthkit - - com.apple.developer.healthkit.access -