Compare commits

..

2 commits

Author SHA1 Message Date
c6451b440b Audio Tap FFT:
- MTAudioProcessingTap with lock-free ring buffer + pre-allocated vDSP FFT
- Tap installs on readyToPlay (fixes 'no audio track' on live streams)
- Shared tap serves FFT + Shazam simultaneously (no more tap conflict)
- Ring buffer reset on removeTap (prevents stale FFT frames)
- Simulation runs as placeholder until stream connects
- All 6 paths verified: start, station change, goLive, seekBack, bg/fg, radio→music

Bug fixes:
- playerItem tracking in radioGoLive/radioSeekBack (was orphaned)
- Combine sinks: .receive(on: .main) on all 4 notification handlers
- ShazamRecognizer stopAll: audio session now actually restores after mic fallback
- processTapBuffer/convertAndMatch methods restored (were dropped in refactor)

Lyrics:
- Bottom toolbar on overlay: Search, Timing (±0.1s/±0.5s inline), Edit
- LyricsManager.globalOffset applied to syncToTime + wordProgress
- openLyricsEditor notification wired to NowPlayingView

UI:
- Server selection button hidden when Dynamic Island active
- Debug: Capture Audio Tap (5s WAV) with share sheet in Visualizer Settings"
2026-04-14 19:45:05 -07:00
3385b88270 Audio Tap Infrastructure:
- AudioTapProcessor: shared MTAudioProcessingTap with lock-free PCM ring buffer
- Pre-allocated vDSP FFT (1024-sample, Hann window, log-frequency 30-band output)
- Zero per-frame heap allocation in FFT path
- Shared tap serves both FFT visualizer and Shazam simultaneously

Fixes (blockers for tap to work):
- radioGoLive/radioSeekBack now update self.playerItem (was orphaned)
- Tap reinstalled on every AVPlayerItem swap (seek, live, station change)
- Tap removed on background, reinstalled on foreground
- Tap removed on radio→music transition

Shazam rework:
- Uses shared AudioTapProcessor instead of creating its own tap
- Fixes tap conflict where Shazam overwrote FFT audioMix
- 500ms wait for tapPrepare callback (sourceFormat timing race)
- Fixed pre-existing bug: stopAll() audio session never restored after mic fallback

Debug capture:
- Capture Audio Tap button in Visualizer Settings
- Records 5s of raw tap PCM as playable WAV file
- Uses actual stream sample rate (not hardcoded 44100)
- Share sheet via Notification pattern (survives view dismiss)
- Spinner auto-resets on appear if capture interrupted by background

Also includes from main branch:
- Edit History UI, batch undo, companion API 7-bug fix
- Recently Played tab, Discover section, Play Queue sync, Share links"
2026-04-14 17:15:34 -07:00
23 changed files with 265 additions and 774 deletions

View file

@ -1,67 +0,0 @@
# 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
View file

@ -1,104 +0,0 @@
# 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,13 +486,7 @@ class AudioPlayer: NSObject, ObservableObject {
isPlaying = true
// Set AudioPlayer.player to the active crossfade player
// 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
}
// so Lock Screen controls and visualizer still work
player = crossfade.activePlayer
// Safety: if boundary observer doesn't fire (short track, bad profile),
@ -706,34 +700,29 @@ class AudioPlayer: NSObject, ObservableObject {
installAudioTapIfNeeded(on: item)
#endif
// 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.
// KVO on status more reliable than polling; fires immediately on failure too
snapshotStatusObservation = item.observe(\.status, options: [.new, .initial]) { [weak self] observedItem, _ in
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
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
}
}
}
@ -1116,21 +1105,6 @@ 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()
@ -1255,13 +1229,7 @@ class AudioPlayer: NSObject, ObservableObject {
currentTime: 0, currentSongId: self.currentSong?.id
)
// 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
}
// Swap AudioPlayer.player to the new active crossfade player
self.player = crossfade.activePlayer
// Re-register the safety-net AVPlayerItemDidPlayToEndTime observer
@ -1399,11 +1367,6 @@ class AudioPlayer: NSObject, ObservableObject {
}
}
func setVolume(_ vol: Float) {
volume = vol
player?.volume = vol
}
// MARK: - Queue Management
func playNext(_ song: Song) {
@ -1524,14 +1487,6 @@ 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
}
@ -1636,9 +1591,6 @@ 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
@ -1662,7 +1614,6 @@ 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
@ -1695,10 +1646,7 @@ class AudioPlayer: NSObject, ObservableObject {
cutoff: cutoff,
extractSmartDJ: needsSmartDJ
) { [weak self] pct in
Task { @MainActor in
guard self?.currentSong?.id == songId else { return }
self?.offlineVisProgress = pct
}
Task { @MainActor in self?.offlineVisProgress = pct }
}
// Seed SmartDJ cache from on-device analysis if we extracted it
@ -1728,7 +1676,6 @@ 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
@ -2107,15 +2054,12 @@ class AudioPlayer: NSObject, ObservableObject {
// sourceFormat from confusing Shazam into skipping tap installation
AudioTapProcessor.shared.removeTap(from: playerItem)
#endif
// 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.
player?.pause()
player?.replaceCurrentItem(with: nil)
if let observer = timeObserver {
player?.removeTimeObserver(observer)
timeObserver = nil
}
player?.pause()
player?.replaceCurrentItem(with: nil)
playerItem = nil
}
@ -2157,10 +2101,6 @@ 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,15 +394,11 @@ 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,
"shuffleEnabled": player.shuffleEnabled,
"repeatMode": player.repeatMode == .off ? 0 : player.repeatMode == .all ? 1 : 2,
"volume": Double(player.volume)
"duration": duration
]
if let song = song {
@ -547,39 +543,6 @@ 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 {
@ -598,26 +561,6 @@ 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)"])
}

View file

@ -1,55 +0,0 @@
# 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,4 +216,3 @@ struct RootView: View {
}
}
}

View file

@ -88,10 +88,9 @@ struct MainTabView: View {
}
.tint(accentPink)
.safeAreaInset(edge: .bottom) {
// 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)
// Reserve space for MiniPlayerBar so content never gets obscured
if audioPlayer.currentSong != nil && !showNowPlaying {
Color.clear.frame(height: 80)
}
}
.overlay(alignment: .top) {
@ -653,7 +652,7 @@ struct MiniPlayerBar: View {
height: VisualizerSettings.shared.miniPlayerHeight
)
.matchedGeometryEffect(id: "visWave", in: namespace)
.offset(y: VisualizerSettings.shared.miniVisYOffset)
.offset(y: 10)
.opacity(VisualizerSettings.shared.miniOpacity)
.allowsHitTesting(false)
}

View file

@ -65,10 +65,6 @@ 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!
@ -85,23 +81,16 @@ class SmartDJCache {
}
func get(_ relativePath: String) -> SmartDJProfile? {
lock.lock()
if let cached = memoryCache[relativePath] { lock.unlock(); return cached }
lock.unlock()
if let cached = memoryCache[relativePath] { return cached }
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)
@ -117,11 +106,9 @@ 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"
@ -131,37 +118,28 @@ 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",
"DJ bulk import: \(profiles.count) profiles cached (\(memoryCache.count) total in memory)",
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 {
lock.lock()
let count = memoryCache.count
lock.unlock()
return count
}
var cachedCount: Int { memoryCache.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: 80)
Color.clear.frame(height: 120)
}
} else if loadFailed {
VStack(spacing: 16) {

View file

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

View file

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

View file

@ -123,8 +123,6 @@ 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: 80)
Color.clear.frame(height: 100)
}
}
}

View file

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

View file

@ -154,7 +154,6 @@ 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: 80)
Color.clear.frame(height: 120)
}
}
}
@ -216,7 +216,7 @@ struct SearchView: View {
.padding(.leading, 72)
}
}
Color.clear.frame(height: 80)
Color.clear.frame(height: 120)
}
}
}

View file

@ -130,7 +130,6 @@ 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"
@ -199,7 +198,6 @@ 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 {
@ -1187,7 +1185,6 @@ 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
}
}
@ -1271,20 +1268,6 @@ 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: "15"
CURRENT_PROJECT_VERSION: "9"
DEAD_CODE_STRIPPING: true
ENABLE_USER_SCRIPT_SANDBOXING: true
DEVELOPMENT_TEAM: E9C9AGS9K6

View file

@ -23,30 +23,11 @@ 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() {
@ -99,70 +80,19 @@ class WatchSessionManager: NSObject, ObservableObject {
}
func sendPlayCommand() {
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)
session?.sendMessage(["type": "playCommand"], replyHandler: nil, errorHandler: nil)
}
func sendNextCommand() {
session?.sendMessage(["type": "nextCommand"], replyHandler: { _ in }, errorHandler: nil)
session?.sendMessage(["type": "nextCommand"], replyHandler: nil, errorHandler: nil)
}
func sendPreviousCommand() {
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 })
session?.sendMessage(["type": "previousCommand"], replyHandler: nil, errorHandler: nil)
}
func requestDownload(songId: String) {
session?.sendMessage(["type": "requestDownload", "songId": songId], replyHandler: { _ in }, errorHandler: nil)
session?.sendMessage(["type": "requestDownload", "songId": songId], replyHandler: nil, errorHandler: nil)
}
// MARK: - Direct API Access (watch can also talk to server directly)
@ -464,12 +394,15 @@ extension WatchSessionManager: WCSessionDelegate {
if type == "nowPlaying" {
DispatchQueue.main.async {
// 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)
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
)
}
} 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. Session must be
/// activated via `activate(options:completionHandler:)`.
/// Uses `.longFormAudio` policy. Requires Bluetooth/AirPlay. Background playback is
/// handled by the audio session itself.
///
/// **Speaker Mode** (Watch Ultra):
/// 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.
/// 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.
///
/// Toggle between modes in the Now Playing route indicator.
class WatchAudioPlayer: NSObject, ObservableObject {
@ -61,6 +61,11 @@ 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")
@ -78,6 +83,7 @@ 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")
}
@ -87,14 +93,15 @@ class WatchAudioPlayer: NSObject, ObservableObject {
private func configureAudioSession() {
let session = AVAudioSession.sharedInstance()
do {
// 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")
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)")
}
updateRouteInfo()
} catch {
print("[WatchAudio] Session config FAILED: \(error.localizedDescription)")
@ -105,16 +112,77 @@ class WatchAudioPlayer: NSObject, ObservableObject {
let wasPlaying = isPlaying
if wasPlaying { player?.pause() }
configureAudioSession()
if useSpeakerMode {
startWorkoutSession()
} else {
stopWorkoutSession()
}
if wasPlaying { player?.play() }
updateRouteInfo()
}
// MARK: - Session Activation
// 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)
/// 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
@ -161,14 +229,9 @@ class WatchAudioPlayer: NSObject, ObservableObject {
self.updateRouteInfo()
switch reason {
case .oldDeviceUnavailable:
// 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")
if !self.useSpeakerMode {
print("[WatchAudio] Bluetooth disconnected — pausing")
self.pause()
} else {
print("[WatchAudio] Bluetooth disconnected — continuing on speaker")
}
case .newDeviceAvailable:
print("[WatchAudio] New route: \(self.currentRouteName)")
@ -229,9 +292,16 @@ class WatchAudioPlayer: NSObject, ObservableObject {
return
}
// Activate session before playback. .longFormAudio provides background
// runtime. On watchOS 11+, routes to speaker if no BT connected.
if isSessionActive && (isBluetoothConnected || useSpeakerMode) {
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 {
playFromURL(playURL)
return
}
@ -304,6 +374,7 @@ class WatchAudioPlayer: NSObject, ObservableObject {
}
func resume() {
if useSpeakerMode { startWorkoutSession() }
player?.play()
isPlaying = true
updateNowPlayingInfo()
@ -358,6 +429,7 @@ 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
@ -397,3 +469,28 @@ 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,6 +30,21 @@
<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,5 +6,9 @@
<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,28 +6,15 @@ struct WatchNowPlayingView: View {
@State private var crownVolume: Double = 0.8
@State private var showRouteInfo = false
@FocusState private var isCrownFocused: Bool
var body: some View {
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()
}
if let song = audioPlayer.currentSong {
localNowPlaying(song)
} else if let phone = watchManager.phoneNowPlaying {
remoteNowPlaying(phone)
} else {
emptyState
}
}
@ -52,24 +39,18 @@ struct WatchNowPlayingView: View {
}
.padding(.top, 2)
// Progress bar (tap to seek)
// Progress bar
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule()
.fill(Color.white.opacity(0.2))
.frame(height: 4)
.frame(height: 3)
Capsule()
.fill(Color.pink)
.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(width: geo.size.width * audioPlayer.progressPercent, height: 3)
}
}
.frame(height: 20)
.frame(height: 3)
.padding(.horizontal, 8)
// Time labels
@ -118,60 +99,58 @@ struct WatchNowPlayingView: View {
Spacer()
}
// Shuffle / Repeat
HStack(spacing: 20) {
// Bottom row
HStack(spacing: 14) {
Button(action: { audioPlayer.toggleShuffle() }) {
Image(systemName: "shuffle")
.font(.system(size: 13))
.font(.system(size: 11))
.foregroundColor(audioPlayer.shuffleEnabled ? .pink : .gray)
}
.buttonStyle(.plain)
Button(action: { audioPlayer.cycleRepeat() }) {
Image(systemName: audioPlayer.repeatMode == .one ? "repeat.1" : "repeat")
.font(.system(size: 13))
.font(.system(size: 11))
.foregroundColor(audioPlayer.repeatMode != .off ? .pink : .gray)
}
.buttonStyle(.plain)
}
// 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)
// 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)
}
.buttonStyle(.plain)
.frame(width: 60)
}
.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
}
@ -302,193 +281,59 @@ struct WatchNowPlayingView: View {
// MARK: - Remote Control (iPhone is playing)
private func remoteNowPlaying(_ nowPlaying: WatchSessionManager.PhoneNowPlaying) -> some View {
VStack(spacing: 4) {
// "Playing on iPhone" badge
VStack(spacing: 8) {
HStack(spacing: 4) {
Image(systemName: "iphone")
.font(.system(size: 9))
.font(.system(size: 10))
.foregroundColor(.blue)
Text("iPhone")
.font(.system(size: 9, weight: .medium))
Text("Playing on iPhone")
.font(.system(size: 10))
.foregroundColor(.blue)
}
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(Capsule().fill(Color.blue.opacity(0.15)))
// 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)
)
}
VStack(spacing: 2) {
Text(nowPlaying.title)
.font(.system(size: 14, weight: .semibold))
.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)
Text(nowPlaying.artist)
.font(.system(size: 11))
.foregroundColor(.pink)
.lineLimit(1)
}
.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: 4)
Capsule().fill(Color.white.opacity(0.2)).frame(height: 3)
Capsule().fill(Color.pink)
.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(width: geo.size.width * pct, height: 3)
}
}
.frame(height: 20)
.frame(height: 3)
.padding(.horizontal, 8)
// 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()
HStack(spacing: 20) {
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(.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)
.padding()
}
// MARK: - Empty State
@ -502,19 +347,7 @@ struct WatchNowPlayingView: View {
.font(.caption)
.foregroundColor(.gray)
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)
// Show connect button if no Bluetooth
if !audioPlayer.isBluetoothConnected {
Button(action: {
Task { await audioPlayer.activateSession() }