178 lines
6.6 KiB
Swift
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)
|
|
}
|
|
}
|