NavidromeApp/iOS/Data/WidgetBridge.swift
Dallas Groot 3c28413af8 bug fixes and improvements
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.
2026-04-12 16:16:32 -07:00

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