Compare commits
No commits in common. "main" and "feature/live-lyrics" have entirely different histories.
main
...
feature/li
41 changed files with 519 additions and 4051 deletions
67
CHANGELOG.md
67
CHANGELOG.md
|
|
@ -1,67 +0,0 @@
|
|||
# CHANGELOG — Race Condition & Crash Audit
|
||||
|
||||
## Build 13 (1.0.0) — 2026-04-30
|
||||
|
||||
### 🟢 ARCHITECTURE — Remove HealthKit, use standard audio background mode for speaker
|
||||
|
||||
**Files:** `watchOS/Audio/WatchAudioPlayer.swift`, `watchOS/Resources/Info.plist`, `watchOS/Resources/NavidromeWatch.entitlements`
|
||||
**What changed:**
|
||||
1. Removed `import HealthKit`, `HKHealthStore`, `HKWorkoutSession`, `HKLiveWorkoutBuilder`, and both delegate extensions
|
||||
2. Removed `startWorkoutSession()` / `stopWorkoutSession()` and all call sites (play, resume, stop, reconfigure)
|
||||
3. Info.plist: `WKBackgroundModes` changed from `workout-processing` to `audio`; removed `NSHealthShareUsageDescription` and `NSHealthUpdateUsageDescription`
|
||||
4. Entitlements: removed `com.apple.developer.healthkit` and `com.apple.developer.healthkit.access`
|
||||
5. Speaker mode now uses `setActive(true)` in `configureAudioSession` instead of deferring to workout session
|
||||
**Why:** The HKWorkoutSession workaround was a watchOS 3 technique that Apple closed in watchOS 4. The standard `audio` background mode (available since watchOS 5/WWDC 2018) keeps the app alive while audio is actively playing through AVAudioSession — no HealthKit needed. The workout session was activating the heart rate sensor (green LEDs), showing the green workout indicator, contributing to Activity Rings, and draining 40%+ extra battery. Speaker routing is controlled entirely by AVAudioSession policy: `.default` = speaker, `.longFormAudio` = Bluetooth.
|
||||
|
||||
---
|
||||
|
||||
### 🔴 NEW — `SmartDJCache.bulkImport` concurrent Dictionary crash
|
||||
|
||||
**File:** `iOS/Views/Companion/CompanionAPIService.swift`
|
||||
**Function:** `SmartDJCache` — `get()`, `store()`, `bulkImport()`, `loadBulkCache()`, `clearAll()`, `cachedCount`
|
||||
**What changed:** Added `NSLock` to serialize all `memoryCache` reads and writes.
|
||||
**Why:** Two crash logs (April 27 `EXC_BAD_ACCESS` + April 28 `SIGABRT doesNotRecognizeSelector`) both crash inside `SmartDJCache.bulkImport()`. Root cause: `memoryCache` is a plain `[String: SmartDJProfile]` dictionary mutated from the main thread (`loadBulkCache` at app launch) and a background `Task.detached` (`bulkImport` from server fetch) simultaneously. Swift Dictionary is not thread-safe — concurrent mutation corrupts the hash table, causing either a segfault on the corrupted bucket chain or a garbage pointer that fails `objc_msgSend`.
|
||||
**Crash logs:** `NavidromePlayer-2026-04-27-180336.ips`, `NavidromePlayer-2026-04-28-211506.ips`
|
||||
|
||||
---
|
||||
|
||||
### 🔴 NEW — `stopAVPlayer` crashes on `removeTimeObserver` after crossfade player swap
|
||||
|
||||
**File:** `Shared/Audio/AudioPlayer.swift`
|
||||
**Functions:** `stopAVPlayer()`, `play(song:)` crossfade path, `prepareNextForCrossfade()` needsNextTrack callback
|
||||
**What changed:**
|
||||
1. Both player swap sites (`player = crossfade.activePlayer`) now remove `timeObserver` from the OLD player first
|
||||
2. `stopAVPlayer()` reordered: observer removal now happens BEFORE `replaceCurrentItem(with: nil)`
|
||||
**Why:** Crash log shows `SIGABRT` in `-[AVPlayer removeTimeObserver:]` called from `stopAVPlayer()` → `stopAll()` → `play(song:)` → `previous()`. Root cause: SmartCrossfadeManager's `finalizeCrossfade()` swaps `self.player` to a different AVPlayer instance, but `timeObserver` was registered on the OLD player. When `stopAVPlayer()` later tries `player?.removeTimeObserver(observer)`, it removes from the wrong AVPlayer → NSException. The fix ensures the observer is always removed from the correct player before the swap.
|
||||
**Crash log:** `NavidromePlayer-2026-04-30-141820.ips`
|
||||
|
||||
---
|
||||
|
||||
### 🔴 Finding 2 — KVO in `radioSeekBack` fires on non-main thread
|
||||
|
||||
**File:** `Shared/Audio/AudioPlayer.swift`
|
||||
**Function:** `radioSeekBack(to:)`
|
||||
**What changed:** Wrapped the entire `item.observe(\.status)` KVO callback body in `DispatchQueue.main.async { }`.
|
||||
**Why:** `NSKeyValueObservation` fires on whatever thread changes the observed property. `AVPlayerItem.status` is changed on AVFoundation's internal media processing thread, NOT the main thread. The callback was accessing `self.player?.seek()`, `self.snapshotStatusObservation?.invalidate()`, `self.radioGoLive()`, and other main-thread-only state from a background thread — a data race that could corrupt AudioPlayer state or crash under the right timing (rapid seek-back → go-live during radio timeshift).
|
||||
|
||||
---
|
||||
|
||||
### 🟡 Finding 1 — Stale `analysisTask` overwrites visualizer buffer after track skip
|
||||
|
||||
**File:** `Shared/Audio/AudioPlayer.swift`
|
||||
**Function:** `loadOfflineVisualizer(songId:url:)`
|
||||
**What changed:** Added `guard self.currentSong?.id == songId else { return }` to all three `MainActor.run` blocks (cache hit, server fetch, local analysis) and to the progress callback.
|
||||
**Why:** When the user skips tracks quickly, `stopAll()` cancels `analysisTask` and clears `offlineVisBuffer`. But if the previous Task's async work had already completed, its `MainActor.run` block was already queued and would fire AFTER `stopAll()`, overwriting the new song's cleared buffer with the old song's stale vis data. This caused the visualizer to either show the wrong song's waveform or fail to start entirely for the new song. The songId guard ensures stale completions are silently discarded.
|
||||
|
||||
---
|
||||
|
||||
### Findings NOT fixed (documented, acceptable risk)
|
||||
|
||||
| # | Sev | Finding | Rationale |
|
||||
|---|---|---|---|
|
||||
| 5 | 🟡 | `shazamHandler` heap alloc on render thread | Pre-existing pattern, 15s max duration, no crash risk |
|
||||
| 6 | 🟡 | `debugWriteSamples` I/O on render thread | Diagnostic tool only, 5s capture window, acceptable |
|
||||
| 3 | 🟢 | `processFFT` dead code data race | `playLocalWithEngine` is never called — unreachable |
|
||||
| 4 | 🟢 | `timeUpdate` closure fragile without `@MainActor` | All callers use `.main` queue today — future risk only |
|
||||
| 7 | 🟢 | `sourceFormat` data race | ARM64 naturally-atomic pointer writes — safe in practice |
|
||||
| 8 | 🟢 | Stale status observer memory spike | Guard `self.playerItem === itemToObserve` handles it |
|
||||
104
CLAUDE.md
104
CLAUDE.md
|
|
@ -1,104 +0,0 @@
|
|||
# NavidromePlayer
|
||||
|
||||
iOS/watchOS native Subsonic music player connecting to a Navidrome server.
|
||||
XcodeGen-based project (`project.yml` + `./generate.sh`).
|
||||
|
||||
## Build
|
||||
|
||||
- `./generate.sh` to regenerate Xcode project after adding/removing files
|
||||
- Bundle prefix: `ca.dallasgroot`, Team ID: `E9C9AGS9K6`
|
||||
- Targets: iOS 26+, watchOS 26+
|
||||
- App Group: `group.com.navidromeplayer.shared`
|
||||
- TestFlight build: currently at 13 (version 1.0.0)
|
||||
- Always increment `CURRENT_PROJECT_VERSION` in `project.yml` before any release zip
|
||||
- Git repo: `https://repo.dallasgroot.ca/dallasgroot/NavidromeApp.git`
|
||||
- Push via SSH (Forgejo hooks disabled — push works, Forgejo reads repo directly)
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Shared/ — Models, SubsonicClient, AudioPlayer (compiles for iOS + watchOS)
|
||||
iOS/ — Views, data managers, visualizer, audio tap (iOS only)
|
||||
watchOS/ — Watch app, WatchAudioPlayer, WatchSessionManager
|
||||
Widget/ — Now Playing widget extension
|
||||
companion-api/ — Python FastAPI server on Raspberry Pi (Docker)
|
||||
```
|
||||
|
||||
- `project.yml` iOS target sources: `iOS/` + `Shared/`
|
||||
- `project.yml` watchOS target sources: `watchOS/` + `Shared/`
|
||||
- Widget target sources: `Widget/` only
|
||||
|
||||
## Critical Rules
|
||||
|
||||
### Swift / Xcode
|
||||
- `AudioPlayer.swift` is in `Shared/` — ALL iOS-only code MUST be inside `#if os(iOS)` / `#endif`
|
||||
- `AudioTapProcessor`, `VisualizerSettings`, `PlayQueueSyncManager` are iOS-only files (in `iOS/`)
|
||||
- Never reference iOS-only types from `Shared/` without `#if os(iOS)` guards
|
||||
- `CompanionAPIService` is a Swift `actor` — model structs MUST be declared at TOP LEVEL outside the actor
|
||||
- `FlowLayout` is defined in `LyricsOverlayView.swift` — never redeclare it
|
||||
- New Swift types should go in EXISTING registered files when possible
|
||||
- Never use placeholders like `// existing code` or `// rest unchanged` — always output complete file contents
|
||||
- `PlaybackStateStore.swift` lives in `Shared/Storage/` (was moved from `iOS/Data/`)
|
||||
|
||||
### Threading & Concurrency (from crash audit)
|
||||
- `SmartDJCache` uses `NSLock` for all `memoryCache` access — NEVER access without lock
|
||||
- When swapping `self.player` to `crossfade.activePlayer`, ALWAYS remove `timeObserver` from the OLD player first — removing from wrong player throws NSException → SIGABRT
|
||||
- `stopAVPlayer()` removes time observer BEFORE `replaceCurrentItem(with: nil)`
|
||||
- All Combine `.sink` handlers on NotificationCenter publishers MUST use `.receive(on: DispatchQueue.main)`
|
||||
- KVO `item.observe(\.status)` callbacks fire on AVFoundation's internal thread — wrap body in `DispatchQueue.main.async`
|
||||
- `loadOfflineVisualizer` Task completion blocks MUST guard `self.currentSong?.id == songId` to prevent stale vis data after track skip
|
||||
|
||||
### Visualizer / Audio Tap
|
||||
- Radio streams use `MTAudioProcessingTap` via `AudioTapProcessor.shared` for real-time FFT
|
||||
- Tap MUST install on `.readyToPlay` status, NOT immediately after `player.play()`
|
||||
- `startRadioSimulation()` runs as visual placeholder until tap installs
|
||||
- `ShazamRecognizer` subscribes to shared tap via `shazamHandler` — does NOT create its own tap
|
||||
- Background: tap removed in `suspendVisTimers()`, reinstalled in `resumeVisTimers()`
|
||||
- Every `player?.replaceCurrentItem(with: item)` MUST also set `self.playerItem = item`
|
||||
|
||||
### watchOS Audio
|
||||
- NO HealthKit — uses `.longFormAudio` policy for both speaker and Bluetooth
|
||||
- `.longFormAudio` provides background runtime automatically
|
||||
- On watchOS 11+, `.longFormAudio` falls through to built-in speaker when no Bluetooth connected
|
||||
- Activation MUST use `activate(options:completionHandler:)` — NOT `setActive(true)` (throws OSStatus 561145203)
|
||||
- `useSpeakerMode` toggle is a user preference only — underlying session policy is always `.longFormAudio`
|
||||
- Watch remote control: iPhone pushes state via `sendNowPlayingToWatch()` in `updateNowPlayingInfo()`
|
||||
- All WCSession `sendMessage` calls MUST use `replyHandler: { _ in }` (not `nil`) — `nil` routes to wrong delegate method
|
||||
- iPhone command handlers (`playCommand`, `nextCommand`, etc.) dispatch to `DispatchQueue.main.async`
|
||||
|
||||
### Companion API
|
||||
- Tag writes MUST call `backup_tags()` first and abort if it returns `None`
|
||||
- See `companion-api/CLAUDE.md` for server-specific rules
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### Subsonic API
|
||||
- `SubsonicClient.swift` — all server communication
|
||||
- Response types in `Shared/Models/Models.swift` via `SubsonicResponseBody`
|
||||
|
||||
### Data flow for visualizer
|
||||
```
|
||||
AVPlayerItem → MTAudioProcessingTap (C callback, render thread)
|
||||
→ PCMRingBuffer (lock-free, 8192 samples)
|
||||
→ Timer at 30fps (main thread)
|
||||
→ vDSP FFT (1024 samples, Hann window, log-frequency bands)
|
||||
→ setLevels([Float]) → _audioLevels
|
||||
→ MitsuhaVisualizerView reads currentLevels()
|
||||
```
|
||||
|
||||
### Watch remote data flow
|
||||
```
|
||||
iPhone: updateNowPlayingInfo() → sendNowPlayingToWatch() → WCSession message
|
||||
Watch: didReceiveMessage → phoneNowPlaying → WatchNowPlayingView
|
||||
Watch → iPhone: sendPlayCommand/nextCommand/etc → didReceiveMessage:replyHandler:
|
||||
```
|
||||
|
||||
## Known Crash History (all fixed)
|
||||
- Build 11: `removeTimeObserver` on wrong AVPlayer after crossfade swap (SIGABRT)
|
||||
- Build 11: `SmartDJCache.bulkImport` concurrent Dictionary mutation (EXC_BAD_ACCESS + doesNotRecognizeSelector)
|
||||
- Build 12: Same `removeTimeObserver` crash reproduced, confirmed fix in build 13
|
||||
|
||||
## Testing
|
||||
- Build and run on device via Xcode
|
||||
- TestFlight for distribution testing
|
||||
- Companion API: `docker compose build music-companion && docker compose up -d`
|
||||
|
|
@ -329,34 +329,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -134,9 +134,7 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
vDSP_hann_window(&fftWindow, 1024, Int32(vDSP_HANN_NORM))
|
||||
|
||||
super.init()
|
||||
// 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).
|
||||
configureAudioSession()
|
||||
setupRemoteControls()
|
||||
|
||||
#if os(iOS)
|
||||
|
|
@ -152,13 +150,11 @@ 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)
|
||||
|
||||
|
|
@ -167,14 +163,12 @@ 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
|
||||
|
|
@ -242,12 +236,6 @@ 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 {
|
||||
|
|
@ -257,6 +245,15 @@ 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.
|
||||
|
|
@ -281,19 +278,9 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
if isUsingCrossfade {
|
||||
SmartCrossfadeManager.shared.resumeFromBackground()
|
||||
}
|
||||
// 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.
|
||||
// Only restart vis timers if actually playing
|
||||
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.
|
||||
|
|
@ -306,15 +293,9 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
|
||||
if isUsingOfflineVis {
|
||||
startOfflineVisSync()
|
||||
} 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 {
|
||||
} else if !isUsingCrossfade {
|
||||
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))")
|
||||
}
|
||||
|
||||
|
|
@ -420,7 +401,6 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
radioStreamURL = nil
|
||||
#if os(iOS)
|
||||
RadioStreamBuffer.shared.stopBuffering()
|
||||
AudioTapProcessor.shared.removeTap(from: playerItem)
|
||||
isPlayingFromBuffer = false
|
||||
#endif
|
||||
}
|
||||
|
|
@ -433,10 +413,6 @@ 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
|
||||
|
|
@ -453,46 +429,14 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
let nextSong = SmartCrossfadeManager.shared.pendingNextSong,
|
||||
let url = SmartCrossfadeManager.shared.resolveURL(for: nextSong)
|
||||
else { return }
|
||||
// Clear stale vis from outgoing song before loading new
|
||||
self.offlineVisBuffer = VisFrameBuffer.empty
|
||||
self.setLevels(Array(repeating: 0, count: self._audioLevels.count))
|
||||
self.loadOfflineVisualizer(songId: nextSong.id, url: url)
|
||||
}
|
||||
// At crossfade midpoint, transition metadata/artwork/colors/widget
|
||||
// so the UI reflects the incoming song while audio is still fading.
|
||||
crossfade.songHandoff = { [weak self] incomingSong in
|
||||
guard let self = self else { return }
|
||||
// Scrobble the outgoing song before updating currentSong
|
||||
if let outgoing = self.currentSong {
|
||||
Task { try? await ServerManager.shared.client.scrobble(id: outgoing.id) }
|
||||
}
|
||||
self.currentSong = incomingSong
|
||||
if let idx = self.queue.firstIndex(where: { $0.id == incomingSong.id }) {
|
||||
self.queueIndex = idx
|
||||
}
|
||||
self.updateNowPlayingInfo()
|
||||
self.fetchAndSetArtwork(coverArtId: incomingSong.coverArt)
|
||||
self.pushWidgetState()
|
||||
LyricsManager.shared.songChanged(
|
||||
songId: incomingSong.id,
|
||||
artist: incomingSong.artist ?? "Unknown",
|
||||
title: incomingSong.title,
|
||||
path: incomingSong.path,
|
||||
duration: self.duration
|
||||
)
|
||||
}
|
||||
|
||||
crossfade.playSong(song, url: url)
|
||||
isPlaying = true
|
||||
|
||||
// Set AudioPlayer.player to the active crossfade player
|
||||
// so Lock Screen controls and visualizer still work.
|
||||
// Remove any stale time observer first — it was registered on
|
||||
// the old player and would crash if removed from the new one.
|
||||
if let obs = timeObserver {
|
||||
player?.removeTimeObserver(obs)
|
||||
timeObserver = nil
|
||||
}
|
||||
// so Lock Screen controls and visualizer still work
|
||||
player = crossfade.activePlayer
|
||||
|
||||
// Safety: if boundary observer doesn't fire (short track, bad profile),
|
||||
|
|
@ -505,9 +449,7 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
|
||||
updateNowPlayingInfo()
|
||||
fetchAndSetArtwork(coverArtId: song.coverArt)
|
||||
if !VisualizerSettings.shared.realAudioAnalysis {
|
||||
startLevelSimulation()
|
||||
}
|
||||
startLevelSimulation()
|
||||
|
||||
// Load offline visualizer for crossfade path too
|
||||
if VisualizerSettings.shared.realAudioAnalysis {
|
||||
|
|
@ -691,7 +633,6 @@ 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(
|
||||
|
|
@ -702,38 +643,30 @@ 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.
|
||||
// IMPORTANT: NSKeyValueObservation fires on whatever thread changes the property.
|
||||
// AVPlayerItem.status is changed on an internal AVFoundation thread, NOT main.
|
||||
// All state access must be dispatched to main to prevent data races.
|
||||
// KVO on status — more reliable than polling; fires immediately on failure too
|
||||
snapshotStatusObservation = item.observe(\.status, options: [.new, .initial]) { [weak self] observedItem, _ in
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
switch observedItem.status {
|
||||
case .readyToPlay:
|
||||
self.snapshotStatusObservation?.invalidate()
|
||||
self.snapshotStatusObservation = nil
|
||||
// Generous tolerance for raw audio without index tables
|
||||
let cmTime = CMTime(seconds: seekTime, preferredTimescale: 600)
|
||||
let tol = CMTime(seconds: 1, preferredTimescale: 600)
|
||||
self.player?.seek(to: cmTime, toleranceBefore: tol, toleranceAfter: tol) { [weak self] _ in
|
||||
self?.player?.play()
|
||||
self?.alog("Radio seek: ▶ playing from \(String(format: "%.1f", seekTime))s — \(String(format: "%.0f", snapshotBufSec - seekTime))s behind live")
|
||||
}
|
||||
case .failed:
|
||||
self.snapshotStatusObservation?.invalidate()
|
||||
self.snapshotStatusObservation = nil
|
||||
self.alog("Radio seek FAILED: \(observedItem.error?.localizedDescription ?? "unknown error")")
|
||||
try? FileManager.default.removeItem(at: snapURL)
|
||||
self.currentSnapshotURL = nil
|
||||
self.radioGoLive() // fall back to live
|
||||
default:
|
||||
break
|
||||
guard let self else { return }
|
||||
switch observedItem.status {
|
||||
case .readyToPlay:
|
||||
self.snapshotStatusObservation?.invalidate()
|
||||
self.snapshotStatusObservation = nil
|
||||
// Generous tolerance for raw audio without index tables
|
||||
let cmTime = CMTime(seconds: seekTime, preferredTimescale: 600)
|
||||
let tol = CMTime(seconds: 1, preferredTimescale: 600)
|
||||
self.player?.seek(to: cmTime, toleranceBefore: tol, toleranceAfter: tol) { [weak self] _ in
|
||||
self?.player?.play()
|
||||
self?.alog("Radio seek: ▶ playing from \(String(format: "%.1f", seekTime))s — \(String(format: "%.0f", snapshotBufSec - seekTime))s behind live")
|
||||
}
|
||||
case .failed:
|
||||
self.snapshotStatusObservation?.invalidate()
|
||||
self.snapshotStatusObservation = nil
|
||||
self.alog("Radio seek FAILED: \(observedItem.error?.localizedDescription ?? "unknown error")")
|
||||
try? FileManager.default.removeItem(at: snapURL)
|
||||
self.currentSnapshotURL = nil
|
||||
self.radioGoLive() // fall back to live
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -758,24 +691,8 @@ 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")
|
||||
}
|
||||
|
||||
|
|
@ -849,7 +766,6 @@ 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 }
|
||||
|
|
@ -861,12 +777,6 @@ 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
|
||||
}
|
||||
|
|
@ -925,18 +835,15 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
|
||||
updateNowPlayingInfo()
|
||||
fetchAndSetArtwork(coverArtId: currentSong?.coverArt)
|
||||
// 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.
|
||||
// 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.
|
||||
#if os(iOS)
|
||||
if isRadioStream {
|
||||
startRadioSimulation()
|
||||
} else if !VisualizerSettings.shared.realAudioAnalysis {
|
||||
// Simulation mode only — real analysis waits for offline vis data
|
||||
} else {
|
||||
startLevelSimulation()
|
||||
}
|
||||
// When realAudioAnalysis is on: levels stay at 0 until
|
||||
// loadOfflineVisualizer → startOfflineVisSync fills them.
|
||||
#else
|
||||
startLevelSimulation()
|
||||
#endif
|
||||
|
|
@ -947,7 +854,6 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
private func playLocalWithEngine(_ url: URL) {
|
||||
#if os(iOS)
|
||||
alog("Engine path: \(url.lastPathComponent)")
|
||||
configureAudioSession()
|
||||
activateAudioSession()
|
||||
stopAll()
|
||||
|
||||
|
|
@ -1116,21 +1022,6 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
}
|
||||
|
||||
func resume() {
|
||||
// Cold restore: app was killed, queue restored from PlaybackStateStore,
|
||||
// but AVPlayer was never created. player?.play() would be a nil no-op.
|
||||
// Detect this and go through the full play(song:) path, then seek to
|
||||
// the saved position so playback resumes where the user left off.
|
||||
if player == nil, let song = currentSong {
|
||||
let savedPosition = currentTime
|
||||
alog("Cold resume: loading \(song.title) at \(Int(savedPosition))s")
|
||||
play(song: song)
|
||||
// AVPlayer queues seeks — this executes once the item is ready
|
||||
if savedPosition > 1 {
|
||||
seek(to: savedPosition)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
if isUsingCrossfade {
|
||||
SmartCrossfadeManager.shared.resume()
|
||||
|
|
@ -1173,7 +1064,7 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
guard let self = self else { return }
|
||||
self.setLevels(self.rawFFTLevels)
|
||||
}
|
||||
} else if !VisualizerSettings.shared.realAudioAnalysis {
|
||||
} else {
|
||||
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.
|
||||
|
|
@ -1183,7 +1074,6 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
setLevels(internalLevels)
|
||||
if levelTimer == nil { startLevelSimulation() }
|
||||
}
|
||||
// else: realAudioAnalysis on, vis data loading — stay at 0
|
||||
pushWidgetState()
|
||||
#endif
|
||||
updateNowPlayingInfo()
|
||||
|
|
@ -1219,20 +1109,20 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
crossfade.needsNextTrack = { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Scrobble + currentSong + artwork already handled by songHandoff at midpoint.
|
||||
// Here we only handle post-swap housekeeping.
|
||||
// Scrobble the song that just finished.
|
||||
// playerDidFinish only fires on natural item end — crossfade replaces the item
|
||||
// before that happens, so we scrobble here instead.
|
||||
if let finishedSong = self.currentSong {
|
||||
Task { try? await ServerManager.shared.client.scrobble(id: finishedSong.id) }
|
||||
}
|
||||
|
||||
// Ensure queueIndex is synced (songHandoff may have set it, but verify)
|
||||
// Sync queueIndex to whatever was actually in standby (pendingNextSong),
|
||||
// not just queueIndex+1. The queue may have changed between prepareNext()
|
||||
// and now — this guarantees currentSong matches what the active player is playing.
|
||||
if let nowPlaying = crossfade.pendingNextSong,
|
||||
let idx = self.queue.firstIndex(where: { $0.id == nowPlaying.id }) {
|
||||
self.queueIndex = idx
|
||||
// Only update currentSong if songHandoff somehow didn't fire
|
||||
if self.currentSong?.id != nowPlaying.id {
|
||||
self.currentSong = nowPlaying
|
||||
self.updateNowPlayingInfo()
|
||||
self.fetchAndSetArtwork(coverArtId: nowPlaying.coverArt)
|
||||
self.pushWidgetState()
|
||||
}
|
||||
self.currentSong = nowPlaying
|
||||
} else {
|
||||
// Fallback: queue was cleared or song removed — advance sequentially
|
||||
let hasNext = self.repeatMode == .all || self.queueIndex + 1 < self.queue.count
|
||||
|
|
@ -1243,28 +1133,21 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
self.queueIndex = min(self.queueIndex + 1, self.queue.count - 1)
|
||||
}
|
||||
self.currentSong = self.queue[self.queueIndex]
|
||||
self.updateNowPlayingInfo()
|
||||
self.fetchAndSetArtwork(coverArtId: self.currentSong?.coverArt)
|
||||
self.pushWidgetState()
|
||||
}
|
||||
}
|
||||
|
||||
// Persist updated queue position
|
||||
// Persist updated queue position — time observer only saves position now
|
||||
PlaybackStateStore.shared.save(
|
||||
queue: self.queue, index: self.queueIndex,
|
||||
currentTime: 0, currentSongId: self.currentSong?.id
|
||||
)
|
||||
|
||||
// Swap AudioPlayer.player to the new active crossfade player.
|
||||
// Remove stale time observer first — it was registered on the old
|
||||
// player instance and removing it from the new one throws NSException.
|
||||
if let obs = self.timeObserver {
|
||||
self.player?.removeTimeObserver(obs)
|
||||
self.timeObserver = nil
|
||||
}
|
||||
self.player = crossfade.activePlayer
|
||||
|
||||
// Re-register the safety-net AVPlayerItemDidPlayToEndTime observer
|
||||
// Re-register the safety-net AVPlayerItemDidPlayToEndTime observer on the
|
||||
// new active item. play() registered it on the first item only — subsequent
|
||||
// crossfades swap the active slot so the original observer is now stale.
|
||||
// Without this, a boundary-observer failure on track 2+ has no fallback.
|
||||
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil)
|
||||
if let newItem = crossfade.activePlayer.currentItem {
|
||||
NotificationCenter.default.addObserver(
|
||||
|
|
@ -1273,6 +1156,10 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
)
|
||||
}
|
||||
|
||||
self.updateNowPlayingInfo()
|
||||
self.fetchAndSetArtwork(coverArtId: self.currentSong?.coverArt)
|
||||
self.pushWidgetState()
|
||||
|
||||
// Prepare next-next (picks up any queue changes that happened mid-fade)
|
||||
self.prepareNextForCrossfade()
|
||||
}
|
||||
|
|
@ -1399,11 +1286,6 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
func setVolume(_ vol: Float) {
|
||||
volume = vol
|
||||
player?.volume = vol
|
||||
}
|
||||
|
||||
// MARK: - Queue Management
|
||||
|
||||
func playNext(_ song: Song) {
|
||||
|
|
@ -1524,14 +1406,6 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
info[MPMediaItemPropertyArtwork] = artwork
|
||||
}
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
|
||||
|
||||
// Push state to Apple Watch — fires on song change, play/pause, and every 5s
|
||||
WatchConnectivityManager.shared.sendNowPlayingToWatch(
|
||||
song: currentSong,
|
||||
isPlaying: isPlaying,
|
||||
currentTime: currentTime,
|
||||
duration: duration
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
|
@ -1636,9 +1510,6 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
if let buffer = try? await storage.loadCache(for: songId) {
|
||||
let normalized = Self.normalizeVisBuffer(buffer)
|
||||
await MainActor.run {
|
||||
// Guard: if user skipped tracks while cache was loading,
|
||||
// don't apply stale vis data from the previous song.
|
||||
guard self.currentSong?.id == songId else { return }
|
||||
self.offlineVisBuffer = normalized
|
||||
self.isUsingOfflineVis = true
|
||||
self.isUsingRealFFT = true
|
||||
|
|
@ -1662,7 +1533,6 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
let normalized = Self.normalizeVisBuffer(rawBuf)
|
||||
alog("Offline vis: fetched \(serverFrames.count) frames from server")
|
||||
await MainActor.run {
|
||||
guard self.currentSong?.id == songId else { return }
|
||||
self.offlineVisBuffer = normalized
|
||||
self.isUsingOfflineVis = true
|
||||
self.isUsingRealFFT = true
|
||||
|
|
@ -1695,10 +1565,7 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
cutoff: cutoff,
|
||||
extractSmartDJ: needsSmartDJ
|
||||
) { [weak self] pct in
|
||||
Task { @MainActor in
|
||||
guard self?.currentSong?.id == songId else { return }
|
||||
self?.offlineVisProgress = pct
|
||||
}
|
||||
Task { @MainActor in self?.offlineVisProgress = pct }
|
||||
}
|
||||
|
||||
// Seed SmartDJ cache from on-device analysis if we extracted it
|
||||
|
|
@ -1728,7 +1595,6 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
alog("Offline vis: cached \(frames.count) frames for \(songId)")
|
||||
|
||||
await MainActor.run {
|
||||
guard self.currentSong?.id == songId else { return }
|
||||
self.offlineVisBuffer = normalized
|
||||
self.isUsingOfflineVis = true
|
||||
self.isUsingRealFFT = true
|
||||
|
|
@ -1737,7 +1603,7 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
}
|
||||
} catch {
|
||||
alog("Offline vis: analysis failed: \(error.localizedDescription)")
|
||||
// Don't fall back to simulation — levels stay at 0
|
||||
await MainActor.run { self.startLevelSimulation() }
|
||||
}
|
||||
} // end if !fetched
|
||||
}
|
||||
|
|
@ -1829,57 +1695,6 @@ 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.
|
||||
|
|
@ -1983,21 +1798,8 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
duration: duration
|
||||
)
|
||||
|
||||
let upcomingSongs = Array(queue.dropFirst(queueIndex + 1).prefix(3))
|
||||
let upcoming: [(title: String, artist: String, coverData: Data?)] = upcomingSongs.map { s in
|
||||
var artData: Data?
|
||||
if let id = s.coverArt {
|
||||
if let custom = AlbumCoverStore.shared.loadCover(for: id) {
|
||||
// Downscale to tiny thumbnail for widget (~40pt @ 2x = 80px)
|
||||
artData = custom.preparingThumbnail(of: CGSize(width: 80, height: 80))?
|
||||
.jpegData(compressionQuality: 0.5)
|
||||
} else if let url = ServerManager.shared.client.coverArtURL(id: id, size: 80) {
|
||||
artData = ImageCache.shared.cachedImage(for: url)?
|
||||
.jpegData(compressionQuality: 0.5)
|
||||
}
|
||||
}
|
||||
return (title: s.title, artist: s.artist ?? "Unknown", coverData: artData)
|
||||
}
|
||||
let upcoming = Array(queue.dropFirst(queueIndex + 1).prefix(3))
|
||||
.map { (title: $0.title, artist: $0.artist ?? "Unknown") }
|
||||
|
||||
// Cover art: custom first, then server (memory + disk).
|
||||
var coverImage: UIImage?
|
||||
|
|
@ -2102,20 +1904,12 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
}
|
||||
|
||||
private func stopAVPlayer() {
|
||||
#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.
|
||||
player?.pause()
|
||||
player?.replaceCurrentItem(with: nil)
|
||||
if let observer = timeObserver {
|
||||
player?.removeTimeObserver(observer)
|
||||
timeObserver = nil
|
||||
}
|
||||
player?.pause()
|
||||
player?.replaceCurrentItem(with: nil)
|
||||
playerItem = nil
|
||||
}
|
||||
|
||||
|
|
@ -2132,17 +1926,10 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
SmartCrossfadeManager.shared.stop()
|
||||
isUsingCrossfade = false
|
||||
}
|
||||
// Zero the vis buffer so stale frames from the previous song don't render
|
||||
// while the new song is loading. The new loadOfflineVisualizer() call will
|
||||
// repopulate it once analysis finishes.
|
||||
offlineVisBuffer = VisFrameBuffer.empty
|
||||
#endif
|
||||
stopEngine()
|
||||
stopAVPlayer()
|
||||
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil)
|
||||
// Zero audio levels immediately — prevents the Canvas from painting stale
|
||||
// FFT/vis data during track transitions (the "lifted bars" bug).
|
||||
setLevels(Array(repeating: 0, count: _audioLevels.count))
|
||||
}
|
||||
|
||||
func stop() {
|
||||
|
|
@ -2157,10 +1944,6 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
#if os(iOS)
|
||||
offlineVisBuffer = VisFrameBuffer.empty
|
||||
WidgetBridge.shared.clear()
|
||||
// Tell watch playback stopped
|
||||
WatchConnectivityManager.shared.sendNowPlayingToWatch(
|
||||
song: nil, isPlaying: false, currentTime: 0, duration: 0
|
||||
)
|
||||
#endif
|
||||
if isRadioStream {
|
||||
isRadioStream = false
|
||||
|
|
|
|||
|
|
@ -52,8 +52,6 @@ struct SubsonicResponseBody: Codable {
|
|||
let lyrics: LyricsResult?
|
||||
let internetRadioStations: InternetRadioContainer?
|
||||
let similarSongs2: SimilarSongsContainer?
|
||||
let playQueue: PlayQueue?
|
||||
let shares: SharesContainer?
|
||||
}
|
||||
|
||||
struct SubsonicError: Codable {
|
||||
|
|
@ -322,35 +320,6 @@ 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.
|
||||
|
|
|
|||
|
|
@ -394,15 +394,11 @@ class WatchConnectivityManager: NSObject, ObservableObject {
|
|||
func sendNowPlayingToWatch(song: Song?, isPlaying: Bool, currentTime: TimeInterval, duration: TimeInterval) {
|
||||
guard let session = session, session.isReachable else { return }
|
||||
|
||||
let player = AudioPlayer.shared
|
||||
var info: [String: Any] = [
|
||||
"type": "nowPlaying",
|
||||
"isPlaying": isPlaying,
|
||||
"currentTime": currentTime,
|
||||
"duration": duration,
|
||||
"shuffleEnabled": player.shuffleEnabled,
|
||||
"repeatMode": player.repeatMode == .off ? 0 : player.repeatMode == .all ? 1 : 2,
|
||||
"volume": Double(player.volume)
|
||||
"duration": duration
|
||||
]
|
||||
|
||||
if let song = song {
|
||||
|
|
@ -547,39 +543,6 @@ extension WatchConnectivityManager: WCSessionDelegate {
|
|||
replyHandler(["success": true])
|
||||
}
|
||||
|
||||
case "seekCommand":
|
||||
if let time = message["time"] as? TimeInterval {
|
||||
DispatchQueue.main.async {
|
||||
AudioPlayer.shared.seek(to: time)
|
||||
replyHandler(["success": true])
|
||||
}
|
||||
} else {
|
||||
replyHandler(["error": "missing time"])
|
||||
}
|
||||
|
||||
case "shuffleCommand":
|
||||
DispatchQueue.main.async {
|
||||
AudioPlayer.shared.toggleShuffle()
|
||||
replyHandler(["shuffleEnabled": AudioPlayer.shared.shuffleEnabled])
|
||||
}
|
||||
|
||||
case "repeatCommand":
|
||||
DispatchQueue.main.async {
|
||||
AudioPlayer.shared.cycleRepeat()
|
||||
let mode = AudioPlayer.shared.repeatMode
|
||||
replyHandler(["repeatMode": mode == .off ? 0 : mode == .all ? 1 : 2])
|
||||
}
|
||||
|
||||
case "volumeCommand":
|
||||
if let vol = message["volume"] as? Double {
|
||||
DispatchQueue.main.async {
|
||||
AudioPlayer.shared.setVolume(Float(vol))
|
||||
replyHandler(["success": true])
|
||||
}
|
||||
} else {
|
||||
replyHandler(["error": "missing volume"])
|
||||
}
|
||||
|
||||
case "requestDownload":
|
||||
if let songId = message["songId"] as? String {
|
||||
Task {
|
||||
|
|
@ -598,26 +561,6 @@ extension WatchConnectivityManager: WCSessionDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
case "requestNowPlaying":
|
||||
DispatchQueue.main.async {
|
||||
let player = AudioPlayer.shared
|
||||
var reply: [String: Any] = [
|
||||
"isPlaying": player.isPlaying,
|
||||
"currentTime": player.currentTime,
|
||||
"duration": player.duration,
|
||||
"shuffleEnabled": player.shuffleEnabled,
|
||||
"repeatMode": player.repeatMode == .off ? 0 : player.repeatMode == .all ? 1 : 2,
|
||||
"volume": Double(player.volume)
|
||||
]
|
||||
if let song = player.currentSong {
|
||||
reply["title"] = song.title
|
||||
reply["artist"] = song.artist ?? ""
|
||||
reply["album"] = song.album ?? ""
|
||||
reply["coverArtId"] = song.coverArt ?? ""
|
||||
}
|
||||
replyHandler(reply)
|
||||
}
|
||||
|
||||
default:
|
||||
replyHandler(["error": "unhandled type: \(type)"])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import Foundation
|
|||
struct WidgetQueueItem: Codable, Equatable {
|
||||
let title: String
|
||||
let artist: String
|
||||
let coverArtData: Data? // small JPEG thumbnail (~40×40 → ~2KB each)
|
||||
}
|
||||
|
||||
/// Commands the widget can send to the main app.
|
||||
|
|
|
|||
|
|
@ -179,6 +179,5 @@ struct NowPlayingWidget: Widget {
|
|||
.configurationDisplayName("Now Playing")
|
||||
.description("Control playback and see what's playing.")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
||||
.contentMarginsDisabled()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,28 +109,24 @@ struct NowPlayingWidgetView: View {
|
|||
|
||||
struct GlassPanel<Content: View>: View {
|
||||
let colors: WidgetColors
|
||||
var flush: Bool = false
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
let radius: CGFloat = flush ? 0 : 16
|
||||
let outerPad: CGFloat = flush ? 0 : 8
|
||||
|
||||
content()
|
||||
.padding(flush ? 14 : 12)
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(colors.accent.opacity(0.06))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||
.stroke(.white.opacity(0.12), lineWidth: flush ? 0 : 0.5)
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.stroke(.white.opacity(0.12), lineWidth: 0.5)
|
||||
)
|
||||
)
|
||||
.padding(outerPad)
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -338,7 +334,7 @@ struct MediumContent: View {
|
|||
let colors: WidgetColors
|
||||
|
||||
var body: some View {
|
||||
GlassPanel(colors: colors, flush: true) {
|
||||
GlassPanel(colors: colors) {
|
||||
VStack(spacing: 6) {
|
||||
// Top row: art + song info
|
||||
HStack(spacing: 14) {
|
||||
|
|
@ -448,7 +444,7 @@ struct LargeContent: View {
|
|||
let colors: WidgetColors
|
||||
|
||||
var body: some View {
|
||||
GlassPanel(colors: colors, flush: true) {
|
||||
GlassPanel(colors: colors) {
|
||||
VStack(spacing: 8) {
|
||||
// Header: art + info + waveform
|
||||
HStack(spacing: 16) {
|
||||
|
|
@ -541,23 +537,15 @@ struct LargeContent: View {
|
|||
.foregroundStyle(.white.opacity(0.3))
|
||||
.frame(width: 14, alignment: .center)
|
||||
|
||||
// Mini cover art — real data from queue, fallback to accent placeholder
|
||||
Group {
|
||||
if let data = item.coverArtData, let img = UIImage(data: data) {
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
} else {
|
||||
ZStack {
|
||||
colors.accent.opacity(0.2)
|
||||
Image(systemName: "music.note")
|
||||
.font(.system(size: 11, weight: .light))
|
||||
.foregroundStyle(.white.opacity(0.2))
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 32, height: 32)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous))
|
||||
// Mini art placeholder (no per-track art data in widget)
|
||||
RoundedRectangle(cornerRadius: 5, style: .continuous)
|
||||
.fill(colors.accent.opacity(0.2))
|
||||
.frame(width: 32, height: 32)
|
||||
.overlay(
|
||||
Image(systemName: "music.note")
|
||||
.font(.system(size: 11, weight: .light))
|
||||
.foregroundStyle(.white.opacity(0.2))
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(item.title)
|
||||
|
|
@ -595,6 +583,17 @@ struct LargeContent: View {
|
|||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.leading, 2)
|
||||
} else if !entry.isPlaying {
|
||||
HStack(spacing: 4) {
|
||||
Text("⏸")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.white.opacity(0.4))
|
||||
Text("Paused")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(colors.accent.opacity(0.7))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.leading, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
# Companion API
|
||||
|
||||
Python FastAPI server running on Raspberry Pi 5 inside Docker.
|
||||
Provides metadata editing, Smart DJ analysis, visualizer precomputation, lyrics, and library management.
|
||||
|
||||
## Deployment
|
||||
|
||||
```bash
|
||||
# On the Pi, from ~/docker/navidrome/
|
||||
docker compose build music-companion && docker compose up -d music-companion
|
||||
```
|
||||
|
||||
- Port: 8000
|
||||
- Music dir inside container: `/music` (mapped from `/home/pi/navidrome/music`)
|
||||
- Navidrome DB: `/navidrome_data/navidrome.db` (read-only)
|
||||
- Companion data: `/app/data/` (smart_dj.db, vis_cache, cover_art, tag backups)
|
||||
|
||||
## Architecture
|
||||
|
||||
- `main.py` is the entire server (~3800 lines, single file)
|
||||
- `Dockerfile` + `docker-compose.yml` for containerization
|
||||
- `diagnose.py` — standalone diagnostic script
|
||||
- `pre_analyze.py` — bulk pre-analysis for Smart DJ profiles
|
||||
|
||||
## Critical Rules
|
||||
|
||||
### Tag Safety
|
||||
- ALL tag writes go through `apply_tags()` (single track) or `apply_tags_dict()` (batch)
|
||||
- Both functions call `backup_tags()` FIRST and abort with `RuntimeError` if backup fails
|
||||
- Files are NEVER modified without a backup safety net
|
||||
- `enforce_tag_whitelist()` has separate paths:
|
||||
- FLAC/OGG/Opus: checks against `NAVIDROME_TAGS` (Vorbis Comment names)
|
||||
- MP3: checks against `ID3_FRAME_WHITELIST` (ID3v2 frame IDs like TPE1, TALB)
|
||||
- AIFF: uses `mutagen.aiff.AIFF` with raw ID3 frames (easy=True returns None for AIFF)
|
||||
- NEVER use `audio.delete()` in restore — it destroys APIC (album art) and USLT (lyrics)
|
||||
|
||||
### Backup System
|
||||
- Dual-key backups: `_backup_key(full_path)` + `_backup_key_rel(relative_path)`
|
||||
- `_find_backup()` tries 4 strategies (current path, current rel, original path, original rel)
|
||||
- Survives file moves from `/bulk-fix` restructure
|
||||
- `save_batch_manifest()` stores: batch_id, paths, tags_changed, affected_albums/artists, edit_type, is_reverted
|
||||
- Single-track edits (`PATCH /edit-metadata`) also create manifests with `edit_type: "single"`
|
||||
- Double undo prevented: `is_reverted` flag checked, HTTP 409 on repeat
|
||||
|
||||
### Path Resolution
|
||||
- `resolve_path()` has 5 fallback strategies for finding files
|
||||
- Handles URL encoding, NFC unicode normalization, library folder prefixes
|
||||
- Falls back to Companion songs table and fuzzy title matching
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- `MutagenFile(path, easy=True)` returns `None` for AIFF — always check
|
||||
- `datetime.utcnow().isoformat()` produces timestamps WITHOUT timezone suffix — Swift must parse with fallback formats
|
||||
- `ffmpeg` subprocess can hang on corrupt files — 120s timeout with process kill
|
||||
- Pi has limited CPU (4 cores) — `deploy.resources.limits.cpus: '2.0'` in docker-compose
|
||||
|
|
@ -6,9 +6,6 @@ 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
|
||||
|
|
@ -32,12 +29,6 @@ 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
|
||||
|
|
@ -61,7 +52,6 @@ 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")
|
||||
|
|
@ -198,16 +188,6 @@ 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)")
|
||||
|
|
@ -1184,294 +1164,6 @@ 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 field→value 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',
|
||||
|
|
@ -1480,29 +1172,6 @@ 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',
|
||||
|
|
@ -1570,27 +1239,10 @@ 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()):
|
||||
# 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:
|
||||
ku = k.upper().split(':')[0].strip()
|
||||
if ku not in allowed:
|
||||
to_remove.append(k)
|
||||
to_remove = list(set(to_remove))
|
||||
result["removed"] = to_remove
|
||||
|
|
@ -1791,12 +1443,6 @@ 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)
|
||||
|
|
@ -1810,24 +1456,10 @@ 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.
|
||||
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."
|
||||
)
|
||||
|
||||
"""Write tags then enforce whitelist — only Navidrome tags survive."""
|
||||
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
|
||||
|
|
@ -1836,36 +1468,15 @@ 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
|
||||
# Enforce whitelist after writing — nukes everything not in NAVIDROME_TAGS
|
||||
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.
|
||||
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."
|
||||
)
|
||||
|
||||
"""Write tags dict then enforce whitelist — only Navidrome tags survive."""
|
||||
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'}
|
||||
|
|
@ -1877,13 +1488,6 @@ 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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -2080,55 +1684,19 @@ 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 "",
|
||||
"batch_id": batch_id,
|
||||
"album": update.album or ""
|
||||
})
|
||||
return {"status": "success", "file": new_relative, "resolved": fp, "batch_id": batch_id}
|
||||
return {"status": "success", "file": new_relative, "resolved": fp}
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
|
@ -2137,9 +1705,7 @@ async def edit_metadata(update: MetadataUpdate):
|
|||
|
||||
@app.patch("/batch-edit-metadata")
|
||||
async def batch_edit_metadata(update: BatchMetadataUpdate):
|
||||
# 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}
|
||||
results = {"succeeded": [], "failed": []}
|
||||
tags = {}
|
||||
if update.title: tags["title"] = update.title
|
||||
if update.artist: tags["artist"] = update.artist
|
||||
|
|
@ -2148,43 +1714,18 @@ 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.
|
||||
Each file is backed up BEFORE any write — undo is always possible."""
|
||||
resolved_paths = []
|
||||
albums_seen = []
|
||||
artists_seen = []
|
||||
"""Blocking tag writes run in a thread so the event loop stays free."""
|
||||
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()
|
||||
|
|
@ -2193,143 +1734,12 @@ 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(...),
|
||||
|
|
@ -2555,258 +1965,6 @@ 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)
|
||||
|
|
|
|||
|
|
@ -196,13 +196,6 @@ 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
|
||||
|
|
@ -216,4 +209,3 @@ struct RootView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,422 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -74,7 +74,7 @@ final class WidgetBridge {
|
|||
duration: TimeInterval,
|
||||
coverArtId: String?,
|
||||
coverArtImage: UIImage?,
|
||||
queue: [(title: String, artist: String, coverData: Data?)],
|
||||
queue: [(title: String, artist: String)],
|
||||
waveformSamples: [Float]? = nil,
|
||||
crossfadeAt: TimeInterval? = nil
|
||||
) {
|
||||
|
|
@ -91,9 +91,7 @@ final class WidgetBridge {
|
|||
cachedSecondaryHex = secondary
|
||||
}
|
||||
|
||||
let queueItems = queue.prefix(3).map {
|
||||
WidgetQueueItem(title: $0.title, artist: $0.artist, coverArtData: $0.coverData)
|
||||
}
|
||||
let queueItems = queue.prefix(3).map { WidgetQueueItem(title: $0.title, artist: $0.artist) }
|
||||
|
||||
state.pushState(
|
||||
title: title,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ 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?
|
||||
|
|
@ -49,8 +48,7 @@ struct MainTabView: View {
|
|||
MyMusicView(
|
||||
navigateToPlaylistId: $navigateToPlaylistId,
|
||||
navigateToAlbumId: $navigateToAlbumId,
|
||||
navigateToArtistId: $navigateToArtistId,
|
||||
isDynamicIsland: $isDynamicIsland
|
||||
navigateToArtistId: $navigateToArtistId
|
||||
)
|
||||
.tabItem {
|
||||
Image(systemName: "music.note")
|
||||
|
|
@ -88,55 +86,9 @@ struct MainTabView: View {
|
|||
}
|
||||
.tint(accentPink)
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
// Reserve space for MiniPlayerBar so content never gets obscured.
|
||||
// Only when the mini player is at the bottom (not in Dynamic Island mode).
|
||||
if audioPlayer.currentSong != nil && !showNowPlaying && !isDynamicIsland {
|
||||
Color.clear.frame(height: 72)
|
||||
}
|
||||
}
|
||||
.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)
|
||||
// Reserve space for MiniPlayerBar so content never gets obscured
|
||||
if audioPlayer.currentSong != nil && !showNowPlaying {
|
||||
Color.clear.frame(height: 80)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -653,7 +605,7 @@ struct MiniPlayerBar: View {
|
|||
height: VisualizerSettings.shared.miniPlayerHeight
|
||||
)
|
||||
.matchedGeometryEffect(id: "visWave", in: namespace)
|
||||
.offset(y: VisualizerSettings.shared.miniVisYOffset)
|
||||
.offset(y: 10)
|
||||
.opacity(VisualizerSettings.shared.miniOpacity)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,8 +30,6 @@ 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
|
||||
|
||||
|
|
@ -237,38 +235,6 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -397,7 +363,6 @@ 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 {
|
||||
|
|
|
|||
|
|
@ -65,10 +65,6 @@ class SmartDJCache {
|
|||
private let cacheDir: URL
|
||||
private let bulkCacheURL: URL
|
||||
private var memoryCache: [String: SmartDJProfile] = [:]
|
||||
/// Serializes all memoryCache reads/writes — prevents concurrent Dictionary
|
||||
/// mutation crashes (EXC_BAD_ACCESS) when bulkImport runs on a background
|
||||
/// Task while get/store fire from other threads.
|
||||
private let lock = NSLock()
|
||||
|
||||
private init() {
|
||||
let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!
|
||||
|
|
@ -85,23 +81,16 @@ class SmartDJCache {
|
|||
}
|
||||
|
||||
func get(_ relativePath: String) -> SmartDJProfile? {
|
||||
lock.lock()
|
||||
if let cached = memoryCache[relativePath] { lock.unlock(); return cached }
|
||||
lock.unlock()
|
||||
|
||||
if let cached = memoryCache[relativePath] { return cached }
|
||||
let url = cacheURL(for: relativePath)
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let profile = try? JSONDecoder().decode(SmartDJProfile.self, from: data) else { return nil }
|
||||
lock.lock()
|
||||
memoryCache[relativePath] = profile
|
||||
lock.unlock()
|
||||
return profile
|
||||
}
|
||||
|
||||
func store(_ profile: SmartDJProfile, for relativePath: String) {
|
||||
lock.lock()
|
||||
memoryCache[relativePath] = profile
|
||||
lock.unlock()
|
||||
let url = cacheURL(for: relativePath)
|
||||
if let data = try? JSONEncoder().encode(profile) {
|
||||
try? data.write(to: url, options: .atomic)
|
||||
|
|
@ -117,11 +106,9 @@ class SmartDJCache {
|
|||
let profiles = try? JSONDecoder().decode([String: SmartDJProfile].self, from: data)
|
||||
else { return }
|
||||
// Merge: individual stores (from cache misses) take priority over bulk
|
||||
lock.lock()
|
||||
for (path, profile) in profiles where memoryCache[path] == nil {
|
||||
memoryCache[path] = profile
|
||||
}
|
||||
lock.unlock()
|
||||
DebugLogger.shared.log(
|
||||
"DJ bulk cache loaded: \(profiles.count) profiles from disk",
|
||||
category: "SmartDJ"
|
||||
|
|
@ -131,37 +118,28 @@ class SmartDJCache {
|
|||
/// Import all profiles from the server export. Updates memory immediately,
|
||||
/// writes a single bulk JSON file to disk for next launch.
|
||||
func bulkImport(_ profiles: [String: SmartDJProfile]) {
|
||||
lock.lock()
|
||||
for (path, profile) in profiles {
|
||||
memoryCache[path] = profile
|
||||
}
|
||||
lock.unlock()
|
||||
// Write single bulk file — one atomic write, no per-profile I/O
|
||||
if let data = try? JSONEncoder().encode(profiles) {
|
||||
try? data.write(to: bulkCacheURL, options: .atomic)
|
||||
}
|
||||
DebugLogger.shared.log(
|
||||
"DJ bulk import: \(profiles.count) profiles cached",
|
||||
"DJ bulk import: \(profiles.count) profiles cached (\(memoryCache.count) total in memory)",
|
||||
category: "SmartDJ"
|
||||
)
|
||||
}
|
||||
|
||||
func clearAll() {
|
||||
lock.lock()
|
||||
memoryCache.removeAll()
|
||||
lock.unlock()
|
||||
try? fileManager.removeItem(at: bulkCacheURL)
|
||||
if let files = try? fileManager.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil) {
|
||||
for file in files { try? fileManager.removeItem(at: file) }
|
||||
}
|
||||
}
|
||||
|
||||
var cachedCount: Int {
|
||||
lock.lock()
|
||||
let count = memoryCache.count
|
||||
lock.unlock()
|
||||
return count
|
||||
}
|
||||
var cachedCount: Int { memoryCache.count }
|
||||
}
|
||||
|
||||
// MARK: - Metadata Edit Request (matches Python MetadataUpdate model)
|
||||
|
|
@ -217,116 +195,6 @@ 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 {
|
||||
|
|
@ -395,7 +263,7 @@ actor CompanionAPIService {
|
|||
/// Call once on app launch to populate SmartDJCache.bulkImport().
|
||||
func fetchAllProfiles() async throws -> [String: SmartDJProfile] {
|
||||
let base = try baseURL()
|
||||
let url = URL(string: "\(base)/smart-dj/profiles/export")!
|
||||
let url = base.appendingPathComponent("smart-dj/profiles/export")
|
||||
let (data, response) = try await session.data(from: url)
|
||||
try validateResponse(response)
|
||||
|
||||
|
|
@ -448,6 +316,15 @@ 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()
|
||||
|
|
@ -471,44 +348,6 @@ 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 {
|
||||
|
|
@ -543,7 +382,7 @@ actor CompanionAPIService {
|
|||
guard !paths.isEmpty else { return [:] }
|
||||
|
||||
let base = try baseURL()
|
||||
var components = URLComponents(string: "\(base)/smart-dj/bulk-profiles")!
|
||||
var components = URLComponents(url: base.appendingPathComponent("smart-dj/bulk-profiles"), resolvingAgainstBaseURL: false)!
|
||||
components.queryItems = [URLQueryItem(name: "paths", value: paths.joined(separator: ","))]
|
||||
|
||||
guard let url = components.url else { throw CompanionError.invalidURL }
|
||||
|
|
@ -578,7 +417,7 @@ actor CompanionAPIService {
|
|||
/// Fetch lyrics from LRCLIB via companion API (exact match by artist/title/duration).
|
||||
func fetchLyricsFromLRCLIB(artist: String, title: String, duration: Double) async throws -> LyricsResponse {
|
||||
let base = try baseURL()
|
||||
var components = URLComponents(string: "\(base)/lyrics/fetch")!
|
||||
var components = URLComponents(url: base.appendingPathComponent("lyrics/fetch"), resolvingAgainstBaseURL: false)!
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "artist", value: artist),
|
||||
URLQueryItem(name: "title", value: title),
|
||||
|
|
@ -593,7 +432,7 @@ actor CompanionAPIService {
|
|||
/// Search LRCLIB for lyrics matching a query.
|
||||
func searchLyrics(query: String) async throws -> [LRCLIBResult] {
|
||||
let base = try baseURL()
|
||||
var components = URLComponents(string: "\(base)/lyrics/search")!
|
||||
var components = URLComponents(url: base.appendingPathComponent("lyrics/search"), resolvingAgainstBaseURL: false)!
|
||||
components.queryItems = [URLQueryItem(name: "q", value: query)]
|
||||
guard let url = components.url else { throw CompanionError.invalidURL }
|
||||
let (data, response) = try await session.data(from: url)
|
||||
|
|
@ -604,7 +443,7 @@ actor CompanionAPIService {
|
|||
/// Embed LRC lyrics into an audio file via Companion API.
|
||||
func embedLyrics(relativePath: String, lrcContent: String) async throws {
|
||||
let base = try baseURL()
|
||||
let url = URL(string: "\(base)/lyrics/embed")!
|
||||
let url = base.appendingPathComponent("lyrics/embed")
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,431 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -39,8 +39,6 @@ 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
|
||||
|
|
@ -315,37 +313,6 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -456,7 +423,6 @@ 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 {
|
||||
|
|
|
|||
|
|
@ -86,8 +86,6 @@ class SmartCrossfadeManager: ObservableObject {
|
|||
var needsNextTrack: (() -> Void)?
|
||||
var timeUpdate: ((TimeInterval, TimeInterval) -> Void)?
|
||||
var visualizerHandoff: ((AVPlayer) -> Void)?
|
||||
/// Fires at crossfade midpoint — AudioPlayer updates currentSong, artwork, colors.
|
||||
var songHandoff: ((Song) -> Void)?
|
||||
|
||||
private init() {
|
||||
isEnabled = UserDefaults.standard.bool(forKey: "smart_crossfade_enabled")
|
||||
|
|
@ -344,10 +342,10 @@ class SmartCrossfadeManager: ObservableObject {
|
|||
timeObserver = activePlayer.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
|
||||
guard let self else { return }
|
||||
|
||||
// During crossfade, report from the INCOMING player immediately.
|
||||
// The user hears the new song fading in — seek bar should match.
|
||||
// During crossfade past midpoint, report from the INCOMING player
|
||||
// so the seek bar shows the new track's position, not -0:00
|
||||
let reportPlayer: AVPlayer
|
||||
if self.isCrossfading {
|
||||
if self.isCrossfading && self.didHandoffVisualizer {
|
||||
reportPlayer = self.standbyPlayer
|
||||
} else {
|
||||
reportPlayer = self.activePlayer
|
||||
|
|
@ -446,11 +444,6 @@ class SmartCrossfadeManager: ObservableObject {
|
|||
if progress >= 0.5 && !didHandoffVisualizer {
|
||||
didHandoffVisualizer = true
|
||||
visualizerHandoff?(standbyPlayer)
|
||||
// Transition metadata/artwork/colors at the midpoint so the UI
|
||||
// reflects the incoming song while the audio is still fading.
|
||||
if let incoming = pendingNextSong {
|
||||
songHandoff?(incoming)
|
||||
}
|
||||
}
|
||||
|
||||
if progress >= 1.0 {
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ struct AlbumDetailView: View {
|
|||
songList(album)
|
||||
|
||||
// Bottom spacing
|
||||
Color.clear.frame(height: 80)
|
||||
Color.clear.frame(height: 120)
|
||||
}
|
||||
} else if loadFailed {
|
||||
VStack(spacing: 16) {
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ struct ArtistDetailView: View {
|
|||
}
|
||||
}
|
||||
|
||||
Color.clear.frame(height: 80)
|
||||
Color.clear.frame(height: 120)
|
||||
}
|
||||
} else if loadFailed {
|
||||
// Connection failed — show retry
|
||||
|
|
@ -217,7 +217,7 @@ struct GenreDetailView: View {
|
|||
}
|
||||
.padding(16)
|
||||
|
||||
Color.clear.frame(height: 80)
|
||||
Color.clear.frame(height: 120)
|
||||
} else if loadFailed {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "wifi.slash")
|
||||
|
|
|
|||
|
|
@ -140,8 +140,6 @@ struct DownloadsView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {} footer: { Spacer().frame(height: 80) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -358,8 +356,6 @@ struct DownloadsView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {} footer: { Spacer().frame(height: 80) }
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
|
|
@ -750,7 +746,7 @@ struct SettingsView: View {
|
|||
|
||||
// Extra space so the last items aren't hidden behind the mini player
|
||||
Section {} footer: {
|
||||
Spacer().frame(height: 80)
|
||||
Spacer().frame(height: 60)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
|
|
@ -1082,26 +1078,6 @@ 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) {
|
||||
|
|
|
|||
|
|
@ -123,8 +123,6 @@ struct LicensesView: View {
|
|||
} header: {
|
||||
Text("Acknowledgments")
|
||||
}
|
||||
|
||||
Section {} footer: { Spacer().frame(height: 80) }
|
||||
}
|
||||
.navigationTitle("Licenses")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,11 +18,8 @@ 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
|
||||
|
|
@ -65,7 +62,6 @@ struct MyMusicView: View {
|
|||
|
||||
enum SortMode: String, CaseIterable {
|
||||
case recentlyAdded = "Recently Added"
|
||||
case recentlyPlayed = "Recently Played"
|
||||
case favourites = "Favourites"
|
||||
case artists = "Artists"
|
||||
case albums = "Albums"
|
||||
|
|
@ -88,9 +84,6 @@ 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))
|
||||
|
|
@ -107,8 +100,8 @@ struct MyMusicView: View {
|
|||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
// Search bar (for all tabs except Recently Added and Recently Played)
|
||||
if sortMode != .recentlyAdded && sortMode != .recentlyPlayed {
|
||||
// Search bar (for all tabs except Recently Added)
|
||||
if sortMode != .recentlyAdded {
|
||||
searchBar
|
||||
}
|
||||
|
||||
|
|
@ -117,14 +110,13 @@ 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: 80)
|
||||
Color.clear.frame(height: 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -163,7 +155,7 @@ struct MyMusicView: View {
|
|||
.foregroundColor(selectedAlbumIds.isEmpty ? .gray : accentPink)
|
||||
}
|
||||
.disabled(selectedAlbumIds.isEmpty)
|
||||
} else if !isDynamicIsland {
|
||||
} else {
|
||||
Button(action: { showServerPicker = true }) {
|
||||
Image(systemName: "server.rack")
|
||||
.foregroundColor(accentPink)
|
||||
|
|
@ -286,249 +278,10 @@ 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 {
|
||||
|
|
@ -1449,11 +1202,6 @@ 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.
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ struct PlaylistDetailView: View {
|
|||
|
||||
playlistSongList(playlist)
|
||||
|
||||
Color.clear.frame(height: 80)
|
||||
Color.clear.frame(height: 120)
|
||||
}
|
||||
} else if isLoading {
|
||||
ProgressView().tint(accentPink).padding(.top, 100)
|
||||
|
|
|
|||
|
|
@ -154,7 +154,6 @@ struct RadioView: View {
|
|||
ForEach(stations) { station in
|
||||
stationRow(station)
|
||||
}
|
||||
Section {} footer: { Spacer().frame(height: 80) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ struct SearchView: View {
|
|||
.background(Color.white.opacity(0.06))
|
||||
.padding(.leading, 72)
|
||||
}
|
||||
Color.clear.frame(height: 80)
|
||||
Color.clear.frame(height: 120)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -216,7 +216,7 @@ struct SearchView: View {
|
|||
.padding(.leading, 72)
|
||||
}
|
||||
}
|
||||
Color.clear.frame(height: 80)
|
||||
Color.clear.frame(height: 120)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,10 +28,6 @@ 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.
|
||||
|
|
@ -85,7 +81,6 @@ final class LyricsManager: ObservableObject {
|
|||
currentLineIndex = 0
|
||||
currentWordIndex = 0
|
||||
lastSyncedTime = 0
|
||||
globalOffset = 0
|
||||
isLyricsVisible = false
|
||||
}
|
||||
|
||||
|
|
@ -97,10 +92,7 @@ final class LyricsManager: ObservableObject {
|
|||
lastSyncedTime = time
|
||||
guard let lyrics = currentLyrics, !lyrics.lines.isEmpty else { return }
|
||||
|
||||
// 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)
|
||||
let newIndex = findLineIndex(at: time, in: lyrics.lines)
|
||||
if newIndex != currentLineIndex {
|
||||
currentLineIndex = newIndex
|
||||
}
|
||||
|
|
@ -109,7 +101,7 @@ final class LyricsManager: ObservableObject {
|
|||
if isLyricsVisible,
|
||||
newIndex < lyrics.lines.count,
|
||||
let words = lyrics.lines[newIndex].words {
|
||||
currentWordIndex = findWordIndex(at: adjusted, in: words)
|
||||
currentWordIndex = findWordIndex(at: time, in: words)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -120,24 +112,21 @@ final class LyricsManager: ObservableObject {
|
|||
return (0, 0, 0)
|
||||
}
|
||||
|
||||
// Apply global offset
|
||||
let adjusted = time - globalOffset
|
||||
|
||||
let lineIdx = findLineIndex(at: adjusted, in: lyrics.lines)
|
||||
let lineIdx = findLineIndex(at: time, in: lyrics.lines)
|
||||
let line = lyrics.lines[lineIdx]
|
||||
|
||||
guard let words = line.words, !words.isEmpty else {
|
||||
return (lineIdx, 0, 0)
|
||||
}
|
||||
|
||||
let wordIdx = findWordIndex(at: adjusted, in: words)
|
||||
let wordIdx = findWordIndex(at: time, 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((adjusted - word.startTime) / wordDuration, 0), 1)
|
||||
fraction = min(max((time - word.startTime) / wordDuration, 0), 1)
|
||||
} else {
|
||||
fraction = adjusted >= word.startTime ? 1 : 0
|
||||
fraction = time >= word.startTime ? 1 : 0
|
||||
}
|
||||
|
||||
return (lineIdx, wordIdx, fraction)
|
||||
|
|
@ -191,63 +180,59 @@ final class LyricsManager: ObservableObject {
|
|||
// MARK: - Private: Fetch Pipeline
|
||||
|
||||
private func fetchLyrics(artist: String, title: String, path: String?, duration: Double) async {
|
||||
let service = LyricsService.shared
|
||||
|
||||
// 1. On-device: read embedded lyrics from downloaded file
|
||||
if let songId = currentSongId {
|
||||
if let response = await service.readEmbedded(songId: songId) {
|
||||
if let synced = response.syncedLyrics, !synced.isEmpty {
|
||||
let lines = LRCParser.parse(synced)
|
||||
let data = LyricsData(lines: lines, source: "embedded", hasSynced: true)
|
||||
await MainActor.run { self.currentLyrics = data; self.isLoading = false }
|
||||
LyricsCache.shared.store(data, artist: artist, title: title)
|
||||
DebugLogger.shared.log("Lyrics: embedded in local file (\(lines.count) lines)", category: "Lyrics")
|
||||
return
|
||||
} else if let plain = response.plainLyrics, !plain.isEmpty {
|
||||
await MainActor.run { self.importPlain(plain, source: "embedded"); self.isLoading = false }
|
||||
DebugLogger.shared.log("Lyrics: plain text embedded in local file", category: "Lyrics")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. LRCLIB direct from iOS (no companion needed)
|
||||
do {
|
||||
let response = try await service.fetch(artist: artist, title: title, duration: duration)
|
||||
if let synced = response.syncedLyrics, !synced.isEmpty {
|
||||
let lines = LRCParser.parse(synced)
|
||||
let data = LyricsData(lines: lines, source: "lrclib", hasSynced: true)
|
||||
await MainActor.run { self.currentLyrics = data; self.isLoading = false }
|
||||
LyricsCache.shared.store(data, artist: artist, title: title)
|
||||
DebugLogger.shared.log("Lyrics: LRCLIB direct (synced, \(lines.count) lines)", category: "Lyrics")
|
||||
return
|
||||
} else if let plain = response.plainLyrics, !plain.isEmpty {
|
||||
await MainActor.run { self.importPlain(plain, source: "lrclib"); self.isLoading = false }
|
||||
DebugLogger.shared.log("Lyrics: LRCLIB direct (plain text)", category: "Lyrics")
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
DebugLogger.shared.log("Lyrics: LRCLIB direct failed: \(error.localizedDescription)", category: "Lyrics")
|
||||
}
|
||||
|
||||
// 3. Companion API fallback (reads server-side embedded tags + .lrc sidecars)
|
||||
// 1. Try Companion API /lyrics/get (checks embedded + .lrc + cache)
|
||||
if CompanionSettings.shared.isEnabled, let path = path {
|
||||
do {
|
||||
let api = CompanionAPIService.shared
|
||||
let response = try await api.fetchLyricsForPath(relativePath: path)
|
||||
if let synced = response.syncedLyrics, !synced.isEmpty {
|
||||
let lines = LRCParser.parse(synced)
|
||||
let data = LyricsData(lines: lines, source: response.source ?? "companion", hasSynced: true)
|
||||
await MainActor.run { self.currentLyrics = data; self.isLoading = false }
|
||||
let data = LyricsData(lines: lines, source: response.source ?? "embedded", hasSynced: true)
|
||||
await MainActor.run {
|
||||
self.currentLyrics = data
|
||||
self.isLoading = false
|
||||
}
|
||||
LyricsCache.shared.store(data, artist: artist, title: title)
|
||||
DebugLogger.shared.log("Lyrics: companion fallback (\(response.source ?? "?"), \(lines.count) lines)", category: "Lyrics")
|
||||
DebugLogger.shared.log("Lyrics: found via companion (\(response.source ?? "?"), \(lines.count) lines)", category: "Lyrics")
|
||||
return
|
||||
} else if let plain = response.plainLyrics, !plain.isEmpty {
|
||||
await MainActor.run { self.importPlain(plain, source: response.source ?? "companion"); self.isLoading = false }
|
||||
await MainActor.run {
|
||||
self.importPlain(plain, source: response.source ?? "embedded")
|
||||
self.isLoading = false
|
||||
}
|
||||
DebugLogger.shared.log("Lyrics: plain text via companion (\(response.source ?? "?"))", category: "Lyrics")
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
DebugLogger.shared.log("Lyrics: companion fallback failed: \(error.localizedDescription)", category: "Lyrics")
|
||||
DebugLogger.shared.log("Lyrics: companion fetch failed: \(error.localizedDescription)", category: "Lyrics")
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try LRCLIB via Companion API
|
||||
if CompanionSettings.shared.isEnabled {
|
||||
do {
|
||||
let api = CompanionAPIService.shared
|
||||
let response = try await api.fetchLyricsFromLRCLIB(artist: artist, title: title, duration: duration)
|
||||
if let synced = response.syncedLyrics, !synced.isEmpty {
|
||||
let lines = LRCParser.parse(synced)
|
||||
let data = LyricsData(lines: lines, source: "lrclib", hasSynced: true)
|
||||
await MainActor.run {
|
||||
self.currentLyrics = data
|
||||
self.isLoading = false
|
||||
}
|
||||
LyricsCache.shared.store(data, artist: artist, title: title)
|
||||
DebugLogger.shared.log("Lyrics: found on LRCLIB (synced, \(lines.count) lines)", category: "Lyrics")
|
||||
return
|
||||
} else if let plain = response.plainLyrics, !plain.isEmpty {
|
||||
await MainActor.run {
|
||||
self.importPlain(plain, source: "lrclib")
|
||||
self.isLoading = false
|
||||
}
|
||||
DebugLogger.shared.log("Lyrics: found on LRCLIB (plain text)", category: "Lyrics")
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
DebugLogger.shared.log("Lyrics: LRCLIB fetch failed: \(error.localizedDescription)", category: "Lyrics")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,28 +15,22 @@ 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 {
|
||||
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
|
||||
)
|
||||
}
|
||||
TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: !audioPlayer.isPlaying)) { timeline in
|
||||
let now = audioPlayer.currentTime
|
||||
let progress = lyricsManager.wordProgress(at: now)
|
||||
|
||||
// Bottom toolbar — search, timing, edit
|
||||
lyricsToolbar(hasSynced: lyrics.hasSynced)
|
||||
lyricsContent(
|
||||
lyrics: lyrics,
|
||||
currentTime: now,
|
||||
lineIndex: progress.lineIndex,
|
||||
wordIndex: progress.wordIndex,
|
||||
wordFraction: progress.wordFraction
|
||||
)
|
||||
}
|
||||
} else if lyricsManager.isLoading {
|
||||
VStack {
|
||||
|
|
@ -61,6 +55,7 @@ struct LyricsOverlayView: View {
|
|||
.padding(.top, 8)
|
||||
|
||||
Button {
|
||||
// Open search sheet
|
||||
NotificationCenter.default.post(name: .openLyricsSearch, object: nil)
|
||||
} label: {
|
||||
Text("Search Lyrics")
|
||||
|
|
@ -78,118 +73,6 @@ 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
|
||||
|
|
@ -375,5 +258,4 @@ struct FlowLayout: Layout {
|
|||
|
||||
extension Notification.Name {
|
||||
static let openLyricsSearch = Notification.Name("openLyricsSearch")
|
||||
static let openLyricsEditor = Notification.Name("openLyricsEditor")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -188,7 +188,8 @@ struct LyricsSearchSheet: View {
|
|||
|
||||
Task {
|
||||
do {
|
||||
let fetched = try await LyricsService.shared.search(query: query)
|
||||
let api = CompanionAPIService.shared
|
||||
let fetched = try await api.searchLyrics(query: query)
|
||||
await MainActor.run {
|
||||
results = fetched
|
||||
isSearching = false
|
||||
|
|
|
|||
|
|
@ -1,178 +0,0 @@
|
|||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// LyricsService.swift
|
||||
// iOS-native lyrics fetching — no Companion API dependency.
|
||||
//
|
||||
// Priority:
|
||||
// 1. Local cache (previously fetched)
|
||||
// 2. Embedded lyrics in downloaded file (AVAsset metadata)
|
||||
// 3. LRCLIB direct (free public API, no auth)
|
||||
// 4. Companion API fallback (for server-side embedded tags)
|
||||
//
|
||||
// LRCLIB API: https://lrclib.net/api
|
||||
// GET /search?q=... → [LRCLIBResult]
|
||||
// GET /get?artist_name=...&track_name=...&duration=... → LRCLIBResult
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
final class LyricsService {
|
||||
static let shared = LyricsService()
|
||||
|
||||
private let lrclibBase = "https://lrclib.net/api"
|
||||
private let session: URLSession
|
||||
|
||||
private init() {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = 10
|
||||
config.timeoutIntervalForResource = 15
|
||||
session = URLSession(configuration: config)
|
||||
}
|
||||
|
||||
// MARK: - Search LRCLIB (direct)
|
||||
|
||||
/// Search LRCLIB for lyrics matching a free-text query.
|
||||
/// Returns an array of matches with synced/plain lyrics included.
|
||||
func search(query: String) async throws -> [LRCLIBResult] {
|
||||
guard var components = URLComponents(string: "\(lrclibBase)/search") else {
|
||||
throw LyricsServiceError.invalidURL
|
||||
}
|
||||
components.queryItems = [URLQueryItem(name: "q", value: query)]
|
||||
guard let url = components.url else { throw LyricsServiceError.invalidURL }
|
||||
|
||||
let (data, response) = try await session.data(from: url)
|
||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
||||
throw LyricsServiceError.httpError((response as? HTTPURLResponse)?.statusCode ?? 0)
|
||||
}
|
||||
|
||||
// LRCLIB returns slightly different field names than our model
|
||||
let raw = try JSONDecoder().decode([LRCLIBRawResult].self, from: data)
|
||||
return raw.map { $0.toLRCLIBResult() }
|
||||
}
|
||||
|
||||
// MARK: - Fetch from LRCLIB (exact match)
|
||||
|
||||
/// Exact-match fetch from LRCLIB by artist + title + duration.
|
||||
func fetch(artist: String, title: String, duration: Double) async throws -> LyricsResponse {
|
||||
guard var components = URLComponents(string: "\(lrclibBase)/get") else {
|
||||
throw LyricsServiceError.invalidURL
|
||||
}
|
||||
var items = [
|
||||
URLQueryItem(name: "artist_name", value: artist),
|
||||
URLQueryItem(name: "track_name", value: title),
|
||||
]
|
||||
if duration > 0 {
|
||||
items.append(URLQueryItem(name: "duration", value: String(Int(duration))))
|
||||
}
|
||||
components.queryItems = items
|
||||
guard let url = components.url else { throw LyricsServiceError.invalidURL }
|
||||
|
||||
let (data, response) = try await session.data(from: url)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw LyricsServiceError.httpError(0)
|
||||
}
|
||||
|
||||
if http.statusCode == 404 {
|
||||
return LyricsResponse(syncedLyrics: nil, plainLyrics: nil, source: "lrclib", cached: false, found: false)
|
||||
}
|
||||
guard http.statusCode == 200 else {
|
||||
throw LyricsServiceError.httpError(http.statusCode)
|
||||
}
|
||||
|
||||
let raw = try JSONDecoder().decode(LRCLIBRawResult.self, from: data)
|
||||
return LyricsResponse(
|
||||
syncedLyrics: raw.syncedLyrics,
|
||||
plainLyrics: raw.plainLyrics,
|
||||
source: "lrclib",
|
||||
cached: false,
|
||||
found: raw.syncedLyrics != nil || raw.plainLyrics != nil
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Read Embedded Lyrics (on-device)
|
||||
|
||||
/// Read lyrics embedded in a local audio file's metadata tags.
|
||||
/// Works for downloaded songs — checks USLT (MP3), LYRICS (FLAC), ©lyr (M4A).
|
||||
func readEmbedded(songId: String) async -> LyricsResponse? {
|
||||
guard let localURL = OfflineManager.shared.localURL(for: songId) else { return nil }
|
||||
|
||||
let asset = AVURLAsset(url: localURL)
|
||||
var lyricsText: String?
|
||||
|
||||
do {
|
||||
// Modern async API (iOS 16+)
|
||||
let commonItems = try await asset.load(.commonMetadata)
|
||||
for item in commonItems {
|
||||
if item.commonKey == .commonKeyTitle { continue }
|
||||
if let key = item.commonKey?.rawValue, key.lowercased().contains("lyric") {
|
||||
lyricsText = try await item.load(.stringValue)
|
||||
}
|
||||
}
|
||||
|
||||
if lyricsText == nil {
|
||||
let allItems = try await asset.load(.metadata)
|
||||
for item in allItems {
|
||||
if let key = item.identifier?.rawValue.lowercased(),
|
||||
(key.contains("lyric") || key.contains("uslt") || key.contains("©lyr")) {
|
||||
lyricsText = try await item.load(.stringValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let text = lyricsText, !text.isEmpty else { return nil }
|
||||
|
||||
let hasTimes = text.contains("[") && text.range(of: #"\[\d{1,2}:\d{2}"#, options: .regularExpression) != nil
|
||||
|
||||
return LyricsResponse(
|
||||
syncedLyrics: hasTimes ? text : nil,
|
||||
plainLyrics: hasTimes ? nil : text,
|
||||
source: "embedded",
|
||||
cached: false,
|
||||
found: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum LyricsServiceError: LocalizedError {
|
||||
case invalidURL
|
||||
case httpError(Int)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL: return "Invalid lyrics URL"
|
||||
case .httpError(let code): return "Lyrics request failed (HTTP \(code))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - LRCLIB Raw Response (matches their actual API format)
|
||||
|
||||
/// LRCLIB's native response format — field names differ from our app model.
|
||||
private struct LRCLIBRawResult: Codable {
|
||||
let id: Int?
|
||||
let trackName: String?
|
||||
let artistName: String?
|
||||
let albumName: String?
|
||||
let duration: Double?
|
||||
let syncedLyrics: String?
|
||||
let plainLyrics: String?
|
||||
|
||||
func toLRCLIBResult() -> LRCLIBResult {
|
||||
LRCLIBResult(
|
||||
id: id,
|
||||
trackName: trackName ?? "",
|
||||
artistName: artistName ?? "",
|
||||
albumName: albumName,
|
||||
duration: duration ?? 0,
|
||||
hasSynced: syncedLyrics != nil,
|
||||
hasPlain: plainLyrics != nil,
|
||||
syncedLyrics: syncedLyrics,
|
||||
plainLyrics: plainLyrics
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -355,9 +355,6 @@ 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
|
||||
|
|
@ -1567,7 +1564,8 @@ class ShazamRecognizer: NSObject, ObservableObject, SHSessionDelegate {
|
|||
private var timeoutTask: Task<Void, Never>?
|
||||
private var hasDeliveredResult = false
|
||||
|
||||
// Tap state — uses shared AudioTapProcessor (no longer owns its own tap)
|
||||
// Tap state — MTAudioProcessingTap is a CF type, ARC manages it directly
|
||||
private var tap: MTAudioProcessingTap?
|
||||
private var converter: AVAudioConverter?
|
||||
private var sourceFormat: AVAudioFormat?
|
||||
private let targetFormat = AVAudioFormat(standardFormatWithSampleRate: 16_000, channels: 1)!
|
||||
|
|
@ -1594,40 +1592,14 @@ class ShazamRecognizer: NSObject, ObservableObject, SHSessionDelegate {
|
|||
|
||||
DebugLogger.shared.log("Shazam: starting recognition", category: "Audio")
|
||||
|
||||
// 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 {
|
||||
// Prefer tap on AVPlayer — async track load, then install tap on main actor
|
||||
if let playerItem = AudioPlayer.shared.currentPlayerItem {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
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)
|
||||
}
|
||||
if await self.installTap(on: playerItem) {
|
||||
self.startTimeout(seconds: 15)
|
||||
DebugLogger.shared.log("Shazam: subscribed to shared audio tap (\(Int(srcFormat.sampleRate))Hz)", category: "Audio")
|
||||
DebugLogger.shared.log("Shazam: tap installed on AVPlayerItem", category: "Audio")
|
||||
} else {
|
||||
DebugLogger.shared.log("Shazam: shared tap has no format — mic fallback", category: "Audio")
|
||||
self.startMicFallback()
|
||||
}
|
||||
}
|
||||
|
|
@ -1636,17 +1608,64 @@ class ShazamRecognizer: NSObject, ObservableObject, SHSessionDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared Tap Consumer
|
||||
// MARK: - MTAudioProcessingTap
|
||||
|
||||
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
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// Called from the shared tap callback — dispatches to analysis queue to avoid blocking render thread.
|
||||
/// Called from the C tap callback — dispatches to analysis queue to avoid blocking render thread.
|
||||
func processTapBuffer(_ bufferList: UnsafeMutablePointer<AudioBufferList>,
|
||||
frameCount: CMItemCount) {
|
||||
guard let session, let conv = converter,
|
||||
|
|
@ -1714,6 +1733,16 @@ 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() {
|
||||
|
|
@ -1803,7 +1832,6 @@ 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)
|
||||
|
|
@ -1814,7 +1842,7 @@ class ShazamRecognizer: NSObject, ObservableObject, SHSessionDelegate {
|
|||
session = nil
|
||||
|
||||
// Restore audio session only if we used the mic fallback
|
||||
if usedMic {
|
||||
if audioEngine != nil {
|
||||
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
}
|
||||
|
|
@ -1826,6 +1854,29 @@ 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
|
||||
|
||||
|
|
@ -1921,10 +1972,6 @@ 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)
|
||||
|
||||
|
|
@ -1964,73 +2011,6 @@ 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)
|
||||
|
|
@ -2042,28 +2022,6 @@ 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)
|
||||
|
|
|
|||
|
|
@ -130,7 +130,6 @@ class VisualizerSettings: ObservableObject {
|
|||
@Published var miniIdleAmplitude: Double { didSet { save("vis_mini_idle", miniIdleAmplitude) } }
|
||||
@Published var miniDepthOffset: Double { didSet { save("vis_mini_depth", miniDepthOffset) } }
|
||||
@Published var miniDepthOpacity: Double { didSet { save("vis_mini_depth_opacity", miniDepthOpacity) } }
|
||||
@Published var miniVisYOffset: Double { didSet { save("vis_mini_y_offset", miniVisYOffset) } }
|
||||
|
||||
enum Style: String, CaseIterable, Codable {
|
||||
case wave = "Wave"
|
||||
|
|
@ -199,7 +198,6 @@ class VisualizerSettings: ObservableObject {
|
|||
miniIdleAmplitude = { let v = d.double(forKey: "vis_mini_idle"); return v > 0 ? v : 0.03 }()
|
||||
miniDepthOffset = d.object(forKey: "vis_mini_depth") as? Double ?? 8.0
|
||||
miniDepthOpacity = d.object(forKey: "vis_mini_depth_opacity") as? Double ?? 0.2
|
||||
miniVisYOffset = d.object(forKey: "vis_mini_y_offset") as? Double ?? 0.0
|
||||
}
|
||||
|
||||
var effectiveFPS: Double {
|
||||
|
|
@ -914,9 +912,6 @@ 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)
|
||||
|
||||
|
|
@ -1047,46 +1042,6 @@ 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)
|
||||
|
|
@ -1095,34 +1050,8 @@ 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
|
||||
|
||||
|
|
@ -1187,7 +1116,6 @@ struct VisualizerSettingsView: View {
|
|||
settings.miniPlayerHeight = 48.0; settings.miniOpacity = 0.5
|
||||
settings.miniAmplitude = 0.7; settings.miniIdleAmplitude = 0.03
|
||||
settings.miniDepthOffset = 8.0; settings.miniDepthOpacity = 0.2
|
||||
settings.miniVisYOffset = 0.0
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1271,20 +1199,6 @@ struct ViewConfigSettingsView: View {
|
|||
sd("Base lift (from bottom)", value: $baseLift, range: 0...300, step: 5, format: "%.0f pt")
|
||||
sd("Wave offset (top)", value: $waveOffset, range: -100...300, step: 5, format: "%.0f")
|
||||
}
|
||||
if isCompact {
|
||||
sd("Height", value: Binding(
|
||||
get: { settings.miniPlayerHeight },
|
||||
set: { settings.miniPlayerHeight = $0 }
|
||||
), range: 20...100, step: 2, format: "%.0f pt")
|
||||
sd("Opacity", value: Binding(
|
||||
get: { settings.miniOpacity },
|
||||
set: { settings.miniOpacity = $0 }
|
||||
), range: 0.1...1.0, step: 0.05, format: "%.2f")
|
||||
sd("Vertical position", value: Binding(
|
||||
get: { settings.miniVisYOffset },
|
||||
set: { settings.miniVisYOffset = $0 }
|
||||
), range: -20...20, step: 1, format: "%+.0f pt")
|
||||
}
|
||||
} header: { Text("LAYOUT & AMPLITUDE") }
|
||||
|
||||
// ── Depth & Idle ──────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ settings:
|
|||
base:
|
||||
SWIFT_VERSION: "5.9"
|
||||
MARKETING_VERSION: "1.0.0"
|
||||
CURRENT_PROJECT_VERSION: "15"
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
DEAD_CODE_STRIPPING: true
|
||||
ENABLE_USER_SCRIPT_SANDBOXING: true
|
||||
DEVELOPMENT_TEAM: E9C9AGS9K6
|
||||
|
|
|
|||
|
|
@ -23,30 +23,11 @@ class WatchSessionManager: NSObject, ObservableObject {
|
|||
var currentTime: TimeInterval
|
||||
var duration: TimeInterval
|
||||
var coverArtId: String?
|
||||
var shuffleEnabled: Bool = false
|
||||
var repeatMode: Int = 0 // 0=off, 1=all, 2=one
|
||||
var volume: Float = 1.0
|
||||
}
|
||||
|
||||
private var session: WCSession?
|
||||
private let client = SubsonicClient()
|
||||
|
||||
/// Parse PhoneNowPlaying from a dictionary (reused by message handler + reply handler)
|
||||
static func parseNowPlaying(from dict: [String: Any]) -> PhoneNowPlaying {
|
||||
PhoneNowPlaying(
|
||||
title: dict["title"] as? String ?? "",
|
||||
artist: dict["artist"] as? String ?? "",
|
||||
album: dict["album"] as? String ?? "",
|
||||
isPlaying: dict["isPlaying"] as? Bool ?? false,
|
||||
currentTime: dict["currentTime"] as? TimeInterval ?? 0,
|
||||
duration: dict["duration"] as? TimeInterval ?? 0,
|
||||
coverArtId: dict["coverArtId"] as? String,
|
||||
shuffleEnabled: dict["shuffleEnabled"] as? Bool ?? false,
|
||||
repeatMode: dict["repeatMode"] as? Int ?? 0,
|
||||
volume: (dict["volume"] as? Double).map { Float($0) } ?? 1.0
|
||||
)
|
||||
}
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
if WCSession.isSupported() {
|
||||
|
|
@ -99,70 +80,19 @@ class WatchSessionManager: NSObject, ObservableObject {
|
|||
}
|
||||
|
||||
func sendPlayCommand() {
|
||||
session?.sendMessage(["type": "playCommand"], replyHandler: { [weak self] reply in
|
||||
if let isPlaying = reply["isPlaying"] as? Bool {
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
self.phoneNowPlaying?.isPlaying = isPlaying
|
||||
}
|
||||
}
|
||||
}, errorHandler: nil)
|
||||
session?.sendMessage(["type": "playCommand"], replyHandler: nil, errorHandler: nil)
|
||||
}
|
||||
|
||||
func sendNextCommand() {
|
||||
session?.sendMessage(["type": "nextCommand"], replyHandler: { _ in }, errorHandler: nil)
|
||||
session?.sendMessage(["type": "nextCommand"], replyHandler: nil, errorHandler: nil)
|
||||
}
|
||||
|
||||
func sendPreviousCommand() {
|
||||
session?.sendMessage(["type": "previousCommand"], replyHandler: { _ in }, errorHandler: nil)
|
||||
}
|
||||
|
||||
func sendSeekCommand(to time: TimeInterval) {
|
||||
session?.sendMessage(["type": "seekCommand", "time": time], replyHandler: { _ in }, errorHandler: nil)
|
||||
}
|
||||
|
||||
func sendShuffleCommand() {
|
||||
session?.sendMessage(["type": "shuffleCommand"], replyHandler: { [weak self] reply in
|
||||
if let enabled = reply["shuffleEnabled"] as? Bool {
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
self.phoneNowPlaying?.shuffleEnabled = enabled
|
||||
}
|
||||
}
|
||||
}, errorHandler: nil)
|
||||
}
|
||||
|
||||
func sendRepeatCommand() {
|
||||
session?.sendMessage(["type": "repeatCommand"], replyHandler: { [weak self] reply in
|
||||
if let mode = reply["repeatMode"] as? Int {
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
self.phoneNowPlaying?.repeatMode = mode
|
||||
}
|
||||
}
|
||||
}, errorHandler: nil)
|
||||
}
|
||||
|
||||
func sendVolumeCommand(_ volume: Float) {
|
||||
session?.sendMessage(["type": "volumeCommand", "volume": volume], replyHandler: { _ in }, errorHandler: nil)
|
||||
}
|
||||
|
||||
/// Ask iPhone for current playback state (used on watch app launch)
|
||||
func requestNowPlaying() {
|
||||
guard let session = session, session.isReachable else { return }
|
||||
session.sendMessage(["type": "requestNowPlaying"], replyHandler: { reply in
|
||||
guard let title = reply["title"] as? String, !title.isEmpty else {
|
||||
DispatchQueue.main.async { self.phoneNowPlaying = nil }
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.phoneNowPlaying = Self.parseNowPlaying(from: reply)
|
||||
}
|
||||
}, errorHandler: { _ in })
|
||||
session?.sendMessage(["type": "previousCommand"], replyHandler: nil, errorHandler: nil)
|
||||
}
|
||||
|
||||
func requestDownload(songId: String) {
|
||||
session?.sendMessage(["type": "requestDownload", "songId": songId], replyHandler: { _ in }, errorHandler: nil)
|
||||
session?.sendMessage(["type": "requestDownload", "songId": songId], replyHandler: nil, errorHandler: nil)
|
||||
}
|
||||
|
||||
// MARK: - Direct API Access (watch can also talk to server directly)
|
||||
|
|
@ -464,12 +394,15 @@ extension WatchSessionManager: WCSessionDelegate {
|
|||
|
||||
if type == "nowPlaying" {
|
||||
DispatchQueue.main.async {
|
||||
// If no title, iPhone stopped playing — clear remote state
|
||||
guard let title = message["title"] as? String, !title.isEmpty else {
|
||||
self.phoneNowPlaying = nil
|
||||
return
|
||||
}
|
||||
self.phoneNowPlaying = Self.parseNowPlaying(from: message)
|
||||
self.phoneNowPlaying = PhoneNowPlaying(
|
||||
title: message["title"] as? String ?? "",
|
||||
artist: message["artist"] as? String ?? "",
|
||||
album: message["album"] as? String ?? "",
|
||||
isPlaying: message["isPlaying"] as? Bool ?? false,
|
||||
currentTime: message["currentTime"] as? TimeInterval ?? 0,
|
||||
duration: message["duration"] as? TimeInterval ?? 0,
|
||||
coverArtId: message["coverArtId"] as? String
|
||||
)
|
||||
}
|
||||
} else if type == "tagsUpdated" {
|
||||
handleTagsUpdated(message)
|
||||
|
|
|
|||
|
|
@ -3,18 +3,18 @@ import AVFoundation
|
|||
import Combine
|
||||
import WatchKit
|
||||
import MediaPlayer
|
||||
import HealthKit
|
||||
|
||||
/// watchOS audio player with dual-mode output:
|
||||
///
|
||||
/// **Bluetooth Mode** (default):
|
||||
/// Uses `.longFormAudio` policy. Requires Bluetooth/AirPlay. Session must be
|
||||
/// activated via `activate(options:completionHandler:)`.
|
||||
/// Uses `.longFormAudio` policy. Requires Bluetooth/AirPlay. Background playback is
|
||||
/// handled by the audio session itself.
|
||||
///
|
||||
/// **Speaker Mode** (Watch Ultra):
|
||||
/// Uses `.default` policy which routes to the built-in speaker. Background
|
||||
/// runtime is provided by the `audio` UIBackgroundModes entitlement — the
|
||||
/// system keeps the app alive while audio is actively playing. No HealthKit
|
||||
/// or HKWorkoutSession required.
|
||||
/// Uses `.default` policy which routes to the built-in speaker. Background runtime is
|
||||
/// maintained by a silent `HKWorkoutSession`. watchOS keeps workout apps alive indefinitely
|
||||
/// even when the screen sleeps. A green workout indicator appears on the watch face.
|
||||
///
|
||||
/// Toggle between modes in the Now Playing route indicator.
|
||||
class WatchAudioPlayer: NSObject, ObservableObject {
|
||||
|
|
@ -61,6 +61,11 @@ class WatchAudioPlayer: NSObject, ObservableObject {
|
|||
private var pendingPlayURL: URL?
|
||||
private var pendingSong: Song?
|
||||
|
||||
// HealthKit workout session for speaker background runtime
|
||||
private let healthStore = HKHealthStore()
|
||||
private var workoutSession: HKWorkoutSession?
|
||||
private var workoutBuilder: HKLiveWorkoutBuilder?
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
useSpeakerMode = UserDefaults.standard.bool(forKey: "watch_speaker_mode")
|
||||
|
|
@ -78,6 +83,7 @@ class WatchAudioPlayer: NSObject, ObservableObject {
|
|||
let hasBuiltIn = route.outputs.contains { $0.portType == .builtInSpeaker }
|
||||
if hasBuiltIn { return true }
|
||||
// Watch Ultra always has a speaker even if not currently routed
|
||||
// Check model name heuristic
|
||||
let model = WKInterfaceDevice.current().model.lowercased()
|
||||
return model.contains("ultra")
|
||||
}
|
||||
|
|
@ -87,14 +93,15 @@ class WatchAudioPlayer: NSObject, ObservableObject {
|
|||
private func configureAudioSession() {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
// Always use .longFormAudio — this is the key to background runtime.
|
||||
// On watchOS 11+, .longFormAudio falls through to the built-in speaker
|
||||
// when no Bluetooth device is connected. The system handles routing:
|
||||
// - Bluetooth connected → routes to Bluetooth
|
||||
// - No Bluetooth → routes to speaker (Watch Ultra)
|
||||
// Activation MUST use activate(options:completionHandler:), not setActive(true).
|
||||
try session.setCategory(.playback, mode: .default, policy: .longFormAudio)
|
||||
print("[WatchAudio] Session: .playback / .longFormAudio")
|
||||
if useSpeakerMode {
|
||||
// Speaker mode: .default policy allows speaker output
|
||||
try session.setCategory(.playback, mode: .default, policy: .default)
|
||||
print("[WatchAudio] Session: .playback / .default (speaker mode)")
|
||||
} else {
|
||||
// Bluetooth mode: .longFormAudio requires BT/AirPlay
|
||||
try session.setCategory(.playback, mode: .default, policy: .longFormAudio)
|
||||
print("[WatchAudio] Session: .playback / .longFormAudio (bluetooth mode)")
|
||||
}
|
||||
updateRouteInfo()
|
||||
} catch {
|
||||
print("[WatchAudio] Session config FAILED: \(error.localizedDescription)")
|
||||
|
|
@ -105,16 +112,77 @@ class WatchAudioPlayer: NSObject, ObservableObject {
|
|||
let wasPlaying = isPlaying
|
||||
if wasPlaying { player?.pause() }
|
||||
configureAudioSession()
|
||||
if useSpeakerMode {
|
||||
startWorkoutSession()
|
||||
} else {
|
||||
stopWorkoutSession()
|
||||
}
|
||||
if wasPlaying { player?.play() }
|
||||
updateRouteInfo()
|
||||
}
|
||||
|
||||
// MARK: - Session Activation
|
||||
// MARK: - HKWorkoutSession (keeps app alive for speaker mode)
|
||||
|
||||
private func startWorkoutSession() {
|
||||
guard workoutSession == nil else { return }
|
||||
guard HKHealthStore.isHealthDataAvailable() else {
|
||||
print("[WatchAudio] HealthKit not available")
|
||||
return
|
||||
}
|
||||
|
||||
let config = HKWorkoutConfiguration()
|
||||
config.activityType = .mindAndBody // Least intrusive workout type
|
||||
config.locationType = .indoor
|
||||
|
||||
do {
|
||||
let session = try HKWorkoutSession(healthStore: healthStore, configuration: config)
|
||||
let builder = session.associatedWorkoutBuilder()
|
||||
builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: config)
|
||||
|
||||
session.delegate = self
|
||||
builder.delegate = self
|
||||
|
||||
session.startActivity(with: Date())
|
||||
builder.beginCollection(withStart: Date()) { success, error in
|
||||
if let error = error {
|
||||
print("[WatchAudio] Workout builder start failed: \(error.localizedDescription)")
|
||||
} else {
|
||||
print("[WatchAudio] Workout session started (speaker background mode)")
|
||||
}
|
||||
}
|
||||
|
||||
workoutSession = session
|
||||
workoutBuilder = builder
|
||||
} catch {
|
||||
print("[WatchAudio] Failed to start workout session: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func stopWorkoutSession() {
|
||||
guard let session = workoutSession else { return }
|
||||
session.end()
|
||||
workoutBuilder?.endCollection(withEnd: Date()) { [weak self] success, error in
|
||||
self?.workoutBuilder?.finishWorkout { workout, error in
|
||||
print("[WatchAudio] Workout session ended")
|
||||
}
|
||||
}
|
||||
workoutSession = nil
|
||||
workoutBuilder = nil
|
||||
}
|
||||
|
||||
// MARK: - Session Activation (Bluetooth mode only)
|
||||
|
||||
/// Activate the audio session. Must be called before playback.
|
||||
/// Uses activate(options:completionHandler:) which is required for .longFormAudio.
|
||||
/// On watchOS 11+, this routes to speaker if no Bluetooth is available.
|
||||
func activateSession() async -> Bool {
|
||||
if useSpeakerMode {
|
||||
// Speaker mode doesn't need activation — just start workout
|
||||
await MainActor.run {
|
||||
startWorkoutSession()
|
||||
isSessionActive = true
|
||||
updateRouteInfo()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
|
||||
let result = await withCheckedContinuation { (continuation: CheckedContinuation<(Bool, String?), Never>) in
|
||||
|
|
@ -161,14 +229,9 @@ class WatchAudioPlayer: NSObject, ObservableObject {
|
|||
self.updateRouteInfo()
|
||||
switch reason {
|
||||
case .oldDeviceUnavailable:
|
||||
// Bluetooth disconnected. If speaker is available (Watch Ultra),
|
||||
// audio will automatically route to speaker — don't pause.
|
||||
// Only pause if no speaker (standard Watch models).
|
||||
if !self.isSpeakerAvailable {
|
||||
print("[WatchAudio] Bluetooth disconnected, no speaker — pausing")
|
||||
if !self.useSpeakerMode {
|
||||
print("[WatchAudio] Bluetooth disconnected — pausing")
|
||||
self.pause()
|
||||
} else {
|
||||
print("[WatchAudio] Bluetooth disconnected — continuing on speaker")
|
||||
}
|
||||
case .newDeviceAvailable:
|
||||
print("[WatchAudio] New route: \(self.currentRouteName)")
|
||||
|
|
@ -229,9 +292,16 @@ class WatchAudioPlayer: NSObject, ObservableObject {
|
|||
return
|
||||
}
|
||||
|
||||
// Activate session before playback. .longFormAudio provides background
|
||||
// runtime. On watchOS 11+, routes to speaker if no BT connected.
|
||||
if isSessionActive && (isBluetoothConnected || useSpeakerMode) {
|
||||
if useSpeakerMode {
|
||||
// Speaker mode: start workout if needed, then play
|
||||
startWorkoutSession()
|
||||
isSessionActive = true
|
||||
playFromURL(playURL)
|
||||
return
|
||||
}
|
||||
|
||||
// Bluetooth mode: activate session if needed
|
||||
if isSessionActive && isBluetoothConnected {
|
||||
playFromURL(playURL)
|
||||
return
|
||||
}
|
||||
|
|
@ -304,6 +374,7 @@ class WatchAudioPlayer: NSObject, ObservableObject {
|
|||
}
|
||||
|
||||
func resume() {
|
||||
if useSpeakerMode { startWorkoutSession() }
|
||||
player?.play()
|
||||
isPlaying = true
|
||||
updateNowPlayingInfo()
|
||||
|
|
@ -358,6 +429,7 @@ class WatchAudioPlayer: NSObject, ObservableObject {
|
|||
levelTimer?.invalidate(); levelTimer = nil
|
||||
audioLevels = Array(repeating: 0, count: 8)
|
||||
if let observer = timeObserver { player?.removeTimeObserver(observer); timeObserver = nil }
|
||||
if useSpeakerMode { stopWorkoutSession() }
|
||||
}
|
||||
|
||||
// MARK: - Now Playing
|
||||
|
|
@ -397,3 +469,28 @@ class WatchAudioPlayer: NSObject, ObservableObject {
|
|||
let s = Int(max(0, seconds)); return String(format: "%d:%02d", s / 60, s % 60)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HKWorkoutSession Delegate
|
||||
|
||||
extension WatchAudioPlayer: HKWorkoutSessionDelegate {
|
||||
func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date) {
|
||||
print("[WatchAudio] Workout state: \(fromState.rawValue) → \(toState.rawValue)")
|
||||
if toState == .ended {
|
||||
DispatchQueue.main.async {
|
||||
self.workoutSession = nil
|
||||
self.workoutBuilder = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {
|
||||
print("[WatchAudio] Workout session failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HKLiveWorkoutBuilder Delegate
|
||||
|
||||
extension WatchAudioPlayer: HKLiveWorkoutBuilderDelegate {
|
||||
func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}
|
||||
func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set<HKSampleType>) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,21 @@
|
|||
<key>WKRunsIndependentlyOfCompanionApp</key>
|
||||
<true/>
|
||||
|
||||
<!-- workout-processing keeps the HKWorkoutSession alive so audio
|
||||
continues playing through the speaker when the screen turns off.
|
||||
'audio' is not a valid WKBackgroundModes value on watchOS. -->
|
||||
<key>WKBackgroundModes</key>
|
||||
<array>
|
||||
<string>workout-processing</string>
|
||||
</array>
|
||||
|
||||
<!-- Required because WatchAudioPlayer uses HKWorkoutSession to
|
||||
maintain background speaker audio. No fitness data is stored. -->
|
||||
<key>NSHealthShareUsageDescription</key>
|
||||
<string>Navidrome uses a workout session to keep audio playing through the speaker when the screen is off. No health or fitness data is read or stored.</string>
|
||||
<key>NSHealthUpdateUsageDescription</key>
|
||||
<string>Navidrome uses a workout session to keep audio playing through the speaker when the screen is off. No health or fitness data is written or stored.</string>
|
||||
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
|
|
|
|||
|
|
@ -6,5 +6,9 @@
|
|||
<array>
|
||||
<string>group.com.navidromeplayer.shared</string>
|
||||
</array>
|
||||
<key>com.apple.developer.healthkit</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.healthkit.access</key>
|
||||
<array/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -6,28 +6,15 @@ struct WatchNowPlayingView: View {
|
|||
|
||||
@State private var crownVolume: Double = 0.8
|
||||
@State private var showRouteInfo = false
|
||||
@FocusState private var isCrownFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let song = audioPlayer.currentSong {
|
||||
localNowPlaying(song)
|
||||
} else if let phone = watchManager.phoneNowPlaying {
|
||||
remoteNowPlaying(phone)
|
||||
} else {
|
||||
emptyState
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
// If nothing is playing locally, ask iPhone for its current state
|
||||
if audioPlayer.currentSong == nil {
|
||||
watchManager.requestNowPlaying()
|
||||
}
|
||||
}
|
||||
.onChange(of: watchManager.isPhoneReachable) { _, reachable in
|
||||
// iPhone reconnected — refresh state
|
||||
if reachable && audioPlayer.currentSong == nil {
|
||||
watchManager.requestNowPlaying()
|
||||
}
|
||||
if let song = audioPlayer.currentSong {
|
||||
localNowPlaying(song)
|
||||
} else if let phone = watchManager.phoneNowPlaying {
|
||||
remoteNowPlaying(phone)
|
||||
} else {
|
||||
emptyState
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -52,24 +39,18 @@ struct WatchNowPlayingView: View {
|
|||
}
|
||||
.padding(.top, 2)
|
||||
|
||||
// Progress bar (tap to seek)
|
||||
// Progress bar
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(Color.white.opacity(0.2))
|
||||
.frame(height: 4)
|
||||
.frame(height: 3)
|
||||
Capsule()
|
||||
.fill(Color.pink)
|
||||
.frame(width: geo.size.width * audioPlayer.progressPercent, height: 4)
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { location in
|
||||
let pct = max(0, min(location.x / geo.size.width, 1))
|
||||
audioPlayer.seek(to: pct * audioPlayer.duration)
|
||||
.frame(width: geo.size.width * audioPlayer.progressPercent, height: 3)
|
||||
}
|
||||
}
|
||||
.frame(height: 20)
|
||||
.frame(height: 3)
|
||||
.padding(.horizontal, 8)
|
||||
|
||||
// Time labels
|
||||
|
|
@ -118,60 +99,58 @@ struct WatchNowPlayingView: View {
|
|||
Spacer()
|
||||
}
|
||||
|
||||
// Shuffle / Repeat
|
||||
HStack(spacing: 20) {
|
||||
// Bottom row
|
||||
HStack(spacing: 14) {
|
||||
Button(action: { audioPlayer.toggleShuffle() }) {
|
||||
Image(systemName: "shuffle")
|
||||
.font(.system(size: 13))
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(audioPlayer.shuffleEnabled ? .pink : .gray)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button(action: { audioPlayer.cycleRepeat() }) {
|
||||
Image(systemName: audioPlayer.repeatMode == .one ? "repeat.1" : "repeat")
|
||||
.font(.system(size: 13))
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(audioPlayer.repeatMode != .off ? .pink : .gray)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// Volume controls — dedicated row with large tap targets
|
||||
HStack(spacing: 0) {
|
||||
Button(action: {
|
||||
let newVol = max(0, audioPlayer.volume - 0.1)
|
||||
audioPlayer.setVolume(newVol)
|
||||
}) {
|
||||
Image(systemName: "speaker.minus.fill")
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity, minHeight: 36)
|
||||
.background(Color.white.opacity(0.08))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// Volume level indicator
|
||||
Text("\(Int(audioPlayer.volume * 100))%")
|
||||
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(.gray)
|
||||
.frame(width: 44)
|
||||
|
||||
Button(action: {
|
||||
let newVol = min(1, audioPlayer.volume + 0.1)
|
||||
audioPlayer.setVolume(newVol)
|
||||
}) {
|
||||
Image(systemName: "speaker.plus.fill")
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity, minHeight: 36)
|
||||
.background(Color.white.opacity(0.08))
|
||||
.cornerRadius(10)
|
||||
// Volume
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: "speaker.fill")
|
||||
.font(.system(size: 7))
|
||||
.foregroundColor(.gray)
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Color.white.opacity(0.15)).frame(height: 2)
|
||||
Capsule().fill(Color.white.opacity(0.6))
|
||||
.frame(width: geo.size.width * CGFloat(audioPlayer.volume), height: 2)
|
||||
}
|
||||
}
|
||||
.frame(height: 8)
|
||||
Image(systemName: "speaker.wave.2.fill")
|
||||
.font(.system(size: 7))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 60)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
.focused($isCrownFocused)
|
||||
.digitalCrownRotation(
|
||||
$crownVolume,
|
||||
from: 0, through: 1, by: 0.05,
|
||||
sensitivity: .medium,
|
||||
isContinuous: false,
|
||||
isHapticFeedbackEnabled: true
|
||||
)
|
||||
.onChange(of: crownVolume) { _, newValue in
|
||||
audioPlayer.setVolume(Float(newValue))
|
||||
}
|
||||
.onAppear {
|
||||
crownVolume = Double(audioPlayer.volume)
|
||||
isCrownFocused = true
|
||||
}
|
||||
.sheet(isPresented: $showRouteInfo) {
|
||||
audioRouteSheet
|
||||
}
|
||||
|
|
@ -302,193 +281,59 @@ struct WatchNowPlayingView: View {
|
|||
// MARK: - Remote Control (iPhone is playing)
|
||||
|
||||
private func remoteNowPlaying(_ nowPlaying: WatchSessionManager.PhoneNowPlaying) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
// "Playing on iPhone" badge
|
||||
VStack(spacing: 8) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "iphone")
|
||||
.font(.system(size: 9))
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.blue)
|
||||
Text("iPhone")
|
||||
.font(.system(size: 9, weight: .medium))
|
||||
Text("Playing on iPhone")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.background(Capsule().fill(Color.blue.opacity(0.15)))
|
||||
|
||||
// Cover art + song info
|
||||
HStack(spacing: 10) {
|
||||
if let coverArtId = nowPlaying.coverArtId,
|
||||
let url = watchManager.coverArtURL(id: coverArtId, size: 80) {
|
||||
AsyncImage(url: url) { image in
|
||||
image.resizable().aspectRatio(contentMode: .fill)
|
||||
} placeholder: {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color.white.opacity(0.1))
|
||||
.overlay(
|
||||
Image(systemName: "music.note")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.gray)
|
||||
)
|
||||
}
|
||||
.frame(width: 40, height: 40)
|
||||
.cornerRadius(6)
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color.white.opacity(0.1))
|
||||
.frame(width: 40, height: 40)
|
||||
.overlay(
|
||||
Image(systemName: "music.note")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.gray)
|
||||
)
|
||||
}
|
||||
VStack(spacing: 2) {
|
||||
Text(nowPlaying.title)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(nowPlaying.title)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
Text(nowPlaying.artist)
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.pink)
|
||||
.lineLimit(1)
|
||||
Text(nowPlaying.album)
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.gray)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
Text(nowPlaying.artist)
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.pink)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
|
||||
// Progress bar (tap to seek)
|
||||
GeometryReader { geo in
|
||||
let pct = nowPlaying.duration > 0 ? nowPlaying.currentTime / nowPlaying.duration : 0
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Color.white.opacity(0.2)).frame(height: 4)
|
||||
Capsule().fill(Color.white.opacity(0.2)).frame(height: 3)
|
||||
Capsule().fill(Color.pink)
|
||||
.frame(width: geo.size.width * pct, height: 4)
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { location in
|
||||
let seekPct = max(0, min(location.x / geo.size.width, 1))
|
||||
let seekTime = nowPlaying.duration * seekPct
|
||||
watchManager.sendSeekCommand(to: seekTime)
|
||||
.frame(width: geo.size.width * pct, height: 3)
|
||||
}
|
||||
}
|
||||
.frame(height: 20)
|
||||
.frame(height: 3)
|
||||
.padding(.horizontal, 8)
|
||||
|
||||
// Time labels
|
||||
HStack {
|
||||
Text(formatTime(nowPlaying.currentTime))
|
||||
.font(.system(size: 9))
|
||||
.foregroundColor(.gray)
|
||||
Spacer()
|
||||
Text("-\(formatTime(max(0, nowPlaying.duration - nowPlaying.currentTime)))")
|
||||
.font(.system(size: 9))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
|
||||
// Transport controls
|
||||
HStack(spacing: 0) {
|
||||
Spacer()
|
||||
HStack(spacing: 20) {
|
||||
Button(action: { watchManager.sendPreviousCommand() }) {
|
||||
Image(systemName: "backward.fill")
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 40, height: 36)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: { watchManager.sendPlayCommand() }) {
|
||||
Image(systemName: nowPlaying.isPlaying ? "pause.fill" : "play.fill")
|
||||
.font(.system(size: 24))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 50, height: 44)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: { watchManager.sendNextCommand() }) {
|
||||
Image(systemName: "forward.fill")
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 40, height: 36)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
// Shuffle / Repeat
|
||||
HStack(spacing: 20) {
|
||||
Button(action: { watchManager.sendShuffleCommand() }) {
|
||||
Image(systemName: "shuffle")
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(nowPlaying.shuffleEnabled ? .pink : .gray)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button(action: { watchManager.sendRepeatCommand() }) {
|
||||
Image(systemName: nowPlaying.repeatMode == 2 ? "repeat.1" : "repeat")
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(nowPlaying.repeatMode != 0 ? .pink : .gray)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// Volume controls — dedicated row with large tap targets
|
||||
HStack(spacing: 0) {
|
||||
Button(action: {
|
||||
crownVolume = max(0, crownVolume - 0.1)
|
||||
watchManager.sendVolumeCommand(Float(crownVolume))
|
||||
}) {
|
||||
Image(systemName: "speaker.minus.fill")
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity, minHeight: 36)
|
||||
.background(Color.white.opacity(0.08))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Text("\(Int(crownVolume * 100))%")
|
||||
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(.gray)
|
||||
.frame(width: 44)
|
||||
|
||||
Button(action: {
|
||||
crownVolume = min(1, crownVolume + 0.1)
|
||||
watchManager.sendVolumeCommand(Float(crownVolume))
|
||||
}) {
|
||||
Image(systemName: "speaker.plus.fill")
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity, minHeight: 36)
|
||||
.background(Color.white.opacity(0.08))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
.onAppear {
|
||||
crownVolume = Double(nowPlaying.volume)
|
||||
}
|
||||
}
|
||||
|
||||
private func formatTime(_ seconds: TimeInterval) -> String {
|
||||
let m = Int(seconds) / 60
|
||||
let s = Int(seconds) % 60
|
||||
return String(format: "%d:%02d", m, s)
|
||||
.padding()
|
||||
}
|
||||
|
||||
// MARK: - Empty State
|
||||
|
|
@ -502,19 +347,7 @@ struct WatchNowPlayingView: View {
|
|||
.font(.caption)
|
||||
.foregroundColor(.gray)
|
||||
|
||||
if watchManager.isPhoneReachable {
|
||||
Text("Play music on your iPhone to control it here")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.gray.opacity(0.7))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 16)
|
||||
} else {
|
||||
Text("iPhone not connected")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.orange.opacity(0.8))
|
||||
}
|
||||
|
||||
// Show connect button if no Bluetooth (for local watch playback)
|
||||
// Show connect button if no Bluetooth
|
||||
if !audioPlayer.isBluetoothConnected {
|
||||
Button(action: {
|
||||
Task { await audioPlayer.activateSession() }
|
||||
|
|
|
|||
Loading…
Reference in a new issue