Merge remote-tracking branch 'refs/remotes/origin/main'
This commit is contained in:
commit
8d1d30c5e5
7 changed files with 165 additions and 55 deletions
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue