NavidromeApp/iOS/Data/WidgetBridge.swift
2026-04-12 12:57:42 -07:00

178 lines
6.6 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
private func handleWidgetCommand() {
guard let cmd = state.dequeueCommand() else { return }
let player = AudioPlayer.shared
switch cmd {
case .play:
player.resume()
case .pause:
player.pause()
case .next:
player.next()
case .previous:
player.previous()
case .seekForward15:
player.seek(to: min(player.currentTime + 15, player.duration))
case .seekBackward15:
player.seek(to: max(player.currentTime - 15, 0))
}
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)
}
}