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