NavidromeApp/iOS/Data/WidgetBridge.swift
Dallas Groot cea4e3868e Seek bar glitch — The time observer was reporting from the outgoing player (near 100%) for the first half, then jumping to the incoming player (near 0%) at 50%. Now it reports from the incoming player as soon as crossfade begins. The user hears the new song fading in — the seek bar matches.
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.
2026-04-14 00:27:10 -07:00

302 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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