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.
226 lines
9.7 KiB
Swift
226 lines
9.7 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
|
||
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
|
||
)
|
||
}
|