Merge remote-tracking branch 'refs/remotes/origin/main'

This commit is contained in:
Dallas Groot 2026-04-12 16:17:40 -07:00
commit 8d1d30c5e5
7 changed files with 165 additions and 55 deletions

View file

@ -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
)

View file

@ -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),

View file

@ -409,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
@ -422,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 {

View file

@ -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()
}
}

View file

@ -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

View file

@ -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")

View file

@ -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
}
}