Compare commits
2 commits
main
...
experiment
| Author | SHA1 | Date | |
|---|---|---|---|
| c6451b440b | |||
| 3385b88270 |
23 changed files with 265 additions and 774 deletions
67
CHANGELOG.md
67
CHANGELOG.md
|
|
@ -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
104
CLAUDE.md
|
|
@ -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`
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)"])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -216,4 +216,3 @@ struct RootView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -123,8 +123,6 @@ struct LicensesView: View {
|
|||
} header: {
|
||||
Text("Acknowledgments")
|
||||
}
|
||||
|
||||
Section {} footer: { Spacer().frame(height: 80) }
|
||||
}
|
||||
.navigationTitle("Licenses")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ struct MyMusicView: View {
|
|||
case .songs: songsTab
|
||||
case .genres: genresTab
|
||||
}
|
||||
Color.clear.frame(height: 80)
|
||||
Color.clear.frame(height: 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -154,7 +154,6 @@ struct RadioView: View {
|
|||
ForEach(stations) { station in
|
||||
stationRow(station)
|
||||
}
|
||||
Section {} footer: { Spacer().frame(height: 80) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
Loading…
Reference in a new issue