NavidromeApp/Shared/Storage/WidgetSharedState.swift
Dallas Groot 53539e5a67 quick fix
2026-04-12 13:39:13 -07:00

179 lines
7.3 KiB
Swift

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
}
/// 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
}
/// 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"
}
// 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]
) {
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)
}
}
/// 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) }
/// 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)
}
/// 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] {
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
)
}