import Foundation // ────────────────────────────────────────────────────────────────────── // WidgetSharedState.swift // TARGET: Compile into BOTH the main app AND the widget extension. // Lives in Shared/Storage/ alongside LibraryCache, PlaybackStateStore, etc. // // Reads/writes playback state via App Group UserDefaults so the widget // can display current song info and the app can receive widget commands. // // App Group: "group.com.navidromeplayer.shared" // Add this App Group capability to BOTH targets in Signing & Capabilities. // ────────────────────────────────────────────────────────────────────── /// Item in the "Up Next" queue displayed by the large widget. struct WidgetQueueItem: Codable, Equatable { let title: String let artist: String let coverArtData: Data? // small JPEG thumbnail (~40×40 → ~2KB each) } /// Commands the widget can send to the main app. enum WidgetCommand: String, Codable { case play case pause case next case previous case seekForward15 case seekBackward15 case seekTo // reads target from w_seekToTime } /// Darwin notification name used for widget → app wake-up signal. /// Darwin notifications are inter-process (no payload — the command /// is written to App Group UserDefaults before posting). let kWidgetCommandNotification = "ca.dallasgroot.NavidromePlayer.widgetCommand" as CFString /// Shared playback state bridge between the main app and the widget extension. /// /// The main app calls `pushState(...)` whenever playback state changes. /// The widget's TimelineProvider calls `pullState()` to build entries. /// Widget AppIntents call `enqueueCommand(...)` then post a Darwin notification. final class WidgetSharedState { static let suiteName = "group.com.navidromeplayer.shared" static let shared = WidgetSharedState() private let defaults: UserDefaults? private init() { defaults = UserDefaults(suiteName: Self.suiteName) } // MARK: - Keys private enum K { static let title = "w_title" static let artist = "w_artist" static let album = "w_album" static let isPlaying = "w_isPlaying" static let currentTime = "w_currentTime" static let duration = "w_duration" static let coverArt = "w_coverArt" // JPEG data, ~80 KB static let blurredArt = "w_blurredArt" // pre-blurred JPEG, ~30 KB static let lastUpdated = "w_lastUpdated" // TimeInterval since 1970 static let queueNext = "w_queueNext" // JSON-encoded [WidgetQueueItem] static let hasData = "w_hasData" static let command = "w_pendingCmd" static let seekToTime = "w_seekToTime" // target time for seekTo command // v2: waveform + adaptive colors + crossfade static let waveform = "w_waveform" // JSON [Float], 40 samples 0.0–1.0 static let accentColor = "w_accentColor" // hex string e.g. "E74C3C" static let secondColor = "w_secondaryColor" // hex string for text/accents static let crossfadeAt = "w_crossfadeAt" // seconds — when crossfade triggers } // MARK: - Write (main app → shared defaults) /// Push the full playback state. Call from AudioPlayer on every meaningful change /// (song change, play/pause, seek, queue edit). Lightweight — just UserDefaults writes. func pushState( title: String, artist: String, album: String, isPlaying: Bool, currentTime: TimeInterval, duration: TimeInterval, coverArtJPEG: Data?, blurredArtJPEG: Data?, queueNext: [WidgetQueueItem], waveformSamples: [Float]? = nil, accentColorHex: String? = nil, secondaryColorHex: String? = nil, crossfadeAt: TimeInterval? = nil ) { let d = defaults d?.set(title, forKey: K.title) d?.set(artist, forKey: K.artist) d?.set(album, forKey: K.album) d?.set(isPlaying, forKey: K.isPlaying) d?.set(currentTime, forKey: K.currentTime) d?.set(duration, forKey: K.duration) d?.set(Date().timeIntervalSince1970, forKey: K.lastUpdated) d?.set(true, forKey: K.hasData) if let art = coverArtJPEG { d?.set(art, forKey: K.coverArt) } if let blur = blurredArtJPEG { d?.set(blur, forKey: K.blurredArt) } if let encoded = try? JSONEncoder().encode(queueNext) { d?.set(encoded, forKey: K.queueNext) } // v2 fields — only write if provided (nil = keep previous value) if let w = waveformSamples, let data = try? JSONEncoder().encode(w) { d?.set(data, forKey: K.waveform) } if let c = accentColorHex { d?.set(c, forKey: K.accentColor) } if let c = secondaryColorHex { d?.set(c, forKey: K.secondColor) } if let t = crossfadeAt { d?.set(t, forKey: K.crossfadeAt) } } /// Lightweight position-only update — call from the periodic time observer /// so the widget progress bar stays roughly accurate without pushing full state. func pushPosition(currentTime: TimeInterval, isPlaying: Bool) { defaults?.set(currentTime, forKey: K.currentTime) defaults?.set(isPlaying, forKey: K.isPlaying) defaults?.set(Date().timeIntervalSince1970, forKey: K.lastUpdated) } // MARK: - Read (widget → shared defaults) var songTitle: String { defaults?.string(forKey: K.title) ?? "" } var artist: String { defaults?.string(forKey: K.artist) ?? "" } var album: String { defaults?.string(forKey: K.album) ?? "" } var isPlaying: Bool { defaults?.bool(forKey: K.isPlaying) ?? false } var currentTime: TimeInterval { defaults?.double(forKey: K.currentTime) ?? 0 } var duration: TimeInterval { defaults?.double(forKey: K.duration) ?? 0 } var hasData: Bool { defaults?.bool(forKey: K.hasData) ?? false } var coverArtData: Data? { defaults?.data(forKey: K.coverArt) } var blurredArtData: Data? { defaults?.data(forKey: K.blurredArt) } /// 40 amplitude samples (0.0–1.0) representing the song's waveform shape. var waveformSamples: [Float] { guard let data = defaults?.data(forKey: K.waveform), let samples = try? JSONDecoder().decode([Float].self, from: data) else { return [] } return samples } /// Dominant color extracted from cover art, as a hex string like "E74C3C". var accentColorHex: String? { defaults?.string(forKey: K.accentColor) } /// Computed secondary color for text/accents. var secondaryColorHex: String? { defaults?.string(forKey: K.secondColor) } /// The playback time (seconds) at which the crossfade will trigger. var crossfadeAt: TimeInterval { defaults?.double(forKey: K.crossfadeAt) ?? 0 } /// Seconds since the state was last written by the main app. var lastUpdatedDate: Date { let ts = defaults?.double(forKey: K.lastUpdated) ?? 0 return Date(timeIntervalSince1970: ts) } var queueNext: [WidgetQueueItem] { guard let data = defaults?.data(forKey: K.queueNext), let items = try? JSONDecoder().decode([WidgetQueueItem].self, from: data) else { return [] } return items } // MARK: - Commands (widget → app) /// Write a command for the main app to pick up via Darwin notification observer. func enqueueCommand(_ cmd: WidgetCommand) { defaults?.set(cmd.rawValue, forKey: K.command) } /// Write a seekTo target time alongside the seekTo command. func enqueueSeekTo(time: TimeInterval) { defaults?.set(time, forKey: K.seekToTime) enqueueCommand(.seekTo) } /// The target time for the most recent seekTo command. var seekToTime: TimeInterval { defaults?.double(forKey: K.seekToTime) ?? 0 } /// Read and clear the pending command. Called by the app's Darwin observer. func dequeueCommand() -> WidgetCommand? { guard let raw = defaults?.string(forKey: K.command), let cmd = WidgetCommand(rawValue: raw) else { return nil } defaults?.removeObject(forKey: K.command) return cmd } // MARK: - Optimistic Toggle (for widget-side instant UI update) /// Toggle isPlaying optimistically so the widget re-renders immediately /// before the app processes the command. func togglePlayingOptimistic() { let current = isPlaying defaults?.set(!current, forKey: K.isPlaying) } // MARK: - Clear func clearAll() { for key in [K.title, K.artist, K.album, K.isPlaying, K.currentTime, K.duration, K.coverArt, K.blurredArt, K.lastUpdated, K.queueNext, K.hasData, K.command, K.seekToTime, K.waveform, K.accentColor, K.secondColor, K.crossfadeAt] { defaults?.removeObject(forKey: key) } } } // MARK: - Darwin Notification Helpers /// Post a Darwin notification (inter-process, no payload). func postDarwinNotification(_ name: CFString) { CFNotificationCenterPostNotification( CFNotificationCenterGetDarwinNotifyCenter(), CFNotificationName(name), nil, nil, true ) }