added widget support
This commit is contained in:
parent
346b3ef378
commit
098e9b9363
15 changed files with 1304 additions and 171 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
179
Shared/Storage/WidgetSharedState.swift
Normal file
179
Shared/Storage/WidgetSharedState.swift
Normal 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
|
||||
)
|
||||
}
|
||||
185
Widget/NowPlayingWidget.swift
Normal file
185
Widget/NowPlayingWidget.swift
Normal 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])
|
||||
}
|
||||
}
|
||||
504
Widget/NowPlayingWidgetViews.swift
Normal file
504
Widget/NowPlayingWidgetViews.swift
Normal 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
|
||||
91
Widget/WidgetIntents.swift
Normal file
91
Widget/WidgetIntents.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
178
iOS/Data/WidgetBridge.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
Loading…
Reference in a new issue