added widget support

This commit is contained in:
Dallas Groot 2026-04-12 12:57:42 -07:00
parent 346b3ef378
commit 098e9b9363
15 changed files with 1304 additions and 171 deletions

View file

@ -61,32 +61,6 @@ class SubsonicClient: ObservableObject {
// MARK: - Generic Request
private func request<T: Codable>(endpoint: String, params: [URLQueryItem] = [], server: ServerConfig? = nil) async throws -> T {
guard let url = buildURL(server: server, endpoint: endpoint, params: params) else {
throw APIError.invalidURL
}
let (data, response) = try await session.data(from: url)
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
throw APIError.httpError(httpResponse.statusCode)
}
let decoder = JSONDecoder()
let subsonicResp = try decoder.decode(SubsonicResponse.self, from: data)
if let error = subsonicResp.subsonicResponse.error {
throw APIError.subsonicError(error.code, error.message)
}
// Re-decode to extract the specific type
return try decoder.decode(T.self, from: data)
}
private func requestBody(endpoint: String, params: [URLQueryItem] = [], server: ServerConfig? = nil) async throws -> SubsonicResponseBody {
guard let url = buildURL(server: server, endpoint: endpoint, params: params) else {
throw APIError.invalidURL
@ -141,26 +115,8 @@ class SubsonicClient: ObservableObject {
return body.status == "ok"
}
func getLicense(server: ServerConfig? = nil) async throws -> SubsonicResponseBody {
return try await requestBody(endpoint: "getLicense", server: server)
}
func getScanStatus() async throws -> ScanStatus? {
let body = try await requestBody(endpoint: "getScanStatus")
return body.scanStatus
}
func startScan() async throws {
_ = try await requestBody(endpoint: "startScan")
}
// MARK: - Browsing
func getMusicFolders() async throws -> [MusicFolder] {
let body = try await requestBody(endpoint: "getMusicFolders")
return body.musicFolders?.musicFolder ?? []
}
func getIndexes(musicFolderId: String? = nil, ifModifiedSince: Int64? = nil) async throws -> [ArtistIndex] {
var params: [URLQueryItem] = []
if let fid = musicFolderId { params.append(URLQueryItem(name: "musicFolderId", value: fid)) }
@ -169,12 +125,6 @@ class SubsonicClient: ObservableObject {
return body.indexes?.index ?? []
}
func getMusicDirectory(id: String) async throws -> DirectoryContainer? {
let params = [URLQueryItem(name: "id", value: id)]
let body = try await requestBody(endpoint: "getMusicDirectory", params: params)
return body.directory
}
func getGenres() async throws -> [Genre] {
let body = try await requestBody(endpoint: "getGenres")
return body.genres?.genre ?? []
@ -233,11 +183,6 @@ class SubsonicClient: ObservableObject {
return body.randomSongs?.song ?? []
}
func getNowPlaying() async throws -> [NowPlayingEntry] {
let body = try await requestBody(endpoint: "getNowPlaying")
return body.nowPlaying?.entry ?? []
}
func getStarred2(musicFolderId: String? = nil) async throws -> Starred2Container? {
var params: [URLQueryItem] = []
if let fid = musicFolderId { params.append(URLQueryItem(name: "musicFolderId", value: fid)) }
@ -361,14 +306,6 @@ class SubsonicClient: ObservableObject {
_ = try await requestBody(endpoint: "unstar", params: params)
}
func setRating(id: String, rating: Int) async throws {
let params = [
URLQueryItem(name: "id", value: id),
URLQueryItem(name: "rating", value: "\(rating)")
]
_ = try await requestBody(endpoint: "setRating", params: params)
}
func scrobble(id: String, time: Int64? = nil, submission: Bool = true) async throws {
var params: [URLQueryItem] = [
URLQueryItem(name: "id", value: id),
@ -378,17 +315,6 @@ class SubsonicClient: ObservableObject {
_ = try await requestBody(endpoint: "scrobble", params: params)
}
// MARK: - User Management
func getUser(username: String) async throws -> SubsonicResponseBody {
let params = [URLQueryItem(name: "username", value: username)]
return try await requestBody(endpoint: "getUser", params: params)
}
func getUsers() async throws -> SubsonicResponseBody {
return try await requestBody(endpoint: "getUsers")
}
// MARK: - Media Retrieval
func downloadSong(id: String) async throws -> (Data, URLResponse) {
@ -396,31 +322,6 @@ class SubsonicClient: ObservableObject {
return try await requestData(endpoint: "download", params: params)
}
func getAvatar(username: String) async throws -> (Data, URLResponse) {
let params = [URLQueryItem(name: "username", value: username)]
return try await requestData(endpoint: "getAvatar", params: params)
}
// MARK: - Bookmarks
func getBookmarks() async throws -> SubsonicResponseBody {
return try await requestBody(endpoint: "getBookmarks")
}
func createBookmark(id: String, position: Int64, comment: String? = nil) async throws {
var params: [URLQueryItem] = [
URLQueryItem(name: "id", value: id),
URLQueryItem(name: "position", value: "\(position)")
]
if let c = comment { params.append(URLQueryItem(name: "comment", value: c)) }
_ = try await requestBody(endpoint: "createBookmark", params: params)
}
func deleteBookmark(id: String) async throws {
let params = [URLQueryItem(name: "id", value: id)]
_ = try await requestBody(endpoint: "deleteBookmark", params: params)
}
// MARK: - Internet Radio
func getInternetRadioStations() async throws -> [RadioStation] {
@ -428,24 +329,6 @@ class SubsonicClient: ObservableObject {
return body.internetRadioStations?.internetRadioStation ?? []
}
// MARK: - Shares
func getShares() async throws -> SubsonicResponseBody {
return try await requestBody(endpoint: "getShares")
}
func createShare(ids: [String], description: String? = nil, expires: Int64? = nil) async throws -> SubsonicResponseBody {
var params: [URLQueryItem] = []
for id in ids { params.append(URLQueryItem(name: "id", value: id)) }
if let d = description { params.append(URLQueryItem(name: "description", value: d)) }
if let e = expires { params.append(URLQueryItem(name: "expires", value: "\(e)")) }
return try await requestBody(endpoint: "createShare", params: params)
}
func deleteShare(id: String) async throws {
let params = [URLQueryItem(name: "id", value: id)]
_ = try await requestBody(endpoint: "deleteShare", params: params)
}
}
// MARK: - API Errors

View file

@ -6,6 +6,7 @@ import Accelerate
import os
#if os(iOS)
import UIKit
import WidgetKit
#endif
/// Shared audio player with real FFT visualizer for local files
@ -486,6 +487,7 @@ class AudioPlayer: NSObject, ObservableObject {
// Pre-fetch next tracks for instant playback
#if os(iOS)
AudioPreFetcher.shared.prefetchUpcoming(queue: queue, currentIndex: queueIndex)
pushWidgetState()
#endif
}
@ -772,15 +774,14 @@ class AudioPlayer: NSObject, ObservableObject {
if let dur = self.playerItem?.duration.seconds, !dur.isNaN, dur != self.duration {
self.duration = dur
}
// Throttle state saves to once per 5 seconds saves are cheap but
// the observer fires 10x/sec so we don't need to write that often.
// Throttle position saves to once per 5 seconds.
// Only writes a single Double queue shape is saved separately
// in play(song:) and notifyQueueChanged().
if Int(time.seconds * 10) % 50 == 0 {
PlaybackStateStore.shared.save(
queue: self.queue,
index: self.queueIndex,
currentTime: time.seconds,
currentSongId: self.currentSong?.id
)
PlaybackStateStore.shared.savePosition(time.seconds)
#if os(iOS)
WidgetBridge.shared.updatePosition(currentTime: time.seconds, isPlaying: self.isPlaying)
#endif
}
}
@ -963,6 +964,7 @@ class AudioPlayer: NSObject, ObservableObject {
stopOfflineVisTimer()
stopLevelTimer()
updateNowPlayingInfo()
pushWidgetState()
return
}
#endif
@ -976,6 +978,7 @@ class AudioPlayer: NSObject, ObservableObject {
// so the Canvas keeps re-rendering and the wave visually decays during pause.
// For offline vis path the offlineVisTimer (restarted above) handles this.
internalLevels = Array(repeating: 0, count: 30)
pushWidgetState()
#endif
updateNowPlayingInfo()
}
@ -988,6 +991,7 @@ class AudioPlayer: NSObject, ObservableObject {
// offlineVisTimer was stopped on pause restart it so currentLevels() advances
if isUsingOfflineVis { startOfflineVisSync() }
updateNowPlayingInfo()
pushWidgetState()
return
}
#endif
@ -1032,6 +1036,7 @@ class AudioPlayer: NSObject, ObservableObject {
setLevels(internalLevels)
if levelTimer == nil { startLevelSimulation() }
}
pushWidgetState()
#endif
updateNowPlayingInfo()
}
@ -1270,6 +1275,13 @@ class AudioPlayer: NSObject, ObservableObject {
/// Re-prepare crossfade when queue is modified (Play Next / Later / reorder).
private func notifyQueueChanged() {
#if os(iOS)
// Persist queue shape immediately so Play Next / Later / reorder / remove survives termination
PlaybackStateStore.shared.save(
queue: queue,
index: queueIndex,
currentTime: currentTime,
currentSongId: currentSong?.id
)
let crossfade = SmartCrossfadeManager.shared
guard crossfade.isEnabled, !queue.isEmpty else { return }
if crossfade.isCrossfading {
@ -1345,6 +1357,7 @@ class AudioPlayer: NSObject, ObservableObject {
self.cachedArtwork = artwork
self.cachedArtworkCoverArtId = id
self.updateNowPlayingInfo()
self.pushWidgetState()
return
}
@ -1355,6 +1368,7 @@ class AudioPlayer: NSObject, ObservableObject {
self.cachedArtwork = artwork
self.cachedArtworkCoverArtId = id
self.updateNowPlayingInfo()
self.pushWidgetState()
return
}
@ -1368,6 +1382,7 @@ class AudioPlayer: NSObject, ObservableObject {
self.cachedArtwork = artwork
self.cachedArtworkCoverArtId = id
self.updateNowPlayingInfo()
self.pushWidgetState()
}
} catch {
// Log the failure so it's visible in the debug console.
@ -1703,6 +1718,40 @@ class AudioPlayer: NSObject, ObservableObject {
}
}
// MARK: - Widget State Push
#if os(iOS)
/// Push current playback state to the widget via App Group.
/// Called on song change, play/pause, and artwork load.
private func pushWidgetState() {
guard let song = currentSong else { return }
let upcoming = Array(queue.dropFirst(queueIndex + 1).prefix(3))
.map { (title: $0.title, artist: $0.artist ?? "Unknown") }
// Try to get cover art from memory caches (zero I/O)
var coverImage: UIImage?
if let id = song.coverArt {
coverImage = AlbumCoverStore.shared.loadCover(for: id)
if coverImage == nil,
let url = ServerManager.shared.client.coverArtURL(id: id, size: 300) {
coverImage = ImageCache.shared.memoryOnlyImage(for: url)
}
}
WidgetBridge.shared.updateNowPlaying(
title: song.title,
artist: song.artist ?? "Unknown",
album: song.album ?? "",
isPlaying: isPlaying,
currentTime: currentTime,
duration: duration,
coverArtId: song.coverArt,
coverArtImage: coverImage,
queue: upcoming
)
}
#endif
// MARK: - Cleanup
private func stopLevelTimer() {
@ -1766,6 +1815,7 @@ class AudioPlayer: NSObject, ObservableObject {
isUsingOfflineVis = false
#if os(iOS)
offlineVisBuffer = VisFrameBuffer.empty
WidgetBridge.shared.clear()
#endif
if isRadioStream {
isRadioStream = false

View file

@ -7,7 +7,6 @@ struct ServerConfig: Codable, Identifiable, Hashable {
var url: String
var username: String
var password: String // stored in Keychain in production
var isActive: Bool = true
var baseURL: URL? { URL(string: url) }

View file

@ -29,6 +29,14 @@ class LibraryCache: ObservableObject {
private let monitor = NWPathMonitor()
private let monitorQueue = DispatchQueue(label: "com.navidromeplayer.network")
private var serverStateCancellable: AnyCancellable?
/// Shared decoder load() is only called from main thread (views) or serialized
/// sync path, never from the detached preCacheAlbumDetails task.
/// Encoder is NOT shared because save() can be called concurrently from
/// the detached preCacheAlbumDetails task and the periodic sync.
private let decoder = JSONDecoder()
/// In-memory cache eliminates repeated Data(contentsOf:) + JSONDecoder on view loads.
/// NSCache auto-evicts under memory pressure, so this is free memory-wise.
private let memoryCache = NSCache<NSString, CacheEntry>()
private init() {
let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!
@ -62,6 +70,9 @@ class LibraryCache: ObservableObject {
}
// MARK: - Generic Cache Read/Write
/// Wraps raw encoded Data for storage in NSCache (requires reference type).
private class CacheEntry { let data: Data; init(_ data: Data) { self.data = data } }
private func cacheURL(for key: String) -> URL {
cacheDir.appendingPathComponent("\(key).json")
@ -69,11 +80,14 @@ class LibraryCache: ObservableObject {
func save<T: Encodable>(_ data: T, key: String) {
if let encoded = try? JSONEncoder().encode(data) {
// Write-through: memory + disk
memoryCache.setObject(CacheEntry(encoded), forKey: key as NSString)
try? encoded.write(to: cacheURL(for: key), options: .atomic)
}
}
func remove(key: String) {
memoryCache.removeObject(forKey: key as NSString)
try? FileManager.default.removeItem(at: cacheURL(for: key))
}
@ -83,14 +97,23 @@ class LibraryCache: ObservableObject {
guard let files = try? FileManager.default.contentsOfDirectory(
at: cacheDir, includingPropertiesForKeys: nil) else { return }
for url in files where url.lastPathComponent.hasPrefix("album_") {
// Strip ".json" to get the cache key, evict from memory
let key = url.deletingPathExtension().lastPathComponent
memoryCache.removeObject(forKey: key as NSString)
try? FileManager.default.removeItem(at: url)
}
}
func load<T: Decodable>(_ type: T.Type, key: String) -> T? {
// 1. Memory hit zero I/O
if let entry = memoryCache.object(forKey: key as NSString) {
return try? decoder.decode(type, from: entry.data)
}
// 2. Disk fallback promote to memory
let url = cacheURL(for: key)
guard let data = try? Data(contentsOf: url) else { return nil }
return try? JSONDecoder().decode(type, from: data)
memoryCache.setObject(CacheEntry(data), forKey: key as NSString)
return try? decoder.decode(type, from: data)
}
// MARK: - Typed Helpers
@ -147,36 +170,27 @@ class LibraryCache: ObservableObject {
load([CompanionAlbum].self, key: "companion_albums")
}
/// Cache companion songs (all songs, used for search + album detail).
func cacheCompanionAlbumSongs(_ songs: [CompanionSong], albumId: String) {
let safeKey = "companion_album_\(albumId.replacingOccurrences(of: ":", with: "_").replacingOccurrences(of: "|", with: "_").replacingOccurrences(of: " ", with: "_"))"
save(songs, key: safeKey)
}
func loadCompanionAlbumSongs(albumId: String) -> [CompanionSong]? {
let safeKey = "companion_album_\(albumId.replacingOccurrences(of: ":", with: "_").replacingOccurrences(of: "|", with: "_").replacingOccurrences(of: " ", with: "_"))"
return load([CompanionSong].self, key: safeKey)
/// Filesystem-safe cache key for a companion album ID.
private func companionAlbumKey(_ albumId: String) -> String {
let safe = albumId.replacingOccurrences(of: ":", with: "_")
.replacingOccurrences(of: "|", with: "_")
.replacingOccurrences(of: " ", with: "_")
.replacingOccurrences(of: "/", with: "_")
return "companion_album_\(safe)"
}
/// Whether the library data came from Companion API (affects sort order, cover art).
var hasCompanionLibrary: Bool {
loadCompanionAlbums() != nil
/// Cache companion songs (all songs, used for search + album detail).
func cacheCompanionAlbumSongs(_ songs: [CompanionSong], albumId: String) {
save(songs, key: companionAlbumKey(albumId))
}
// MARK: - Download Status Check
/// Returns set of song IDs that are downloaded for offline playback
func downloadedSongIds() -> Set<String> {
let key = "offline_catalog"
guard let data = UserDefaults.standard.data(forKey: key),
let songs = try? JSONDecoder().decode([DownloadedSong].self, from: data) else {
return []
}
return Set(songs.map { $0.id })
func loadCompanionAlbumSongs(albumId: String) -> [CompanionSong]? {
load([CompanionSong].self, key: companionAlbumKey(albumId))
}
// MARK: - Clear
func clearAll() {
memoryCache.removeAllObjects()
if let files = try? fileManager.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil) {
for file in files { try? fileManager.removeItem(at: file) }
}

View file

@ -15,6 +15,10 @@ class OfflineManager: ObservableObject {
private let catalogKey = "offline_catalog"
private var downloadQueue: [(Song, ServerConfig)] = []
private var isProcessingQueue = false
/// Single reusable client for all downloads avoids allocating a new URLSession
/// (with its own connection pool and TLS state) per song. Downloads are sequential
/// so there's no concurrency concern; we just set currentServer before each one.
private let downloadClient = SubsonicClient()
enum DownloadState: Equatable {
case queued
@ -92,7 +96,7 @@ class OfflineManager: ObservableObject {
downloads[song.id] = .downloading(progress: 0)
}
let client = SubsonicClient()
let client = downloadClient
client.currentServer = server
do {

View file

@ -15,15 +15,15 @@ struct PlaybackStateStore {
private let indexKey = "playback_saved_index"
private let timeKey = "playback_saved_time"
private let songIdKey = "playback_saved_song_id"
private let encoder = JSONEncoder()
// MARK: - Save
/// Call whenever the current song, queue, or playback position changes.
/// Writes are cheap just JSON-encoding Song structs to UserDefaults.
/// Save the full queue structure call when queue content or index changes
/// (play, next, reorder, add/remove). Heavy: encodes entire [Song] array.
func save(queue: [Song], index: Int, currentTime: TimeInterval, currentSongId: String?) {
guard !queue.isEmpty else { return }
let encoder = JSONEncoder()
if let data = try? encoder.encode(queue) {
UserDefaults.standard.set(data, forKey: queueKey)
}
@ -32,6 +32,12 @@ struct PlaybackStateStore {
UserDefaults.standard.set(currentSongId, forKey: songIdKey)
}
/// Save only the playback position call from the periodic time observer.
/// Lightweight: writes a single Double, no JSON encoding.
func savePosition(_ currentTime: TimeInterval) {
UserDefaults.standard.set(currentTime, forKey: timeKey)
}
// MARK: - Load
struct RestoredState {

View file

@ -0,0 +1,179 @@
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.ca.dallasgroot.NavidromePlayer"
// 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.ca.dallasgroot.NavidromePlayer"
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
)
}

View file

@ -0,0 +1,185 @@
import WidgetKit
import SwiftUI
//
// NowPlayingWidget.swift
// TARGET: Widget extension only.
//
// Defines the widget bundle, timeline provider, and timeline entry.
// The provider reads from WidgetSharedState (App Group UserDefaults)
// and generates entries with projected playback positions so the
// progress bar advances even between reloads.
//
// MARK: - Widget Bundle
@main
struct NavidromeWidgetBundle: WidgetBundle {
var body: some Widget {
NowPlayingWidget()
}
}
// MARK: - Timeline Entry
struct NowPlayingEntry: TimelineEntry {
let date: Date
// Song metadata
let songTitle: String
let artist: String
let album: String
// Playback state
let isPlaying: Bool
let currentTime: TimeInterval
let duration: TimeInterval
// Images (raw JPEG data decoded in the view)
let coverArtData: Data?
let blurredArtData: Data?
// Up Next (large widget)
let queueNext: [WidgetQueueItem]
// Whether any data has ever been written
let hasData: Bool
// MARK: - Computed
var progress: Double {
guard duration > 0 else { return 0 }
return min(max(currentTime / duration, 0), 1)
}
var currentTimeFormatted: String {
formatTime(currentTime)
}
var remainingTimeFormatted: String {
let remaining = max(duration - currentTime, 0)
return "-\(formatTime(remaining))"
}
private func formatTime(_ t: TimeInterval) -> String {
let total = Int(max(t, 0))
let m = total / 60
let s = total % 60
return String(format: "%d:%02d", m, s)
}
// MARK: - Placeholder
static let placeholder = NowPlayingEntry(
date: .now,
songTitle: "Not Playing",
artist: "NavidromePlayer",
album: "",
isPlaying: false,
currentTime: 0,
duration: 1,
coverArtData: nil,
blurredArtData: nil,
queueNext: [],
hasData: false
)
}
// MARK: - Timeline Provider
struct NowPlayingProvider: TimelineProvider {
func placeholder(in context: Context) -> NowPlayingEntry {
.placeholder
}
func getSnapshot(in context: Context, completion: @escaping (NowPlayingEntry) -> Void) {
completion(buildEntry(at: .now))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<NowPlayingEntry>) -> Void) {
let state = WidgetSharedState.shared
let now = Date()
if state.isPlaying && state.duration > 0 {
// Generate entries every 30 seconds for the next 5 minutes
// so the progress bar visually advances between reloads.
var entries: [NowPlayingEntry] = []
let elapsed = now.timeIntervalSince(state.lastUpdatedDate)
let baseTime = state.currentTime + elapsed
for i in 0..<10 {
let offset = Double(i) * 30.0
let entryDate = now.addingTimeInterval(offset)
let projectedTime = min(baseTime + offset, state.duration)
entries.append(NowPlayingEntry(
date: entryDate,
songTitle: state.songTitle,
artist: state.artist,
album: state.album,
isPlaying: true,
currentTime: projectedTime,
duration: state.duration,
coverArtData: state.coverArtData,
blurredArtData: state.blurredArtData,
queueNext: state.queueNext,
hasData: state.hasData
))
}
// Re-request timeline after the last entry
let refreshDate = now.addingTimeInterval(300)
completion(Timeline(entries: entries, policy: .after(refreshDate)))
} else {
// Paused or no data single static entry
let entry = buildEntry(at: now)
// Refresh every 15 minutes in case the app updates state
let refreshDate = now.addingTimeInterval(15 * 60)
completion(Timeline(entries: [entry], policy: .after(refreshDate)))
}
}
// MARK: - Helpers
private func buildEntry(at date: Date) -> NowPlayingEntry {
let state = WidgetSharedState.shared
// Project currentTime forward if playing
var projectedTime = state.currentTime
if state.isPlaying && state.duration > 0 {
let elapsed = date.timeIntervalSince(state.lastUpdatedDate)
projectedTime = min(state.currentTime + elapsed, state.duration)
}
return NowPlayingEntry(
date: date,
songTitle: state.songTitle.isEmpty ? "Not Playing" : state.songTitle,
artist: state.artist.isEmpty ? "NavidromePlayer" : state.artist,
album: state.album,
isPlaying: state.isPlaying,
currentTime: projectedTime,
duration: state.duration,
coverArtData: state.coverArtData,
blurredArtData: state.blurredArtData,
queueNext: state.queueNext,
hasData: state.hasData
)
}
}
// MARK: - Widget Definition
struct NowPlayingWidget: Widget {
let kind = "NowPlayingWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: NowPlayingProvider()) { entry in
NowPlayingWidgetView(entry: entry)
.containerBackground(.clear, for: .widget)
}
.configurationDisplayName("Now Playing")
.description("Control playback and see what's playing.")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
}
}

View file

@ -0,0 +1,504 @@
import SwiftUI
import WidgetKit
//
// NowPlayingWidgetViews.swift
// TARGET: Widget extension only.
//
// SwiftUI views for small, medium, and large widgets.
// Features:
// Blurred album art background with light/dark scrim
// Interactive play/pause, next, previous via AppIntents
// Progress bar with left/right tap zones for ±15s seek
// "Up Next" queue in large widget
// Graceful idle state when nothing is playing
//
// MARK: - Accent Color
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
// MARK: - Size Router
struct NowPlayingWidgetView: View {
let entry: NowPlayingEntry
@Environment(\.widgetFamily) var family
@Environment(\.colorScheme) var colorScheme
var body: some View {
ZStack {
// Blurred album art background
backgroundLayer
// Dark/light scrim for text readability
scrimOverlay
// Content
switch family {
case .systemSmall: SmallWidgetContent(entry: entry)
case .systemMedium: MediumWidgetContent(entry: entry)
case .systemLarge: LargeWidgetContent(entry: entry)
default: MediumWidgetContent(entry: entry)
}
}
}
// MARK: - Background
@ViewBuilder
private var backgroundLayer: some View {
if let data = entry.blurredArtData, let uiImage = UIImage(data: data) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
} else {
// Fallback gradient when no art is available
LinearGradient(
colors: colorScheme == .dark
? [Color(white: 0.12), Color(white: 0.08)]
: [Color(white: 0.92), Color(white: 0.85)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
}
private var scrimOverlay: some View {
Rectangle().fill(
colorScheme == .dark
? Color.black.opacity(0.55)
: Color.white.opacity(0.50)
)
}
}
//
// MARK: - SMALL WIDGET
//
struct SmallWidgetContent: View {
let entry: NowPlayingEntry
@Environment(\.colorScheme) var colorScheme
private var primaryColor: Color { colorScheme == .dark ? .white : .black }
private var secondaryColor: Color { colorScheme == .dark ? Color(white: 0.7) : Color(white: 0.35) }
var body: some View {
VStack(alignment: .leading, spacing: 6) {
// Album art + song info
HStack(spacing: 10) {
coverArt(size: 48)
VStack(alignment: .leading, spacing: 2) {
Text(entry.songTitle)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(primaryColor)
.lineLimit(2)
Text(entry.artist)
.font(.system(size: 11, weight: .regular))
.foregroundStyle(secondaryColor)
.lineLimit(1)
}
Spacer(minLength: 0)
}
Spacer(minLength: 0)
// Progress bar
ProgressBarView(entry: entry, height: 3, showTimes: false)
// Play/pause button centered
HStack {
Spacer()
Button(intent: PlayPauseIntent()) {
Image(systemName: entry.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 22, weight: .medium))
.foregroundStyle(primaryColor)
}
.buttonStyle(.plain)
Spacer()
}
}
.padding(14)
}
@ViewBuilder
private func coverArt(size: CGFloat) -> some View {
if let data = entry.coverArtData, let uiImage = UIImage(data: data) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(Color.white.opacity(0.15), lineWidth: 0.5)
)
} else {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color(white: colorScheme == .dark ? 0.2 : 0.8))
.frame(width: size, height: size)
.overlay(
Image(systemName: "music.note")
.font(.system(size: size * 0.35))
.foregroundStyle(Color(white: 0.5))
)
}
}
}
//
// MARK: - MEDIUM WIDGET
//
struct MediumWidgetContent: View {
let entry: NowPlayingEntry
@Environment(\.colorScheme) var colorScheme
private var primaryColor: Color { colorScheme == .dark ? .white : .black }
private var secondaryColor: Color { colorScheme == .dark ? Color(white: 0.7) : Color(white: 0.35) }
var body: some View {
HStack(spacing: 14) {
// Album art
coverArt(size: 90)
// Right side: info + progress + controls
VStack(alignment: .leading, spacing: 0) {
// Song info
Text(entry.songTitle)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(primaryColor)
.lineLimit(1)
Text(entry.artist)
.font(.system(size: 13, weight: .regular))
.foregroundStyle(secondaryColor)
.lineLimit(1)
.padding(.bottom, 2)
Spacer(minLength: 0)
// Progress bar with times and seek tap zones
ProgressBarView(entry: entry, height: 4, showTimes: true)
.padding(.bottom, 8)
// Transport controls
HStack(spacing: 0) {
Spacer(minLength: 0)
transportButton(
icon: "backward.fill",
size: 18,
intent: PreviousTrackIntent()
)
Spacer(minLength: 0)
transportButton(
icon: entry.isPlaying ? "pause.fill" : "play.fill",
size: 26,
intent: PlayPauseIntent()
)
Spacer(minLength: 0)
transportButton(
icon: "forward.fill",
size: 18,
intent: NextTrackIntent()
)
Spacer(minLength: 0)
}
}
}
.padding(14)
}
@ViewBuilder
private func coverArt(size: CGFloat) -> some View {
if let data = entry.coverArtData, let uiImage = UIImage(data: data) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(Color.white.opacity(0.15), lineWidth: 0.5)
)
.shadow(color: .black.opacity(0.3), radius: 8, x: 0, y: 4)
} else {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color(white: colorScheme == .dark ? 0.2 : 0.8))
.frame(width: size, height: size)
.overlay(
Image(systemName: "music.note")
.font(.system(size: size * 0.3))
.foregroundStyle(Color(white: 0.5))
)
}
}
private func transportButton<I: AppIntent>(icon: String, size: CGFloat, intent: I) -> some View {
Button(intent: intent) {
Image(systemName: icon)
.font(.system(size: size, weight: .medium))
.foregroundStyle(primaryColor)
.frame(width: 44, height: 36)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
//
// MARK: - LARGE WIDGET
//
struct LargeWidgetContent: View {
let entry: NowPlayingEntry
@Environment(\.colorScheme) var colorScheme
private var primaryColor: Color { colorScheme == .dark ? .white : .black }
private var secondaryColor: Color { colorScheme == .dark ? Color(white: 0.7) : Color(white: 0.35) }
private var tertiaryColor: Color { colorScheme == .dark ? Color(white: 0.45) : Color(white: 0.55) }
var body: some View {
VStack(spacing: 0) {
Spacer(minLength: 4)
// Centered album art
coverArt(size: 140)
.padding(.bottom, 12)
// Song info
Text(entry.songTitle)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(primaryColor)
.lineLimit(1)
.padding(.bottom, 1)
Text(entry.artist)
.font(.system(size: 14, weight: .regular))
.foregroundStyle(secondaryColor)
.lineLimit(1)
if !entry.album.isEmpty {
Text(entry.album)
.font(.system(size: 12, weight: .regular))
.foregroundStyle(tertiaryColor)
.lineLimit(1)
.padding(.top, 1)
}
Spacer(minLength: 8)
// Progress bar with times and seek tap zones
ProgressBarView(entry: entry, height: 4, showTimes: true)
.padding(.horizontal, 8)
.padding(.bottom, 10)
// Transport controls
HStack(spacing: 0) {
Spacer()
transportButton(icon: "backward.fill", size: 20, intent: PreviousTrackIntent())
Spacer()
transportButton(
icon: entry.isPlaying ? "pause.fill" : "play.fill",
size: 30,
intent: PlayPauseIntent()
)
Spacer()
transportButton(icon: "forward.fill", size: 20, intent: NextTrackIntent())
Spacer()
}
.padding(.bottom, 10)
// Up Next divider + queue
if !entry.queueNext.isEmpty {
Divider()
.background(tertiaryColor.opacity(0.3))
.padding(.horizontal, 8)
VStack(alignment: .leading, spacing: 4) {
Text("UP NEXT")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(tertiaryColor)
.tracking(1.2)
.padding(.top, 6)
.padding(.leading, 4)
ForEach(Array(entry.queueNext.prefix(3).enumerated()), id: \.offset) { idx, item in
HStack(spacing: 6) {
Text("\(idx + 2).")
.font(.system(size: 12, weight: .medium).monospacedDigit())
.foregroundStyle(accentPink)
.frame(width: 18, alignment: .trailing)
Text(item.title)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(primaryColor)
.lineLimit(1)
Text("\(item.artist)")
.font(.system(size: 12, weight: .regular))
.foregroundStyle(secondaryColor)
.lineLimit(1)
Spacer(minLength: 0)
}
.padding(.leading, 4)
}
}
}
Spacer(minLength: 4)
}
.padding(14)
}
@ViewBuilder
private func coverArt(size: CGFloat) -> some View {
if let data = entry.coverArtData, let uiImage = UIImage(data: data) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color.white.opacity(0.15), lineWidth: 0.5)
)
.shadow(color: .black.opacity(0.4), radius: 12, x: 0, y: 6)
} else {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(white: colorScheme == .dark ? 0.2 : 0.8))
.frame(width: size, height: size)
.overlay(
Image(systemName: "music.note")
.font(.system(size: size * 0.25))
.foregroundStyle(Color(white: 0.5))
)
}
}
private func transportButton<I: AppIntent>(icon: String, size: CGFloat, intent: I) -> some View {
Button(intent: intent) {
Image(systemName: icon)
.font(.system(size: size, weight: .medium))
.foregroundStyle(primaryColor)
.frame(width: 50, height: 40)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
//
// MARK: - PROGRESS BAR (shared component)
//
struct ProgressBarView: View {
let entry: NowPlayingEntry
let height: CGFloat
let showTimes: Bool
@Environment(\.colorScheme) var colorScheme
private var trackColor: Color {
colorScheme == .dark ? Color(white: 0.3) : Color(white: 0.7)
}
private var timeColor: Color {
colorScheme == .dark ? Color(white: 0.6) : Color(white: 0.4)
}
var body: some View {
VStack(spacing: 4) {
// Progress track
GeometryReader { geo in
ZStack(alignment: .leading) {
// Background track
Capsule()
.fill(trackColor)
.frame(height: height)
// Filled progress
Capsule()
.fill(accentPink)
.frame(width: max(geo.size.width * entry.progress, height), height: height)
// Seek tap zones invisible buttons overlaid on the bar
HStack(spacing: 0) {
// Left half seek backward 15s
Button(intent: SeekBackwardIntent()) {
Color.clear
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
// Right half seek forward 15s
Button(intent: SeekForwardIntent()) {
Color.clear
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
.frame(height: max(height, 24)) // 24pt tap target
}
}
.frame(height: max(height, 24))
// Time labels
if showTimes {
HStack {
Text(entry.currentTimeFormatted)
.font(.system(size: 10, weight: .medium).monospacedDigit())
.foregroundStyle(timeColor)
Spacer()
Text(entry.remainingTimeFormatted)
.font(.system(size: 10, weight: .medium).monospacedDigit())
.foregroundStyle(timeColor)
}
}
}
}
}
// MARK: - Previews
#if DEBUG
#Preview("Small — Dark", as: .systemSmall) {
NowPlayingWidget()
} timeline: {
NowPlayingEntry(
date: .now, songTitle: "This Is A Test", artist: "Armin van Buuren",
album: "Imagine", isPlaying: true, currentTime: 83, duration: 210,
coverArtData: nil, blurredArtData: nil,
queueNext: [], hasData: true
)
}
#Preview("Medium — Dark", as: .systemMedium) {
NowPlayingWidget()
} timeline: {
NowPlayingEntry(
date: .now, songTitle: "This Is A Test", artist: "Armin van Buuren",
album: "Imagine", isPlaying: true, currentTime: 83, duration: 210,
coverArtData: nil, blurredArtData: nil,
queueNext: [], hasData: true
)
}
#Preview("Large — Dark", as: .systemLarge) {
NowPlayingWidget()
} timeline: {
NowPlayingEntry(
date: .now, songTitle: "This Is A Test", artist: "Armin van Buuren",
album: "Imagine", isPlaying: true, currentTime: 83, duration: 210,
coverArtData: nil, blurredArtData: nil,
queueNext: [
WidgetQueueItem(title: "Blah Blah Blah", artist: "Armin van Buuren"),
WidgetQueueItem(title: "In And Out of Love", artist: "Armin van Buuren"),
],
hasData: true
)
}
#endif

View file

@ -0,0 +1,91 @@
import AppIntents
import WidgetKit
//
// WidgetIntents.swift
// TARGET: Widget extension only.
//
// iOS 17+ interactive widget controls via AppIntents.
// Each intent writes a command to App Group UserDefaults, optimistically
// updates the widget state, posts a Darwin notification to wake the app,
// then reloads timelines so the widget re-renders immediately.
//
// MARK: - Play / Pause
struct PlayPauseIntent: AppIntent {
static var title: LocalizedStringResource = "Toggle Playback"
static var description: IntentDescription = "Play or pause the current track."
func perform() async throws -> some IntentResult {
let state = WidgetSharedState.shared
// Optimistic toggle widget re-renders with new state instantly
state.togglePlayingOptimistic()
// Enqueue command for the main app
let wasPlaying = !state.isPlaying // we already toggled
state.enqueueCommand(wasPlaying ? .pause : .play)
postDarwinNotification(kWidgetCommandNotification)
WidgetCenter.shared.reloadAllTimelines()
return .result()
}
}
// MARK: - Next Track
struct NextTrackIntent: AppIntent {
static var title: LocalizedStringResource = "Next Track"
static var description: IntentDescription = "Skip to the next track."
func perform() async throws -> some IntentResult {
let state = WidgetSharedState.shared
state.enqueueCommand(.next)
postDarwinNotification(kWidgetCommandNotification)
WidgetCenter.shared.reloadAllTimelines()
return .result()
}
}
// MARK: - Previous Track
struct PreviousTrackIntent: AppIntent {
static var title: LocalizedStringResource = "Previous Track"
static var description: IntentDescription = "Go to the previous track."
func perform() async throws -> some IntentResult {
let state = WidgetSharedState.shared
state.enqueueCommand(.previous)
postDarwinNotification(kWidgetCommandNotification)
WidgetCenter.shared.reloadAllTimelines()
return .result()
}
}
// MARK: - Seek Forward (+15s)
struct SeekForwardIntent: AppIntent {
static var title: LocalizedStringResource = "Seek Forward"
static var description: IntentDescription = "Skip forward 15 seconds."
func perform() async throws -> some IntentResult {
let state = WidgetSharedState.shared
state.enqueueCommand(.seekForward15)
postDarwinNotification(kWidgetCommandNotification)
WidgetCenter.shared.reloadAllTimelines()
return .result()
}
}
// MARK: - Seek Backward (-15s)
struct SeekBackwardIntent: AppIntent {
static var title: LocalizedStringResource = "Seek Backward"
static var description: IntentDescription = "Skip backward 15 seconds."
func perform() async throws -> some IntentResult {
let state = WidgetSharedState.shared
state.enqueueCommand(.seekBackward15)
postDarwinNotification(kWidgetCommandNotification)
WidgetCenter.shared.reloadAllTimelines()
return .result()
}
}

View file

@ -1,6 +1,5 @@
import SwiftUI
import BackgroundTasks
import Combine
// MARK: - App Delegate (Background Sessions + BGTask Registration)
@ -151,6 +150,7 @@ struct RootView: View {
.task {
ImageCache.shared.trimDiskCache()
AudioPreFetcher.shared.cleanOldPrefetches(keeping: AudioPlayer.shared.queue)
WidgetBridge.shared.start()
if serverManager.activeServer != nil {
await serverManager.connectToActive()

View file

@ -22,6 +22,9 @@ class SyncEngine: ObservableObject {
private let cache = LibraryCache.shared
private var syncTask: Task<Void, Never>?
private var periodicTimer: Timer?
/// Tracks which album details have been pre-cached in this session.
/// Avoids a disk read + JSON decode per album in preCacheAlbumDetails().
private var preCachedAlbumIds = Set<String>()
private let syncInterval: TimeInterval = 15 * 60 // 15 minutes
@ -46,6 +49,7 @@ class SyncEngine: ObservableObject {
// so next fetch returns the fresh Navidrome paths instead of stale ones
LibraryCache.shared.remove(key: "all_songs_sorted")
LibraryCache.shared.removeAlbumDetails()
self?.preCachedAlbumIds.removeAll()
self?.lastSyncTimestamp = 0
self?.syncIfNeeded()
}
@ -110,6 +114,7 @@ class SyncEngine: ObservableObject {
/// Force a full re-sync (user-triggered)
func forceSync() {
lastSyncTimestamp = 0
preCachedAlbumIds.removeAll()
syncTask?.cancel()
syncTask = nil
isSyncing = false
@ -255,15 +260,16 @@ class SyncEngine: ObservableObject {
// MARK: - Album Detail Pre-Caching
/// Fetches full album details (with songs) for every album and caches them.
/// Runs in background at low priority skips already-cached albums.
/// Runs in background at low priority skips already-cached albums using
/// an in-memory set instead of reading every file from disk.
private func preCacheAlbumDetails(_ albums: [Album]) async {
let client = ServerManager.shared.client
var cached = 0
var skipped = 0
for album in albums {
// Skip if already cached
if cache.loadAlbumDetail(id: album.id) != nil {
// Skip if already cached this session avoids disk I/O per album
if preCachedAlbumIds.contains(album.id) {
skipped += 1
continue
}
@ -271,6 +277,7 @@ class SyncEngine: ObservableObject {
do {
if let detail = try await client.getAlbum(id: album.id) {
cache.cacheAlbumDetail(detail)
preCachedAlbumIds.insert(album.id)
cached += 1
}
} catch {
@ -292,6 +299,7 @@ class SyncEngine: ObservableObject {
func serverChanged() {
lastSyncTimestamp = 0
lastSyncDate = nil
preCachedAlbumIds.removeAll()
cache.clearAll()
DebugLogger.shared.log("Server changed — cache cleared, will re-bootstrap", category: "Sync")
}

178
iOS/Data/WidgetBridge.swift Normal file
View file

@ -0,0 +1,178 @@
import Foundation
import UIKit
import CoreImage
import CoreImage.CIFilterBuiltins
import WidgetKit
//
// WidgetBridge.swift
// TARGET: Main app only (NOT the widget extension).
//
// Responsibilities:
// 1. Pre-blurs album art for the widget background (CIGaussianBlur)
// 2. Pushes playback state to App Group on every change
// 3. Observes Darwin notifications for widget app commands
// 4. Reloads widget timelines when state changes
//
// Integration: call WidgetBridge.shared.start() once from app launch,
// then call updateNowPlaying(...) from AudioPlayer on every state change.
//
final class WidgetBridge {
static let shared = WidgetBridge()
private let state = WidgetSharedState.shared
private let blurContext = CIContext(options: [.useSoftwareRenderer: false])
private var isObserving = false
/// Cached blurred art to avoid re-blurring on every time-observer tick.
/// Reset when the cover art ID changes.
private var cachedBlurCoverArtId: String?
private var cachedBlurData: Data?
private var cachedArtData: Data?
private init() {}
// MARK: - Lifecycle
/// Call once from app launch (e.g. NavidromePlayerApp.init or AppDelegate).
/// Sets up the Darwin notification observer for widget commands.
func start() {
guard !isObserving else { return }
isObserving = true
let center = CFNotificationCenterGetDarwinNotifyCenter()
let observer = Unmanaged.passUnretained(self).toOpaque()
CFNotificationCenterAddObserver(
center,
observer,
{ _, observer, _, _, _ in
guard let observer else { return }
let bridge = Unmanaged<WidgetBridge>.fromOpaque(observer).takeUnretainedValue()
DispatchQueue.main.async { bridge.handleWidgetCommand() }
},
kWidgetCommandNotification,
nil,
.deliverImmediately
)
}
// MARK: - Push State to Widget
/// Full state push call on song change, play/pause, queue edit.
/// Blurs the album art if the cover changed; skips blur if same art.
func updateNowPlaying(
title: String,
artist: String,
album: String,
isPlaying: Bool,
currentTime: TimeInterval,
duration: TimeInterval,
coverArtId: String?,
coverArtImage: UIImage?,
queue: [(title: String, artist: String)]
) {
// Only re-blur if the cover art actually changed
if let id = coverArtId, id != cachedBlurCoverArtId, let image = coverArtImage {
cachedBlurCoverArtId = id
cachedArtData = image.jpegData(compressionQuality: 0.7)
// Blur on a background queue JPEG encode is ~2ms, blur is ~5ms
let blurred = blurImage(image, radius: 40)
cachedBlurData = blurred?.jpegData(compressionQuality: 0.5)
}
let queueItems = queue.prefix(3).map { WidgetQueueItem(title: $0.title, artist: $0.artist) }
state.pushState(
title: title,
artist: artist,
album: album,
isPlaying: isPlaying,
currentTime: currentTime,
duration: duration,
coverArtJPEG: cachedArtData,
blurredArtJPEG: cachedBlurData,
queueNext: queueItems
)
WidgetCenter.shared.reloadAllTimelines()
}
/// Lightweight position-only push call from the time observer (~every 5s).
/// Does NOT reload timelines (the timeline entries already project forward).
func updatePosition(currentTime: TimeInterval, isPlaying: Bool) {
state.pushPosition(currentTime: currentTime, isPlaying: isPlaying)
}
/// Clear widget state (e.g. on logout or server change).
func clear() {
cachedBlurCoverArtId = nil
cachedBlurData = nil
cachedArtData = nil
state.clearAll()
WidgetCenter.shared.reloadAllTimelines()
}
// MARK: - Handle Widget Commands
private func handleWidgetCommand() {
guard let cmd = state.dequeueCommand() else { return }
let player = AudioPlayer.shared
switch cmd {
case .play:
player.resume()
case .pause:
player.pause()
case .next:
player.next()
case .previous:
player.previous()
case .seekForward15:
player.seek(to: min(player.currentTime + 15, player.duration))
case .seekBackward15:
player.seek(to: max(player.currentTime - 15, 0))
}
DebugLogger.shared.log("Widget command: \(cmd.rawValue)", category: "Widget")
}
// MARK: - Gaussian Blur
/// Applies a heavy gaussian blur to the image for the widget background.
/// Uses CoreImage on the GPU fast even for large images.
private func blurImage(_ image: UIImage, radius: CGFloat) -> UIImage? {
// Downscale to ~200px wide first no need for full-res blur
let maxDim: CGFloat = 200
let scale = min(maxDim / image.size.width, maxDim / image.size.height, 1.0)
let targetSize = CGSize(
width: image.size.width * scale,
height: image.size.height * scale
)
UIGraphicsBeginImageContextWithOptions(targetSize, true, 1.0)
image.draw(in: CGRect(origin: .zero, size: targetSize))
let downscaled = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
guard let cgImage = (downscaled ?? image).cgImage else { return nil }
let ciImage = CIImage(cgImage: cgImage)
// Clamp edges to prevent dark border artifacts from the blur kernel
let clamped = ciImage.clampedToExtent()
guard let blurFilter = CIFilter(name: "CIGaussianBlur") else { return nil }
blurFilter.setValue(clamped, forKey: kCIInputImageKey)
blurFilter.setValue(radius, forKey: kCIInputRadiusKey)
guard let output = blurFilter.outputImage else { return nil }
// Crop back to original extent (blur expands the image)
let cropped = output.cropped(to: ciImage.extent)
guard let cgResult = blurContext.createCGImage(cropped, from: ciImage.extent) else { return nil }
return UIImage(cgImage: cgResult)
}
}

View file

@ -44,7 +44,14 @@ class ImageCache: @unchecked Sendable {
.map { "\($0.name)=\($0.value ?? "")" }
.joined(separator: "&")
let keyStr = "\(endpoint)?\(params)"
let hash = keyStr.utf8.reduce(0) { ($0 &* 31) &+ UInt64($1) }
// FNV-1a 64-bit deterministic, excellent distribution for short strings.
// The previous polynomial hash (x*31+c) had high collision risk for
// sequential cover art IDs like "al-1001", "al-1002".
var hash: UInt64 = 14695981039346656037 // FNV offset basis
for byte in keyStr.utf8 {
hash ^= UInt64(byte)
hash &*= 1099511628211 // FNV prime
}
return String(hash, radix: 16)
}
@ -193,6 +200,9 @@ class AlbumCoverStore: ObservableObject {
private let fileManager = FileManager.default
private let coverDir: URL
/// In-memory cache eliminates repeated Data(contentsOf:) disk reads
/// when SwiftUI re-evaluates body. Auto-evicts under memory pressure.
private let memoryCache = NSCache<NSString, UIImage>()
private init() {
let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
@ -207,24 +217,33 @@ class AlbumCoverStore: ObservableObject {
}
func hasCover(for coverArtId: String) -> Bool {
fileManager.fileExists(atPath: coverURL(for: coverArtId).path)
memoryCache.object(forKey: coverArtId as NSString) != nil
|| fileManager.fileExists(atPath: coverURL(for: coverArtId).path)
}
func loadCover(for coverArtId: String) -> UIImage? {
let key = coverArtId as NSString
// Memory hit no I/O
if let cached = memoryCache.object(forKey: key) { return cached }
// Disk fallback promote to memory
let url = coverURL(for: coverArtId)
guard let data = try? Data(contentsOf: url) else { return nil }
return UIImage(data: data)
guard let data = try? Data(contentsOf: url),
let image = UIImage(data: data) else { return nil }
memoryCache.setObject(image, forKey: key)
return image
}
func saveCover(_ image: UIImage, for coverArtId: String) {
let url = coverURL(for: coverArtId)
if let data = image.jpegData(compressionQuality: 0.85) {
try? data.write(to: url, options: .atomic)
memoryCache.setObject(image, forKey: coverArtId as NSString)
DispatchQueue.main.async { self.updateTrigger = UUID() }
}
}
func removeCover(for coverArtId: String) {
memoryCache.removeObject(forKey: coverArtId as NSString)
let url = coverURL(for: coverArtId)
try? fileManager.removeItem(at: url)
DispatchQueue.main.async { self.updateTrigger = UUID() }
@ -240,6 +259,7 @@ class ArtistCoverStore: ObservableObject {
private let fileManager = FileManager.default
private let coverDir: URL
private let memoryCache = NSCache<NSString, UIImage>()
private init() {
let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
@ -253,24 +273,31 @@ class ArtistCoverStore: ObservableObject {
}
func hasCover(for artistId: String) -> Bool {
fileManager.fileExists(atPath: coverURL(for: artistId).path)
memoryCache.object(forKey: artistId as NSString) != nil
|| fileManager.fileExists(atPath: coverURL(for: artistId).path)
}
func loadCover(for artistId: String) -> UIImage? {
let key = artistId as NSString
if let cached = memoryCache.object(forKey: key) { return cached }
let url = coverURL(for: artistId)
guard let data = try? Data(contentsOf: url) else { return nil }
return UIImage(data: data)
guard let data = try? Data(contentsOf: url),
let image = UIImage(data: data) else { return nil }
memoryCache.setObject(image, forKey: key)
return image
}
func saveCover(_ image: UIImage, for artistId: String) {
let url = coverURL(for: artistId)
if let data = image.jpegData(compressionQuality: 0.85) {
try? data.write(to: url, options: .atomic)
memoryCache.setObject(image, forKey: artistId as NSString)
DispatchQueue.main.async { self.updateTrigger = UUID() }
}
}
func removeCover(for artistId: String) {
memoryCache.removeObject(forKey: artistId as NSString)
let url = coverURL(for: artistId)
try? fileManager.removeItem(at: url)
DispatchQueue.main.async { self.updateTrigger = UUID() }
@ -284,7 +311,6 @@ class CachedImageLoader: ObservableObject {
@Published var isLoading = false
private var url: URL?
private var task: URLSessionDataTask?
func load(from url: URL) {
// Skip if already loaded this URL
@ -299,7 +325,6 @@ class CachedImageLoader: ObservableObject {
// 2. Async check (disk network) never blocks main thread
isLoading = true
task?.cancel()
Task { @MainActor in
// Check disk off main thread
if let cached = await ImageCache.shared.cachedImageAsync(for: url) {
@ -324,8 +349,7 @@ class CachedImageLoader: ObservableObject {
}
func cancel() {
task?.cancel()
task = nil
url = nil
}
}

View file

@ -12,6 +12,7 @@ class RadioCoverStore: ObservableObject {
private let fileManager = FileManager.default
private let coverDir: URL
private let memoryCache = NSCache<NSString, UIImage>()
private init() {
let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
@ -24,24 +25,31 @@ class RadioCoverStore: ObservableObject {
}
func hasCover(for stationId: String) -> Bool {
fileManager.fileExists(atPath: coverURL(for: stationId).path)
memoryCache.object(forKey: stationId as NSString) != nil
|| fileManager.fileExists(atPath: coverURL(for: stationId).path)
}
func loadCover(for stationId: String) -> UIImage? {
let key = stationId as NSString
if let cached = memoryCache.object(forKey: key) { return cached }
let url = coverURL(for: stationId)
guard let data = try? Data(contentsOf: url) else { return nil }
return UIImage(data: data)
guard let data = try? Data(contentsOf: url),
let image = UIImage(data: data) else { return nil }
memoryCache.setObject(image, forKey: key)
return image
}
func saveCover(_ image: UIImage, for stationId: String) {
let url = coverURL(for: stationId)
if let data = image.jpegData(compressionQuality: 0.85) {
try? data.write(to: url, options: .atomic)
memoryCache.setObject(image, forKey: stationId as NSString)
DispatchQueue.main.async { self.updateTrigger = UUID() }
}
}
func removeCover(for stationId: String) {
memoryCache.removeObject(forKey: stationId as NSString)
let url = coverURL(for: stationId)
try? fileManager.removeItem(at: url)
DispatchQueue.main.async { self.updateTrigger = UUID() }