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.
192 lines
7.8 KiB
Swift
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
|
|
)
|
|
}
|