Compare commits

...

9 commits

Author SHA1 Message Date
97f2f9ab76 Watch: large volume buttons, remove Digital Crown volume
Volume controls moved to dedicated row with large tap targets
(36pt height, full-width, dark background, percentage readout).
Digital Crown binding removed — buttons are the primary control.
Applied to both local playback and iPhone remote views.
2026-04-30 23:13:40 -07:00
99bf17ec1a Fix 3 crashes from crash logs: SmartDJCache race, AVPlayer observer, KVO threading
🔴 SmartDJCache concurrent Dictionary crash (April 27+28 crash logs):
- EXC_BAD_ACCESS + doesNotRecognizeSelector in bulkImport()
- memoryCache dictionary mutated from main thread (loadBulkCache)
  and background Task.detached (bulkImport) simultaneously
- Fix: NSLock serializes all memoryCache reads and writes

🔴 stopAVPlayer removeTimeObserver crash (April 30 crash log):
- SIGABRT in -[AVPlayer removeTimeObserver:] from Previous button
- timeObserver registered on old player, self.player swapped to
  crossfade's active player by finalizeCrossfade
- Fix: remove observer from OLD player at both swap sites before
  assignment + reorder stopAVPlayer (observer before replaceCurrentItem)

🟡 Audit fixes (no crash logs, preventative):
- KVO in radioSeekBack wrapped in DispatchQueue.main.async
- Stale vis Task guarded by songId in all MainActor.run blocks
- CHANGELOG.md with full findings documentation
2026-04-30 17:27:54 -07:00
5205d708c3 Watch: fix command routing, seek bar, volume UI
Fix 1 — Command routing:
All sendMessage calls now use replyHandler (even { _ in }) so
WCSession routes them to didReceiveMessage:replyHandler: where
the actual command handling lives. Previously next/prev/seek/volume
silently went to the fire-and-forget handler which dropped them.

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

Fix 3 — Volume:
Replaced tiny slider bar with speaker.minus/speaker.plus buttons.
Crown still works for fine control. Both local and remote views.
2026-04-20 13:06:06 -07:00
127d5926a3 test: verify Forgejo hooks 2026-04-17 10:04:20 -07:00
fba2da4700 UI scaling on scrollabile windows 2026-04-17 09:58:05 -07:00
72c622ab44 quick fix 2026-04-14 12:59:22 -07:00
53b15aac27 Add edit history, discover, queue sync, share links
- Edit History: full timeline UI with revert capability, accessible from Issues & Conflicts
- Batch Undo: inline undo button in BatchAlbumEditorSheet + MultiAlbumEditorSheet
- Companion API: 7 bug fixes (audio.delete data loss, path-key backup, AIFF support,
  restructure backup, manifest context, double-undo prevention, single-track history)
- Recently Played: new tab between Recently Added and Favourites
- Discover: random albums scroll, shuffle all, genre quick-play chips on home tab
- Play Queue Sync: auto-saves to server, cross-device resume banner on launch
- Share Links: create + copy + share via sheet in Get Info
- Models: PlayQueue, ShareItem, EditHistoryEntry, UndoResult
- SubsonicClient: savePlayQueue, getPlayQueue, createShare endpoints
2026-04-14 11:35:52 -07:00
2b81032455 Fix audio session stealing from other apps on launch
Deferred audio session configuration from AudioPlayer.init() to first
play. Setting .playback category on init interrupted podcasts and other
audio before the user even tapped a song.

- configureAudioSession() removed from init, added to crossfade path
  and playLocalWithEngine (playWithAVPlayer already had it)
- resumeVisTimers: setActive(true) moved below guard isPlaying so
  foregrounding the app without active playback leaves other audio alone
- All 4 setActive(true) sites now gated behind actual playback or
  system interruption recovery
2026-04-14 00:57:23 -07:00
842cb6353b Widget v2 glassmorphism, lyrics, backup, crossfade fixes
Widget v2:
- Glassmorphism glass panel, 40-bar waveform Canvas with SeekToIntent
- CIAreaAverage color extraction + secondary color for adaptive theming
- Small: inset glass. Medium/Large: flush edge-to-edge (contentMarginsDisabled)
- Large: 3-item queue with real cover art thumbnails, crossfade countdown
- Waveform sampled from offline vis buffer, seeded PRNG fallback

Live Lyrics:
- LyricsService: direct LRCLIB from iOS, no companion dependency
- Embedded lyrics read via AVAsset.load(.metadata) from downloads
- Karaoke word-by-word gradient fill, auto-scroll, fade edges
- Tap-to-sync timing editor with +/-0.1s offset adjust
- Companion API fallback only for server-side embedded tags

Backup System:
- .nvdbackup export/import via ZIPFoundation
- UTI registered for AirDrop, passwords stripped on export
- PendingOperationsQueue with retry + disk persistence

Crossfade fixes:
- Seek bar: reports from incoming player immediately, not at 50%
- songHandoff at midpoint: art/colors/text/lyrics transition mid-fade
- Scrobble fires before metadata swap (correct outgoing song)

Visualizer fixes:
- stopAll() zeros _audioLevels + clears offlineVisBuffer
- All 5 simulation start sites gated behind realAudioAnalysis check
- Bars stay flat between skips, only rise when real vis data loads

Smart DJ bulk prefetch, appendingPathComponent slash fix
2026-04-14 00:44:38 -07:00
34 changed files with 3716 additions and 420 deletions

67
CHANGELOG.md Normal file
View file

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

104
CLAUDE.md Normal file
View file

@ -0,0 +1,104 @@
# NavidromePlayer
iOS/watchOS native Subsonic music player connecting to a Navidrome server.
XcodeGen-based project (`project.yml` + `./generate.sh`).
## Build
- `./generate.sh` to regenerate Xcode project after adding/removing files
- Bundle prefix: `ca.dallasgroot`, Team ID: `E9C9AGS9K6`
- Targets: iOS 26+, watchOS 26+
- App Group: `group.com.navidromeplayer.shared`
- TestFlight build: currently at 13 (version 1.0.0)
- Always increment `CURRENT_PROJECT_VERSION` in `project.yml` before any release zip
- Git repo: `https://repo.dallasgroot.ca/dallasgroot/NavidromeApp.git`
- Push via SSH (Forgejo hooks disabled — push works, Forgejo reads repo directly)
## Architecture
```
Shared/ — Models, SubsonicClient, AudioPlayer (compiles for iOS + watchOS)
iOS/ — Views, data managers, visualizer, audio tap (iOS only)
watchOS/ — Watch app, WatchAudioPlayer, WatchSessionManager
Widget/ — Now Playing widget extension
companion-api/ — Python FastAPI server on Raspberry Pi (Docker)
```
- `project.yml` iOS target sources: `iOS/` + `Shared/`
- `project.yml` watchOS target sources: `watchOS/` + `Shared/`
- Widget target sources: `Widget/` only
## Critical Rules
### Swift / Xcode
- `AudioPlayer.swift` is in `Shared/` — ALL iOS-only code MUST be inside `#if os(iOS)` / `#endif`
- `AudioTapProcessor`, `VisualizerSettings`, `PlayQueueSyncManager` are iOS-only files (in `iOS/`)
- Never reference iOS-only types from `Shared/` without `#if os(iOS)` guards
- `CompanionAPIService` is a Swift `actor` — model structs MUST be declared at TOP LEVEL outside the actor
- `FlowLayout` is defined in `LyricsOverlayView.swift` — never redeclare it
- New Swift types should go in EXISTING registered files when possible
- Never use placeholders like `// existing code` or `// rest unchanged` — always output complete file contents
- `PlaybackStateStore.swift` lives in `Shared/Storage/` (was moved from `iOS/Data/`)
### Threading & Concurrency (from crash audit)
- `SmartDJCache` uses `NSLock` for all `memoryCache` access — NEVER access without lock
- When swapping `self.player` to `crossfade.activePlayer`, ALWAYS remove `timeObserver` from the OLD player first — removing from wrong player throws NSException → SIGABRT
- `stopAVPlayer()` removes time observer BEFORE `replaceCurrentItem(with: nil)`
- All Combine `.sink` handlers on NotificationCenter publishers MUST use `.receive(on: DispatchQueue.main)`
- KVO `item.observe(\.status)` callbacks fire on AVFoundation's internal thread — wrap body in `DispatchQueue.main.async`
- `loadOfflineVisualizer` Task completion blocks MUST guard `self.currentSong?.id == songId` to prevent stale vis data after track skip
### Visualizer / Audio Tap
- Radio streams use `MTAudioProcessingTap` via `AudioTapProcessor.shared` for real-time FFT
- Tap MUST install on `.readyToPlay` status, NOT immediately after `player.play()`
- `startRadioSimulation()` runs as visual placeholder until tap installs
- `ShazamRecognizer` subscribes to shared tap via `shazamHandler` — does NOT create its own tap
- Background: tap removed in `suspendVisTimers()`, reinstalled in `resumeVisTimers()`
- Every `player?.replaceCurrentItem(with: item)` MUST also set `self.playerItem = item`
### watchOS Audio
- NO HealthKit — uses `.longFormAudio` policy for both speaker and Bluetooth
- `.longFormAudio` provides background runtime automatically
- On watchOS 11+, `.longFormAudio` falls through to built-in speaker when no Bluetooth connected
- Activation MUST use `activate(options:completionHandler:)` — NOT `setActive(true)` (throws OSStatus 561145203)
- `useSpeakerMode` toggle is a user preference only — underlying session policy is always `.longFormAudio`
- Watch remote control: iPhone pushes state via `sendNowPlayingToWatch()` in `updateNowPlayingInfo()`
- All WCSession `sendMessage` calls MUST use `replyHandler: { _ in }` (not `nil`) — `nil` routes to wrong delegate method
- iPhone command handlers (`playCommand`, `nextCommand`, etc.) dispatch to `DispatchQueue.main.async`
### Companion API
- Tag writes MUST call `backup_tags()` first and abort if it returns `None`
- See `companion-api/CLAUDE.md` for server-specific rules
## Key Patterns
### Subsonic API
- `SubsonicClient.swift` — all server communication
- Response types in `Shared/Models/Models.swift` via `SubsonicResponseBody`
### Data flow for visualizer
```
AVPlayerItem → MTAudioProcessingTap (C callback, render thread)
→ PCMRingBuffer (lock-free, 8192 samples)
→ Timer at 30fps (main thread)
→ vDSP FFT (1024 samples, Hann window, log-frequency bands)
→ setLevels([Float]) → _audioLevels
→ MitsuhaVisualizerView reads currentLevels()
```
### Watch remote data flow
```
iPhone: updateNowPlayingInfo() → sendNowPlayingToWatch() → WCSession message
Watch: didReceiveMessage → phoneNowPlaying → WatchNowPlayingView
Watch → iPhone: sendPlayCommand/nextCommand/etc → didReceiveMessage:replyHandler:
```
## Known Crash History (all fixed)
- Build 11: `removeTimeObserver` on wrong AVPlayer after crossfade swap (SIGABRT)
- Build 11: `SmartDJCache.bulkImport` concurrent Dictionary mutation (EXC_BAD_ACCESS + doesNotRecognizeSelector)
- Build 12: Same `removeTimeObserver` crash reproduced, confirmed fix in build 13
## Testing
- Build and run on device via Xcode
- TestFlight for distribution testing
- Companion API: `docker compose build music-companion && docker compose up -d`

View file

@ -329,6 +329,34 @@ class SubsonicClient: ObservableObject {
return body.internetRadioStations?.internetRadioStation ?? []
}
// MARK: - Play Queue (cross-device sync)
/// Save the current play queue to the server for cross-device resume.
func savePlayQueue(songIds: [String], current: String? = nil, position: Int64 = 0) async throws {
var params: [URLQueryItem] = songIds.map { URLQueryItem(name: "id", value: $0) }
if let c = current { params.append(URLQueryItem(name: "current", value: c)) }
params.append(URLQueryItem(name: "position", value: "\(position)"))
_ = try await requestBody(endpoint: "savePlayQueue", params: params)
}
/// Get the saved play queue from the server (saved by any device).
func getPlayQueue() async throws -> PlayQueue? {
let body = try await requestBody(endpoint: "getPlayQueue")
return body.playQueue
}
// MARK: - Shares (public links)
/// Create a sharing link for a song, album, or playlist.
/// Returns the created share, or nil if the server doesn't support sharing.
func createShare(id: String, description: String? = nil, expires: Int64? = nil) async throws -> ShareItem? {
var params = [URLQueryItem(name: "id", value: id)]
if let d = description { params.append(URLQueryItem(name: "description", value: d)) }
if let e = expires { params.append(URLQueryItem(name: "expires", value: "\(e)")) }
let body = try await requestBody(endpoint: "createShare", params: params)
return body.shares?.share?.first
}
}
// MARK: - API Errors

View file

@ -134,7 +134,9 @@ class AudioPlayer: NSObject, ObservableObject {
vDSP_hann_window(&fftWindow, 1024, Int32(vDSP_HANN_NORM))
super.init()
configureAudioSession()
// Audio session is configured lazily on first play not here.
// Setting .playback category on init interrupts whatever audio is
// already playing (podcasts, other music apps).
setupRemoteControls()
#if os(iOS)
@ -150,11 +152,13 @@ class AudioPlayer: NSObject, ObservableObject {
// Stop all visualizer timers when backgrounded they serve no purpose without
// a visible Canvas and burn CPU (causing XPC_EXIT_REASON_FAULT at ~169% CPU).
NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.suspendVisTimers() }
.store(in: &cancellables)
// Restart the correct timer when returning to foreground
NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.resumeVisTimers() }
.store(in: &cancellables)
@ -163,12 +167,14 @@ class AudioPlayer: NSObject, ObservableObject {
// other apps taking the audio session. Without this, playback stops but
// isPlaying stays true timers keep firing and music never auto-resumes.
NotificationCenter.default.publisher(for: AVAudioSession.interruptionNotification)
.receive(on: DispatchQueue.main)
.sink { [weak self] notification in self?.handleAudioInterruption(notification) }
.store(in: &cancellables)
// Audio route change (headphones unplugged etc.)
// Apple HIG: pause on oldDeviceUnavailable (headphones pulled out).
NotificationCenter.default.publisher(for: AVAudioSession.routeChangeNotification)
.receive(on: DispatchQueue.main)
.sink { [weak self] notification in self?.handleRouteChange(notification) }
.store(in: &cancellables)
#endif
@ -236,6 +242,12 @@ class AudioPlayer: NSObject, ObservableObject {
analysisTask = nil
AudioPreFetcher.shared.cancelAll()
removeTimeObserver()
#if os(iOS)
// Remove audio tap on background no point running FFT when no one can see it
if isRadioStream {
AudioTapProcessor.shared.removeTap(from: playerItem)
}
#endif
// SmartCrossfadeManager has its own 10Hz time observer that writes
// @Published currentTime/duration same SwiftUI churn as AudioPlayer's
if isUsingCrossfade {
@ -245,15 +257,6 @@ class AudioPlayer: NSObject, ObservableObject {
}
private func resumeVisTimers() {
// Re-activate the audio session another app may have taken it while we
// were in the background (e.g. a game or Spotify). Without this, player.play()
// silently fails and isPlaying shows true with no audio output.
do {
try AVAudioSession.sharedInstance().setActive(true)
} catch {
alog("Foreground: session reactivation failed: \(error)")
}
// Pick up any widget commands that arrived while the process was suspended.
// Darwin notifications can't wake suspended processes, so commands written
// by widget intents sit in App Group UserDefaults until we check here.
@ -278,9 +281,19 @@ class AudioPlayer: NSObject, ObservableObject {
if isUsingCrossfade {
SmartCrossfadeManager.shared.resumeFromBackground()
}
// Only restart vis timers if actually playing
// Only restart vis timers + reclaim audio session if actually playing.
// If nothing is playing, don't touch the session let podcasts/other apps keep it.
guard isPlaying else { return }
// Re-activate the audio session another app may have taken it while we
// were in the background (e.g. a game or Spotify). Without this, player.play()
// silently fails and isPlaying shows true with no audio output.
do {
try AVAudioSession.sharedInstance().setActive(true)
} catch {
alog("Foreground: session reactivation failed: \(error)")
}
// Restart Now Playing sync timer keeps Lock Screen / Dynamic Island
// seek bar accurate. Created in playWithAVPlayer but never restarted
// after background cycle; without this the system seek bar drifts.
@ -293,9 +306,15 @@ class AudioPlayer: NSObject, ObservableObject {
if isUsingOfflineVis {
startOfflineVisSync()
} else if !isUsingCrossfade {
} else if isRadioStream {
#if os(iOS)
// Reinstall the audio tap that was removed on background
installAudioTapIfNeeded(on: playerItem)
#endif
} else if !isUsingCrossfade && !VisualizerSettings.shared.realAudioAnalysis {
startLevelSimulation()
}
// When realAudioAnalysis is on but vis data hasn't loaded yet: stay at 0.
alog("Foreground: session reactivated, observers + vis timers resumed (crossfade=\(isUsingCrossfade) offlineVis=\(isUsingOfflineVis))")
}
@ -401,6 +420,7 @@ class AudioPlayer: NSObject, ObservableObject {
radioStreamURL = nil
#if os(iOS)
RadioStreamBuffer.shared.stopBuffering()
AudioTapProcessor.shared.removeTap(from: playerItem)
isPlayingFromBuffer = false
#endif
}
@ -413,6 +433,10 @@ class AudioPlayer: NSObject, ObservableObject {
stopAll()
isUsingCrossfade = true
// Claim audio session deferred from init() to first play
configureAudioSession()
activateAudioSession()
// Wire callbacks
crossfade.timeUpdate = { [weak self] time, dur in
self?.currentTime = time
@ -462,7 +486,13 @@ class AudioPlayer: NSObject, ObservableObject {
isPlaying = true
// Set AudioPlayer.player to the active crossfade player
// so Lock Screen controls and visualizer still work
// so Lock Screen controls and visualizer still work.
// Remove any stale time observer first it was registered on
// the old player and would crash if removed from the new one.
if let obs = timeObserver {
player?.removeTimeObserver(obs)
timeObserver = nil
}
player = crossfade.activePlayer
// Safety: if boundary observer doesn't fire (short track, bad profile),
@ -475,7 +505,9 @@ class AudioPlayer: NSObject, ObservableObject {
updateNowPlayingInfo()
fetchAndSetArtwork(coverArtId: song.coverArt)
startLevelSimulation()
if !VisualizerSettings.shared.realAudioAnalysis {
startLevelSimulation()
}
// Load offline visualizer for crossfade path too
if VisualizerSettings.shared.realAudioAnalysis {
@ -659,6 +691,7 @@ class AudioPlayer: NSObject, ObservableObject {
let asset = AVURLAsset(url: snapURL)
let item = AVPlayerItem(asset: asset)
playerItem = item // Keep self.playerItem in sync taps and Shazam depend on this
// Auto-return to live when snapshot playback reaches the end
snapshotEndObserver = NotificationCenter.default.addObserver(
@ -669,30 +702,38 @@ class AudioPlayer: NSObject, ObservableObject {
}
player?.replaceCurrentItem(with: item)
#if os(iOS)
installAudioTapIfNeeded(on: item)
#endif
// KVO on status more reliable than polling; fires immediately on failure too
// KVO on status more reliable than polling; fires immediately on failure too.
// IMPORTANT: NSKeyValueObservation fires on whatever thread changes the property.
// AVPlayerItem.status is changed on an internal AVFoundation thread, NOT main.
// All state access must be dispatched to main to prevent data races.
snapshotStatusObservation = item.observe(\.status, options: [.new, .initial]) { [weak self] observedItem, _ in
guard let self else { return }
switch observedItem.status {
case .readyToPlay:
self.snapshotStatusObservation?.invalidate()
self.snapshotStatusObservation = nil
// Generous tolerance for raw audio without index tables
let cmTime = CMTime(seconds: seekTime, preferredTimescale: 600)
let tol = CMTime(seconds: 1, preferredTimescale: 600)
self.player?.seek(to: cmTime, toleranceBefore: tol, toleranceAfter: tol) { [weak self] _ in
self?.player?.play()
self?.alog("Radio seek: ▶ playing from \(String(format: "%.1f", seekTime))s — \(String(format: "%.0f", snapshotBufSec - seekTime))s behind live")
DispatchQueue.main.async {
guard let self else { return }
switch observedItem.status {
case .readyToPlay:
self.snapshotStatusObservation?.invalidate()
self.snapshotStatusObservation = nil
// Generous tolerance for raw audio without index tables
let cmTime = CMTime(seconds: seekTime, preferredTimescale: 600)
let tol = CMTime(seconds: 1, preferredTimescale: 600)
self.player?.seek(to: cmTime, toleranceBefore: tol, toleranceAfter: tol) { [weak self] _ in
self?.player?.play()
self?.alog("Radio seek: ▶ playing from \(String(format: "%.1f", seekTime))s — \(String(format: "%.0f", snapshotBufSec - seekTime))s behind live")
}
case .failed:
self.snapshotStatusObservation?.invalidate()
self.snapshotStatusObservation = nil
self.alog("Radio seek FAILED: \(observedItem.error?.localizedDescription ?? "unknown error")")
try? FileManager.default.removeItem(at: snapURL)
self.currentSnapshotURL = nil
self.radioGoLive() // fall back to live
default:
break
}
case .failed:
self.snapshotStatusObservation?.invalidate()
self.snapshotStatusObservation = nil
self.alog("Radio seek FAILED: \(observedItem.error?.localizedDescription ?? "unknown error")")
try? FileManager.default.removeItem(at: snapURL)
self.currentSnapshotURL = nil
self.radioGoLive() // fall back to live
default:
break
}
}
}
@ -717,8 +758,24 @@ class AudioPlayer: NSObject, ObservableObject {
let asset = AVURLAsset(url: liveURL)
let item = AVPlayerItem(asset: asset)
playerItem = item // Keep self.playerItem in sync taps and Shazam depend on this
player?.replaceCurrentItem(with: item)
player?.play()
#if os(iOS)
// Start simulation immediately tap installs when stream reaches readyToPlay
startRadioSimulation()
let liveItem = item
Task { @MainActor [weak self] in
for await status in liveItem.publisher(for: \.status).values {
guard let self, self.playerItem === liveItem else { break }
if status == .readyToPlay {
self.installAudioTapIfNeeded(on: liveItem)
break
}
if status == .failed { break }
}
}
#endif
alog("Radio: ▶ live")
}
@ -792,6 +849,7 @@ class AudioPlayer: NSObject, ObservableObject {
// errors that AVPlayer swallows silently otherwise
#if os(iOS)
let itemToObserve = playerItem!
let isRadio = isRadioStream
Task { @MainActor [weak self] in
for await status in itemToObserve.publisher(for: \.status).values {
guard let self = self, self.playerItem === itemToObserve else { break }
@ -803,6 +861,12 @@ class AudioPlayer: NSObject, ObservableObject {
category: "Audio", level: .error)
case .readyToPlay:
DebugLogger.shared.log("✓ Ready: \(url.lastPathComponent)", category: "Audio")
// Install audio tap AFTER the stream has connected and the asset
// has parsed its container. Before readyToPlay, loadTracks returns
// empty for live streams tap silently fails.
if isRadio {
self.installAudioTapIfNeeded(on: itemToObserve)
}
default:
break
}
@ -861,15 +925,18 @@ class AudioPlayer: NSObject, ObservableObject {
updateNowPlayingInfo()
fetchAndSetArtwork(coverArtId: currentSong?.coverArt)
// Radio streams use a smooth sinusoidal simulation random phase
// simulation reacts to currentTime jumps (buffer seeks) causing
// the peakFollower to spike and "raise" the wave unexpectedly.
// Radio streams: start simulation immediately for visual feedback while
// the stream connects. The readyToPlay status observer (above) will install
// the real FFT audio tap once the asset has loaded its tracks.
#if os(iOS)
if isRadioStream {
startRadioSimulation()
} else {
} else if !VisualizerSettings.shared.realAudioAnalysis {
// Simulation mode only real analysis waits for offline vis data
startLevelSimulation()
}
// When realAudioAnalysis is on: levels stay at 0 until
// loadOfflineVisualizer startOfflineVisSync fills them.
#else
startLevelSimulation()
#endif
@ -880,6 +947,7 @@ class AudioPlayer: NSObject, ObservableObject {
private func playLocalWithEngine(_ url: URL) {
#if os(iOS)
alog("Engine path: \(url.lastPathComponent)")
configureAudioSession()
activateAudioSession()
stopAll()
@ -1048,6 +1116,21 @@ class AudioPlayer: NSObject, ObservableObject {
}
func resume() {
// Cold restore: app was killed, queue restored from PlaybackStateStore,
// but AVPlayer was never created. player?.play() would be a nil no-op.
// Detect this and go through the full play(song:) path, then seek to
// the saved position so playback resumes where the user left off.
if player == nil, let song = currentSong {
let savedPosition = currentTime
alog("Cold resume: loading \(song.title) at \(Int(savedPosition))s")
play(song: song)
// AVPlayer queues seeks this executes once the item is ready
if savedPosition > 1 {
seek(to: savedPosition)
}
return
}
#if os(iOS)
if isUsingCrossfade {
SmartCrossfadeManager.shared.resume()
@ -1090,7 +1173,7 @@ class AudioPlayer: NSObject, ObservableObject {
guard let self = self else { return }
self.setLevels(self.rawFFTLevels)
}
} else {
} else if !VisualizerSettings.shared.realAudioAnalysis {
DebugLogger.shared.log("resume: simulation path — levelTimer nil=\(levelTimer == nil)", category: "VisDebug")
// Simulation path: seed internalLevels from a fresh random target
// so the wave has height immediately rather than starting from floor.
@ -1100,6 +1183,7 @@ class AudioPlayer: NSObject, ObservableObject {
setLevels(internalLevels)
if levelTimer == nil { startLevelSimulation() }
}
// else: realAudioAnalysis on, vis data loading stay at 0
pushWidgetState()
#endif
updateNowPlayingInfo()
@ -1171,7 +1255,13 @@ class AudioPlayer: NSObject, ObservableObject {
currentTime: 0, currentSongId: self.currentSong?.id
)
// Swap AudioPlayer.player to the new active crossfade player
// Swap AudioPlayer.player to the new active crossfade player.
// Remove stale time observer first it was registered on the old
// player instance and removing it from the new one throws NSException.
if let obs = self.timeObserver {
self.player?.removeTimeObserver(obs)
self.timeObserver = nil
}
self.player = crossfade.activePlayer
// Re-register the safety-net AVPlayerItemDidPlayToEndTime observer
@ -1309,6 +1399,11 @@ class AudioPlayer: NSObject, ObservableObject {
}
}
func setVolume(_ vol: Float) {
volume = vol
player?.volume = vol
}
// MARK: - Queue Management
func playNext(_ song: Song) {
@ -1429,6 +1524,14 @@ class AudioPlayer: NSObject, ObservableObject {
info[MPMediaItemPropertyArtwork] = artwork
}
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
// Push state to Apple Watch fires on song change, play/pause, and every 5s
WatchConnectivityManager.shared.sendNowPlayingToWatch(
song: currentSong,
isPlaying: isPlaying,
currentTime: currentTime,
duration: duration
)
#endif
}
@ -1533,6 +1636,9 @@ class AudioPlayer: NSObject, ObservableObject {
if let buffer = try? await storage.loadCache(for: songId) {
let normalized = Self.normalizeVisBuffer(buffer)
await MainActor.run {
// Guard: if user skipped tracks while cache was loading,
// don't apply stale vis data from the previous song.
guard self.currentSong?.id == songId else { return }
self.offlineVisBuffer = normalized
self.isUsingOfflineVis = true
self.isUsingRealFFT = true
@ -1556,6 +1662,7 @@ class AudioPlayer: NSObject, ObservableObject {
let normalized = Self.normalizeVisBuffer(rawBuf)
alog("Offline vis: fetched \(serverFrames.count) frames from server")
await MainActor.run {
guard self.currentSong?.id == songId else { return }
self.offlineVisBuffer = normalized
self.isUsingOfflineVis = true
self.isUsingRealFFT = true
@ -1588,7 +1695,10 @@ class AudioPlayer: NSObject, ObservableObject {
cutoff: cutoff,
extractSmartDJ: needsSmartDJ
) { [weak self] pct in
Task { @MainActor in self?.offlineVisProgress = pct }
Task { @MainActor in
guard self?.currentSong?.id == songId else { return }
self?.offlineVisProgress = pct
}
}
// Seed SmartDJ cache from on-device analysis if we extracted it
@ -1618,6 +1728,7 @@ class AudioPlayer: NSObject, ObservableObject {
alog("Offline vis: cached \(frames.count) frames for \(songId)")
await MainActor.run {
guard self.currentSong?.id == songId else { return }
self.offlineVisBuffer = normalized
self.isUsingOfflineVis = true
self.isUsingRealFFT = true
@ -1626,7 +1737,7 @@ class AudioPlayer: NSObject, ObservableObject {
}
} catch {
alog("Offline vis: analysis failed: \(error.localizedDescription)")
await MainActor.run { self.startLevelSimulation() }
// Don't fall back to simulation levels stay at 0
}
} // end if !fetched
}
@ -1718,6 +1829,57 @@ class AudioPlayer: NSObject, ObservableObject {
}
#endif
// MARK: - Real-time FFT via Audio Tap (radio streams)
#if os(iOS)
/// Install the shared audio tap on a player item for real-time FFT visualization.
/// Async because `loadTracks(withMediaType:)` is the modern non-deprecated API.
/// Falls back to sinusoidal simulation if tap installation fails.
func installAudioTapIfNeeded(on item: AVPlayerItem?) {
guard let item else {
startRadioSimulation()
return
}
Task { @MainActor in
let success = await AudioTapProcessor.shared.installTap(on: item)
if success {
startRadioFFT()
} else {
alog("Audio tap failed — falling back to simulation")
startRadioSimulation()
}
}
}
/// Timer that reads real PCM samples from the audio tap ring buffer,
/// runs vDSP FFT, and feeds 30 frequency bands to the visualizer.
/// Replaces startRadioSimulation() when the tap is active.
private func startRadioFFT() {
stopLevelTimer()
alog("Radio FFT: ▶ real-time analysis active")
levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { [weak self] _ in
guard let self else { return }
guard VisualizerSettings.shared.enabled else { return }
guard self.isPlaying else {
if self.internalLevels.contains(where: { $0 > 0.01 }) {
for i in 0..<self.internalLevels.count { self.internalLevels[i] = 0 }
self.setLevels(self.internalLevels)
}
return
}
let bands = AudioTapProcessor.shared.computeFFTBands(bandCount: self.internalLevels.count)
// Apply viscosity smoothing (same physics as offline vis path)
let viscosity = Float(VisualizerSettings.shared.viscosity)
for i in 0..<self.internalLevels.count {
self.internalLevels[i] = self.internalLevels[i] * viscosity + bands[i] * (1 - viscosity)
}
self.setLevels(self.internalLevels)
}
}
#endif
// MARK: - Simulated Level Animation (for streams)
/// Radio-specific simulation: smooth sinusoidal wave that never spikes.
@ -1940,12 +2102,20 @@ class AudioPlayer: NSObject, ObservableObject {
}
private func stopAVPlayer() {
player?.pause()
player?.replaceCurrentItem(with: nil)
#if os(iOS)
// Clean up audio tap before nilling the playerItem prevents stale
// 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.
if let observer = timeObserver {
player?.removeTimeObserver(observer)
timeObserver = nil
}
player?.pause()
player?.replaceCurrentItem(with: nil)
playerItem = nil
}
@ -1987,6 +2157,10 @@ class AudioPlayer: NSObject, ObservableObject {
#if os(iOS)
offlineVisBuffer = VisFrameBuffer.empty
WidgetBridge.shared.clear()
// Tell watch playback stopped
WatchConnectivityManager.shared.sendNowPlayingToWatch(
song: nil, isPlaying: false, currentTime: 0, duration: 0
)
#endif
if isRadioStream {
isRadioStream = false

View file

@ -52,6 +52,8 @@ struct SubsonicResponseBody: Codable {
let lyrics: LyricsResult?
let internetRadioStations: InternetRadioContainer?
let similarSongs2: SimilarSongsContainer?
let playQueue: PlayQueue?
let shares: SharesContainer?
}
struct SubsonicError: Codable {
@ -320,6 +322,35 @@ struct SimilarSongsContainer: Codable {
let song: [Song]?
}
// MARK: - Play Queue (cross-device resume)
struct PlayQueueContainer: Codable {
let playQueue: PlayQueue?
}
struct PlayQueue: Codable {
let entry: [Song]?
let current: String? // song ID of current track
let position: Int64? // position in ms
let changed: String? // ISO timestamp of last save
let changedBy: String? // client that saved
let username: String?
}
// MARK: - Shares (public links)
struct SharesContainer: Codable {
let share: [ShareItem]?
}
struct ShareItem: Codable, Identifiable {
let id: String
let url: String?
let description: String?
let username: String?
let created: String?
let expires: String?
let lastVisited: String?
let visitCount: Int?
let entry: [Song]?
}
// MARK: - Companion Library Models
// These map the Companion API /library/* responses.
// They convert to standard Song/Album objects using navidrome_id for stream URLs.

View file

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

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

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

View file

@ -6,6 +6,9 @@ Endpoints (existing - unchanged):
POST /reindex
PATCH /edit-metadata
PATCH /batch-edit-metadata
POST /undo-batch-edit/{batch_id}
POST /restore-tags?relative_path=...
GET /batch-edit-history
POST /upload-track
POST /upload-tracks
GET /smart-dj/profile
@ -29,6 +32,12 @@ Endpoints (Phase 1 - library database):
POST /library/cover-art-by-path
POST /library/artist-photo
GET /library/artist-photo/{artist_name}
Endpoints (Lyrics):
GET /lyrics/search
GET /lyrics/fetch
GET /lyrics/get
POST /lyrics/embed
"""
import os, re, json, asyncio, hashlib, sqlite3, subprocess, shutil, time, warnings, unicodedata
from pathlib import Path
@ -52,6 +61,7 @@ MUSIC_DIR = os.getenv("MUSIC_DIR", "/music")
DB_PATH = os.getenv("DB_PATH", "/app/data/smart_dj.db")
VIS_CACHE_DIR = os.getenv("VIS_CACHE_DIR", "/app/data/vis_cache")
COVER_ART_DIR = os.getenv("COVER_ART_DIR", "/app/data/cover_art")
TAG_BACKUP_DIR = os.getenv("TAG_BACKUP_DIR", "/app/data/tag_backups")
ARTIST_PHOTO_DIR = os.getenv("ARTIST_PHOTO_DIR", "/app/data/artist_photos")
NAVIDROME_URL = os.getenv("NAVIDROME_URL", "http://navidrome:4533/navidrome")
SUBSONIC_USER = os.getenv("SUBSONIC_USER")
@ -188,6 +198,16 @@ def init_db():
photo_path TEXT NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""")
c.execute("""CREATE TABLE IF NOT EXISTS lyrics (
artist TEXT NOT NULL,
title TEXT NOT NULL,
duration REAL,
synced_lyrics TEXT,
plain_lyrics TEXT,
source TEXT DEFAULT 'lrclib',
fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (artist, title))""")
c.execute("CREATE INDEX IF NOT EXISTS idx_songs_album ON songs(sort_album, disc_number, track_number)")
c.execute("CREATE INDEX IF NOT EXISTS idx_songs_artist ON songs(sort_artist, sort_album)")
c.execute("CREATE INDEX IF NOT EXISTS idx_songs_album_artist ON songs(sort_album_artist, sort_album)")
@ -1164,6 +1184,294 @@ async def sync_navidrome_ids_task():
# cause album splitting, wrong grouping, and other library issues.
# Using a whitelist (keep only these) is safer than a blacklist (remove known bad ones)
# because it handles any future Picard tags automatically.
# ── Tag Backup System ────────────────────────────────────────────────────────
# Snapshots all tags before any destructive write. Enables per-file and
# batch-level undo. Backups are JSON files keyed by MD5 of the file path.
# Batch edits also create a manifest linking all affected files for bulk undo.
os.makedirs(TAG_BACKUP_DIR, exist_ok=True)
def _backup_key(full_path: str) -> str:
return hashlib.md5(full_path.encode()).hexdigest()
def _backup_key_rel(relative_path: str) -> str:
"""Secondary key based on relative path — survives full_path changes from restructure."""
return "rel_" + hashlib.md5(relative_path.encode()).hexdigest()
def backup_tags(full_path: str) -> Optional[str]:
"""Snapshot all current tags to a JSON file before any write.
Creates TWO backup files with different keys:
1. Keyed by full_path (fast lookup when path hasn't changed)
2. Keyed by relative_path (fallback after restructure moves the file)
Both files contain identical data. This ensures undo works even after
/bulk-fix restructures files to new directories.
Handles AIFF files specially (easy=True returns None for AIFF).
Returns the primary backup file path, or None on failure.
"""
try:
ext = Path(full_path).suffix.lower()
if ext in ('.aiff', '.aif'):
tags = _read_aiff_tags_for_backup(full_path)
if tags is None:
return None
else:
audio = MutagenFile(full_path, easy=True)
if audio is None:
return None
tags = {k: v for k, v in audio.items()}
relative = os.path.relpath(full_path, MUSIC_DIR)
backup = {
"path": full_path,
"relative_path": relative,
"filename": os.path.basename(full_path),
"timestamp": datetime.utcnow().isoformat(),
"tags": tags,
}
data = json.dumps(backup, indent=2)
# Primary key: full absolute path
primary_path = os.path.join(TAG_BACKUP_DIR, f"{_backup_key(full_path)}.json")
with open(primary_path, 'w') as f:
f.write(data)
# Secondary key: relative path (survives restructure)
secondary_path = os.path.join(TAG_BACKUP_DIR, f"{_backup_key_rel(relative)}.json")
with open(secondary_path, 'w') as f:
f.write(data)
return primary_path
except Exception as e:
print(f" backup_tags FAILED for {os.path.basename(full_path)}: {e}", flush=True)
return None
def _read_aiff_tags_for_backup(full_path: str) -> Optional[dict]:
"""Read AIFF tags via raw ID3 frames and return as easy-mode-style dict.
Returns None if the file can't be read."""
try:
from mutagen.aiff import AIFF
audio = AIFF(full_path)
if audio.tags is None:
return {}
# Map raw ID3 frames to easy-mode key names
frame_to_easy = {
'TIT2': 'title',
'TPE1': 'artist',
'TALB': 'album',
'TPE2': 'albumartist',
'TRCK': 'tracknumber',
'TPOS': 'discnumber',
'TDRC': 'date',
'TYER': 'date',
'TCON': 'genre',
'TCOM': 'composer',
}
tags = {}
for frame_id, easy_key in frame_to_easy.items():
frame = audio.tags.get(frame_id)
if frame:
# ID3 text frames store values as lists
val = [str(t) for t in frame.text] if hasattr(frame, 'text') else [str(frame)]
if val and val[0]:
tags[easy_key] = val
return tags
except Exception as e:
print(f" _read_aiff_tags FAILED for {os.path.basename(full_path)}: {e}", flush=True)
return None
def _find_backup(full_path: str, original_path: str = None) -> Optional[str]:
"""Find the backup JSON for a file, trying multiple key strategies.
Lookup order:
1. Current full_path key (fast no restructure happened)
2. Current relative_path key (file moved but relative_path recalculated)
3. Original full_path key (manifest stored the old path, backup keyed to it)
4. Original relative_path key (old relative path from manifest)
Returns the backup file path, or None if not found.
"""
# 1. Current full_path
bp = os.path.join(TAG_BACKUP_DIR, f"{_backup_key(full_path)}.json")
if os.path.exists(bp):
return bp
# 2. Current relative_path
try:
rel = os.path.relpath(full_path, MUSIC_DIR)
bp = os.path.join(TAG_BACKUP_DIR, f"{_backup_key_rel(rel)}.json")
if os.path.exists(bp):
return bp
except ValueError:
pass
if original_path and original_path != full_path:
# 3. Original full_path
bp = os.path.join(TAG_BACKUP_DIR, f"{_backup_key(original_path)}.json")
if os.path.exists(bp):
return bp
# 4. Original relative_path
try:
old_rel = os.path.relpath(original_path, MUSIC_DIR)
bp = os.path.join(TAG_BACKUP_DIR, f"{_backup_key_rel(old_rel)}.json")
if os.path.exists(bp):
return bp
except ValueError:
pass
return None
def restore_tags_from_backup(full_path: str, original_path: str = None) -> bool:
"""Restore tags from the most recent backup for this file.
Uses _find_backup() to locate the backup JSON, trying multiple key strategies
(current path, relative path, original path). This ensures undo works even
after restructure has moved the file to a different directory.
IMPORTANT: Only overwrites text tags (title, artist, album, etc.) that
easy=True exposes. Does NOT touch binary frames like APIC (album art),
USLT (lyrics), or COMM (comments). Previous version used audio.delete()
which nuked everything including embedded art that was a data-loss bug.
"""
backup_path = _find_backup(full_path, original_path=original_path)
if not backup_path:
print(f" restore: no backup found for {os.path.basename(full_path)}"
f" (original: {os.path.basename(original_path) if original_path else 'none'})", flush=True)
return False
try:
with open(backup_path) as f:
backup = json.load(f)
ext = Path(full_path).suffix.lower()
if ext in ('.aiff', '.aif'):
return _restore_aiff_from_backup(full_path, backup)
audio = MutagenFile(full_path, easy=True)
if audio is None:
return False
backup_tags_dict = backup.get("tags", {})
# 1. Remove any easy-mode tags currently on file that AREN'T in the backup.
# This undoes tags that were ADDED by the edit.
# Only touches easy-namespace keys — APIC, USLT, COMM are untouched.
current_keys = list(audio.keys())
for k in current_keys:
if k not in backup_tags_dict:
try:
del audio[k]
except Exception:
pass
# 2. Set every tag from the backup (overwrites edited values, restores deleted ones).
for k, v in backup_tags_dict.items():
try:
audio[k] = v
except Exception:
pass
audio.save()
return True
except Exception as e:
print(f" restore_tags FAILED for {os.path.basename(full_path)}: {e}", flush=True)
return False
def _restore_aiff_from_backup(full_path: str, backup: dict) -> bool:
"""Restore AIFF tags from backup using raw ID3 frames."""
from mutagen.aiff import AIFF
from mutagen.id3 import TIT2, TPE1, TALB, TPE2, TRCK, TPOS, TDRC, TCON
try:
audio = AIFF(full_path)
if audio.tags is None:
audio.add_tags()
tags = audio.tags
# Map easy-mode names back to ID3 frames
frame_map = {
'title': lambda v: TIT2(encoding=3, text=v),
'artist': lambda v: TPE1(encoding=3, text=v),
'album': lambda v: TALB(encoding=3, text=v),
'albumartist': lambda v: TPE2(encoding=3, text=v),
'tracknumber': lambda v: TRCK(encoding=3, text=v),
'discnumber': lambda v: TPOS(encoding=3, text=v),
'date': lambda v: TDRC(encoding=3, text=v),
'genre': lambda v: TCON(encoding=3, text=v),
}
for k, v in backup.get("tags", {}).items():
k_lower = k.lower()
if k_lower in frame_map:
val = v if isinstance(v, list) else [v]
tags.add(frame_map[k_lower](val))
audio.save()
return True
except Exception as e:
print(f" restore_aiff FAILED for {os.path.basename(full_path)}: {e}", flush=True)
return False
def save_batch_manifest(batch_id: str, paths: list, tags_changed: dict = None,
affected_albums: list = None, affected_artists: list = None,
edit_type: str = "batch"):
"""Save a manifest of a batch (or single) edit for undo + history UI.
Args:
batch_id: unique identifier for this edit
paths: list of absolute file paths that were modified
tags_changed: dict of fieldvalue that was applied (e.g. {"genre": "Rock"})
affected_albums: list of album names touched (for UI display)
affected_artists: list of artist names touched (for UI display)
edit_type: "batch", "single", or "restructure"
"""
manifest = {
"batch_id": batch_id,
"timestamp": datetime.utcnow().isoformat(),
"file_count": len(paths),
"paths": paths,
"tags_changed": tags_changed or {},
"affected_albums": list(set(affected_albums or [])),
"affected_artists": list(set(affected_artists or [])),
"edit_type": edit_type,
"is_reverted": False,
}
manifest_path = os.path.join(TAG_BACKUP_DIR, f"batch_{batch_id}.json")
with open(manifest_path, 'w') as f:
json.dump(manifest, f, indent=2)
return manifest_path
def validate_essential_tags(full_path: str, had_artist: bool, had_album: bool, had_title: bool) -> list:
"""Check that essential tags weren't destroyed. Returns list of problems."""
problems = []
try:
audio = MutagenFile(full_path, easy=True)
if audio is None:
return ["unsupported format"]
now_artist = bool(audio.get('artist', [''])[0].strip())
now_album = bool(audio.get('album', [''])[0].strip())
now_title = bool(audio.get('title', [''])[0].strip())
if had_artist and not now_artist:
problems.append("artist lost")
if had_album and not now_album:
problems.append("album lost")
if had_title and not now_title:
problems.append("title lost")
except:
pass
return problems
NAVIDROME_TAGS = {
'TITLE', 'ARTIST', 'ALBUM', 'ALBUMARTIST',
'TRACKNUMBER', 'DISCNUMBER', 'DATE',
@ -1172,6 +1480,29 @@ NAVIDROME_TAGS = {
'REPLAYGAIN_ALBUM_GAIN', 'REPLAYGAIN_ALBUM_PEAK',
}
# ID3v2 frame IDs that correspond to the Vorbis Comment names above.
# enforce_tag_whitelist uses NAVIDROME_TAGS (Vorbis names) for FLAC,
# but MP3 files use raw ID3 frame IDs — without this mapping, EVERY
# MP3 tag gets deleted because "TPE1" != "ARTIST".
ID3_FRAME_WHITELIST = {
'TIT2', # TITLE
'TPE1', # ARTIST
'TALB', # ALBUM
'TPE2', # ALBUMARTIST
'TRCK', # TRACKNUMBER
'TPOS', # DISCNUMBER
'TDRC', # DATE (ID3v2.4)
'TYER', # DATE (ID3v2.3 legacy)
'TCON', # GENRE
'TCOM', # COMPOSER
'COMM', # COMMENT
'TSRC', # ISRC
'APIC', # Album art — always keep
'USLT', # LYRICS (unsynced)
'SYLT', # LYRICS (synced)
'TXXX', # User-defined (checked by sub-key below)
}
# Keep the blacklist for reference / legacy clean-tags endpoint
PICARD_TAGS_TO_REMOVE = {
'MUSICBRAINZ_TRACKID', 'MUSICBRAINZ_ALBUMID', 'MUSICBRAINZ_RELEASETRACKID',
@ -1239,10 +1570,27 @@ def enforce_tag_whitelist(
if audio is None:
result["error"] = "Unsupported format"
return result
# For MP3/ID3 files, check raw frame IDs against ID3_FRAME_WHITELIST
# (not NAVIDROME_TAGS, which uses Vorbis Comment names like "ARTIST"
# that don't match ID3 frame IDs like "TPE1").
allowed_rg = {
'REPLAYGAIN_TRACK_GAIN', 'REPLAYGAIN_TRACK_PEAK',
'REPLAYGAIN_ALBUM_GAIN', 'REPLAYGAIN_ALBUM_PEAK',
}
to_remove = []
for k in list(audio.keys()):
ku = k.upper().split(':')[0].strip()
if ku not in allowed:
# ID3 keys look like "TPE1", "TXXX:replaygain_track_gain",
# "APIC:", "COMM::eng", "USLT::eng"
frame_id = k.split(':')[0].strip().upper()
if frame_id in ID3_FRAME_WHITELIST:
# Frame type is allowed — but for TXXX, also check the
# sub-key (description) against known ReplayGain names
if frame_id == 'TXXX':
desc = k.split(':', 1)[1].upper() if ':' in k else ''
if desc not in allowed_rg:
to_remove.append(k)
# Otherwise keep it
else:
to_remove.append(k)
to_remove = list(set(to_remove))
result["removed"] = to_remove
@ -1443,6 +1791,12 @@ def restructure_all() -> dict:
if not os.path.isfile(full_path):
skipped += 1
continue
# Safety net: snapshot tags BEFORE enforce_tag_whitelist touches them.
# If whitelist enforcement damages tags (as it did for MP3s before the
# ID3_FRAME_WHITELIST fix), the backup enables recovery.
# Non-fatal if backup fails — log and continue (restructure is bulk).
if not backup_tags(full_path):
print(f" restructure: backup failed for {os.path.basename(full_path)}, proceeding cautiously", flush=True)
# Enforce whitelist before restructuring — clean tags first so
# build_target_path reads clean data and generates the correct path
enforce_tag_whitelist(full_path, preserve_composer=True, preserve_lyrics=True)
@ -1456,10 +1810,24 @@ def restructure_all() -> dict:
def apply_tags(path: str, u, preserve_composer: bool = True, preserve_lyrics: bool = True):
"""Write tags then enforce whitelist — only Navidrome tags survive."""
"""Write tags then enforce whitelist — only Navidrome tags survive.
Backs up all tags BEFORE writing so undo is always possible.
REFUSES to write if backup fails never modifies tags without a safety net."""
# Snapshot current state for undo — MUST succeed before we touch anything
if not backup_tags(path):
raise RuntimeError(
f"Cannot edit {os.path.basename(path)}: backup failed. "
"Tags untouched. Check disk space and file permissions in TAG_BACKUP_DIR."
)
audio = MutagenFile(path, easy=True)
if audio is None:
raise ValueError(f"Unsupported format: {path}")
# Record what was present before write
had_artist = bool(audio.get('artist', [''])[0].strip())
had_album = bool(audio.get('album', [''])[0].strip())
had_title = bool(audio.get('title', [''])[0].strip())
if u.title: audio['title'] = u.title
if u.artist: audio['artist'] = u.artist
if u.album: audio['album'] = u.album
@ -1468,15 +1836,36 @@ def apply_tags(path: str, u, preserve_composer: bool = True, preserve_lyrics: bo
if u.year: audio['date'] = str(u.year)
if u.track_number: audio['tracknumber'] = str(u.track_number)
audio.save()
# Enforce whitelist after writing — nukes everything not in NAVIDROME_TAGS
# Enforce whitelist after writing
enforce_tag_whitelist(path, preserve_composer=preserve_composer, preserve_lyrics=preserve_lyrics)
# Validate — if essential tags were destroyed, auto-restore
problems = validate_essential_tags(path, had_artist, had_album, had_title)
if problems:
print(f" ⚠ TAG DAMAGE DETECTED in {os.path.basename(path)}: {problems} — auto-restoring", flush=True)
restore_tags_from_backup(path)
raise RuntimeError(f"Tag write damaged {os.path.basename(path)}: {problems}. Restored from backup.")
def apply_tags_dict(path: str, tags: dict, preserve_composer: bool = True, preserve_lyrics: bool = True):
"""Write tags dict then enforce whitelist — only Navidrome tags survive."""
"""Write tags dict then enforce whitelist — only Navidrome tags survive.
Backs up all tags BEFORE writing so undo is always possible.
REFUSES to write if backup fails never modifies tags without a safety net."""
# Snapshot current state for undo — MUST succeed before we touch anything
if not backup_tags(path):
raise RuntimeError(
f"Cannot edit {os.path.basename(path)}: backup failed. "
"Tags untouched. Check disk space and file permissions in TAG_BACKUP_DIR."
)
audio = MutagenFile(path, easy=True)
if audio is None:
raise ValueError(f"Unsupported format: {path}")
# Record what was present before write
had_artist = bool(audio.get('artist', [''])[0].strip())
had_album = bool(audio.get('album', [''])[0].strip())
had_title = bool(audio.get('title', [''])[0].strip())
mapping = {'title': 'title', 'artist': 'artist', 'album': 'album',
'album_artist': 'albumartist', 'genre': 'genre',
'year': 'date', 'track_number': 'tracknumber'}
@ -1488,6 +1877,13 @@ def apply_tags_dict(path: str, tags: dict, preserve_composer: bool = True, prese
# Enforce whitelist after writing
enforce_tag_whitelist(path, preserve_composer=preserve_composer, preserve_lyrics=preserve_lyrics)
# Validate — if essential tags were destroyed, auto-restore
problems = validate_essential_tags(path, had_artist, had_album, had_title)
if problems:
print(f" ⚠ TAG DAMAGE DETECTED in {os.path.basename(path)}: {problems} — auto-restoring", flush=True)
restore_tags_from_backup(path)
raise RuntimeError(f"Tag write damaged {os.path.basename(path)}: {problems}. Restored from backup.")
# ── Analysis ─────────────────────────────────────────────────────────────────
@ -1684,19 +2080,55 @@ async def edit_metadata(update: MetadataUpdate):
fp = resolve_path(update.relative_path)
if not fp:
raise HTTPException(404, f"File not found. raw='{update.relative_path}' MUSIC_DIR='{MUSIC_DIR}'")
# Generate a batch ID so single-track edits appear in edit history too
batch_id = hashlib.md5(f"{time.time()}_{update.relative_path}".encode()).hexdigest()[:12]
try:
# Collect context before the edit (for history UI)
album_name = ""
artist_name = ""
try:
pre = MutagenFile(fp, easy=True)
if pre:
album_name = pre.get('album', [''])[0]
artist_name = pre.get('albumartist', pre.get('artist', ['']))[0]
except Exception:
pass
# Offload blocking Mutagen I/O to a thread — audio.save() on FLAC can
# take 100-500ms and must not block the event loop (AUDIT-009)
await asyncio.to_thread(apply_tags, fp, update)
await asyncio.to_thread(update_song_in_db, fp)
new_relative = os.path.relpath(fp, MUSIC_DIR)
# Build tags_changed from update fields
tags_changed = {}
if update.title: tags_changed["title"] = update.title
if update.artist: tags_changed["artist"] = update.artist
if update.album: tags_changed["album"] = update.album
if update.album_artist: tags_changed["album_artist"] = update.album_artist
if update.genre: tags_changed["genre"] = update.genre
if update.year: tags_changed["year"] = str(update.year)
if update.track_number: tags_changed["track_number"] = str(update.track_number)
# Save manifest so single edits appear in edit history
save_batch_manifest(
batch_id, [fp],
tags_changed=tags_changed,
affected_albums=[album_name] if album_name else [],
affected_artists=[artist_name] if artist_name else [],
edit_type="single",
)
await trigger_scan()
await push.broadcast("metadata_updated", {
"path": new_relative,
"title": update.title or "", "artist": update.artist or "",
"album": update.album or ""
"album": update.album or "",
"batch_id": batch_id,
})
return {"status": "success", "file": new_relative, "resolved": fp}
return {"status": "success", "file": new_relative, "resolved": fp, "batch_id": batch_id}
except Exception as e:
import traceback
traceback.print_exc()
@ -1705,7 +2137,9 @@ async def edit_metadata(update: MetadataUpdate):
@app.patch("/batch-edit-metadata")
async def batch_edit_metadata(update: BatchMetadataUpdate):
results = {"succeeded": [], "failed": []}
# Generate a batch ID for undo support
batch_id = hashlib.md5(f"{time.time()}_{len(update.relative_paths)}".encode()).hexdigest()[:12]
results = {"succeeded": [], "failed": [], "batch_id": batch_id}
tags = {}
if update.title: tags["title"] = update.title
if update.artist: tags["artist"] = update.artist
@ -1714,18 +2148,43 @@ async def batch_edit_metadata(update: BatchMetadataUpdate):
if update.genre: tags["genre"] = update.genre
if update.year: tags["year"] = str(update.year)
def _apply_batch():
"""Blocking tag writes run in a thread so the event loop stays free."""
"""Blocking tag writes run in a thread so the event loop stays free.
Each file is backed up BEFORE any write undo is always possible."""
resolved_paths = []
albums_seen = []
artists_seen = []
for rp in update.relative_paths:
fp = resolve_path(rp)
if not fp:
results["failed"].append({"path": rp, "error": "File not found"})
continue
try:
# Collect album/artist context BEFORE the edit (for history UI)
try:
pre = MutagenFile(fp, easy=True)
if pre:
a = pre.get('album', [''])[0]
ar = pre.get('albumartist', pre.get('artist', ['']))[0]
if a: albums_seen.append(a)
if ar: artists_seen.append(ar)
except Exception:
pass
apply_tags_dict(fp, tags)
update_song_in_db(fp)
results["succeeded"].append(os.path.relpath(fp, MUSIC_DIR))
resolved_paths.append(fp)
except Exception as e:
results["failed"].append({"path": rp, "error": str(e)})
# Save batch manifest for bulk undo — includes what changed and what was affected
if resolved_paths:
save_batch_manifest(
batch_id, resolved_paths,
tags_changed=tags,
affected_albums=albums_seen,
affected_artists=artists_seen,
edit_type="batch",
)
await asyncio.to_thread(_apply_batch)
await trigger_scan()
@ -1734,12 +2193,143 @@ async def batch_edit_metadata(update: BatchMetadataUpdate):
await push.broadcast("batch_metadata_updated",
{"count": len(results["succeeded"]),
"album": update.album or "",
"batch_id": batch_id,
"paths_changed": "true"})
# Run conflict check in background after every batch edit
_create_task(_run_conflict_check_and_broadcast())
return results
@app.post("/undo-batch-edit/{batch_id}")
async def undo_batch_edit(batch_id: str):
"""Restore all files in a batch edit to their pre-edit tags.
The batch_id is returned by PATCH /batch-edit-metadata.
Handles files that moved after restructure: if the manifest path
no longer exists, resolves the current location and uses the
original path to find the backup.
"""
manifest_path = os.path.join(TAG_BACKUP_DIR, f"batch_{batch_id}.json")
if not os.path.exists(manifest_path):
raise HTTPException(404, f"Batch {batch_id} not found. Backups may have been cleaned up.")
with open(manifest_path) as f:
manifest = json.load(f)
# Prevent double-undo: if already reverted, reject
if manifest.get("is_reverted"):
reverted_at = manifest.get("reverted_at", "unknown time")
raise HTTPException(409, f"Batch {batch_id} was already reverted at {reverted_at}.")
results = {"restored": [], "failed": []}
def _restore():
for original_fp in manifest["paths"]:
# Find the file — it may have moved since the edit
if os.path.isfile(original_fp):
current_fp = original_fp
else:
# File moved (restructure happened). Try resolve_path.
old_rel = os.path.relpath(original_fp, MUSIC_DIR) if original_fp.startswith(MUSIC_DIR) else original_fp
current_fp = resolve_path(old_rel)
if not current_fp:
# Last resort: search by filename anywhere in MUSIC_DIR
fname = os.path.basename(original_fp)
for root, dirs, files in os.walk(MUSIC_DIR):
if fname in files:
current_fp = os.path.join(root, fname)
break
if not current_fp:
results["failed"].append({
"path": os.path.relpath(original_fp, MUSIC_DIR),
"error": "File not found (may have been deleted)"
})
continue
try:
ok = restore_tags_from_backup(current_fp, original_path=original_fp)
if ok:
results["restored"].append(os.path.relpath(current_fp, MUSIC_DIR))
else:
results["failed"].append({
"path": os.path.relpath(current_fp, MUSIC_DIR),
"error": "No backup found for this file"
})
except Exception as e:
results["failed"].append({
"path": os.path.relpath(original_fp, MUSIC_DIR),
"error": str(e)
})
await asyncio.to_thread(_restore)
# Mark the manifest as reverted so it can't be undone again
manifest["is_reverted"] = True
manifest["reverted_at"] = datetime.utcnow().isoformat()
manifest["restore_results"] = {
"restored": len(results["restored"]),
"failed": len(results["failed"]),
}
with open(manifest_path, 'w') as f:
json.dump(manifest, f, indent=2)
await trigger_scan()
await asyncio.sleep(4)
await push.broadcast("batch_undo_complete", {
"batch_id": batch_id,
"restored": len(results["restored"]),
"failed": len(results["failed"]),
})
return results
@app.post("/restore-tags")
async def restore_single_file_tags(relative_path: str = Query(...)):
"""Restore a single file's tags from its backup."""
fp = resolve_path(relative_path)
if not fp:
raise HTTPException(404, "File not found")
ok = await asyncio.to_thread(restore_tags_from_backup, fp)
if not ok:
raise HTTPException(404, "No backup found for this file")
await asyncio.to_thread(update_song_in_db, fp)
await trigger_scan()
return {"status": "restored", "path": relative_path}
@app.get("/batch-edit-history")
async def batch_edit_history():
"""List recent edits (batch + single) that can be undone.
Returns enough data for the Edit History UI to display:
- what changed (tags_changed pills)
- what was affected (album/artist names)
- whether already reverted
- edit type (batch/single)
"""
manifests = []
if os.path.isdir(TAG_BACKUP_DIR):
for f in sorted(os.listdir(TAG_BACKUP_DIR), reverse=True):
if f.startswith("batch_") and f.endswith(".json"):
try:
with open(os.path.join(TAG_BACKUP_DIR, f)) as fh:
m = json.load(fh)
manifests.append({
"batch_id": m.get("batch_id", ""),
"timestamp": m.get("timestamp", ""),
"file_count": m.get("file_count", 0),
"tags_changed": m.get("tags_changed", {}),
"affected_albums": m.get("affected_albums", []),
"affected_artists": m.get("affected_artists", []),
"edit_type": m.get("edit_type", "batch"),
"is_reverted": m.get("is_reverted", False),
"reverted_at": m.get("reverted_at"),
})
except:
pass
# Sort by timestamp descending (filenames aren't guaranteed to sort by time)
manifests.sort(key=lambda x: x["timestamp"], reverse=True)
return {"batches": manifests[:50]} # last 50
@app.post("/upload-track")
async def upload_track(
file: UploadFile = File(...),
@ -1965,6 +2555,258 @@ async def export_all_profiles():
return {"count": len(profiles), "profiles": profiles}
# ── Lyrics ─────────────────────────────────────────────────────────────────
LRCLIB_BASE = "https://lrclib.net/api"
@app.get("/lyrics/search")
async def lyrics_search(q: str):
"""Search LRCLIB for lyrics matching a query string. Returns array of matches."""
async with httpx.AsyncClient(timeout=10) as client:
try:
resp = await client.get(f"{LRCLIB_BASE}/search", params={"q": q})
resp.raise_for_status()
results = resp.json()
return [
{
"id": r.get("id"),
"trackName": r.get("trackName", ""),
"artistName": r.get("artistName", ""),
"albumName": r.get("albumName", ""),
"duration": r.get("duration", 0),
"hasSynced": r.get("syncedLyrics") is not None,
"hasPlain": r.get("plainLyrics") is not None,
"syncedLyrics": r.get("syncedLyrics"),
"plainLyrics": r.get("plainLyrics"),
}
for r in results
]
except Exception as e:
raise HTTPException(502, f"LRCLIB search failed: {e}")
@app.get("/lyrics/fetch")
async def lyrics_fetch(artist: str, title: str, duration: float = 0):
"""
Exact-match fetch from LRCLIB by artist + title + duration.
Caches result in local DB. Returns cached version if available.
"""
# Check local cache first
def _check_cache():
with get_db() as c:
row = c.execute(
"SELECT synced_lyrics, plain_lyrics, source FROM lyrics WHERE artist=? AND title=?",
(artist.lower(), title.lower())
).fetchone()
if row:
return {"syncedLyrics": row[0], "plainLyrics": row[1],
"source": row[2], "cached": True}
return None
cached = await asyncio.to_thread(_check_cache)
if cached:
return cached
# Fetch from LRCLIB
params = {"artist_name": artist, "track_name": title}
if duration > 0:
params["duration"] = int(duration)
async with httpx.AsyncClient(timeout=10) as client:
try:
resp = await client.get(f"{LRCLIB_BASE}/get", params=params)
if resp.status_code == 404:
return {"syncedLyrics": None, "plainLyrics": None,
"source": "lrclib", "cached": False, "found": False}
resp.raise_for_status()
data = resp.json()
except httpx.HTTPStatusError:
return {"syncedLyrics": None, "plainLyrics": None,
"source": "lrclib", "cached": False, "found": False}
except Exception as e:
raise HTTPException(502, f"LRCLIB fetch failed: {e}")
synced = data.get("syncedLyrics")
plain = data.get("plainLyrics")
# Cache the result
if synced or plain:
def _cache():
with get_db() as c:
c.execute(
"INSERT OR REPLACE INTO lyrics (artist, title, duration, synced_lyrics, plain_lyrics, source) "
"VALUES (?, ?, ?, ?, ?, 'lrclib')",
(artist.lower(), title.lower(), duration, synced, plain)
)
await asyncio.to_thread(_cache)
print(f" [lyrics] Cached: {artist}{title} ({'synced' if synced else 'plain'})", flush=True)
return {"syncedLyrics": synced, "plainLyrics": plain,
"source": "lrclib", "cached": False, "found": bool(synced or plain)}
@app.get("/lyrics/get")
async def lyrics_get(relative_path: str):
"""
Get lyrics for a song. Priority:
1. Embedded in audio file tags (USLT/SYLT/LYRICS)
2. .lrc sidecar file in same directory
3. Local DB cache (from previous LRCLIB fetch)
"""
fp = resolve_path(relative_path)
if not fp:
raise HTTPException(404, "File not found")
def _read_all_sources():
# 1. Embedded tags via mutagen
try:
audio = MutagenFile(fp)
if audio and audio.tags:
# MP3: SYLT (synced) or USLT (unsynced)
synced_text = None
plain_text = None
for key in audio.tags:
if key.startswith("SYLT"):
# SYLT contains list of (text, timestamp_ms) tuples
sylt = audio.tags[key]
lines = []
for text, ts in sylt.text:
mins = int(ts / 60000)
secs = (ts % 60000) / 1000
lines.append(f"[{mins:02d}:{secs:05.2f}]{text}")
synced_text = "\n".join(lines)
elif key.startswith("USLT"):
plain_text = str(audio.tags[key])
# FLAC/OGG: LYRICS tag
if hasattr(audio, 'tags') and audio.tags:
for tag_key in ("lyrics", "LYRICS", "UNSYNCEDLYRICS"):
val = audio.tags.get(tag_key)
if val:
text = val[0] if isinstance(val, list) else str(val)
if text.strip():
# Check if it looks like LRC (has timestamps)
if "[" in text and "]" in text:
synced_text = text
else:
plain_text = text
# M4A: ©lyr
if hasattr(audio.tags, '__contains__') and '©lyr' in audio.tags:
val = audio.tags['©lyr']
text = val[0] if isinstance(val, list) else str(val)
if text.strip():
if "[" in text and "]" in text:
synced_text = text
else:
plain_text = text
if synced_text or plain_text:
return {"syncedLyrics": synced_text, "plainLyrics": plain_text,
"source": "embedded"}
except Exception:
pass
# 2. .lrc sidecar file
lrc_path = os.path.splitext(fp)[0] + ".lrc"
if os.path.exists(lrc_path):
try:
with open(lrc_path, "r", encoding="utf-8") as f:
lrc_content = f.read()
return {"syncedLyrics": lrc_content, "plainLyrics": None,
"source": "lrc_file"}
except Exception:
pass
# 3. DB cache
try:
# Extract artist/title from file tags for DB lookup
audio = MutagenFile(fp, easy=True)
if audio:
artist = (audio.get("artist", [None])[0] or "").lower()
title_tag = (audio.get("title", [None])[0] or "").lower()
if artist and title_tag:
with get_db() as c:
row = c.execute(
"SELECT synced_lyrics, plain_lyrics, source FROM lyrics "
"WHERE artist=? AND title=?",
(artist, title_tag)
).fetchone()
if row:
return {"syncedLyrics": row[0], "plainLyrics": row[1],
"source": row[2]}
except Exception:
pass
return None
result = await asyncio.to_thread(_read_all_sources)
if result:
return result
return {"syncedLyrics": None, "plainLyrics": None, "source": None}
@app.post("/lyrics/embed")
async def lyrics_embed(relative_path: str = Form(...), lrc_content: str = Form(...)):
"""
Embed synced lyrics (LRC format) into an audio file's metadata tags.
MP3 USLT tag (plain text with timestamps in content)
FLAC LYRICS tag
M4A ©lyr tag
Then triggers a Navidrome scan to pick up the changes.
"""
fp = resolve_path(relative_path)
if not fp:
raise HTTPException(404, "File not found")
def _embed():
audio = MutagenFile(fp)
if audio is None:
raise ValueError(f"Cannot open: {fp}")
ext = os.path.splitext(fp)[1].lower()
if ext == ".mp3":
from mutagen.id3 import USLT
audio.tags.delall("USLT")
audio.tags.add(USLT(encoding=3, lang="eng", desc="synced", text=lrc_content))
elif ext in (".flac", ".ogg", ".opus"):
audio["LYRICS"] = lrc_content
elif ext in (".m4a", ".mp4", ".aac"):
audio.tags["©lyr"] = [lrc_content]
else:
raise ValueError(f"Unsupported format: {ext}")
audio.save()
# Also write .lrc sidecar for maximum compatibility
lrc_path = os.path.splitext(fp)[0] + ".lrc"
with open(lrc_path, "w", encoding="utf-8") as f:
f.write(lrc_content)
try:
await asyncio.to_thread(_embed)
except Exception as e:
raise HTTPException(500, f"Embed failed: {e}")
# Trigger Navidrome scan
asyncio.create_task(_trigger_scan())
print(f" [lyrics] Embedded LRC into: {os.path.basename(fp)}", flush=True)
# Notify connected clients
await push.broadcast("lyrics_updated", {"path": relative_path})
return {"status": "ok", "path": relative_path}
async def _trigger_scan():
"""Trigger Navidrome library scan."""
try:
navidrome_url = os.getenv("NAVIDROME_URL", "http://localhost:4533")
async with httpx.AsyncClient(timeout=10) as client:
await client.post(f"{navidrome_url}/api/scan", headers={
"x-nd-authorization": f"Bearer {os.getenv('NAVIDROME_TOKEN', '')}"
})
except Exception:
pass
@app.get("/visualizer/frames")
async def vis_frames(relative_path: str):
fp = resolve_path(relative_path)

View file

@ -196,6 +196,13 @@ struct RootView: View {
category: "Audio", level: .info
)
}
// Play Queue Sync start observing for saves, check server for cross-device resume
let queueSync = PlayQueueSyncManager.shared
queueSync.start()
if AudioPlayer.shared.currentSong == nil {
await queueSync.checkServerQueue()
}
}
// Connect Companion push client if enabled
@ -209,3 +216,4 @@ struct RootView: View {
}
}
}

View file

@ -0,0 +1,422 @@
import Foundation
import AVFoundation
import MediaToolbox
import Accelerate
// MARK: - Lock-free Ring Buffer for audio samples
/// Single-producer (audio render thread), single-consumer (main thread) ring buffer.
/// No locks, no allocations on the audio thread. ARM64 naturally-atomic Int writes
/// ensure the single write index is safely visible across threads.
final class PCMRingBuffer {
let capacity: Int
private let buffer: UnsafeMutablePointer<Float>
// Atomic indices render thread writes `writeIndex`, main thread writes `readIndex`
private var _writeIndex: Int = 0
init(capacity: Int) {
self.capacity = capacity
buffer = .allocate(capacity: capacity)
buffer.initialize(repeating: 0, count: capacity)
}
deinit {
buffer.deallocate()
}
/// Write samples from the audio render thread. Lock-free.
func write(_ samples: UnsafePointer<Float>, count: Int) {
let wi = _writeIndex
let space = capacity
for i in 0..<count {
buffer[(wi + i) % space] = samples[i]
}
_writeIndex = (wi + count) % space
}
/// Read the most recent `count` samples from the main thread. Lock-free.
/// Returns the number of samples actually copied.
@discardableResult
func readMostRecent(into dest: UnsafeMutablePointer<Float>, count: Int) -> Int {
let wi = _writeIndex
let start = (wi - count + capacity) % capacity
for i in 0..<count {
dest[i] = buffer[(start + i) % capacity]
}
return count
}
func reset() {
_writeIndex = 0
buffer.initialize(repeating: 0, count: capacity)
}
}
// MARK: - Audio Tap Processor
/// Installs an MTAudioProcessingTap on an AVPlayerItem and makes raw PCM samples
/// available for FFT visualization and Shazam recognition.
///
/// Architecture:
/// AVPlayerItem MTAudioProcessingTap (C callback on render thread)
/// PCMRingBuffer (lock-free)
/// Timer at 30fps reads buffer, runs vDSP FFT 30 bands setLevels()
/// Optional: Shazam consumer subscribes via `shazamHandler`
///
/// Thread safety:
/// - Tap callback: CoreAudio real-time render thread (no locks, no ObjC, no heap alloc)
/// - FFT timer: main thread
/// - Ring buffer: lock-free single-producer/single-consumer
final class AudioTapProcessor {
static let shared = AudioTapProcessor()
// Ring buffer: 8192 samples 186ms at 44.1kHz plenty for 1024-sample FFT windows
let ringBuffer = PCMRingBuffer(capacity: 8192)
// Shazam consumer set by ShazamRecognizer, cleared when done
var shazamHandler: ((UnsafeMutablePointer<AudioBufferList>, CMItemCount) -> Void)?
// Pre-allocated FFT resources created once, reused every frame (30fps)
private let fftSize = 1024
private let fftLog2n: vDSP_Length
private let fftSetup: FFTSetup
private var hannWindow: [Float]
private var fftTimeDomain: [Float]
private var fftRealp: [Float]
private var fftImagp: [Float]
private var fftMagnitudes: [Float]
// Debug: save PCM to WAV file for verification tap the share button in settings
var debugDumpEnabled = false
var debugDumpURL: URL?
private var debugFileHandle: FileHandle?
private var debugSamplesWritten: Int = 0
private var debugMaxSamplesActual = 44100 * 5 // recalculated in startDebugDump from actual rate
/// Posted on main thread when debug capture completes. userInfo contains "url": URL.
static let captureCompleteNotification = Notification.Name("AudioTapCaptureComplete")
// Source format detected by the tap's prepare callback
var sourceFormat: AVAudioFormat?
private init() {
let halfSize = fftSize / 2
fftLog2n = vDSP_Length(log2(Float(fftSize)))
fftSetup = vDSP_create_fftsetup(fftLog2n, FFTRadix(kFFTRadix2))!
hannWindow = [Float](repeating: 0, count: fftSize)
vDSP_hann_window(&hannWindow, vDSP_Length(fftSize), Int32(vDSP_HANN_NORM))
fftTimeDomain = [Float](repeating: 0, count: fftSize)
fftRealp = [Float](repeating: 0, count: halfSize)
fftImagp = [Float](repeating: 0, count: halfSize)
fftMagnitudes = [Float](repeating: 0, count: halfSize)
}
deinit {
vDSP_destroy_fftsetup(fftSetup)
}
// MARK: - Install / Remove Tap
/// Install the shared tap on a player item. Returns true if successful.
/// Safe to call multiple times removes any existing tap first.
@MainActor
func installTap(on playerItem: AVPlayerItem) async -> Bool {
// Remove existing tap
removeTap(from: playerItem)
// Load audio tracks async API (non-deprecated)
guard let audioTrack = try? await playerItem.asset
.loadTracks(withMediaType: .audio).first else {
print("[AudioTap] No audio track found on playerItem")
return false
}
// Create the MTAudioProcessingTap with C callbacks
var callbacks = MTAudioProcessingTapCallbacks(
version: kMTAudioProcessingTapCallbacksVersion_0,
clientInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()),
init: tapInit,
finalize: nil,
prepare: tapPrepare,
unprepare: nil,
process: tapProcess
)
var tapOut: MTAudioProcessingTap?
let status = MTAudioProcessingTapCreate(
kCFAllocatorDefault, &callbacks,
kMTAudioProcessingTapCreationFlag_PostEffects, &tapOut
)
guard status == noErr, let tap = tapOut else {
print("[AudioTap] MTAudioProcessingTapCreate failed: \(status)")
return false
}
let inputParams = AVMutableAudioMixInputParameters(track: audioTrack)
inputParams.audioTapProcessor = tap
let mix = AVMutableAudioMix()
mix.inputParameters = [inputParams]
playerItem.audioMix = mix
print("[AudioTap] Tap installed successfully")
// Start debug dump if enabled
if debugDumpEnabled {
startDebugDump()
}
return true
}
/// Remove the tap from a player item.
func removeTap(from playerItem: AVPlayerItem?) {
playerItem?.audioMix = nil
sourceFormat = nil
ringBuffer.reset() // Prevent stale samples from previous stream bleeding into next FFT
stopDebugDump()
}
// MARK: - FFT Processing (called from main thread timer)
/// Perform FFT on the most recent samples and return frequency bands (0.0-1.0).
/// Uses pre-allocated buffers minimal heap allocation per call.
/// Call this at 30fps from the visualizer timer.
func computeFFTBands(bandCount: Int = 30) -> [Float] {
let halfSize = fftSize / 2
// Read most recent 1024 samples from ring buffer into pre-allocated array
_ = fftTimeDomain.withUnsafeMutableBufferPointer { buf in
ringBuffer.readMostRecent(into: buf.baseAddress!, count: fftSize)
}
// Apply Hann window (pre-computed) to reduce spectral leakage
vDSP_vmul(fftTimeDomain, 1, hannWindow, 1, &fftTimeDomain, 1, vDSP_Length(fftSize))
// Zero the split complex buffers
for i in 0..<halfSize { fftRealp[i] = 0; fftImagp[i] = 0 }
// Run FFT using withUnsafeMutableBufferPointer to get stable pointers
// that outlive the DSPSplitComplex init call.
fftRealp.withUnsafeMutableBufferPointer { realpBuf in
fftImagp.withUnsafeMutableBufferPointer { imagpBuf in
var splitComplex = DSPSplitComplex(
realp: realpBuf.baseAddress!,
imagp: imagpBuf.baseAddress!
)
fftTimeDomain.withUnsafeBufferPointer { ptr in
ptr.baseAddress!.withMemoryRebound(to: DSPComplex.self, capacity: halfSize) {
vDSP_ctoz($0, 2, &splitComplex, 1, vDSP_Length(halfSize))
}
}
vDSP_fft_zrip(fftSetup, &splitComplex, 1, fftLog2n, FFTDirection(kFFTDirection_Forward))
vDSP_zvmags(&splitComplex, 1, &fftMagnitudes, 1, vDSP_Length(halfSize))
}
}
// Convert to dB scale magnitude 0 -inf, clamped to 0 in normalization step
var one: Float = 1.0
vDSP_vdbcon(fftMagnitudes, 1, &one, &fftMagnitudes, 1, vDSP_Length(halfSize), 1)
// Map to frequency bands with logarithmic spacing (more bass/mid resolution)
var bands = [Float](repeating: 0, count: bandCount)
let maxBin = min(halfSize, 300) // Cap at ~13kHz (300/512 * 22050)
for i in 0..<bandCount {
let lowPct = Float(i) / Float(bandCount)
let highPct = Float(i + 1) / Float(bandCount)
let lowBin = Int(powf(lowPct, 2.0) * Float(maxBin))
let highBin = max(lowBin + 1, Int(powf(highPct, 2.0) * Float(maxBin)))
let clampedHigh = min(highBin, maxBin)
if lowBin < clampedHigh {
var sum: Float = 0
var count: Float = 0
for bin in lowBin..<clampedHigh {
sum += fftMagnitudes[bin]
count += 1
}
bands[i] = sum / count
}
}
// Normalize: map dB range to 0.0-1.0
let minDB: Float = -50
let maxDB: Float = 15
let range = maxDB - minDB
for i in 0..<bandCount {
bands[i] = max(0, min(1, (bands[i] - minDB) / range))
}
return bands
}
// MARK: - Debug Dump (WAV file)
/// Start capturing audio tap output to a WAV file. Captures 5 seconds then auto-stops.
func startDebugDump() {
stopDebugDump()
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("audio_tap_capture_\(Int(Date().timeIntervalSince1970)).wav")
debugDumpURL = url
// Use the actual stream sample rate, default 44100 if unknown
let sampleRate = UInt32(sourceFormat?.sampleRate ?? 44100)
debugMaxSamplesActual = Int(sampleRate) * 5 // 5 seconds at actual rate
// Write WAV header placeholder (44 bytes) we'll patch the size fields when done
var header = Data(count: 44)
header.withUnsafeMutableBytes { ptr in
let p = ptr.baseAddress!.assumingMemoryBound(to: UInt8.self)
// "RIFF"
p[0] = 0x52; p[1] = 0x49; p[2] = 0x46; p[3] = 0x46
// File size placeholder (patch later)
// "WAVE"
p[8] = 0x57; p[9] = 0x41; p[10] = 0x56; p[11] = 0x45
// "fmt "
p[12] = 0x66; p[13] = 0x6D; p[14] = 0x74; p[15] = 0x20
// Chunk size: 16
p[16] = 16; p[17] = 0; p[18] = 0; p[19] = 0
// Format: IEEE float (3)
p[20] = 3; p[21] = 0
// Channels: 1
p[22] = 1; p[23] = 0
// Sample rate (from actual stream format)
let sr = sampleRate
p[24] = UInt8(sr & 0xFF); p[25] = UInt8((sr >> 8) & 0xFF)
p[26] = UInt8((sr >> 16) & 0xFF); p[27] = UInt8((sr >> 24) & 0xFF)
// Byte rate: sampleRate * 1ch * 4 bytes
let br = sr * 4
p[28] = UInt8(br & 0xFF); p[29] = UInt8((br >> 8) & 0xFF)
p[30] = UInt8((br >> 16) & 0xFF); p[31] = UInt8((br >> 24) & 0xFF)
// Block align: 4
p[32] = 4; p[33] = 0
// Bits per sample: 32
p[34] = 32; p[35] = 0
// "data"
p[36] = 0x64; p[37] = 0x61; p[38] = 0x74; p[39] = 0x61
// Data size placeholder (patch later)
}
FileManager.default.createFile(atPath: url.path, contents: header)
debugFileHandle = FileHandle(forWritingAtPath: url.path)
debugFileHandle?.seekToEndOfFile()
debugSamplesWritten = 0
debugDumpEnabled = true
print("[AudioTap] Debug capture started: \(url.lastPathComponent) at \(sampleRate)Hz")
}
/// Stop capturing and finalize the WAV header with correct sizes.
func stopDebugDump() {
guard let fh = debugFileHandle else { return }
debugDumpEnabled = false
// Patch WAV header with correct sizes
let dataSize = UInt32(debugSamplesWritten * MemoryLayout<Float>.size)
let fileSize = dataSize + 36 // 44 - 8 = 36
fh.seek(toFileOffset: 4)
var fs = fileSize; fh.write(Data(bytes: &fs, count: 4))
fh.seek(toFileOffset: 40)
var ds = dataSize; fh.write(Data(bytes: &ds, count: 4))
fh.closeFile()
debugFileHandle = nil
if debugSamplesWritten > 0 {
let rate = sourceFormat?.sampleRate ?? 44100
let duration = Double(debugSamplesWritten) / rate
print("[AudioTap] Debug capture complete: \(String(format: "%.1f", duration))s, \(dataSize) bytes")
}
debugSamplesWritten = 0
}
/// Called from the tap process callback to write samples to WAV file.
func debugWriteSamples(_ samples: UnsafePointer<Float>, count: Int) {
guard debugDumpEnabled, debugSamplesWritten < debugMaxSamplesActual else {
if debugDumpEnabled && debugSamplesWritten >= debugMaxSamplesActual {
// Auto-stop after 5 seconds
debugDumpEnabled = false
DispatchQueue.main.async { [weak self] in
self?.stopDebugDump()
if let url = self?.debugDumpURL {
NotificationCenter.default.post(
name: AudioTapProcessor.captureCompleteNotification,
object: nil,
userInfo: ["url": url]
)
}
}
}
return
}
let data = Data(bytes: samples, count: count * MemoryLayout<Float>.size)
debugFileHandle?.write(data)
debugSamplesWritten += count
}
}
// MARK: - C Tap Callbacks (free functions, not methods)
private func tapInit(
tap: MTAudioProcessingTap,
clientInfo: UnsafeMutableRawPointer?,
tapStorageOut: UnsafeMutablePointer<UnsafeMutableRawPointer?>
) {
tapStorageOut.pointee = clientInfo
}
private func tapPrepare(
tap: MTAudioProcessingTap,
maxFrames: CMItemCount,
processingFormat: UnsafePointer<AudioStreamBasicDescription>
) {
let processor = Unmanaged<AudioTapProcessor>
.fromOpaque(MTAudioProcessingTapGetStorage(tap))
.takeUnretainedValue()
let format = AVAudioFormat(streamDescription: processingFormat)
processor.sourceFormat = format
print("[AudioTap] Prepared: \(processingFormat.pointee.mSampleRate)Hz, " +
"\(processingFormat.pointee.mChannelsPerFrame)ch, " +
"\(processingFormat.pointee.mBitsPerChannel)bit, " +
"float=\(processingFormat.pointee.mFormatFlags & kAudioFormatFlagIsFloat != 0)")
}
private func tapProcess(
tap: MTAudioProcessingTap,
numberFrames: CMItemCount,
flags: MTAudioProcessingTapFlags,
bufferListInOut: UnsafeMutablePointer<AudioBufferList>,
numberFramesOut: UnsafeMutablePointer<CMItemCount>,
flagsOut: UnsafeMutablePointer<MTAudioProcessingTapFlags>
) {
// Fetch audio from the source passes through to player unchanged
let status = MTAudioProcessingTapGetSourceAudio(
tap, numberFrames, bufferListInOut, flagsOut, nil, numberFramesOut
)
guard status == noErr else { return }
let processor = Unmanaged<AudioTapProcessor>
.fromOpaque(MTAudioProcessingTapGetStorage(tap))
.takeUnretainedValue()
// Extract mono float samples from the first channel
let abl = UnsafeMutableAudioBufferListPointer(bufferListInOut)
guard let firstBuffer = abl.first,
let data = firstBuffer.mData else { return }
let floatPtr = data.assumingMemoryBound(to: Float.self)
let frameCount = Int(numberFramesOut.pointee)
// Write to ring buffer (lock-free, no allocation)
processor.ringBuffer.write(floatPtr, count: frameCount)
// Forward to Shazam handler if active
processor.shazamHandler?(bufferListInOut, numberFramesOut.pointee)
// Debug dump if enabled
if processor.debugDumpEnabled {
processor.debugWriteSamples(floatPtr, count: frameCount)
}
}

View file

@ -0,0 +1,145 @@
import Foundation
import Combine
/// Syncs the current play queue to the Subsonic server for cross-device resume.
///
/// Flow:
/// 1. AudioPlayer changes song notifies via Combine publisher
/// 2. PlayQueueSyncManager debounces (10s) and saves to server
/// 3. On app launch, if local queue is empty, offers to restore from server
///
/// The server queue is a single shared state all devices read/write it.
/// Last-write-wins, which matches user expectation (most recent device is active).
@MainActor
class PlayQueueSyncManager: ObservableObject {
static let shared = PlayQueueSyncManager()
@Published var isEnabled: Bool {
didSet { UserDefaults.standard.set(isEnabled, forKey: "playqueue_sync_enabled") }
}
@Published var serverQueue: PlayQueue?
@Published var hasPendingRestore = false
private var cancellables = Set<AnyCancellable>()
private var saveTask: Task<Void, Never>?
private var lastSavedSongId: String?
private init() {
isEnabled = UserDefaults.standard.bool(forKey: "playqueue_sync_enabled")
}
/// Call once from app launch. Starts observing AudioPlayer for queue changes.
func start() {
guard isEnabled else { return }
// Observe song changes debounce 10s before saving to server
AudioPlayer.shared.$currentSong
.compactMap { $0 }
.removeDuplicates { $0.id == $1.id }
.debounce(for: .seconds(10), scheduler: DispatchQueue.main)
.sink { [weak self] song in
guard let self, self.isEnabled else { return }
Task { await self.saveToServer() }
}
.store(in: &cancellables)
}
/// Stop observing call when disabling sync or on logout.
func stop() {
cancellables.removeAll()
saveTask?.cancel()
saveTask = nil
}
// MARK: - Save to Server
/// Save the current queue + position to the Subsonic server.
/// Debounced safe to call frequently. Skips if nothing changed.
func saveToServer() async {
let player = AudioPlayer.shared
guard isEnabled,
!player.queue.isEmpty,
let current = player.currentSong else { return }
// Skip if same song as last save (no actual change)
if current.id == lastSavedSongId { return }
let ids = player.queue.map { $0.id }
let positionMs = Int64(player.currentTime * 1000)
do {
try await ServerManager.shared.client.savePlayQueue(
songIds: ids,
current: current.id,
position: positionMs
)
lastSavedSongId = current.id
#if DEBUG
print("[QueueSync] Saved: \(ids.count) songs, current=\(current.title), pos=\(positionMs)ms")
#endif
} catch {
// Non-fatal server queue sync is best-effort
print("[QueueSync] Save failed: \(error.localizedDescription)")
}
}
// MARK: - Restore from Server
/// Check the server for a saved queue from another device.
/// Sets `hasPendingRestore` if a valid queue is found and local queue is empty.
func checkServerQueue() async {
guard isEnabled else { return }
let player = AudioPlayer.shared
// Only offer restore if no local playback state
guard player.currentSong == nil, player.queue.isEmpty else { return }
do {
guard let pq = try await ServerManager.shared.client.getPlayQueue(),
let entries = pq.entry, !entries.isEmpty else { return }
serverQueue = pq
hasPendingRestore = true
DebugLogger.shared.log(
"Server queue found: \(entries.count) songs, current=\(pq.current ?? "?"), changed by \(pq.changedBy ?? "?")",
category: "QueueSync"
)
} catch {
// Non-fatal just means no server queue or server doesn't support it
print("[QueueSync] Check failed: \(error.localizedDescription)")
}
}
/// Restore playback from the server queue. Starts paused at the saved position.
func restoreFromServer() {
guard let pq = serverQueue,
let entries = pq.entry, !entries.isEmpty else { return }
let player = AudioPlayer.shared
let currentId = pq.current ?? entries[0].id
let positionSec = TimeInterval(pq.position ?? 0) / 1000.0
// Find the index of the current song
let index = entries.firstIndex { $0.id == currentId } ?? 0
// Load queue without starting playback
player.queue = entries
player.queueIndex = index
player.currentSong = entries[index]
player.currentTime = positionSec
DebugLogger.shared.log(
"Restored server queue: \(entries.count) songs, index \(index), pos=\(Int(positionSec))s, from \(pq.changedBy ?? "unknown")",
category: "QueueSync"
)
hasPendingRestore = false
serverQueue = nil
}
/// Dismiss the restore offer without restoring.
func dismissRestore() {
hasPendingRestore = false
serverQueue = nil
}
}

View file

@ -5,6 +5,7 @@ import Combine
struct MainTabView: View {
@EnvironmentObject var audioPlayer: AudioPlayer
@ObservedObject private var debugLogger = DebugLogger.shared
@ObservedObject private var queueSync = PlayQueueSyncManager.shared
@State private var selectedTab = 0
@State private var navigateToPlaylistId: String?
@State private var navigateToAlbumId: String?
@ -48,7 +49,8 @@ struct MainTabView: View {
MyMusicView(
navigateToPlaylistId: $navigateToPlaylistId,
navigateToAlbumId: $navigateToAlbumId,
navigateToArtistId: $navigateToArtistId
navigateToArtistId: $navigateToArtistId,
isDynamicIsland: $isDynamicIsland
)
.tabItem {
Image(systemName: "music.note")
@ -86,9 +88,55 @@ struct MainTabView: View {
}
.tint(accentPink)
.safeAreaInset(edge: .bottom) {
// Reserve space for MiniPlayerBar so content never gets obscured
if audioPlayer.currentSong != nil && !showNowPlaying {
Color.clear.frame(height: 80)
// Reserve space for MiniPlayerBar so content never gets obscured.
// Only when the mini player is at the bottom (not in Dynamic Island mode).
if audioPlayer.currentSong != nil && !showNowPlaying && !isDynamicIsland {
Color.clear.frame(height: 72)
}
}
.overlay(alignment: .top) {
// Cross-device queue resume banner
if queueSync.hasPendingRestore, let pq = queueSync.serverQueue {
let songCount = pq.entry?.count ?? 0
let device = pq.changedBy ?? "another device"
VStack(spacing: 0) {
HStack(spacing: 12) {
Image(systemName: "arrow.triangle.2.circlepath")
.font(.system(size: 18, weight: .medium))
.foregroundColor(accentPink)
VStack(alignment: .leading, spacing: 2) {
Text("Resume from \(device)?")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.white)
Text("\(songCount) song\(songCount == 1 ? "" : "s") in queue")
.font(.system(size: 12))
.foregroundColor(.gray)
}
Spacer()
Button(action: { queueSync.restoreFromServer() }) {
Text("Resume")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.white)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(accentPink)
.cornerRadius(8)
}
Button(action: { queueSync.dismissRestore() }) {
Image(systemName: "xmark")
.font(.system(size: 12, weight: .bold))
.foregroundColor(.gray)
}
}
.padding(14)
.background(.ultraThinMaterial)
}
.transition(.move(edge: .top).combined(with: .opacity))
.animation(.easeInOut, value: queueSync.hasPendingRestore)
}
}
@ -605,7 +653,7 @@ struct MiniPlayerBar: View {
height: VisualizerSettings.shared.miniPlayerHeight
)
.matchedGeometryEffect(id: "visWave", in: namespace)
.offset(y: 10)
.offset(y: VisualizerSettings.shared.miniVisYOffset)
.opacity(VisualizerSettings.shared.miniOpacity)
.allowsHitTesting(false)
}

View file

@ -30,6 +30,8 @@ struct BatchAlbumEditorSheet: View {
@State private var progress: Double = 0
@State private var resultMessage: String?
@State private var failedTracks: [String] = []
@State private var lastBatchId: String?
@State private var isUndoing = false
@ObservedObject private var settings = CompanionSettings.shared
@ -235,6 +237,38 @@ struct BatchAlbumEditorSheet: View {
.font(.system(size: 11))
.foregroundColor(.red)
}
// Undo button visible when a batch_id was returned
if let batchId = lastBatchId, failedTracks.isEmpty {
Button(action: {
Task {
isUndoing = true
do {
let r = try await api.undoBatchEdit(batchId: batchId)
let restored = r.restored?.count ?? 0
resultMessage = "↩ Reverted \(restored) tracks to previous tags"
lastBatchId = nil
} catch {
resultMessage = "Undo failed: \(error.localizedDescription)"
}
isUndoing = false
}
}) {
HStack(spacing: 6) {
if isUndoing {
ProgressView().tint(accentPink).scaleEffect(0.7)
} else {
Image(systemName: "arrow.uturn.backward")
.font(.system(size: 12))
}
Text("Undo")
.font(.system(size: 13, weight: .semibold))
}
.foregroundColor(accentPink)
.padding(.top, 4)
}
.disabled(isUndoing)
.buttonStyle(.plain)
}
}
}
}
@ -363,6 +397,7 @@ struct BatchAlbumEditorSheet: View {
await MainActor.run {
progress = 1.0
isSaving = false
lastBatchId = result.batch_id
let succeeded = result.succeeded?.count ?? 0
let failed = result.failed ?? []
if failed.isEmpty {

View file

@ -65,6 +65,10 @@ class SmartDJCache {
private let cacheDir: URL
private let bulkCacheURL: URL
private var memoryCache: [String: SmartDJProfile] = [:]
/// Serializes all memoryCache reads/writes prevents concurrent Dictionary
/// mutation crashes (EXC_BAD_ACCESS) when bulkImport runs on a background
/// Task while get/store fire from other threads.
private let lock = NSLock()
private init() {
let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!
@ -81,16 +85,23 @@ class SmartDJCache {
}
func get(_ relativePath: String) -> SmartDJProfile? {
if let cached = memoryCache[relativePath] { return cached }
lock.lock()
if let cached = memoryCache[relativePath] { lock.unlock(); return cached }
lock.unlock()
let url = cacheURL(for: relativePath)
guard let data = try? Data(contentsOf: url),
let profile = try? JSONDecoder().decode(SmartDJProfile.self, from: data) else { return nil }
lock.lock()
memoryCache[relativePath] = profile
lock.unlock()
return profile
}
func store(_ profile: SmartDJProfile, for relativePath: String) {
lock.lock()
memoryCache[relativePath] = profile
lock.unlock()
let url = cacheURL(for: relativePath)
if let data = try? JSONEncoder().encode(profile) {
try? data.write(to: url, options: .atomic)
@ -106,9 +117,11 @@ class SmartDJCache {
let profiles = try? JSONDecoder().decode([String: SmartDJProfile].self, from: data)
else { return }
// Merge: individual stores (from cache misses) take priority over bulk
lock.lock()
for (path, profile) in profiles where memoryCache[path] == nil {
memoryCache[path] = profile
}
lock.unlock()
DebugLogger.shared.log(
"DJ bulk cache loaded: \(profiles.count) profiles from disk",
category: "SmartDJ"
@ -118,28 +131,37 @@ class SmartDJCache {
/// Import all profiles from the server export. Updates memory immediately,
/// writes a single bulk JSON file to disk for next launch.
func bulkImport(_ profiles: [String: SmartDJProfile]) {
lock.lock()
for (path, profile) in profiles {
memoryCache[path] = profile
}
lock.unlock()
// Write single bulk file one atomic write, no per-profile I/O
if let data = try? JSONEncoder().encode(profiles) {
try? data.write(to: bulkCacheURL, options: .atomic)
}
DebugLogger.shared.log(
"DJ bulk import: \(profiles.count) profiles cached (\(memoryCache.count) total in memory)",
"DJ bulk import: \(profiles.count) profiles cached",
category: "SmartDJ"
)
}
func clearAll() {
lock.lock()
memoryCache.removeAll()
lock.unlock()
try? fileManager.removeItem(at: bulkCacheURL)
if let files = try? fileManager.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil) {
for file in files { try? fileManager.removeItem(at: file) }
}
}
var cachedCount: Int { memoryCache.count }
var cachedCount: Int {
lock.lock()
let count = memoryCache.count
lock.unlock()
return count
}
}
// MARK: - Metadata Edit Request (matches Python MetadataUpdate model)
@ -195,6 +217,116 @@ struct UploadMetadata {
var preserveLyrics: Bool = false
}
// MARK: - Batch Edit Result
struct BatchEditResult: Codable {
let succeeded: [String]?
let failed: [BatchEditFailure]?
let batch_id: String?
}
struct BatchEditFailure: Codable {
let path: String
let error: String
}
// MARK: - Edit History Models
struct EditHistoryResponse: Codable {
let batches: [EditHistoryEntry]
}
struct EditHistoryEntry: Codable, Identifiable {
let batch_id: String
let timestamp: String
let file_count: Int
let tags_changed: [String: String]?
let affected_albums: [String]?
let affected_artists: [String]?
let edit_type: String?
let is_reverted: Bool?
let reverted_at: String?
var id: String { batch_id }
/// Parse the Python UTC timestamp (which lacks timezone suffix).
private var parsedDate: Date? {
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let d = iso.date(from: timestamp) { return d }
iso.formatOptions = [.withInternetDateTime]
if let d = iso.date(from: timestamp) { return d }
if !timestamp.hasSuffix("Z") && !timestamp.contains("+") {
if let d = iso.date(from: timestamp + "Z") { return d }
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let d = iso.date(from: timestamp + "Z") { return d }
}
let df = DateFormatter()
df.locale = Locale(identifier: "en_US_POSIX")
df.timeZone = TimeZone(identifier: "UTC")
for fmt in ["yyyy-MM-dd'T'HH:mm:ss.SSSSSS", "yyyy-MM-dd'T'HH:mm:ss"] {
df.dateFormat = fmt
if let d = df.date(from: timestamp) { return d }
}
return nil
}
var timeAgo: String {
guard let date = parsedDate else { return timestamp }
let interval = Date().timeIntervalSince(date)
if interval < 60 { return "just now" }
if interval < 3600 { return "\(Int(interval / 60))m ago" }
if interval < 86400 { return "\(Int(interval / 3600))h ago" }
if interval < 604800 { return "\(Int(interval / 86400))d ago" }
let df = DateFormatter()
df.dateStyle = .short
return df.string(from: date)
}
var sectionDate: String {
guard let date = parsedDate else { return "Unknown" }
if Calendar.current.isDateInToday(date) { return "Today" }
if Calendar.current.isDateInYesterday(date) { return "Yesterday" }
let df = DateFormatter()
df.dateFormat = "EEEE, MMM d"
return df.string(from: date)
}
var changeSummary: String {
guard let changes = tags_changed, !changes.isEmpty else { return "Tags edited" }
if changes.count == 1, let (key, val) = changes.first {
return "\(key.capitalized)\(val)"
}
return changes.keys.map { $0.capitalized }.joined(separator: ", ") + " edited"
}
var editTitle: String {
guard let changes = tags_changed, !changes.isEmpty else { return "Tag Edit" }
if changes.count == 1, let key = changes.keys.first {
return "\(key.capitalized) Edit"
}
return "Multi-field Edit"
}
var contextString: String {
let artists = affected_artists ?? []
let albums = affected_albums ?? []
if !artists.isEmpty {
let display = artists.prefix(3).joined(separator: ", ")
return artists.count > 3 ? "\(display)" : display
}
if !albums.isEmpty {
let display = albums.prefix(2).joined(separator: ", ")
return albums.count > 2 ? "\(display)" : display
}
return "\(file_count) file\(file_count == 1 ? "" : "s")"
}
}
struct UndoResult: Codable {
let restored: [String]?
let failed: [BatchEditFailure]?
}
// MARK: - Companion API Service
actor CompanionAPIService {
@ -316,15 +448,6 @@ actor CompanionAPIService {
// MARK: - Batch Edit Metadata (PATCH /batch-edit-metadata)
struct BatchEditResult: Codable {
let succeeded: [String]?
let failed: [BatchEditFailure]?
}
struct BatchEditFailure: Codable {
let path: String
let error: String
}
/// Edit the same tags on multiple files in a single request + single Navidrome scan.
func batchEditMetadata(_ request: BatchMetadataEditRequest) async throws -> BatchEditResult {
let base = try baseURL()
@ -348,6 +471,44 @@ actor CompanionAPIService {
return try JSONDecoder().decode(BatchEditResult.self, from: data)
}
// MARK: - Edit History & Undo
/// Fetch list of recent batch/single edits with undo capability.
func fetchEditHistory() async throws -> [EditHistoryEntry] {
let base = try baseURL()
let url = base.appendingPathComponent("batch-edit-history")
let (data, response) = try await session.data(from: url)
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
throw CompanionError.invalidResponse
}
let decoded = try JSONDecoder().decode(EditHistoryResponse.self, from: data)
return decoded.batches
}
/// Undo a batch edit by restoring all files to their pre-edit tags.
/// Returns (restored count, failed count).
func undoBatchEdit(batchId: String) async throws -> UndoResult {
let base = try baseURL()
let url = base.appendingPathComponent("undo-batch-edit/\(batchId)")
var req = URLRequest(url: url)
req.httpMethod = "POST"
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else { throw CompanionError.invalidResponse }
if http.statusCode == 409 {
let detail = (try? JSONDecoder().decode([String:String].self, from: data))?["detail"]
?? "Already reverted"
throw CompanionError.serverErrorDetail(409, detail)
}
if !(200...299).contains(http.statusCode) {
let detail = (try? JSONDecoder().decode([String:String].self, from: data))?["detail"]
?? String(data: data, encoding: .utf8)
?? "Unknown error"
throw CompanionError.serverErrorDetail(http.statusCode, detail)
}
return try JSONDecoder().decode(UndoResult.self, from: data)
}
// MARK: - Upload Track (POST /upload-track)
func uploadTrack(fileURL: URL, metadata: UploadMetadata) async throws {

View file

@ -0,0 +1,431 @@
import SwiftUI
// MARK: - Edit History View
struct EditHistoryView: View {
@State private var entries: [EditHistoryEntry] = []
@State private var isLoading = true
@State private var errorMessage: String?
@State private var revertingId: String?
@State private var confirmRevert: EditHistoryEntry?
@State private var undoResult: (restored: Int, failed: Int)?
@State private var undoError: String?
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
private let api = CompanionAPIService.shared
var body: some View {
List {
if isLoading {
Section {
HStack {
ProgressView().tint(accentPink)
Text("Loading edit history…")
.font(.system(size: 14))
.foregroundColor(.gray)
.padding(.leading, 8)
}
.padding(.vertical, 4)
}
} else if let error = errorMessage {
Section {
Text(error)
.font(.system(size: 13))
.foregroundColor(.red)
}
} else if entries.isEmpty {
Section {
HStack(spacing: 12) {
Image(systemName: "clock.arrow.circlepath")
.font(.system(size: 28))
.foregroundColor(.gray)
VStack(alignment: .leading, spacing: 2) {
Text("No edit history")
.font(.system(size: 15, weight: .medium))
Text("Tag edits will appear here with the option to revert.")
.font(.system(size: 13))
.foregroundColor(.gray)
}
}
.padding(.vertical, 8)
}
} else {
// Group entries by section date
ForEach(sectionDates, id: \.self) { section in
Section(header: Text(section).font(.system(size: 13, weight: .semibold))) {
ForEach(entriesForSection(section)) { entry in
EditHistoryCard(
entry: entry,
isReverting: revertingId == entry.batch_id,
onRevert: { confirmRevert = entry }
)
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
}
}
}
}
}
.listStyle(.insetGrouped)
.navigationTitle("Edit History")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(action: { Task { await loadHistory() } }) {
Image(systemName: "arrow.clockwise")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(accentPink)
}
}
}
.task { await loadHistory() }
.confirmationDialog(
"Revert this edit?",
isPresented: Binding(
get: { confirmRevert != nil },
set: { if !$0 { confirmRevert = nil } }
),
titleVisibility: .visible
) {
if let entry = confirmRevert {
Button("Revert \(entry.file_count) track\(entry.file_count == 1 ? "" : "s")", role: .destructive) {
Task { await performRevert(entry) }
}
Button("Cancel", role: .cancel) { confirmRevert = nil }
}
} message: {
if let entry = confirmRevert {
Text("This will restore all \(entry.file_count) track\(entry.file_count == 1 ? "" : "s") to their previous tags. The files will be updated on disk and Navidrome will rescan.")
}
}
.overlay(alignment: .bottom) {
// Undo result toast
if let result = undoResult {
UndoResultToast(
restored: result.restored,
failed: result.failed,
onDismiss: { withAnimation { undoResult = nil } }
)
.transition(.move(edge: .bottom).combined(with: .opacity))
.padding(.bottom, 16)
}
if let error = undoError {
UndoErrorToast(
message: error,
onDismiss: { withAnimation { undoError = nil } }
)
.transition(.move(edge: .bottom).combined(with: .opacity))
.padding(.bottom, 16)
}
}
}
// MARK: - Section grouping
private var sectionDates: [String] {
var seen = Set<String>()
var result: [String] = []
for e in entries {
let s = e.sectionDate
if seen.insert(s).inserted {
result.append(s)
}
}
return result
}
private func entriesForSection(_ section: String) -> [EditHistoryEntry] {
entries.filter { $0.sectionDate == section }
}
// MARK: - API
private func loadHistory() async {
isLoading = true
errorMessage = nil
do {
entries = try await api.fetchEditHistory()
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
private func performRevert(_ entry: EditHistoryEntry) async {
revertingId = entry.batch_id
do {
let result = try await api.undoBatchEdit(batchId: entry.batch_id)
let restored = result.restored?.count ?? 0
let failed = result.failed?.count ?? 0
withAnimation { undoResult = (restored, failed) }
// Refresh the list so the entry shows as reverted
await loadHistory()
// Auto-dismiss after 5s
Task {
try? await Task.sleep(for: .seconds(5))
withAnimation { undoResult = nil }
}
} catch {
withAnimation { undoError = error.localizedDescription }
Task {
try? await Task.sleep(for: .seconds(5))
withAnimation { undoError = nil }
}
}
revertingId = nil
}
}
// MARK: - Edit History Card
private struct EditHistoryCard: View {
let entry: EditHistoryEntry
let isReverting: Bool
let onRevert: () -> Void
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
private var isReverted: Bool { entry.is_reverted ?? false }
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Top row: icon + title + time
HStack(alignment: .top, spacing: 10) {
editIcon
.frame(width: 34, height: 34)
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(entry.editTitle)
.font(.system(size: 15, weight: .semibold))
.foregroundColor(isReverted ? .gray : .white)
if entry.edit_type == "single" {
Text("single")
.font(.system(size: 10, weight: .medium))
.foregroundColor(.gray)
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(Color.white.opacity(0.06))
.clipShape(Capsule())
}
}
Text("\(entry.file_count) track\(entry.file_count == 1 ? "" : "s") · \(entry.contextString)")
.font(.system(size: 13))
.foregroundColor(.gray)
.lineLimit(1)
}
Spacer(minLength: 0)
Text(entry.timeAgo)
.font(.system(size: 12))
.foregroundColor(Color(white: 0.4))
}
// Change pills
if let changes = entry.tags_changed, !changes.isEmpty {
changePills(changes)
}
// Action row
HStack {
// Affected context
if let albums = entry.affected_albums, !albums.isEmpty {
Text(albums.prefix(2).joined(separator: ", "))
.font(.system(size: 12))
.foregroundColor(Color(white: 0.35))
.lineLimit(1)
}
Spacer()
if isReverting {
ProgressView()
.tint(accentPink)
.scaleEffect(0.8)
} else if isReverted {
HStack(spacing: 4) {
Image(systemName: "arrow.uturn.backward")
.font(.system(size: 11))
Text("Reverted")
.font(.system(size: 13, weight: .semibold))
}
.foregroundColor(.green.opacity(0.7))
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Color.green.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
Button(action: onRevert) {
HStack(spacing: 4) {
Image(systemName: "arrow.uturn.backward")
.font(.system(size: 11))
Text("Revert")
.font(.system(size: 13, weight: .semibold))
}
.foregroundColor(accentPink)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(accentPink.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(accentPink.opacity(0.25), lineWidth: 1)
)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
}
}
}
.padding(.vertical, 4)
.opacity(isReverted ? 0.5 : 1.0)
}
// MARK: - Icon
@ViewBuilder
private var editIcon: some View {
let changes = entry.tags_changed ?? [:]
let keys = Set(changes.keys)
if keys.count == 1, keys.contains("genre") {
iconBox(symbol: "music.note", bgColor: .purple)
} else if keys.count == 1, keys.contains("album") {
iconBox(symbol: "square.stack", bgColor: .blue)
} else if keys.count == 1, (keys.contains("artist") || keys.contains("album_artist")) {
iconBox(symbol: "person.fill", bgColor: .orange)
} else if keys.count > 1 {
iconBox(symbol: "pencil", bgColor: accentPink)
} else {
iconBox(symbol: "tag.fill", bgColor: .gray)
}
}
private func iconBox(symbol: String, bgColor: Color) -> some View {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(bgColor.opacity(0.15))
.overlay(
Image(systemName: symbol)
.font(.system(size: 15, weight: .medium))
.foregroundColor(bgColor)
)
}
// MARK: - Change pills
private func changePills(_ changes: [String: String]) -> some View {
let sorted = changes.sorted { $0.key < $1.key }
return FlowLayout(spacing: 6) {
ForEach(sorted, id: \.key) { key, value in
HStack(spacing: 3) {
Text(key.capitalized)
.font(.system(size: 11, weight: .medium))
Text("")
.font(.system(size: 10))
.opacity(0.5)
Text(value)
.font(.system(size: 11, weight: .medium))
.lineLimit(1)
}
.foregroundColor(pillColor(for: key))
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(pillColor(for: key).opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(pillColor(for: key).opacity(0.2), lineWidth: 1)
)
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
}
private func pillColor(for key: String) -> Color {
switch key.lowercased() {
case "genre": return .purple
case "album": return .blue
case "artist", "album_artist": return .orange
case "year", "date": return .green
case "title": return .cyan
default: return .gray
}
}
}
// FlowLayout is defined in LyricsOverlayView.swift reused here for change pills.
// MARK: - Undo Result Toast
private struct UndoResultToast: View {
let restored: Int
let failed: Int
let onDismiss: () -> Void
var body: some View {
HStack(spacing: 12) {
Image(systemName: failed == 0 ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
.font(.system(size: 20))
.foregroundColor(failed == 0 ? .green : .yellow)
VStack(alignment: .leading, spacing: 1) {
Text(failed == 0
? "Reverted \(restored) track\(restored == 1 ? "" : "s")"
: "\(restored) reverted, \(failed) failed")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.white)
Text("Tags restored to previous state")
.font(.system(size: 12))
.foregroundColor(.gray)
}
Spacer()
Button(action: onDismiss) {
Image(systemName: "xmark")
.font(.system(size: 12, weight: .bold))
.foregroundColor(.gray)
}
}
.padding(14)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(Color.white.opacity(0.08), lineWidth: 1)
)
.padding(.horizontal, 16)
}
}
// MARK: - Undo Error Toast
private struct UndoErrorToast: View {
let message: String
let onDismiss: () -> Void
var body: some View {
HStack(spacing: 12) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 20))
.foregroundColor(.red)
Text(message)
.font(.system(size: 13, weight: .medium))
.foregroundColor(.white)
.lineLimit(2)
Spacer()
Button(action: onDismiss) {
Image(systemName: "xmark")
.font(.system(size: 12, weight: .bold))
.foregroundColor(.gray)
}
}
.padding(14)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(Color.red.opacity(0.2), lineWidth: 1)
)
.padding(.horizontal, 16)
}
}

View file

@ -39,6 +39,8 @@ struct MultiAlbumEditorSheet: View {
@State private var progress: Double = 0
@State private var resultMessage: String?
@State private var failedTracks: [String] = []
@State private var lastBatchId: String?
@State private var isUndoing = false
@ObservedObject private var settings = CompanionSettings.shared
private let api = CompanionAPIService.shared
@ -313,6 +315,37 @@ struct MultiAlbumEditorSheet: View {
.font(.system(size: 11))
.foregroundColor(.red)
}
if let batchId = lastBatchId, failedTracks.isEmpty {
Button(action: {
Task {
isUndoing = true
do {
let r = try await CompanionAPIService.shared.undoBatchEdit(batchId: batchId)
let restored = r.restored?.count ?? 0
resultMessage = "↩ Reverted \(restored) tracks to previous tags"
lastBatchId = nil
} catch {
resultMessage = "Undo failed: \(error.localizedDescription)"
}
isUndoing = false
}
}) {
HStack(spacing: 6) {
if isUndoing {
ProgressView().tint(Color(red: 1, green: 0.176, blue: 0.333)).scaleEffect(0.7)
} else {
Image(systemName: "arrow.uturn.backward")
.font(.system(size: 12))
}
Text("Undo")
.font(.system(size: 13, weight: .semibold))
}
.foregroundColor(Color(red: 1, green: 0.176, blue: 0.333))
.padding(.top, 4)
}
.disabled(isUndoing)
.buttonStyle(.plain)
}
}
}
}
@ -423,6 +456,7 @@ struct MultiAlbumEditorSheet: View {
await MainActor.run {
progress = 1.0
isSaving = false
lastBatchId = result.batch_id
let succeeded = result.succeeded?.count ?? 0
let failed = result.failed ?? []
if failed.isEmpty {

View file

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

View file

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

View file

@ -140,6 +140,8 @@ struct DownloadsView: View {
}
}
}
Section {} footer: { Spacer().frame(height: 80) }
}
}
@ -356,6 +358,8 @@ struct DownloadsView: View {
}
}
}
Section {} footer: { Spacer().frame(height: 80) }
}
}
.onAppear {
@ -746,7 +750,7 @@ struct SettingsView: View {
// Extra space so the last items aren't hidden behind the mini player
Section {} footer: {
Spacer().frame(height: 60)
Spacer().frame(height: 80)
}
}
.navigationTitle("Settings")
@ -1078,6 +1082,26 @@ struct LibraryConflictsView: View {
var body: some View {
List {
// Edit History link always visible at top
Section {
NavigationLink {
EditHistoryView()
} label: {
HStack(spacing: 12) {
Image(systemName: "clock.arrow.circlepath")
.font(.system(size: 16, weight: .medium))
.foregroundColor(accentPink)
.frame(width: 28)
Text("Edit History")
.font(.system(size: 15, weight: .medium))
Spacer()
Text("View & revert")
.font(.system(size: 12))
.foregroundColor(.gray)
}
}
}
// Tab picker
Section {
Picker("", selection: $selectedTab) {

View file

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

View file

@ -18,8 +18,11 @@ struct MyMusicView: View {
@Binding var navigateToPlaylistId: String?
@Binding var navigateToAlbumId: String?
@Binding var navigateToArtistId: String?
@Binding var isDynamicIsland: Bool
@State private var recentAlbums: [Album] = []
@State private var recentlyPlayedAlbums: [Album] = []
@State private var randomAlbums: [Album] = []
@State private var allAlbums: [Album] = []
@State private var allSongs: [Song] = []
@State private var songsLoaded = false
@ -62,6 +65,7 @@ struct MyMusicView: View {
enum SortMode: String, CaseIterable {
case recentlyAdded = "Recently Added"
case recentlyPlayed = "Recently Played"
case favourites = "Favourites"
case artists = "Artists"
case albums = "Albums"
@ -84,6 +88,9 @@ struct MyMusicView: View {
if mode == .favourites {
Image(systemName: "heart.fill")
.font(.system(size: 11))
} else if mode == .recentlyPlayed {
Image(systemName: "clock.fill")
.font(.system(size: 11))
}
Text(mode == .favourites ? "Favourites" : mode.rawValue)
.font(.system(size: 13, weight: .medium))
@ -100,8 +107,8 @@ struct MyMusicView: View {
.padding(.vertical, 10)
}
// Search bar (for all tabs except Recently Added)
if sortMode != .recentlyAdded {
// Search bar (for all tabs except Recently Added and Recently Played)
if sortMode != .recentlyAdded && sortMode != .recentlyPlayed {
searchBar
}
@ -110,13 +117,14 @@ struct MyMusicView: View {
VStack(alignment: .leading, spacing: 0) {
switch sortMode {
case .recentlyAdded: recentlyAddedTab
case .recentlyPlayed: recentlyPlayedTab
case .favourites: favouritesTab
case .artists: artistsTab
case .albums: albumsTab
case .songs: songsTab
case .genres: genresTab
}
Color.clear.frame(height: 100)
Color.clear.frame(height: 80)
}
}
}
@ -155,7 +163,7 @@ struct MyMusicView: View {
.foregroundColor(selectedAlbumIds.isEmpty ? .gray : accentPink)
}
.disabled(selectedAlbumIds.isEmpty)
} else {
} else if !isDynamicIsland {
Button(action: { showServerPicker = true }) {
Image(systemName: "server.rack")
.foregroundColor(accentPink)
@ -278,10 +286,249 @@ struct MyMusicView: View {
private var recentlyAddedTab: some View {
VStack(alignment: .leading, spacing: 0) {
recentlyAddedSection
discoverSection
playlistsSection
}
}
// MARK: - Recently Played Tab
private var recentlyPlayedTab: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Recently Played")
.font(.system(size: 22, weight: .bold))
.foregroundColor(.white)
Spacer()
}
.padding(.horizontal, 16)
.padding(.top, 16)
if recentlyPlayedAlbums.isEmpty {
VStack(spacing: 8) {
Image(systemName: "clock")
.font(.system(size: 36))
.foregroundColor(.gray)
Text("No recently played albums")
.font(.system(size: 14))
.foregroundColor(.gray)
Text("Albums will appear here as you listen.")
.font(.system(size: 12))
.foregroundColor(Color(white: 0.4))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
} else {
LazyVStack(spacing: 0) {
ForEach(recentlyPlayedAlbums) { album in
NavigationLink(destination: AlbumDetailView(albumId: album.id)) {
HStack(spacing: 12) {
AsyncCoverArt(coverArtId: album.coverArt, size: 60)
.frame(width: 56, height: 56)
.cornerRadius(4)
VStack(alignment: .leading, spacing: 3) {
Text(album.name)
.font(.system(size: 15, weight: .medium))
.foregroundColor(.white)
.lineLimit(1)
Text(album.artist ?? "")
.font(.system(size: 13))
.foregroundColor(.gray)
.lineLimit(1)
}
Spacer()
Button(action: { playAlbum(album) }) {
Image(systemName: "play.circle.fill")
.font(.system(size: 28))
.foregroundColor(accentPink)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
.contextMenu {
Button(action: { playAlbum(album) }) {
Label("Play", systemImage: "play.fill")
}
Button(action: { playAlbumNext(album) }) {
Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward")
}
}
}
}
}
}
.task {
if recentlyPlayedAlbums.isEmpty {
await loadRecentlyPlayed()
}
}
}
private func loadRecentlyPlayed() async {
do {
let albums = try await serverManager.client.getAlbumList2(type: "recent", size: 30)
await MainActor.run { recentlyPlayedAlbums = albums }
} catch {
print("[MyMusicView] Failed to load recently played: \(error)")
}
}
// MARK: - Discover Section
private var discoverSection: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Discover")
.font(.system(size: 22, weight: .bold))
.foregroundColor(.white)
Spacer()
Button(action: { Task { await refreshRandom() } }) {
Image(systemName: "arrow.clockwise")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(accentPink)
}
}
.padding(.horizontal, 16)
.padding(.top, 20)
// Random Albums horizontal scroll
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 14) {
// Shuffle All button
Button(action: { Task { await playRandomMix() } }) {
VStack(spacing: 8) {
ZStack {
RoundedRectangle(cornerRadius: 4)
.fill(
LinearGradient(
colors: [accentPink.opacity(0.3), accentPink.opacity(0.1)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 140, height: 140)
VStack(spacing: 6) {
Image(systemName: "shuffle")
.font(.system(size: 28, weight: .medium))
.foregroundColor(accentPink)
Text("Shuffle All")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(accentPink)
}
}
VStack(alignment: .leading, spacing: 2) {
Text("I'm Feeling Lucky")
.font(.system(size: 12, weight: .medium))
.foregroundColor(.white)
.lineLimit(1)
Text("Random mix")
.font(.system(size: 11))
.foregroundColor(.gray)
.lineLimit(1)
}
}
.frame(width: 140)
}
.buttonStyle(.plain)
// Random albums
ForEach(deduplicateAlbums(randomAlbums)) { album in
VStack(alignment: .leading, spacing: 6) {
ZStack(alignment: .bottomTrailing) {
NavigationLink(destination: AlbumDetailView(albumId: album.id)) {
AsyncCoverArt(coverArtId: album.coverArt, size: 140)
.frame(width: 140, height: 140)
.cornerRadius(4)
.shadow(color: .black.opacity(0.3), radius: 4, y: 2)
}
Button(action: { playAlbum(album) }) {
Image(systemName: "play.circle.fill")
.font(.system(size: 28))
.foregroundColor(.white)
.shadow(radius: 4)
}
.padding(4)
}
NavigationLink(destination: AlbumDetailView(albumId: album.id)) {
VStack(alignment: .leading, spacing: 2) {
Text(album.name)
.font(.system(size: 12, weight: .medium))
.foregroundColor(.white)
.lineLimit(1)
Text(album.artist ?? "")
.font(.system(size: 11))
.foregroundColor(.gray)
.lineLimit(1)
}
}
}
.frame(width: 140)
}
}
.padding(.horizontal, 16)
}
// Genre quick-play chips
if !genres.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(genres.prefix(12)) { genre in
Button(action: { Task { await playGenreMix(genre.value) } }) {
Text(genre.value)
.font(.system(size: 12, weight: .medium))
.foregroundColor(.white)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.white.opacity(0.08))
.cornerRadius(14)
}
}
}
.padding(.horizontal, 16)
.padding(.top, 4)
}
}
}
}
private func refreshRandom() async {
do {
let albums = try await serverManager.client.getAlbumList2(type: "random", size: 10)
await MainActor.run { randomAlbums = albums }
} catch {
print("[MyMusicView] Failed to load random albums: \(error)")
}
}
private func playRandomMix() async {
do {
let songs = try await serverManager.client.getRandomSongs(size: 50)
if !songs.isEmpty {
AudioPlayer.shared.play(song: songs[0], fromQueue: songs, at: 0)
}
} catch {
print("[MyMusicView] Failed to play random mix: \(error)")
}
}
private func playGenreMix(_ genre: String) async {
do {
let songs = try await serverManager.client.getRandomSongs(size: 50, genre: genre)
if !songs.isEmpty {
AudioPlayer.shared.play(song: songs[0], fromQueue: songs, at: 0)
}
} catch {
print("[MyMusicView] Failed to play genre mix \(genre): \(error)")
}
}
private var recentlyAddedSection: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
@ -1202,6 +1449,11 @@ struct MyMusicView: View {
if favouriteSongs.isEmpty, let v = cache.load([Song].self, key: "starred_songs") { favouriteSongs = v }
isLoading = false
// Load random albums for Discover section (always fresh, not cached)
if randomAlbums.isEmpty {
Task { await refreshRandom() }
}
// If the cache was empty (first launch or after a clear), kick off a sync
// and wait for it to fill the cache, then reload.

View file

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

View file

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

View file

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

View file

@ -28,6 +28,10 @@ final class LyricsManager: ObservableObject {
/// Whether the lyrics overlay is visible in Now Playing.
@Published var isLyricsVisible = false
/// Global timing offset in seconds. Positive = lyrics appear later, negative = earlier.
/// Applied in syncToTime and wordProgress instant feedback without editing timestamps.
@Published var globalOffset: Double = 0
// MARK: - Non-Published State (polled per-frame by views)
/// Current word index within the active line. Updated per-frame when visible.
@ -81,6 +85,7 @@ final class LyricsManager: ObservableObject {
currentLineIndex = 0
currentWordIndex = 0
lastSyncedTime = 0
globalOffset = 0
isLyricsVisible = false
}
@ -92,7 +97,10 @@ final class LyricsManager: ObservableObject {
lastSyncedTime = time
guard let lyrics = currentLyrics, !lyrics.lines.isEmpty else { return }
let newIndex = findLineIndex(at: time, in: lyrics.lines)
// Apply global offset: positive = lyrics later, so we look up an earlier time
let adjusted = time - globalOffset
let newIndex = findLineIndex(at: adjusted, in: lyrics.lines)
if newIndex != currentLineIndex {
currentLineIndex = newIndex
}
@ -101,7 +109,7 @@ final class LyricsManager: ObservableObject {
if isLyricsVisible,
newIndex < lyrics.lines.count,
let words = lyrics.lines[newIndex].words {
currentWordIndex = findWordIndex(at: time, in: words)
currentWordIndex = findWordIndex(at: adjusted, in: words)
}
}
@ -112,21 +120,24 @@ final class LyricsManager: ObservableObject {
return (0, 0, 0)
}
let lineIdx = findLineIndex(at: time, in: lyrics.lines)
// Apply global offset
let adjusted = time - globalOffset
let lineIdx = findLineIndex(at: adjusted, in: lyrics.lines)
let line = lyrics.lines[lineIdx]
guard let words = line.words, !words.isEmpty else {
return (lineIdx, 0, 0)
}
let wordIdx = findWordIndex(at: time, in: words)
let wordIdx = findWordIndex(at: adjusted, in: words)
let word = words[min(wordIdx, words.count - 1)]
let wordDuration = word.endTime - word.startTime
let fraction: Double
if wordDuration > 0 {
fraction = min(max((time - word.startTime) / wordDuration, 0), 1)
fraction = min(max((adjusted - word.startTime) / wordDuration, 0), 1)
} else {
fraction = time >= word.startTime ? 1 : 0
fraction = adjusted >= word.startTime ? 1 : 0
}
return (lineIdx, wordIdx, fraction)

View file

@ -15,22 +15,28 @@ import SwiftUI
struct LyricsOverlayView: View {
@ObservedObject var lyricsManager = LyricsManager.shared
@ObservedObject var audioPlayer = AudioPlayer.shared
@State private var showTimingAdjust = false
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
if let lyrics = lyricsManager.currentLyrics, !lyrics.lines.isEmpty {
TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: !audioPlayer.isPlaying)) { timeline in
let now = audioPlayer.currentTime
let progress = lyricsManager.wordProgress(at: now)
ZStack(alignment: .bottom) {
TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: !audioPlayer.isPlaying)) { timeline in
let now = audioPlayer.currentTime
let progress = lyricsManager.wordProgress(at: now)
lyricsContent(
lyrics: lyrics,
currentTime: now,
lineIndex: progress.lineIndex,
wordIndex: progress.wordIndex,
wordFraction: progress.wordFraction
)
}
lyricsContent(
lyrics: lyrics,
currentTime: now,
lineIndex: progress.lineIndex,
wordIndex: progress.wordIndex,
wordFraction: progress.wordFraction
)
// Bottom toolbar search, timing, edit
lyricsToolbar(hasSynced: lyrics.hasSynced)
}
} else if lyricsManager.isLoading {
VStack {
@ -55,7 +61,6 @@ struct LyricsOverlayView: View {
.padding(.top, 8)
Button {
// Open search sheet
NotificationCenter.default.post(name: .openLyricsSearch, object: nil)
} label: {
Text("Search Lyrics")
@ -73,6 +78,118 @@ struct LyricsOverlayView: View {
}
}
// MARK: - Lyrics Toolbar
@ViewBuilder
private func lyricsToolbar(hasSynced: Bool) -> some View {
VStack(spacing: 0) {
// Timing adjustment (expandable)
if showTimingAdjust && hasSynced {
HStack(spacing: 12) {
Button { lyricsManager.globalOffset -= 0.5 } label: {
Text("-0.5s")
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.foregroundColor(.white)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.white.opacity(0.1))
.cornerRadius(8)
}
Button { lyricsManager.globalOffset -= 0.1 } label: {
Text("-0.1s")
.font(.system(size: 13, weight: .medium, design: .monospaced))
.foregroundColor(.white.opacity(0.7))
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(Color.white.opacity(0.06))
.cornerRadius(8)
}
Text(String(format: "%+.1fs", lyricsManager.globalOffset))
.font(.system(size: 15, weight: .bold, design: .monospaced))
.foregroundColor(lyricsManager.globalOffset == 0 ? .white.opacity(0.5) : accentPink)
.frame(width: 60)
.onTapGesture { lyricsManager.globalOffset = 0 }
Button { lyricsManager.globalOffset += 0.1 } label: {
Text("+0.1s")
.font(.system(size: 13, weight: .medium, design: .monospaced))
.foregroundColor(.white.opacity(0.7))
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(Color.white.opacity(0.06))
.cornerRadius(8)
}
Button { lyricsManager.globalOffset += 0.5 } label: {
Text("+0.5s")
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.foregroundColor(.white)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.white.opacity(0.1))
.cornerRadius(8)
}
}
.padding(.vertical, 10)
.padding(.horizontal, 16)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
// Main toolbar buttons
HStack(spacing: 0) {
// Re-search lyrics
Button {
NotificationCenter.default.post(name: .openLyricsSearch, object: nil)
} label: {
VStack(spacing: 3) {
Image(systemName: "magnifyingglass")
.font(.system(size: 16))
Text("Search")
.font(.system(size: 10, weight: .medium))
}
.foregroundColor(.white.opacity(0.6))
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
// Timing offset toggle (only for synced lyrics)
if hasSynced {
Button {
withAnimation(.spring(response: 0.3)) { showTimingAdjust.toggle() }
} label: {
VStack(spacing: 3) {
Image(systemName: "timer")
.font(.system(size: 16))
Text("Timing")
.font(.system(size: 10, weight: .medium))
}
.foregroundColor(showTimingAdjust ? accentPink : .white.opacity(0.6))
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
}
// Open full editor
Button {
NotificationCenter.default.post(name: .openLyricsEditor, object: nil)
} label: {
VStack(spacing: 3) {
Image(systemName: "pencil.line")
.font(.system(size: 16))
Text("Edit")
.font(.system(size: 10, weight: .medium))
}
.foregroundColor(.white.opacity(0.6))
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
}
.background(.ultraThinMaterial.opacity(0.8))
}
}
// MARK: - Lyrics Content
@ViewBuilder
@ -258,4 +375,5 @@ struct FlowLayout: Layout {
extension Notification.Name {
static let openLyricsSearch = Notification.Name("openLyricsSearch")
static let openLyricsEditor = Notification.Name("openLyricsEditor")
}

View file

@ -355,6 +355,9 @@ struct NowPlayingView: View {
.onReceive(NotificationCenter.default.publisher(for: .openLyricsSearch)) { _ in
showLyricsSearch = true
}
.onReceive(NotificationCenter.default.publisher(for: .openLyricsEditor)) { _ in
showLyricsEditor = true
}
.onAppear {
dragOffset = 0
isStarred = audioPlayer.currentSong?.starred != nil
@ -1564,8 +1567,7 @@ class ShazamRecognizer: NSObject, ObservableObject, SHSessionDelegate {
private var timeoutTask: Task<Void, Never>?
private var hasDeliveredResult = false
// Tap state MTAudioProcessingTap is a CF type, ARC manages it directly
private var tap: MTAudioProcessingTap?
// Tap state uses shared AudioTapProcessor (no longer owns its own tap)
private var converter: AVAudioConverter?
private var sourceFormat: AVAudioFormat?
private let targetFormat = AVAudioFormat(standardFormatWithSampleRate: 16_000, channels: 1)!
@ -1592,14 +1594,40 @@ class ShazamRecognizer: NSObject, ObservableObject, SHSessionDelegate {
DebugLogger.shared.log("Shazam: starting recognition", category: "Audio")
// Prefer tap on AVPlayer async track load, then install tap on main actor
if let playerItem = AudioPlayer.shared.currentPlayerItem {
// Use the shared AudioTapProcessor piggyback on FFT tap if active,
// or install a new one. Fixes the old bug where Shazam's own tap
// would overwrite the FFT visualizer tap (or vice versa).
if AudioPlayer.shared.currentPlayerItem != nil {
Task { @MainActor [weak self] in
guard let self else { return }
if await self.installTap(on: playerItem) {
let tapProc = AudioTapProcessor.shared
// Ensure the shared tap is installed (may already be active for FFT)
if tapProc.sourceFormat == nil,
let item = AudioPlayer.shared.currentPlayerItem {
let _ = await tapProc.installTap(on: item)
}
// tapPrepare callback fires async on the audio thread after installTap.
// Wait briefly for sourceFormat to be populated without this,
// sourceFormat is often nil and Shazam falls back to mic unnecessarily.
if tapProc.sourceFormat == nil {
try? await Task.sleep(for: .milliseconds(500))
}
if let srcFormat = tapProc.sourceFormat {
self.sourceFormat = srcFormat
self.converter = AVAudioConverter(from: srcFormat, to: self.targetFormat)
// Subscribe to the shared tap receives buffers from render thread
tapProc.shazamHandler = { [weak self] bufferList, frameCount in
guard let self else { return }
self.processTapBuffer(bufferList, frameCount: frameCount)
}
self.startTimeout(seconds: 15)
DebugLogger.shared.log("Shazam: tap installed on AVPlayerItem", category: "Audio")
DebugLogger.shared.log("Shazam: subscribed to shared audio tap (\(Int(srcFormat.sampleRate))Hz)", category: "Audio")
} else {
DebugLogger.shared.log("Shazam: shared tap has no format — mic fallback", category: "Audio")
self.startMicFallback()
}
}
@ -1608,64 +1636,17 @@ class ShazamRecognizer: NSObject, ObservableObject, SHSessionDelegate {
}
}
// MARK: - MTAudioProcessingTap
// MARK: - Shared Tap Consumer
/// Async because `loadTracks(withMediaType:)` is the non-deprecated API (iOS 16+).
@MainActor
private func installTap(on playerItem: AVPlayerItem) async -> Bool {
// loadTracks is the non-deprecated replacement for tracks(withMediaType:)
guard let audioTrack = try? await playerItem.asset
.loadTracks(withMediaType: .audio).first else {
DebugLogger.shared.log("Shazam: no audio track on playerItem", category: "Audio")
return false
}
var callbacks = MTAudioProcessingTapCallbacks(
version: kMTAudioProcessingTapCallbacksVersion_0,
clientInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()),
init: { tap, clientInfo, tapStorageOut in
tapStorageOut.pointee = clientInfo
},
finalize: nil,
prepare: { tap, _, processingFormat in
let storage = Unmanaged<ShazamRecognizer>
.fromOpaque(MTAudioProcessingTapGetStorage(tap))
.takeUnretainedValue()
let format = AVAudioFormat(streamDescription: processingFormat)
storage.analysisQueue.async {
storage.sourceFormat = format
if let src = format {
storage.converter = AVAudioConverter(from: src, to: storage.targetFormat)
}
}
},
unprepare: nil,
process: shazamTapProcess
)
// MTAudioProcessingTap is a CF type Swift bridges it as MTAudioProcessingTap?
var tapOut: MTAudioProcessingTap?
let status = MTAudioProcessingTapCreate(
kCFAllocatorDefault, &callbacks,
kMTAudioProcessingTapCreationFlag_PostEffects, &tapOut
)
guard status == noErr, let tapValue = tapOut else {
DebugLogger.shared.log("Shazam: MTAudioProcessingTapCreate failed \(status)", category: "Audio")
return false
}
tap = tapValue // ARC owns it
let inputParams = AVMutableAudioMixInputParameters(track: audioTrack)
inputParams.audioTapProcessor = tapValue
let mix = AVMutableAudioMix()
mix.inputParameters = [inputParams]
playerItem.audioMix = mix
return true
private func removeTap() {
// Unsubscribe from the shared tap do NOT remove the audioMix
// because the FFT visualizer may still be using it.
AudioTapProcessor.shared.shazamHandler = nil
converter = nil
sourceFormat = nil
}
/// Called from the C tap callback dispatches to analysis queue to avoid blocking render thread.
/// Called from the shared tap callback dispatches to analysis queue to avoid blocking render thread.
func processTapBuffer(_ bufferList: UnsafeMutablePointer<AudioBufferList>,
frameCount: CMItemCount) {
guard let session, let conv = converter,
@ -1733,16 +1714,6 @@ class ShazamRecognizer: NSObject, ObservableObject, SHSessionDelegate {
}
}
private func removeTap() {
// Removing the audioMix disconnects the tap cleanly
if let item = AudioPlayer.shared.currentPlayerItem {
item.audioMix = nil
}
tap = nil
converter = nil
sourceFormat = nil
}
// MARK: - Microphone fallback (local engine / engine path)
private func startMicFallback() {
@ -1832,6 +1803,7 @@ class ShazamRecognizer: NSObject, ObservableObject, SHSessionDelegate {
private func stopAll() {
timeoutTask?.cancel(); timeoutTask = nil
removeTap()
let usedMic = audioEngine != nil
if let engine = audioEngine {
if engine.inputNode.numberOfInputs > 0 {
engine.inputNode.removeTap(onBus: 0)
@ -1842,7 +1814,7 @@ class ShazamRecognizer: NSObject, ObservableObject, SHSessionDelegate {
session = nil
// Restore audio session only if we used the mic fallback
if audioEngine != nil {
if usedMic {
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
try? AVAudioSession.sharedInstance().setActive(true)
}
@ -1854,29 +1826,6 @@ class ShazamRecognizer: NSObject, ObservableObject, SHSessionDelegate {
}
}
// MARK: - MTAudioProcessingTap C callback (must be a free function)
/// Must be a C-style free function cannot be a method or closure.
private func shazamTapProcess(
tap: MTAudioProcessingTap,
numberFrames: CMItemCount,
flags: MTAudioProcessingTapFlags,
bufferListInOut: UnsafeMutablePointer<AudioBufferList>,
numberFramesOut: UnsafeMutablePointer<CMItemCount>,
flagsOut: UnsafeMutablePointer<MTAudioProcessingTapFlags>
) {
// Fetch audio from source passes samples through to the player unchanged
let status = MTAudioProcessingTapGetSourceAudio(
tap, numberFrames, bufferListInOut, flagsOut, nil, numberFramesOut)
guard status == noErr else { return }
// Forward to the recognizer on the analysis queue never block this render thread
let recognizer = Unmanaged<ShazamRecognizer>
.fromOpaque(MTAudioProcessingTapGetStorage(tap))
.takeUnretainedValue()
recognizer.processTapBuffer(bufferListInOut, frameCount: numberFramesOut.pointee)
}
// MARK: - Shazam Result Sheet
@ -1972,6 +1921,10 @@ struct ShazamResultSheet: View {
struct SongInfoSheet: View {
let song: Song
@Environment(\.dismiss) private var dismiss
@State private var shareURL: String?
@State private var isCreatingShare = false
@State private var shareError: String?
@State private var showCopiedToast = false
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
@ -2011,6 +1964,73 @@ struct SongInfoSheet: View {
let isDownloaded = OfflineManager.shared.isSongDownloaded(song.id)
infoRow("Downloaded", isDownloaded ? "Yes" : "No")
}
// Share Link
Section("SHARE") {
if let url = shareURL {
HStack {
Text(url)
.font(.system(size: 12))
.foregroundColor(accentPink)
.lineLimit(2)
Spacer()
Button(action: {
UIPasteboard.general.string = url
withAnimation { showCopiedToast = true }
Task {
try? await Task.sleep(for: .seconds(2))
withAnimation { showCopiedToast = false }
}
}) {
Image(systemName: showCopiedToast ? "checkmark" : "doc.on.clipboard")
.font(.system(size: 14))
.foregroundColor(showCopiedToast ? .green : accentPink)
}
}
// System share sheet
if let shareLink = URL(string: url) {
ShareLink(item: shareLink) {
HStack {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 14))
Text("Share via…")
.font(.system(size: 14))
}
.foregroundColor(accentPink)
}
}
} else if isCreatingShare {
HStack {
ProgressView().tint(accentPink).scaleEffect(0.8)
Text("Creating share link…")
.font(.system(size: 13))
.foregroundColor(.gray)
.padding(.leading, 8)
}
} else if let error = shareError {
VStack(alignment: .leading, spacing: 4) {
Text(error)
.font(.system(size: 13))
.foregroundColor(.red)
Button("Try Again") {
Task { await createShareLink() }
}
.font(.system(size: 13, weight: .medium))
.foregroundColor(accentPink)
}
} else {
Button(action: { Task { await createShareLink() } }) {
HStack {
Image(systemName: "link.badge.plus")
.font(.system(size: 14))
Text("Create Share Link")
.font(.system(size: 14))
}
.foregroundColor(accentPink)
}
}
}
}
.navigationTitle("Get Info")
.navigationBarTitleDisplayMode(.inline)
@ -2022,6 +2042,28 @@ struct SongInfoSheet: View {
}
}
private func createShareLink() async {
isCreatingShare = true
shareError = nil
do {
// Expires in 7 days (ms since epoch)
let expires = Int64((Date().timeIntervalSince1970 + 7 * 86400) * 1000)
let share = try await ServerManager.shared.client.createShare(
id: song.id,
description: "\(song.title)\(song.artist ?? "Unknown")",
expires: expires
)
if let url = share?.url {
shareURL = url
} else {
shareError = "Server returned no share URL. Sharing may be disabled."
}
} catch {
shareError = error.localizedDescription
}
isCreatingShare = false
}
private func infoRow(_ label: String, _ value: String) -> some View {
HStack {
Text(label).foregroundColor(.gray)

View file

@ -130,6 +130,7 @@ class VisualizerSettings: ObservableObject {
@Published var miniIdleAmplitude: Double { didSet { save("vis_mini_idle", miniIdleAmplitude) } }
@Published var miniDepthOffset: Double { didSet { save("vis_mini_depth", miniDepthOffset) } }
@Published var miniDepthOpacity: Double { didSet { save("vis_mini_depth_opacity", miniDepthOpacity) } }
@Published var miniVisYOffset: Double { didSet { save("vis_mini_y_offset", miniVisYOffset) } }
enum Style: String, CaseIterable, Codable {
case wave = "Wave"
@ -198,6 +199,7 @@ class VisualizerSettings: ObservableObject {
miniIdleAmplitude = { let v = d.double(forKey: "vis_mini_idle"); return v > 0 ? v : 0.03 }()
miniDepthOffset = d.object(forKey: "vis_mini_depth") as? Double ?? 8.0
miniDepthOpacity = d.object(forKey: "vis_mini_depth_opacity") as? Double ?? 0.2
miniVisYOffset = d.object(forKey: "vis_mini_y_offset") as? Double ?? 0.0
}
var effectiveFPS: Double {
@ -912,6 +914,9 @@ struct CompactVisualizerView: View {
struct VisualizerSettingsView: View {
@ObservedObject var settings = VisualizerSettings.shared
@Environment(\.dismiss) private var dismiss
@State private var isCapturingTap = false
@State private var showShareSheet = false
@State private var capturedFileURL: URL?
private let pink = Color(red: 1.0, green: 0.176, blue: 0.333)
@ -1042,6 +1047,46 @@ struct VisualizerSettingsView: View {
}
Section { Button("Reset to Defaults", role: .destructive) { resetDefaults() } }
// Debug: Audio Tap Capture
Section {
if isCapturingTap {
HStack {
ProgressView().tint(pink)
Text("Capturing 5s of audio tap…")
.font(.system(size: 13))
.foregroundColor(.gray)
.padding(.leading, 8)
}
} else {
Button(action: startTapCapture) {
HStack {
Image(systemName: "waveform.badge.mic")
.foregroundColor(.orange)
VStack(alignment: .leading) {
Text("Capture Audio Tap").foregroundColor(.white)
Text("Records 5s of raw tap output as WAV")
.font(.caption2).foregroundColor(.gray)
}
}
}
.disabled(!AudioPlayer.shared.isRadioStream)
}
if let fmt = AudioTapProcessor.shared.sourceFormat {
HStack {
Text("Tap format")
.foregroundColor(.gray)
Spacer()
Text("\(Int(fmt.sampleRate))Hz \(fmt.channelCount)ch")
.font(.system(size: 12))
.foregroundColor(.white)
}
.font(.system(size: 13))
}
} header: { Text("DEBUG: AUDIO TAP") } footer: {
Text("Captures raw PCM from MTAudioProcessingTap as a playable WAV file. Only active during radio playback. Use to verify the tap is receiving real audio data.")
}
}
.navigationTitle("Visualizer")
.navigationBarTitleDisplayMode(.inline)
@ -1050,8 +1095,34 @@ struct VisualizerSettingsView: View {
Button("Done") { dismiss() }.foregroundColor(pink)
}
}
.sheet(isPresented: $showShareSheet) {
if let url = capturedFileURL {
ShareSheet(items: [url])
}
}
.onAppear {
// Bug 12 fix: reset stuck spinner if capture was interrupted by background
if isCapturingTap && !AudioTapProcessor.shared.debugDumpEnabled {
isCapturingTap = false
}
}
.onReceive(NotificationCenter.default.publisher(for: AudioTapProcessor.captureCompleteNotification)) { note in
// Bug 11 fix: notification-based completion works even if view was dismissed and re-opened
isCapturingTap = false
if let url = note.userInfo?["url"] as? URL {
capturedFileURL = url
showShareSheet = true
}
}
}
}
// MARK: - Tap Capture
private func startTapCapture() {
isCapturingTap = true
AudioTapProcessor.shared.startDebugDump()
}
// MARK: - Presets
@ -1116,6 +1187,7 @@ struct VisualizerSettingsView: View {
settings.miniPlayerHeight = 48.0; settings.miniOpacity = 0.5
settings.miniAmplitude = 0.7; settings.miniIdleAmplitude = 0.03
settings.miniDepthOffset = 8.0; settings.miniDepthOpacity = 0.2
settings.miniVisYOffset = 0.0
}
}
@ -1199,6 +1271,20 @@ struct ViewConfigSettingsView: View {
sd("Base lift (from bottom)", value: $baseLift, range: 0...300, step: 5, format: "%.0f pt")
sd("Wave offset (top)", value: $waveOffset, range: -100...300, step: 5, format: "%.0f")
}
if isCompact {
sd("Height", value: Binding(
get: { settings.miniPlayerHeight },
set: { settings.miniPlayerHeight = $0 }
), range: 20...100, step: 2, format: "%.0f pt")
sd("Opacity", value: Binding(
get: { settings.miniOpacity },
set: { settings.miniOpacity = $0 }
), range: 0.1...1.0, step: 0.05, format: "%.2f")
sd("Vertical position", value: Binding(
get: { settings.miniVisYOffset },
set: { settings.miniVisYOffset = $0 }
), range: -20...20, step: 1, format: "%+.0f pt")
}
} header: { Text("LAYOUT & AMPLITUDE") }
// Depth & Idle

View file

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

View file

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

View file

@ -3,18 +3,18 @@ import AVFoundation
import Combine
import WatchKit
import MediaPlayer
import HealthKit
/// watchOS audio player with dual-mode output:
///
/// **Bluetooth Mode** (default):
/// Uses `.longFormAudio` policy. Requires Bluetooth/AirPlay. Background playback is
/// handled by the audio session itself.
/// Uses `.longFormAudio` policy. Requires Bluetooth/AirPlay. Session must be
/// activated via `activate(options:completionHandler:)`.
///
/// **Speaker Mode** (Watch Ultra):
/// Uses `.default` policy which routes to the built-in speaker. Background runtime is
/// maintained by a silent `HKWorkoutSession`. watchOS keeps workout apps alive indefinitely
/// even when the screen sleeps. A green workout indicator appears on the watch face.
/// Uses `.default` policy which routes to the built-in speaker. Background
/// runtime is provided by the `audio` UIBackgroundModes entitlement the
/// system keeps the app alive while audio is actively playing. No HealthKit
/// or HKWorkoutSession required.
///
/// Toggle between modes in the Now Playing route indicator.
class WatchAudioPlayer: NSObject, ObservableObject {
@ -61,11 +61,6 @@ class WatchAudioPlayer: NSObject, ObservableObject {
private var pendingPlayURL: URL?
private var pendingSong: Song?
// HealthKit workout session for speaker background runtime
private let healthStore = HKHealthStore()
private var workoutSession: HKWorkoutSession?
private var workoutBuilder: HKLiveWorkoutBuilder?
private override init() {
super.init()
useSpeakerMode = UserDefaults.standard.bool(forKey: "watch_speaker_mode")
@ -83,7 +78,6 @@ class WatchAudioPlayer: NSObject, ObservableObject {
let hasBuiltIn = route.outputs.contains { $0.portType == .builtInSpeaker }
if hasBuiltIn { return true }
// Watch Ultra always has a speaker even if not currently routed
// Check model name heuristic
let model = WKInterfaceDevice.current().model.lowercased()
return model.contains("ultra")
}
@ -93,15 +87,14 @@ class WatchAudioPlayer: NSObject, ObservableObject {
private func configureAudioSession() {
let session = AVAudioSession.sharedInstance()
do {
if useSpeakerMode {
// Speaker mode: .default policy allows speaker output
try session.setCategory(.playback, mode: .default, policy: .default)
print("[WatchAudio] Session: .playback / .default (speaker mode)")
} else {
// Bluetooth mode: .longFormAudio requires BT/AirPlay
try session.setCategory(.playback, mode: .default, policy: .longFormAudio)
print("[WatchAudio] Session: .playback / .longFormAudio (bluetooth mode)")
}
// Always use .longFormAudio this is the key to background runtime.
// On watchOS 11+, .longFormAudio falls through to the built-in speaker
// when no Bluetooth device is connected. The system handles routing:
// - Bluetooth connected routes to Bluetooth
// - No Bluetooth routes to speaker (Watch Ultra)
// Activation MUST use activate(options:completionHandler:), not setActive(true).
try session.setCategory(.playback, mode: .default, policy: .longFormAudio)
print("[WatchAudio] Session: .playback / .longFormAudio")
updateRouteInfo()
} catch {
print("[WatchAudio] Session config FAILED: \(error.localizedDescription)")
@ -112,77 +105,16 @@ class WatchAudioPlayer: NSObject, ObservableObject {
let wasPlaying = isPlaying
if wasPlaying { player?.pause() }
configureAudioSession()
if useSpeakerMode {
startWorkoutSession()
} else {
stopWorkoutSession()
}
if wasPlaying { player?.play() }
updateRouteInfo()
}
// MARK: - HKWorkoutSession (keeps app alive for speaker mode)
private func startWorkoutSession() {
guard workoutSession == nil else { return }
guard HKHealthStore.isHealthDataAvailable() else {
print("[WatchAudio] HealthKit not available")
return
}
let config = HKWorkoutConfiguration()
config.activityType = .mindAndBody // Least intrusive workout type
config.locationType = .indoor
do {
let session = try HKWorkoutSession(healthStore: healthStore, configuration: config)
let builder = session.associatedWorkoutBuilder()
builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: config)
session.delegate = self
builder.delegate = self
session.startActivity(with: Date())
builder.beginCollection(withStart: Date()) { success, error in
if let error = error {
print("[WatchAudio] Workout builder start failed: \(error.localizedDescription)")
} else {
print("[WatchAudio] Workout session started (speaker background mode)")
}
}
workoutSession = session
workoutBuilder = builder
} catch {
print("[WatchAudio] Failed to start workout session: \(error.localizedDescription)")
}
}
private func stopWorkoutSession() {
guard let session = workoutSession else { return }
session.end()
workoutBuilder?.endCollection(withEnd: Date()) { [weak self] success, error in
self?.workoutBuilder?.finishWorkout { workout, error in
print("[WatchAudio] Workout session ended")
}
}
workoutSession = nil
workoutBuilder = nil
}
// MARK: - Session Activation (Bluetooth mode only)
// MARK: - Session Activation
/// Activate the audio session. Must be called before playback.
/// Uses activate(options:completionHandler:) which is required for .longFormAudio.
/// On watchOS 11+, this routes to speaker if no Bluetooth is available.
func activateSession() async -> Bool {
if useSpeakerMode {
// Speaker mode doesn't need activation just start workout
await MainActor.run {
startWorkoutSession()
isSessionActive = true
updateRouteInfo()
}
return true
}
let session = AVAudioSession.sharedInstance()
let result = await withCheckedContinuation { (continuation: CheckedContinuation<(Bool, String?), Never>) in
@ -229,9 +161,14 @@ class WatchAudioPlayer: NSObject, ObservableObject {
self.updateRouteInfo()
switch reason {
case .oldDeviceUnavailable:
if !self.useSpeakerMode {
print("[WatchAudio] Bluetooth disconnected — pausing")
// Bluetooth disconnected. If speaker is available (Watch Ultra),
// audio will automatically route to speaker don't pause.
// Only pause if no speaker (standard Watch models).
if !self.isSpeakerAvailable {
print("[WatchAudio] Bluetooth disconnected, no speaker — pausing")
self.pause()
} else {
print("[WatchAudio] Bluetooth disconnected — continuing on speaker")
}
case .newDeviceAvailable:
print("[WatchAudio] New route: \(self.currentRouteName)")
@ -292,16 +229,9 @@ class WatchAudioPlayer: NSObject, ObservableObject {
return
}
if useSpeakerMode {
// Speaker mode: start workout if needed, then play
startWorkoutSession()
isSessionActive = true
playFromURL(playURL)
return
}
// Bluetooth mode: activate session if needed
if isSessionActive && isBluetoothConnected {
// Activate session before playback. .longFormAudio provides background
// runtime. On watchOS 11+, routes to speaker if no BT connected.
if isSessionActive && (isBluetoothConnected || useSpeakerMode) {
playFromURL(playURL)
return
}
@ -374,7 +304,6 @@ class WatchAudioPlayer: NSObject, ObservableObject {
}
func resume() {
if useSpeakerMode { startWorkoutSession() }
player?.play()
isPlaying = true
updateNowPlayingInfo()
@ -429,7 +358,6 @@ class WatchAudioPlayer: NSObject, ObservableObject {
levelTimer?.invalidate(); levelTimer = nil
audioLevels = Array(repeating: 0, count: 8)
if let observer = timeObserver { player?.removeTimeObserver(observer); timeObserver = nil }
if useSpeakerMode { stopWorkoutSession() }
}
// MARK: - Now Playing
@ -469,28 +397,3 @@ class WatchAudioPlayer: NSObject, ObservableObject {
let s = Int(max(0, seconds)); return String(format: "%d:%02d", s / 60, s % 60)
}
}
// MARK: - HKWorkoutSession Delegate
extension WatchAudioPlayer: HKWorkoutSessionDelegate {
func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date) {
print("[WatchAudio] Workout state: \(fromState.rawValue)\(toState.rawValue)")
if toState == .ended {
DispatchQueue.main.async {
self.workoutSession = nil
self.workoutBuilder = nil
}
}
}
func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {
print("[WatchAudio] Workout session failed: \(error.localizedDescription)")
}
}
// MARK: - HKLiveWorkoutBuilder Delegate
extension WatchAudioPlayer: HKLiveWorkoutBuilderDelegate {
func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}
func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set<HKSampleType>) {}
}

View file

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

View file

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

View file

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