Metadata/art/colors delayed — currentSong, artwork, widget colors, and lyrics only updated after finalizeCrossfade. Now a songHandoff callback fires at the crossfade midpoint (50%) — updates everything mid-fade so the UI transitions with the audio. Scrobble of the outgoing song also fires here (before currentSong changes). Visualizer stale data — Old song’s vis frames stayed in the buffer during the first half of crossfade. Now visualizerHandoff zeros offlineVisBuffer + _audioLevels before loading the new song’s data. Clean slate → simulation fills the gap → real vis takes over. The needsNextTrack callback (post-finalize) now only handles player swap, observer re-registration, and queue persistence — no redundant metadata/scrobble work.
302 lines
12 KiB
Swift
302 lines
12 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 var cachedAccentHex: String?
|
||
private var cachedSecondaryHex: String?
|
||
|
||
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, coverData: Data?)],
|
||
waveformSamples: [Float]? = nil,
|
||
crossfadeAt: TimeInterval? = nil
|
||
) {
|
||
// Only re-process if the cover art actually changed
|
||
if let id = coverArtId, id != cachedBlurCoverArtId, let image = coverArtImage {
|
||
cachedBlurCoverArtId = id
|
||
cachedArtData = image.jpegData(compressionQuality: 0.7)
|
||
let blurred = blurImage(image, radius: 60)
|
||
cachedBlurData = blurred?.jpegData(compressionQuality: 0.5)
|
||
|
||
// Extract dominant color for adaptive theming
|
||
let (accent, secondary) = extractColors(from: image)
|
||
cachedAccentHex = accent
|
||
cachedSecondaryHex = secondary
|
||
}
|
||
|
||
let queueItems = queue.prefix(3).map {
|
||
WidgetQueueItem(title: $0.title, artist: $0.artist, coverArtData: $0.coverData)
|
||
}
|
||
|
||
state.pushState(
|
||
title: title,
|
||
artist: artist,
|
||
album: album,
|
||
isPlaying: isPlaying,
|
||
currentTime: currentTime,
|
||
duration: duration,
|
||
coverArtJPEG: cachedArtData,
|
||
blurredArtJPEG: cachedBlurData,
|
||
queueNext: queueItems,
|
||
waveformSamples: waveformSamples,
|
||
accentColorHex: cachedAccentHex,
|
||
secondaryColorHex: cachedSecondaryHex,
|
||
crossfadeAt: crossfadeAt
|
||
)
|
||
|
||
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
|
||
cachedAccentHex = nil
|
||
cachedSecondaryHex = 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: - Color Extraction
|
||
|
||
/// Extract dominant color from cover art using CIAreaAverage, then compute
|
||
/// a secondary color for text/accents. Returns (accentHex, secondaryHex).
|
||
private func extractColors(from image: UIImage) -> (String, String) {
|
||
let accentHex = dominantColorHex(from: image)
|
||
let secondaryHex = computeSecondaryHex(from: accentHex)
|
||
return (accentHex, secondaryHex)
|
||
}
|
||
|
||
/// Uses CIAreaAverage to find the single dominant color in the image.
|
||
/// Returns a hex string like "E74C3C".
|
||
private func dominantColorHex(from image: UIImage) -> String {
|
||
guard let cgImage = image.cgImage else { return "E74C3C" }
|
||
let ciImage = CIImage(cgImage: cgImage)
|
||
|
||
let extent = ciImage.extent
|
||
guard let filter = CIFilter(name: "CIAreaAverage",
|
||
parameters: [kCIInputImageKey: ciImage,
|
||
kCIInputExtentKey: CIVector(cgRect: extent)]),
|
||
let output = filter.outputImage else { return "E74C3C" }
|
||
|
||
// Read the single 1×1 pixel result
|
||
var pixel = [UInt8](repeating: 0, count: 4)
|
||
blurContext.render(output,
|
||
toBitmap: &pixel,
|
||
rowBytes: 4,
|
||
bounds: CGRect(x: 0, y: 0, width: 1, height: 1),
|
||
format: .RGBA8,
|
||
colorSpace: CGColorSpaceCreateDeviceRGB())
|
||
|
||
var r = CGFloat(pixel[0]) / 255.0
|
||
var g = CGFloat(pixel[1]) / 255.0
|
||
var b = CGFloat(pixel[2]) / 255.0
|
||
|
||
// Boost saturation — averaged colors tend to be muddy.
|
||
// Convert to HSB, clamp brightness, increase saturation.
|
||
var h: CGFloat = 0, s: CGFloat = 0, br: CGFloat = 0, a: CGFloat = 0
|
||
UIColor(red: r, green: g, blue: b, alpha: 1).getHue(&h, saturation: &s, brightness: &br, alpha: &a)
|
||
|
||
// Ensure usable contrast: not too dark, not too light
|
||
br = max(0.35, min(0.80, br))
|
||
s = max(0.30, min(0.90, s * 1.3))
|
||
|
||
let boosted = UIColor(hue: h, saturation: s, brightness: br, alpha: 1)
|
||
boosted.getRed(&r, green: &g, blue: &b, alpha: &a)
|
||
|
||
let ri = Int(r * 255), gi = Int(g * 255), bi = Int(b * 255)
|
||
return String(format: "%02X%02X%02X", ri, gi, bi)
|
||
}
|
||
|
||
/// Compute a lighter/desaturated secondary color from the accent hex.
|
||
/// Used for artist text, time labels, and glass tint.
|
||
private func computeSecondaryHex(from hex: String) -> String {
|
||
let (r, g, b) = hexToRGB(hex)
|
||
var h: CGFloat = 0, s: CGFloat = 0, br: CGFloat = 0, a: CGFloat = 0
|
||
UIColor(red: r, green: g, blue: b, alpha: 1).getHue(&h, saturation: &s, brightness: &br, alpha: &a)
|
||
|
||
// Lighter, less saturated version for text
|
||
let secondary = UIColor(hue: h, saturation: s * 0.5, brightness: min(br + 0.25, 0.95), alpha: 1)
|
||
var sr: CGFloat = 0, sg: CGFloat = 0, sb: CGFloat = 0
|
||
secondary.getRed(&sr, green: &sg, blue: &sb, alpha: &a)
|
||
|
||
return String(format: "%02X%02X%02X", Int(sr * 255), Int(sg * 255), Int(sb * 255))
|
||
}
|
||
|
||
private func hexToRGB(_ hex: String) -> (CGFloat, CGFloat, CGFloat) {
|
||
var h = hex
|
||
if h.hasPrefix("#") { h = String(h.dropFirst()) }
|
||
guard h.count == 6, let val = UInt64(h, radix: 16) else {
|
||
return (0.9, 0.3, 0.24) // fallback pink
|
||
}
|
||
return (CGFloat((val >> 16) & 0xFF) / 255.0,
|
||
CGFloat((val >> 8) & 0xFF) / 255.0,
|
||
CGFloat( val & 0xFF) / 255.0)
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|