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.
209 lines
7.8 KiB
Swift
209 lines
7.8 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import CoreImage
|
|
import CoreImage.CIFilterBuiltins
|
|
import WidgetKit
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// WidgetBridge.swift
|
|
// TARGET: Main app only (NOT the widget extension).
|
|
//
|
|
// Responsibilities:
|
|
// 1. Pre-blurs album art for the widget background (CIGaussianBlur)
|
|
// 2. Pushes playback state to App Group on every change
|
|
// 3. Observes Darwin notifications for widget → app commands
|
|
// 4. Reloads widget timelines when state changes
|
|
//
|
|
// Integration: call WidgetBridge.shared.start() once from app launch,
|
|
// then call updateNowPlaying(...) from AudioPlayer on every state change.
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
final class WidgetBridge {
|
|
|
|
static let shared = WidgetBridge()
|
|
|
|
private let state = WidgetSharedState.shared
|
|
private let blurContext = CIContext(options: [.useSoftwareRenderer: false])
|
|
private var isObserving = false
|
|
|
|
/// Cached blurred art to avoid re-blurring on every time-observer tick.
|
|
/// Reset when the cover art ID changes.
|
|
private var cachedBlurCoverArtId: String?
|
|
private var cachedBlurData: Data?
|
|
private var cachedArtData: Data?
|
|
|
|
private init() {}
|
|
|
|
// MARK: - Lifecycle
|
|
|
|
/// Call once from app launch (e.g. NavidromePlayerApp.init or AppDelegate).
|
|
/// Sets up the Darwin notification observer for widget commands.
|
|
func start() {
|
|
guard !isObserving else { return }
|
|
isObserving = true
|
|
|
|
let center = CFNotificationCenterGetDarwinNotifyCenter()
|
|
let observer = Unmanaged.passUnretained(self).toOpaque()
|
|
|
|
CFNotificationCenterAddObserver(
|
|
center,
|
|
observer,
|
|
{ _, observer, _, _, _ in
|
|
guard let observer else { return }
|
|
let bridge = Unmanaged<WidgetBridge>.fromOpaque(observer).takeUnretainedValue()
|
|
DispatchQueue.main.async { bridge.handleWidgetCommand() }
|
|
},
|
|
kWidgetCommandNotification,
|
|
nil,
|
|
.deliverImmediately
|
|
)
|
|
}
|
|
|
|
// MARK: - Push State to Widget
|
|
|
|
/// Full state push — call on song change, play/pause, queue edit.
|
|
/// Blurs the album art if the cover changed; skips blur if same art.
|
|
func updateNowPlaying(
|
|
title: String,
|
|
artist: String,
|
|
album: String,
|
|
isPlaying: Bool,
|
|
currentTime: TimeInterval,
|
|
duration: TimeInterval,
|
|
coverArtId: String?,
|
|
coverArtImage: UIImage?,
|
|
queue: [(title: String, artist: String)]
|
|
) {
|
|
// Only re-blur if the cover art actually changed
|
|
if let id = coverArtId, id != cachedBlurCoverArtId, let image = coverArtImage {
|
|
cachedBlurCoverArtId = id
|
|
cachedArtData = image.jpegData(compressionQuality: 0.7)
|
|
// Blur on a background queue — JPEG encode is ~2ms, blur is ~5ms
|
|
let blurred = blurImage(image, radius: 40)
|
|
cachedBlurData = blurred?.jpegData(compressionQuality: 0.5)
|
|
}
|
|
|
|
let queueItems = queue.prefix(3).map { WidgetQueueItem(title: $0.title, artist: $0.artist) }
|
|
|
|
state.pushState(
|
|
title: title,
|
|
artist: artist,
|
|
album: album,
|
|
isPlaying: isPlaying,
|
|
currentTime: currentTime,
|
|
duration: duration,
|
|
coverArtJPEG: cachedArtData,
|
|
blurredArtJPEG: cachedBlurData,
|
|
queueNext: queueItems
|
|
)
|
|
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
}
|
|
|
|
/// Lightweight position-only push — call from the time observer (~every 5s).
|
|
/// Does NOT reload timelines (the timeline entries already project forward).
|
|
func updatePosition(currentTime: TimeInterval, isPlaying: Bool) {
|
|
state.pushPosition(currentTime: currentTime, isPlaying: isPlaying)
|
|
}
|
|
|
|
/// Clear widget state (e.g. on logout or server change).
|
|
func clear() {
|
|
cachedBlurCoverArtId = nil
|
|
cachedBlurData = nil
|
|
cachedArtData = nil
|
|
state.clearAll()
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
}
|
|
|
|
// 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:
|
|
// 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")
|
|
}
|
|
|
|
// MARK: - Gaussian Blur
|
|
|
|
/// Applies a heavy gaussian blur to the image for the widget background.
|
|
/// Uses CoreImage on the GPU — fast even for large images.
|
|
private func blurImage(_ image: UIImage, radius: CGFloat) -> UIImage? {
|
|
// Downscale to ~200px wide first — no need for full-res blur
|
|
let maxDim: CGFloat = 200
|
|
let scale = min(maxDim / image.size.width, maxDim / image.size.height, 1.0)
|
|
let targetSize = CGSize(
|
|
width: image.size.width * scale,
|
|
height: image.size.height * scale
|
|
)
|
|
|
|
UIGraphicsBeginImageContextWithOptions(targetSize, true, 1.0)
|
|
image.draw(in: CGRect(origin: .zero, size: targetSize))
|
|
let downscaled = UIGraphicsGetImageFromCurrentImageContext()
|
|
UIGraphicsEndImageContext()
|
|
|
|
guard let cgImage = (downscaled ?? image).cgImage else { return nil }
|
|
let ciImage = CIImage(cgImage: cgImage)
|
|
|
|
// Clamp edges to prevent dark border artifacts from the blur kernel
|
|
let clamped = ciImage.clampedToExtent()
|
|
|
|
guard let blurFilter = CIFilter(name: "CIGaussianBlur") else { return nil }
|
|
blurFilter.setValue(clamped, forKey: kCIInputImageKey)
|
|
blurFilter.setValue(radius, forKey: kCIInputRadiusKey)
|
|
|
|
guard let output = blurFilter.outputImage else { return nil }
|
|
|
|
// Crop back to original extent (blur expands the image)
|
|
let cropped = output.cropped(to: ciImage.extent)
|
|
|
|
guard let cgResult = blurContext.createCGImage(cropped, from: ciImage.extent) else { return nil }
|
|
return UIImage(cgImage: cgResult)
|
|
}
|
|
}
|