Compare commits

..

5 commits

Author SHA1 Message Date
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
99bf17ec1a 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
2026-04-30 17:27:54 -07:00
5205d708c3 Watch: fix command routing, seek bar, volume UI
Fix 1 — Command routing:
All sendMessage calls now use replyHandler (even { _ in }) so
WCSession routes them to didReceiveMessage:replyHandler: where
the actual command handling lives. Previously next/prev/seek/volume
silently went to the fire-and-forget handler which dropped them.

Fix 2 — Seek bar:
Replaced DragGesture with onTapGesture for reliable watchOS taps.
Hit target increased from 3pt to 20pt. Both local and remote views.

Fix 3 — Volume:
Replaced tiny slider bar with speaker.minus/speaker.plus buttons.
Crown still works for fine control. Both local and remote views.
2026-04-20 13:06:06 -07:00
127d5926a3 test: verify Forgejo hooks 2026-04-17 10:04:20 -07:00
fba2da4700 UI scaling on scrollabile windows 2026-04-17 09:58:05 -07:00
23 changed files with 776 additions and 267 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 |

104
CLAUDE.md Normal file
View file

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

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,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
}
}
}
@ -1105,6 +1116,21 @@ class AudioPlayer: NSObject, ObservableObject {
}
func resume() {
// Cold restore: app was killed, queue restored from PlaybackStateStore,
// but AVPlayer was never created. player?.play() would be a nil no-op.
// Detect this and go through the full play(song:) path, then seek to
// the saved position so playback resumes where the user left off.
if player == nil, let song = currentSong {
let savedPosition = currentTime
alog("Cold resume: loading \(song.title) at \(Int(savedPosition))s")
play(song: song)
// AVPlayer queues seeks this executes once the item is ready
if savedPosition > 1 {
seek(to: savedPosition)
}
return
}
#if os(iOS)
if isUsingCrossfade {
SmartCrossfadeManager.shared.resume()
@ -1229,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
@ -1367,6 +1399,11 @@ class AudioPlayer: NSObject, ObservableObject {
}
}
func setVolume(_ vol: Float) {
volume = vol
player?.volume = vol
}
// MARK: - Queue Management
func playNext(_ song: Song) {
@ -1487,6 +1524,14 @@ class AudioPlayer: NSObject, ObservableObject {
info[MPMediaItemPropertyArtwork] = artwork
}
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
// Push state to Apple Watch fires on song change, play/pause, and every 5s
WatchConnectivityManager.shared.sendNowPlayingToWatch(
song: currentSong,
isPlaying: isPlaying,
currentTime: currentTime,
duration: duration
)
#endif
}
@ -1591,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
@ -1614,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
@ -1646,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
@ -1676,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
@ -2054,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
}
@ -2101,6 +2157,10 @@ class AudioPlayer: NSObject, ObservableObject {
#if os(iOS)
offlineVisBuffer = VisFrameBuffer.empty
WidgetBridge.shared.clear()
// Tell watch playback stopped
WatchConnectivityManager.shared.sendNowPlayingToWatch(
song: nil, isPlaying: false, currentTime: 0, duration: 0
)
#endif
if isRadioStream {
isRadioStream = false

View file

@ -394,11 +394,15 @@ class WatchConnectivityManager: NSObject, ObservableObject {
func sendNowPlayingToWatch(song: Song?, isPlaying: Bool, currentTime: TimeInterval, duration: TimeInterval) {
guard let session = session, session.isReachable else { return }
let player = AudioPlayer.shared
var info: [String: Any] = [
"type": "nowPlaying",
"isPlaying": isPlaying,
"currentTime": currentTime,
"duration": duration
"duration": duration,
"shuffleEnabled": player.shuffleEnabled,
"repeatMode": player.repeatMode == .off ? 0 : player.repeatMode == .all ? 1 : 2,
"volume": Double(player.volume)
]
if let song = song {
@ -543,6 +547,39 @@ extension WatchConnectivityManager: WCSessionDelegate {
replyHandler(["success": true])
}
case "seekCommand":
if let time = message["time"] as? TimeInterval {
DispatchQueue.main.async {
AudioPlayer.shared.seek(to: time)
replyHandler(["success": true])
}
} else {
replyHandler(["error": "missing time"])
}
case "shuffleCommand":
DispatchQueue.main.async {
AudioPlayer.shared.toggleShuffle()
replyHandler(["shuffleEnabled": AudioPlayer.shared.shuffleEnabled])
}
case "repeatCommand":
DispatchQueue.main.async {
AudioPlayer.shared.cycleRepeat()
let mode = AudioPlayer.shared.repeatMode
replyHandler(["repeatMode": mode == .off ? 0 : mode == .all ? 1 : 2])
}
case "volumeCommand":
if let vol = message["volume"] as? Double {
DispatchQueue.main.async {
AudioPlayer.shared.setVolume(Float(vol))
replyHandler(["success": true])
}
} else {
replyHandler(["error": "missing volume"])
}
case "requestDownload":
if let songId = message["songId"] as? String {
Task {
@ -561,6 +598,26 @@ extension WatchConnectivityManager: WCSessionDelegate {
}
}
case "requestNowPlaying":
DispatchQueue.main.async {
let player = AudioPlayer.shared
var reply: [String: Any] = [
"isPlaying": player.isPlaying,
"currentTime": player.currentTime,
"duration": player.duration,
"shuffleEnabled": player.shuffleEnabled,
"repeatMode": player.repeatMode == .off ? 0 : player.repeatMode == .all ? 1 : 2,
"volume": Double(player.volume)
]
if let song = player.currentSong {
reply["title"] = song.title
reply["artist"] = song.artist ?? ""
reply["album"] = song.album ?? ""
reply["coverArtId"] = song.coverArt ?? ""
}
replyHandler(reply)
}
default:
replyHandler(["error": "unhandled type: \(type)"])
}

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

@ -88,9 +88,10 @@ struct MainTabView: View {
}
.tint(accentPink)
.safeAreaInset(edge: .bottom) {
// Reserve space for MiniPlayerBar so content never gets obscured
if audioPlayer.currentSong != nil && !showNowPlaying {
Color.clear.frame(height: 80)
// Reserve space for MiniPlayerBar so content never gets obscured.
// Only when the mini player is at the bottom (not in Dynamic Island mode).
if audioPlayer.currentSong != nil && !showNowPlaying && !isDynamicIsland {
Color.clear.frame(height: 72)
}
}
.overlay(alignment: .top) {
@ -652,7 +653,7 @@ struct MiniPlayerBar: View {
height: VisualizerSettings.shared.miniPlayerHeight
)
.matchedGeometryEffect(id: "visWave", in: namespace)
.offset(y: 10)
.offset(y: VisualizerSettings.shared.miniVisYOffset)
.opacity(VisualizerSettings.shared.miniOpacity)
.allowsHitTesting(false)
}

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

@ -37,7 +37,7 @@ struct AlbumDetailView: View {
songList(album)
// Bottom spacing
Color.clear.frame(height: 120)
Color.clear.frame(height: 80)
}
} else if loadFailed {
VStack(spacing: 16) {

View file

@ -86,7 +86,7 @@ struct ArtistDetailView: View {
}
}
Color.clear.frame(height: 120)
Color.clear.frame(height: 80)
}
} else if loadFailed {
// Connection failed show retry
@ -217,7 +217,7 @@ struct GenreDetailView: View {
}
.padding(16)
Color.clear.frame(height: 120)
Color.clear.frame(height: 80)
} else if loadFailed {
VStack(spacing: 16) {
Image(systemName: "wifi.slash")

View file

@ -140,6 +140,8 @@ struct DownloadsView: View {
}
}
}
Section {} footer: { Spacer().frame(height: 80) }
}
}
@ -356,6 +358,8 @@ struct DownloadsView: View {
}
}
}
Section {} footer: { Spacer().frame(height: 80) }
}
}
.onAppear {
@ -746,7 +750,7 @@ struct SettingsView: View {
// Extra space so the last items aren't hidden behind the mini player
Section {} footer: {
Spacer().frame(height: 60)
Spacer().frame(height: 80)
}
}
.navigationTitle("Settings")

View file

@ -123,6 +123,8 @@ struct LicensesView: View {
} header: {
Text("Acknowledgments")
}
Section {} footer: { Spacer().frame(height: 80) }
}
.navigationTitle("Licenses")
}

View file

@ -124,7 +124,7 @@ struct MyMusicView: View {
case .songs: songsTab
case .genres: genresTab
}
Color.clear.frame(height: 100)
Color.clear.frame(height: 80)
}
}
}

View file

@ -144,7 +144,7 @@ struct PlaylistDetailView: View {
playlistSongList(playlist)
Color.clear.frame(height: 120)
Color.clear.frame(height: 80)
}
} else if isLoading {
ProgressView().tint(accentPink).padding(.top, 100)

View file

@ -154,6 +154,7 @@ struct RadioView: View {
ForEach(stations) { station in
stationRow(station)
}
Section {} footer: { Spacer().frame(height: 80) }
}
}
}

View file

@ -111,7 +111,7 @@ struct SearchView: View {
.background(Color.white.opacity(0.06))
.padding(.leading, 72)
}
Color.clear.frame(height: 120)
Color.clear.frame(height: 80)
}
}
}
@ -216,7 +216,7 @@ struct SearchView: View {
.padding(.leading, 72)
}
}
Color.clear.frame(height: 120)
Color.clear.frame(height: 80)
}
}
}

View file

@ -130,6 +130,7 @@ class VisualizerSettings: ObservableObject {
@Published var miniIdleAmplitude: Double { didSet { save("vis_mini_idle", miniIdleAmplitude) } }
@Published var miniDepthOffset: Double { didSet { save("vis_mini_depth", miniDepthOffset) } }
@Published var miniDepthOpacity: Double { didSet { save("vis_mini_depth_opacity", miniDepthOpacity) } }
@Published var miniVisYOffset: Double { didSet { save("vis_mini_y_offset", miniVisYOffset) } }
enum Style: String, CaseIterable, Codable {
case wave = "Wave"
@ -198,6 +199,7 @@ class VisualizerSettings: ObservableObject {
miniIdleAmplitude = { let v = d.double(forKey: "vis_mini_idle"); return v > 0 ? v : 0.03 }()
miniDepthOffset = d.object(forKey: "vis_mini_depth") as? Double ?? 8.0
miniDepthOpacity = d.object(forKey: "vis_mini_depth_opacity") as? Double ?? 0.2
miniVisYOffset = d.object(forKey: "vis_mini_y_offset") as? Double ?? 0.0
}
var effectiveFPS: Double {
@ -1185,6 +1187,7 @@ struct VisualizerSettingsView: View {
settings.miniPlayerHeight = 48.0; settings.miniOpacity = 0.5
settings.miniAmplitude = 0.7; settings.miniIdleAmplitude = 0.03
settings.miniDepthOffset = 8.0; settings.miniDepthOpacity = 0.2
settings.miniVisYOffset = 0.0
}
}
@ -1268,6 +1271,20 @@ struct ViewConfigSettingsView: View {
sd("Base lift (from bottom)", value: $baseLift, range: 0...300, step: 5, format: "%.0f pt")
sd("Wave offset (top)", value: $waveOffset, range: -100...300, step: 5, format: "%.0f")
}
if isCompact {
sd("Height", value: Binding(
get: { settings.miniPlayerHeight },
set: { settings.miniPlayerHeight = $0 }
), range: 20...100, step: 2, format: "%.0f pt")
sd("Opacity", value: Binding(
get: { settings.miniOpacity },
set: { settings.miniOpacity = $0 }
), range: 0.1...1.0, step: 0.05, format: "%.2f")
sd("Vertical position", value: Binding(
get: { settings.miniVisYOffset },
set: { settings.miniVisYOffset = $0 }
), range: -20...20, step: 1, format: "%+.0f pt")
}
} header: { Text("LAYOUT & AMPLITUDE") }
// Depth & Idle

View file

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

View file

@ -23,11 +23,30 @@ class WatchSessionManager: NSObject, ObservableObject {
var currentTime: TimeInterval
var duration: TimeInterval
var coverArtId: String?
var shuffleEnabled: Bool = false
var repeatMode: Int = 0 // 0=off, 1=all, 2=one
var volume: Float = 1.0
}
private var session: WCSession?
private let client = SubsonicClient()
/// Parse PhoneNowPlaying from a dictionary (reused by message handler + reply handler)
static func parseNowPlaying(from dict: [String: Any]) -> PhoneNowPlaying {
PhoneNowPlaying(
title: dict["title"] as? String ?? "",
artist: dict["artist"] as? String ?? "",
album: dict["album"] as? String ?? "",
isPlaying: dict["isPlaying"] as? Bool ?? false,
currentTime: dict["currentTime"] as? TimeInterval ?? 0,
duration: dict["duration"] as? TimeInterval ?? 0,
coverArtId: dict["coverArtId"] as? String,
shuffleEnabled: dict["shuffleEnabled"] as? Bool ?? false,
repeatMode: dict["repeatMode"] as? Int ?? 0,
volume: (dict["volume"] as? Double).map { Float($0) } ?? 1.0
)
}
private override init() {
super.init()
if WCSession.isSupported() {
@ -80,19 +99,70 @@ class WatchSessionManager: NSObject, ObservableObject {
}
func sendPlayCommand() {
session?.sendMessage(["type": "playCommand"], replyHandler: nil, errorHandler: nil)
session?.sendMessage(["type": "playCommand"], replyHandler: { [weak self] reply in
if let isPlaying = reply["isPlaying"] as? Bool {
DispatchQueue.main.async {
guard let self else { return }
self.phoneNowPlaying?.isPlaying = isPlaying
}
}
}, errorHandler: nil)
}
func sendNextCommand() {
session?.sendMessage(["type": "nextCommand"], replyHandler: nil, errorHandler: nil)
session?.sendMessage(["type": "nextCommand"], replyHandler: { _ in }, errorHandler: nil)
}
func sendPreviousCommand() {
session?.sendMessage(["type": "previousCommand"], replyHandler: nil, errorHandler: nil)
session?.sendMessage(["type": "previousCommand"], replyHandler: { _ in }, errorHandler: nil)
}
func sendSeekCommand(to time: TimeInterval) {
session?.sendMessage(["type": "seekCommand", "time": time], replyHandler: { _ in }, errorHandler: nil)
}
func sendShuffleCommand() {
session?.sendMessage(["type": "shuffleCommand"], replyHandler: { [weak self] reply in
if let enabled = reply["shuffleEnabled"] as? Bool {
DispatchQueue.main.async {
guard let self else { return }
self.phoneNowPlaying?.shuffleEnabled = enabled
}
}
}, errorHandler: nil)
}
func sendRepeatCommand() {
session?.sendMessage(["type": "repeatCommand"], replyHandler: { [weak self] reply in
if let mode = reply["repeatMode"] as? Int {
DispatchQueue.main.async {
guard let self else { return }
self.phoneNowPlaying?.repeatMode = mode
}
}
}, errorHandler: nil)
}
func sendVolumeCommand(_ volume: Float) {
session?.sendMessage(["type": "volumeCommand", "volume": volume], replyHandler: { _ in }, errorHandler: nil)
}
/// Ask iPhone for current playback state (used on watch app launch)
func requestNowPlaying() {
guard let session = session, session.isReachable else { return }
session.sendMessage(["type": "requestNowPlaying"], replyHandler: { reply in
guard let title = reply["title"] as? String, !title.isEmpty else {
DispatchQueue.main.async { self.phoneNowPlaying = nil }
return
}
DispatchQueue.main.async {
self.phoneNowPlaying = Self.parseNowPlaying(from: reply)
}
}, errorHandler: { _ in })
}
func requestDownload(songId: String) {
session?.sendMessage(["type": "requestDownload", "songId": songId], replyHandler: nil, errorHandler: nil)
session?.sendMessage(["type": "requestDownload", "songId": songId], replyHandler: { _ in }, errorHandler: nil)
}
// MARK: - Direct API Access (watch can also talk to server directly)
@ -394,15 +464,12 @@ extension WatchSessionManager: WCSessionDelegate {
if type == "nowPlaying" {
DispatchQueue.main.async {
self.phoneNowPlaying = PhoneNowPlaying(
title: message["title"] as? String ?? "",
artist: message["artist"] as? String ?? "",
album: message["album"] as? String ?? "",
isPlaying: message["isPlaying"] as? Bool ?? false,
currentTime: message["currentTime"] as? TimeInterval ?? 0,
duration: message["duration"] as? TimeInterval ?? 0,
coverArtId: message["coverArtId"] as? String
)
// If no title, iPhone stopped playing clear remote state
guard let title = message["title"] as? String, !title.isEmpty else {
self.phoneNowPlaying = nil
return
}
self.phoneNowPlaying = Self.parseNowPlaying(from: message)
}
} else if type == "tagsUpdated" {
handleTagsUpdated(message)

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")
}
@ -93,15 +87,14 @@ class WatchAudioPlayer: NSObject, ObservableObject {
private func configureAudioSession() {
let session = AVAudioSession.sharedInstance()
do {
if useSpeakerMode {
// Speaker mode: .default policy allows speaker output
try session.setCategory(.playback, mode: .default, policy: .default)
print("[WatchAudio] Session: .playback / .default (speaker mode)")
} else {
// Bluetooth mode: .longFormAudio requires BT/AirPlay
try session.setCategory(.playback, mode: .default, policy: .longFormAudio)
print("[WatchAudio] Session: .playback / .longFormAudio (bluetooth mode)")
}
// Always use .longFormAudio this is the key to background runtime.
// On watchOS 11+, .longFormAudio falls through to the built-in speaker
// when no Bluetooth device is connected. The system handles routing:
// - Bluetooth connected routes to Bluetooth
// - No Bluetooth routes to speaker (Watch Ultra)
// Activation MUST use activate(options:completionHandler:), not setActive(true).
try session.setCategory(.playback, mode: .default, policy: .longFormAudio)
print("[WatchAudio] Session: .playback / .longFormAudio")
updateRouteInfo()
} catch {
print("[WatchAudio] Session config FAILED: \(error.localizedDescription)")
@ -112,77 +105,16 @@ 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
/// Activate the audio session. Must be called before playback.
/// Uses activate(options:completionHandler:) which is required for .longFormAudio.
/// On watchOS 11+, this routes to speaker if no Bluetooth is available.
func activateSession() async -> Bool {
if useSpeakerMode {
// Speaker mode doesn't need activation just start workout
await MainActor.run {
startWorkoutSession()
isSessionActive = true
updateRouteInfo()
}
return true
}
let session = AVAudioSession.sharedInstance()
let result = await withCheckedContinuation { (continuation: CheckedContinuation<(Bool, String?), Never>) in
@ -229,9 +161,14 @@ class WatchAudioPlayer: NSObject, ObservableObject {
self.updateRouteInfo()
switch reason {
case .oldDeviceUnavailable:
if !self.useSpeakerMode {
print("[WatchAudio] Bluetooth disconnected — pausing")
// Bluetooth disconnected. If speaker is available (Watch Ultra),
// audio will automatically route to speaker don't pause.
// Only pause if no speaker (standard Watch models).
if !self.isSpeakerAvailable {
print("[WatchAudio] Bluetooth disconnected, no speaker — pausing")
self.pause()
} else {
print("[WatchAudio] Bluetooth disconnected — continuing on speaker")
}
case .newDeviceAvailable:
print("[WatchAudio] New route: \(self.currentRouteName)")
@ -292,16 +229,9 @@ class WatchAudioPlayer: NSObject, ObservableObject {
return
}
if useSpeakerMode {
// Speaker mode: start workout if needed, then play
startWorkoutSession()
isSessionActive = true
playFromURL(playURL)
return
}
// Bluetooth mode: activate session if needed
if isSessionActive && isBluetoothConnected {
// Activate session before playback. .longFormAudio provides background
// runtime. On watchOS 11+, routes to speaker if no BT connected.
if isSessionActive && (isBluetoothConnected || useSpeakerMode) {
playFromURL(playURL)
return
}
@ -374,7 +304,6 @@ class WatchAudioPlayer: NSObject, ObservableObject {
}
func resume() {
if useSpeakerMode { startWorkoutSession() }
player?.play()
isPlaying = true
updateNowPlayingInfo()
@ -429,7 +358,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 +397,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>

View file

@ -6,15 +6,28 @@ struct WatchNowPlayingView: View {
@State private var crownVolume: Double = 0.8
@State private var showRouteInfo = false
@FocusState private var isCrownFocused: Bool
var body: some View {
if let song = audioPlayer.currentSong {
localNowPlaying(song)
} else if let phone = watchManager.phoneNowPlaying {
remoteNowPlaying(phone)
} else {
emptyState
Group {
if let song = audioPlayer.currentSong {
localNowPlaying(song)
} else if let phone = watchManager.phoneNowPlaying {
remoteNowPlaying(phone)
} else {
emptyState
}
}
.onAppear {
// If nothing is playing locally, ask iPhone for its current state
if audioPlayer.currentSong == nil {
watchManager.requestNowPlaying()
}
}
.onChange(of: watchManager.isPhoneReachable) { _, reachable in
// iPhone reconnected refresh state
if reachable && audioPlayer.currentSong == nil {
watchManager.requestNowPlaying()
}
}
}
@ -39,18 +52,24 @@ struct WatchNowPlayingView: View {
}
.padding(.top, 2)
// Progress bar
// Progress bar (tap to seek)
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule()
.fill(Color.white.opacity(0.2))
.frame(height: 3)
.frame(height: 4)
Capsule()
.fill(Color.pink)
.frame(width: geo.size.width * audioPlayer.progressPercent, height: 3)
.frame(width: geo.size.width * audioPlayer.progressPercent, height: 4)
}
.frame(maxHeight: .infinity)
.contentShape(Rectangle())
.onTapGesture { location in
let pct = max(0, min(location.x / geo.size.width, 1))
audioPlayer.seek(to: pct * audioPlayer.duration)
}
}
.frame(height: 3)
.frame(height: 20)
.padding(.horizontal, 8)
// Time labels
@ -99,58 +118,60 @@ struct WatchNowPlayingView: View {
Spacer()
}
// Bottom row
HStack(spacing: 14) {
// Shuffle / Repeat
HStack(spacing: 20) {
Button(action: { audioPlayer.toggleShuffle() }) {
Image(systemName: "shuffle")
.font(.system(size: 11))
.font(.system(size: 13))
.foregroundColor(audioPlayer.shuffleEnabled ? .pink : .gray)
}
.buttonStyle(.plain)
Button(action: { audioPlayer.cycleRepeat() }) {
Image(systemName: audioPlayer.repeatMode == .one ? "repeat.1" : "repeat")
.font(.system(size: 11))
.font(.system(size: 13))
.foregroundColor(audioPlayer.repeatMode != .off ? .pink : .gray)
}
.buttonStyle(.plain)
// Volume
HStack(spacing: 2) {
Image(systemName: "speaker.fill")
.font(.system(size: 7))
.foregroundColor(.gray)
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.white.opacity(0.15)).frame(height: 2)
Capsule().fill(Color.white.opacity(0.6))
.frame(width: geo.size.width * CGFloat(audioPlayer.volume), height: 2)
}
}
.frame(height: 8)
Image(systemName: "speaker.wave.2.fill")
.font(.system(size: 7))
.foregroundColor(.gray)
}
.frame(width: 60)
}
// Volume controls dedicated row with large tap targets
HStack(spacing: 0) {
Button(action: {
let newVol = max(0, audioPlayer.volume - 0.1)
audioPlayer.setVolume(newVol)
}) {
Image(systemName: "speaker.minus.fill")
.font(.system(size: 16))
.foregroundColor(.white)
.frame(maxWidth: .infinity, minHeight: 36)
.background(Color.white.opacity(0.08))
.cornerRadius(10)
}
.buttonStyle(.plain)
// Volume level indicator
Text("\(Int(audioPlayer.volume * 100))%")
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundColor(.gray)
.frame(width: 44)
Button(action: {
let newVol = min(1, audioPlayer.volume + 0.1)
audioPlayer.setVolume(newVol)
}) {
Image(systemName: "speaker.plus.fill")
.font(.system(size: 16))
.foregroundColor(.white)
.frame(maxWidth: .infinity, minHeight: 36)
.background(Color.white.opacity(0.08))
.cornerRadius(10)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 4)
}
.padding(.horizontal, 4)
.focused($isCrownFocused)
.digitalCrownRotation(
$crownVolume,
from: 0, through: 1, by: 0.05,
sensitivity: .medium,
isContinuous: false,
isHapticFeedbackEnabled: true
)
.onChange(of: crownVolume) { _, newValue in
audioPlayer.setVolume(Float(newValue))
}
.onAppear {
crownVolume = Double(audioPlayer.volume)
isCrownFocused = true
}
.sheet(isPresented: $showRouteInfo) {
audioRouteSheet
}
@ -281,59 +302,193 @@ struct WatchNowPlayingView: View {
// MARK: - Remote Control (iPhone is playing)
private func remoteNowPlaying(_ nowPlaying: WatchSessionManager.PhoneNowPlaying) -> some View {
VStack(spacing: 8) {
VStack(spacing: 4) {
// "Playing on iPhone" badge
HStack(spacing: 4) {
Image(systemName: "iphone")
.font(.system(size: 10))
.font(.system(size: 9))
.foregroundColor(.blue)
Text("Playing on iPhone")
.font(.system(size: 10))
Text("iPhone")
.font(.system(size: 9, weight: .medium))
.foregroundColor(.blue)
}
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(Capsule().fill(Color.blue.opacity(0.15)))
VStack(spacing: 2) {
Text(nowPlaying.title)
.font(.system(size: 14, weight: .semibold))
.lineLimit(1)
// Cover art + song info
HStack(spacing: 10) {
if let coverArtId = nowPlaying.coverArtId,
let url = watchManager.coverArtURL(id: coverArtId, size: 80) {
AsyncImage(url: url) { image in
image.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
RoundedRectangle(cornerRadius: 6)
.fill(Color.white.opacity(0.1))
.overlay(
Image(systemName: "music.note")
.font(.system(size: 14))
.foregroundColor(.gray)
)
}
.frame(width: 40, height: 40)
.cornerRadius(6)
} else {
RoundedRectangle(cornerRadius: 6)
.fill(Color.white.opacity(0.1))
.frame(width: 40, height: 40)
.overlay(
Image(systemName: "music.note")
.font(.system(size: 14))
.foregroundColor(.gray)
)
}
Text(nowPlaying.artist)
.font(.system(size: 11))
.foregroundColor(.pink)
.lineLimit(1)
VStack(alignment: .leading, spacing: 2) {
Text(nowPlaying.title)
.font(.system(size: 13, weight: .semibold))
.lineLimit(1)
Text(nowPlaying.artist)
.font(.system(size: 11))
.foregroundColor(.pink)
.lineLimit(1)
Text(nowPlaying.album)
.font(.system(size: 10))
.foregroundColor(.gray)
.lineLimit(1)
}
Spacer(minLength: 0)
}
.padding(.horizontal, 4)
// Progress bar (tap to seek)
GeometryReader { geo in
let pct = nowPlaying.duration > 0 ? nowPlaying.currentTime / nowPlaying.duration : 0
ZStack(alignment: .leading) {
Capsule().fill(Color.white.opacity(0.2)).frame(height: 3)
Capsule().fill(Color.white.opacity(0.2)).frame(height: 4)
Capsule().fill(Color.pink)
.frame(width: geo.size.width * pct, height: 3)
.frame(width: geo.size.width * pct, height: 4)
}
.frame(maxHeight: .infinity)
.contentShape(Rectangle())
.onTapGesture { location in
let seekPct = max(0, min(location.x / geo.size.width, 1))
let seekTime = nowPlaying.duration * seekPct
watchManager.sendSeekCommand(to: seekTime)
}
}
.frame(height: 3)
.frame(height: 20)
.padding(.horizontal, 8)
HStack(spacing: 20) {
// Time labels
HStack {
Text(formatTime(nowPlaying.currentTime))
.font(.system(size: 9))
.foregroundColor(.gray)
Spacer()
Text("-\(formatTime(max(0, nowPlaying.duration - nowPlaying.currentTime)))")
.font(.system(size: 9))
.foregroundColor(.gray)
}
.padding(.horizontal, 8)
// Transport controls
HStack(spacing: 0) {
Spacer()
Button(action: { watchManager.sendPreviousCommand() }) {
Image(systemName: "backward.fill")
.font(.system(size: 16))
.foregroundColor(.white)
}
.buttonStyle(.plain)
.frame(width: 40, height: 36)
Spacer()
Button(action: { watchManager.sendPlayCommand() }) {
Image(systemName: nowPlaying.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 24))
.foregroundColor(.white)
}
.buttonStyle(.plain)
.frame(width: 50, height: 44)
Spacer()
Button(action: { watchManager.sendNextCommand() }) {
Image(systemName: "forward.fill")
.font(.system(size: 16))
.foregroundColor(.white)
}
.buttonStyle(.plain)
.frame(width: 40, height: 36)
Spacer()
}
// Shuffle / Repeat
HStack(spacing: 20) {
Button(action: { watchManager.sendShuffleCommand() }) {
Image(systemName: "shuffle")
.font(.system(size: 13))
.foregroundColor(nowPlaying.shuffleEnabled ? .pink : .gray)
}
.buttonStyle(.plain)
Button(action: { watchManager.sendRepeatCommand() }) {
Image(systemName: nowPlaying.repeatMode == 2 ? "repeat.1" : "repeat")
.font(.system(size: 13))
.foregroundColor(nowPlaying.repeatMode != 0 ? .pink : .gray)
}
.buttonStyle(.plain)
}
// Volume controls dedicated row with large tap targets
HStack(spacing: 0) {
Button(action: {
crownVolume = max(0, crownVolume - 0.1)
watchManager.sendVolumeCommand(Float(crownVolume))
}) {
Image(systemName: "speaker.minus.fill")
.font(.system(size: 16))
.foregroundColor(.white)
.frame(maxWidth: .infinity, minHeight: 36)
.background(Color.white.opacity(0.08))
.cornerRadius(10)
}
.buttonStyle(.plain)
Text("\(Int(crownVolume * 100))%")
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundColor(.gray)
.frame(width: 44)
Button(action: {
crownVolume = min(1, crownVolume + 0.1)
watchManager.sendVolumeCommand(Float(crownVolume))
}) {
Image(systemName: "speaker.plus.fill")
.font(.system(size: 16))
.foregroundColor(.white)
.frame(maxWidth: .infinity, minHeight: 36)
.background(Color.white.opacity(0.08))
.cornerRadius(10)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 4)
}
.padding()
.padding(.horizontal, 4)
.onAppear {
crownVolume = Double(nowPlaying.volume)
}
}
private func formatTime(_ seconds: TimeInterval) -> String {
let m = Int(seconds) / 60
let s = Int(seconds) % 60
return String(format: "%d:%02d", m, s)
}
// MARK: - Empty State
@ -347,7 +502,19 @@ struct WatchNowPlayingView: View {
.font(.caption)
.foregroundColor(.gray)
// Show connect button if no Bluetooth
if watchManager.isPhoneReachable {
Text("Play music on your iPhone to control it here")
.font(.system(size: 10))
.foregroundColor(.gray.opacity(0.7))
.multilineTextAlignment(.center)
.padding(.horizontal, 16)
} else {
Text("iPhone not connected")
.font(.system(size: 10))
.foregroundColor(.orange.opacity(0.8))
}
// Show connect button if no Bluetooth (for local watch playback)
if !audioPlayer.isBluetoothConnected {
Button(action: {
Task { await audioPlayer.activateSession() }