From 098e9b93630c5bad6ac5022181dae1b0cbbe087d Mon Sep 17 00:00:00 2001 From: Dallas Groot Date: Sun, 12 Apr 2026 12:57:42 -0700 Subject: [PATCH] added widget support --- Shared/API/SubsonicClient.swift | 117 ------ Shared/Audio/AudioPlayer.swift | 66 +++- Shared/Models/Models.swift | 1 - Shared/Storage/LibraryCache.swift | 60 +-- Shared/Storage/OfflineManager.swift | 6 +- Shared/Storage/PlaybackStateStore.swift | 12 +- Shared/Storage/WidgetSharedState.swift | 179 +++++++++ Widget/NowPlayingWidget.swift | 185 +++++++++ Widget/NowPlayingWidgetViews.swift | 504 ++++++++++++++++++++++++ Widget/WidgetIntents.swift | 91 +++++ iOS/App/NavidromePlayerApp.swift | 2 +- iOS/Data/SyncEngine.swift | 14 +- iOS/Data/WidgetBridge.swift | 178 +++++++++ iOS/Views/Common/AsyncCoverArt.swift | 46 ++- iOS/Views/Library/RadioView.swift | 14 +- 15 files changed, 1304 insertions(+), 171 deletions(-) create mode 100644 Shared/Storage/WidgetSharedState.swift create mode 100644 Widget/NowPlayingWidget.swift create mode 100644 Widget/NowPlayingWidgetViews.swift create mode 100644 Widget/WidgetIntents.swift create mode 100644 iOS/Data/WidgetBridge.swift diff --git a/Shared/API/SubsonicClient.swift b/Shared/API/SubsonicClient.swift index d4e0840..185f8de 100644 --- a/Shared/API/SubsonicClient.swift +++ b/Shared/API/SubsonicClient.swift @@ -61,32 +61,6 @@ class SubsonicClient: ObservableObject { // MARK: - Generic Request - private func request(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 diff --git a/Shared/Audio/AudioPlayer.swift b/Shared/Audio/AudioPlayer.swift index 68b6d66..060895c 100644 --- a/Shared/Audio/AudioPlayer.swift +++ b/Shared/Audio/AudioPlayer.swift @@ -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 diff --git a/Shared/Models/Models.swift b/Shared/Models/Models.swift index 7d03f2a..65c3f67 100644 --- a/Shared/Models/Models.swift +++ b/Shared/Models/Models.swift @@ -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) } diff --git a/Shared/Storage/LibraryCache.swift b/Shared/Storage/LibraryCache.swift index 7b56045..929fc72 100644 --- a/Shared/Storage/LibraryCache.swift +++ b/Shared/Storage/LibraryCache.swift @@ -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() 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(_ 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(_ 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 { - 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) } } diff --git a/Shared/Storage/OfflineManager.swift b/Shared/Storage/OfflineManager.swift index e6202c5..cd10ec7 100644 --- a/Shared/Storage/OfflineManager.swift +++ b/Shared/Storage/OfflineManager.swift @@ -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 { diff --git a/Shared/Storage/PlaybackStateStore.swift b/Shared/Storage/PlaybackStateStore.swift index d1ed7b5..fdaec3e 100644 --- a/Shared/Storage/PlaybackStateStore.swift +++ b/Shared/Storage/PlaybackStateStore.swift @@ -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 { diff --git a/Shared/Storage/WidgetSharedState.swift b/Shared/Storage/WidgetSharedState.swift new file mode 100644 index 0000000..82ab7e2 --- /dev/null +++ b/Shared/Storage/WidgetSharedState.swift @@ -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 + ) +} diff --git a/Widget/NowPlayingWidget.swift b/Widget/NowPlayingWidget.swift new file mode 100644 index 0000000..91a9985 --- /dev/null +++ b/Widget/NowPlayingWidget.swift @@ -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) -> 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]) + } +} diff --git a/Widget/NowPlayingWidgetViews.swift b/Widget/NowPlayingWidgetViews.swift new file mode 100644 index 0000000..5374440 --- /dev/null +++ b/Widget/NowPlayingWidgetViews.swift @@ -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(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(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 diff --git a/Widget/WidgetIntents.swift b/Widget/WidgetIntents.swift new file mode 100644 index 0000000..5f6bf1c --- /dev/null +++ b/Widget/WidgetIntents.swift @@ -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() + } +} diff --git a/iOS/App/NavidromePlayerApp.swift b/iOS/App/NavidromePlayerApp.swift index 0c0f6c6..ccf6d2e 100644 --- a/iOS/App/NavidromePlayerApp.swift +++ b/iOS/App/NavidromePlayerApp.swift @@ -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() diff --git a/iOS/Data/SyncEngine.swift b/iOS/Data/SyncEngine.swift index 34718de..09e0c12 100644 --- a/iOS/Data/SyncEngine.swift +++ b/iOS/Data/SyncEngine.swift @@ -22,6 +22,9 @@ class SyncEngine: ObservableObject { private let cache = LibraryCache.shared private var syncTask: Task? 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() 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") } diff --git a/iOS/Data/WidgetBridge.swift b/iOS/Data/WidgetBridge.swift new file mode 100644 index 0000000..991f861 --- /dev/null +++ b/iOS/Data/WidgetBridge.swift @@ -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.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) + } +} diff --git a/iOS/Views/Common/AsyncCoverArt.swift b/iOS/Views/Common/AsyncCoverArt.swift index e7c351c..3229ab8 100644 --- a/iOS/Views/Common/AsyncCoverArt.swift +++ b/iOS/Views/Common/AsyncCoverArt.swift @@ -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() 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() 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 } } diff --git a/iOS/Views/Library/RadioView.swift b/iOS/Views/Library/RadioView.swift index 0ba8e9a..08dbc86 100644 --- a/iOS/Views/Library/RadioView.swift +++ b/iOS/Views/Library/RadioView.swift @@ -12,6 +12,7 @@ class RadioCoverStore: ObservableObject { private let fileManager = FileManager.default private let coverDir: URL + private let memoryCache = NSCache() 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() }