NavidromeApp/Shared/Storage/WidgetSharedState.swift
Dallas Groot 3c28413af8 bug fixes and improvements
Gap 1: Lock Screen seek bar drift — nowPlayingSyncTimer (5s timer that
pushes elapsed time to MPNowPlayingInfoCenter) was created in
playWithAVPlayer but never restarted after a background/foreground
cycle. Now invalidated in suspendVisTimers() and recreated in
resumeVisTimers(). The crossfade path benefits too — it never went
through playWithAVPlayer so the timer was never created at all for
crossfade sessions.
Gap 2: Prefetcher re-downloads after restructure — AudioPreFetcher
used isSongDownloaded(song.id) (exact ID match). After a Companion
restructure changes IDs, songs already downloaded were re-fetched.
Changed to isSongAvailableOffline(song) which falls back to
title/artist/duration matching.
Gap 3: Unbounded image disk cache — trimDiskCache() only ran on app
launch. A long browsing session could push well past the 200MB limit.
Now storeToDisk increments a write counter and triggers trim every 50
writes. The counter lives on ioQueue (serial) so no lock needed.
Gap 4: Custom cover art in widget — WidgetBridge cached blur keyed by
coverArtId. Custom covers don’t change the ID, so the bridge skipped
the update. Now pushWidgetState() passes "custom_\(id)" as the key
when AlbumCoverStore has a custom image. Same album’s songs still
share the key → blur is reused, not redone. When custom is removed,
key reverts to the bare ID → re-blurs with server art.
2026-04-12 16:16:32 -07:00

192 lines
7.8 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
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
}
// 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)
}
/// 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] {
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
)
}