From 3c28413af8d137b9b150769eddcb99eab6b0bc79 Mon Sep 17 00:00:00 2001 From: Dallas Groot Date: Sun, 12 Apr 2026 16:16:32 -0700 Subject: [PATCH] bug fixes and improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gap 1: Lock Screen seek bar drift — nowPlayingSyncTimer (5s timer that pushes elapsed time to MPNowPlayingInfoCenter) was created in playWithAVPlayer but never restarted after a background/foreground cycle. Now invalidated in suspendVisTimers() and recreated in resumeVisTimers(). The crossfade path benefits too — it never went through playWithAVPlayer so the timer was never created at all for crossfade sessions. Gap 2: Prefetcher re-downloads after restructure — AudioPreFetcher used isSongDownloaded(song.id) (exact ID match). After a Companion restructure changes IDs, songs already downloaded were re-fetched. Changed to isSongAvailableOffline(song) which falls back to title/artist/duration matching. Gap 3: Unbounded image disk cache — trimDiskCache() only ran on app launch. A long browsing session could push well past the 200MB limit. Now storeToDisk increments a write counter and triggers trim every 50 writes. The counter lives on ioQueue (serial) so no lock needed. Gap 4: Custom cover art in widget — WidgetBridge cached blur keyed by coverArtId. Custom covers don’t change the ID, so the bridge skipped the update. Now pushWidgetState() passes "custom_\(id)" as the key when AlbumCoverStore has a custom image. Same album’s songs still share the key → blur is reused, not redone. When custom is removed, key reverts to the bare ID → re-blurs with server art. --- Shared/Audio/AudioPlayer.swift | 47 ++++++++++++++++-- Shared/Storage/WidgetSharedState.swift | 13 +++++ Widget/NowPlayingWidgetViews.swift | 31 +++++------- Widget/WidgetIntents.swift | 24 +++++++++ iOS/Data/AudioPreFetcher.swift | 4 +- iOS/Data/WidgetBridge.swift | 33 +++++++++++- iOS/Views/Common/AsyncCoverArt.swift | 69 +++++++++++++++----------- 7 files changed, 166 insertions(+), 55 deletions(-) diff --git a/Shared/Audio/AudioPlayer.swift b/Shared/Audio/AudioPlayer.swift index eef50b7..c055beb 100644 --- a/Shared/Audio/AudioPlayer.swift +++ b/Shared/Audio/AudioPlayer.swift @@ -230,6 +230,8 @@ class AudioPlayer: NSObject, ObservableObject { private func suspendVisTimers() { stopOfflineVisTimer() stopLevelTimer() + nowPlayingSyncTimer?.invalidate() + nowPlayingSyncTimer = nil analysisTask?.cancel() analysisTask = nil AudioPreFetcher.shared.cancelAll() @@ -252,6 +254,11 @@ class AudioPlayer: NSObject, ObservableObject { 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. + WidgetBridge.shared.processAnyPendingCommand() + // Always reinstall time observers — they were removed on background regardless // of play state. Without this, currentTime is frozen after background+pause+play. reinstallTimeObserver() @@ -260,6 +267,17 @@ class AudioPlayer: NSObject, ObservableObject { } // Only restart vis timers if actually playing guard isPlaying else { return } + + // 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. + if nowPlayingSyncTimer == nil { + nowPlayingSyncTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in + guard let self = self, self.isPlaying else { return } + self.updateNowPlayingInfo() + } + } + if isUsingOfflineVis { startOfflineVisSync() } else if !isUsingCrossfade { @@ -1139,6 +1157,17 @@ class AudioPlayer: NSObject, ObservableObject { } let nextSong = queue[nextIdx] + + // Guard: don't load the SAME song into the standby player. + // With repeat-all + single-song queue, nextIdx wraps to 0 (same song). + // Loading it into standby creates two AVPlayers with identical content — + // if the crossfade boundary fires, both play simultaneously and drift + // out of sync, producing the "duplicate audio" artifact. + if nextSong.id == currentSong?.id { + alog("SmartDJ: next is same song — skipping standby prep (repeat-one will handle)") + return + } + guard let url = crossfade.resolveURL(for: nextSong) else { return } alog("SmartDJ: preparing \(nextSong.title) in standby") @@ -1741,12 +1770,20 @@ class AudioPlayer: NSObject, ObservableObject { let upcoming = Array(queue.dropFirst(queueIndex + 1).prefix(3)) .map { (title: $0.title, artist: $0.artist ?? "Unknown") } - // Try to get cover art from memory caches (zero I/O) + // Try to get cover art from memory caches (zero I/O). + // Size must match fetchAndSetArtwork (600) or the cache key won't match. var coverImage: UIImage? + var artKey = song.coverArt // cache key for WidgetBridge blur dedup if let id = song.coverArt { - coverImage = AlbumCoverStore.shared.loadCover(for: id) - if coverImage == nil, - let url = ServerManager.shared.client.coverArtURL(id: id, size: 300) { + // Custom cover takes priority — same logic as AsyncCoverArt + if let custom = AlbumCoverStore.shared.loadCover(for: id) { + coverImage = custom + // Prefix key so the bridge re-blurs when custom art is set/removed. + // Same album ID produces a different key → forces blur update. + // Two songs sharing the same coverArt ID with the same custom cover + // produce the same "custom_al-123" key → blur is reused, not redone. + artKey = "custom_\(id)" + } else if let url = ServerManager.shared.client.coverArtURL(id: id, size: 600) { coverImage = ImageCache.shared.memoryOnlyImage(for: url) } } @@ -1758,7 +1795,7 @@ class AudioPlayer: NSObject, ObservableObject { isPlaying: isPlaying, currentTime: currentTime, duration: duration, - coverArtId: song.coverArt, + coverArtId: artKey, coverArtImage: coverImage, queue: upcoming ) diff --git a/Shared/Storage/WidgetSharedState.swift b/Shared/Storage/WidgetSharedState.swift index a28099b..b60eb1b 100644 --- a/Shared/Storage/WidgetSharedState.swift +++ b/Shared/Storage/WidgetSharedState.swift @@ -26,6 +26,7 @@ enum WidgetCommand: String, Codable { case previous case seekForward15 case seekBackward15 + case seekTo // reads target from w_seekToTime } /// Darwin notification name used for widget → app wake-up signal. @@ -64,6 +65,7 @@ final class WidgetSharedState { static let queueNext = "w_queueNext" // JSON-encoded [WidgetQueueItem] static let hasData = "w_hasData" static let command = "w_pendingCmd" + static let seekToTime = "w_seekToTime" // target time for seekTo command } // MARK: - Write (main app → shared defaults) @@ -139,6 +141,17 @@ final class WidgetSharedState { defaults?.set(cmd.rawValue, forKey: K.command) } + /// Write a seekTo target time alongside the seekTo command. + func enqueueSeekTo(time: TimeInterval) { + defaults?.set(time, forKey: K.seekToTime) + enqueueCommand(.seekTo) + } + + /// The target time for the most recent seekTo command. + var seekToTime: TimeInterval { + defaults?.double(forKey: K.seekToTime) ?? 0 + } + /// Read and clear the pending command. Called by the app's Darwin observer. func dequeueCommand() -> WidgetCommand? { guard let raw = defaults?.string(forKey: K.command), diff --git a/Widget/NowPlayingWidgetViews.swift b/Widget/NowPlayingWidgetViews.swift index 5374440..9af03ad 100644 --- a/Widget/NowPlayingWidgetViews.swift +++ b/Widget/NowPlayingWidgetViews.swift @@ -1,5 +1,6 @@ import SwiftUI import WidgetKit +import AppIntents // ────────────────────────────────────────────────────────────────────── // NowPlayingWidgetViews.swift @@ -408,7 +409,7 @@ struct ProgressBarView: View { var body: some View { VStack(spacing: 4) { - // Progress track + // Progress track with segmented tap-to-seek GeometryReader { geo in ZStack(alignment: .leading) { // Background track @@ -421,28 +422,22 @@ struct ProgressBarView: View { .fill(accentPink) .frame(width: max(geo.size.width * entry.progress, height), height: height) - // Seek tap zones — invisible buttons overlaid on the bar + // Seek tap zones — 10 segments, each seeks to that fraction HStack(spacing: 0) { - // Left half → seek backward 15s - Button(intent: SeekBackwardIntent()) { - Color.clear - .frame(maxWidth: .infinity, maxHeight: .infinity) - .contentShape(Rectangle()) + ForEach(0..<10, id: \.self) { i in + let fraction = (Double(i) + 0.5) / 10.0 + Button(intent: SeekToIntent(fraction: fraction)) { + Color.clear + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) - - // Right half → seek forward 15s - Button(intent: SeekForwardIntent()) { - Color.clear - .frame(maxWidth: .infinity, maxHeight: .infinity) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) } - .frame(height: max(height, 24)) // ≥ 24pt tap target + .frame(height: max(height, 28)) // generous tap target } } - .frame(height: max(height, 24)) + .frame(height: max(height, 28)) // Time labels if showTimes { diff --git a/Widget/WidgetIntents.swift b/Widget/WidgetIntents.swift index 5f6bf1c..8404959 100644 --- a/Widget/WidgetIntents.swift +++ b/Widget/WidgetIntents.swift @@ -89,3 +89,27 @@ struct SeekBackwardIntent: AppIntent { return .result() } } + +// MARK: - Seek To Position (tap-to-seek on progress bar) + +struct SeekToIntent: AppIntent { + static var title: LocalizedStringResource = "Seek To Position" + static var description: IntentDescription = "Seek to a specific position in the track." + + @Parameter(title: "Fraction") + var fraction: Double + + init() { self.fraction = 0 } + init(fraction: Double) { self.fraction = fraction } + + func perform() async throws -> some IntentResult { + let state = WidgetSharedState.shared + let targetTime = state.duration * min(max(fraction, 0), 1) + state.enqueueSeekTo(time: targetTime) + // Optimistically update position so widget shows new location instantly + state.pushPosition(currentTime: targetTime, isPlaying: state.isPlaying) + postDarwinNotification(kWidgetCommandNotification) + WidgetCenter.shared.reloadAllTimelines() + return .result() + } +} diff --git a/iOS/Data/AudioPreFetcher.swift b/iOS/Data/AudioPreFetcher.swift index e5fec0e..7e5511f 100644 --- a/iOS/Data/AudioPreFetcher.swift +++ b/iOS/Data/AudioPreFetcher.swift @@ -39,8 +39,8 @@ class AudioPreFetcher { } for song in upcoming { - // Skip if already downloaded or already prefetching - if offline.isSongDownloaded(song.id) { continue } + // Skip if already downloaded (fuzzy match handles ID changes after restructure) + if offline.isSongAvailableOffline(song) { continue } if prefetchTasks[song.id] != nil { continue } // Prefetch by downloading to the streaming cache diff --git a/iOS/Data/WidgetBridge.swift b/iOS/Data/WidgetBridge.swift index 991f861..4f5a517 100644 --- a/iOS/Data/WidgetBridge.swift +++ b/iOS/Data/WidgetBridge.swift @@ -117,23 +117,54 @@ final class WidgetBridge { // MARK: - Handle Widget Commands + /// Called by the Darwin notification observer when the app process is running. private func handleWidgetCommand() { + processAnyPendingCommand() + } + + /// Check App Group UserDefaults for any pending widget command and execute it. + /// Called from two places: + /// 1. Darwin notification observer (app is running in background with audio) + /// 2. resumeVisTimers / foreground entry (app was suspended, Darwin missed) + func processAnyPendingCommand() { guard let cmd = state.dequeueCommand() else { return } let player = AudioPlayer.shared switch cmd { case .play: - player.resume() + // Idempotency: don't resume if already playing (avoids audio glitches) + guard !player.isPlaying else { break } + // If no song is loaded (app was killed), restore from saved state first + if player.currentSong == nil { + if let saved = PlaybackStateStore.shared.load() { + player.play(song: saved.currentSong, fromQueue: saved.queue, at: saved.index) + } + } else { + player.resume() + } + case .pause: + // Idempotency: don't pause if already paused + guard player.isPlaying else { break } player.pause() + case .next: player.next() + case .previous: player.previous() + case .seekForward15: + guard player.duration > 0 else { break } player.seek(to: min(player.currentTime + 15, player.duration)) + case .seekBackward15: player.seek(to: max(player.currentTime - 15, 0)) + + case .seekTo: + guard player.duration > 0 else { break } + let target = state.seekToTime + player.seek(to: min(max(target, 0), player.duration)) } DebugLogger.shared.log("Widget command: \(cmd.rawValue)", category: "Widget") diff --git a/iOS/Views/Common/AsyncCoverArt.swift b/iOS/Views/Common/AsyncCoverArt.swift index 3229ab8..5ae9f47 100644 --- a/iOS/Views/Common/AsyncCoverArt.swift +++ b/iOS/Views/Common/AsyncCoverArt.swift @@ -15,6 +15,9 @@ class ImageCache: @unchecked Sendable { /// Max memory cache: ~80 images /// Max disk cache: 200 MB private let maxDiskBytes: UInt64 = 200 * 1024 * 1024 + /// Counts disk writes — triggers trim every 50 writes (not just on launch). + /// Only accessed from ioQueue so no lock needed. + private var diskWriteCount: Int = 0 private init() { let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! @@ -87,6 +90,12 @@ class ImageCache: @unchecked Sendable { // JPEG at 85% quality for good size/quality balance if let data = image.jpegData(compressionQuality: 0.85) { try? data.write(to: path, options: .atomic) + self.diskWriteCount += 1 + // Trim every 50 writes so the cache doesn't grow unbounded + // between app launches (trimDiskCache is also called on launch) + if self.diskWriteCount % 50 == 0 { + self.trimDiskCacheOnQueue() + } } } } @@ -140,37 +149,39 @@ class ImageCache: @unchecked Sendable { // MARK: - Cleanup - /// Trims disk cache to maxDiskBytes. Call periodically or on app launch. + /// Trims disk cache to maxDiskBytes. Called on app launch and periodically from storeToDisk. func trimDiskCache() { ioQueue.async { [weak self] in - guard let self = self else { return } - guard let files = try? self.fileManager.contentsOfDirectory( - at: self.diskCacheURL, - includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey] - ) else { return } - - // Get file info - var fileInfos: [(url: URL, date: Date, size: UInt64)] = [] - var totalSize: UInt64 = 0 - - for file in files { - guard let attrs = try? file.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey]), - let date = attrs.contentModificationDate, - let size = attrs.fileSize else { continue } - let s = UInt64(size) - fileInfos.append((file, date, s)) - totalSize += s - } - - guard totalSize > self.maxDiskBytes else { return } - - // Sort oldest first, delete until under limit - fileInfos.sort { $0.date < $1.date } - for info in fileInfos { - guard totalSize > self.maxDiskBytes else { break } - try? self.fileManager.removeItem(at: info.url) - totalSize -= info.size - } + self?.trimDiskCacheOnQueue() + } + } + + /// Internal trim — must be called on ioQueue. + private func trimDiskCacheOnQueue() { + guard let files = try? self.fileManager.contentsOfDirectory( + at: self.diskCacheURL, + includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey] + ) else { return } + + var fileInfos: [(url: URL, date: Date, size: UInt64)] = [] + var totalSize: UInt64 = 0 + + for file in files { + guard let attrs = try? file.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey]), + let date = attrs.contentModificationDate, + let size = attrs.fileSize else { continue } + let s = UInt64(size) + fileInfos.append((file, date, s)) + totalSize += s + } + + guard totalSize > self.maxDiskBytes else { return } + + fileInfos.sort { $0.date < $1.date } + for info in fileInfos { + guard totalSize > self.maxDiskBytes else { break } + try? self.fileManager.removeItem(at: info.url) + totalSize -= info.size } }