Updated API calls and Library View
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
|
"filename" : "AppIcon.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
@@ -12,6 +13,7 @@
|
|||||||
"value" : "dark"
|
"value" : "dark"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"filename" : "AppIcon.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
@@ -23,6 +25,7 @@
|
|||||||
"value" : "tinted"
|
"value" : "tinted"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"filename" : "AppIcon.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
|||||||
@@ -10,207 +10,234 @@ import OSLog
|
|||||||
|
|
||||||
private let logger = Logger(subsystem: "com.musicassistant.mobile", category: "Library")
|
private let logger = Logger(subsystem: "com.musicassistant.mobile", category: "Library")
|
||||||
|
|
||||||
/// Manages library data and caching
|
/// Manages library data with in-memory state and JSON disk cache.
|
||||||
@Observable
|
@Observable
|
||||||
final class MALibraryManager {
|
final class MALibraryManager {
|
||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
|
|
||||||
private weak var service: MAService?
|
private weak var service: MAService?
|
||||||
|
|
||||||
// Cache
|
// Published library data
|
||||||
private(set) var artists: [MAArtist] = []
|
private(set) var artists: [MAArtist] = []
|
||||||
private(set) var albums: [MAAlbum] = []
|
private(set) var albums: [MAAlbum] = []
|
||||||
private(set) var playlists: [MAPlaylist] = []
|
private(set) var playlists: [MAPlaylist] = []
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
private var artistsOffset = 0
|
private var artistsOffset = 0
|
||||||
private var albumsOffset = 0
|
private var albumsOffset = 0
|
||||||
private var hasMoreArtists = true
|
private var hasMoreArtists = true
|
||||||
private var hasMoreAlbums = true
|
private var hasMoreAlbums = true
|
||||||
|
|
||||||
private let pageSize = 50
|
private let pageSize = 50
|
||||||
|
|
||||||
// Loading states
|
// Loading states
|
||||||
private(set) var isLoadingArtists = false
|
private(set) var isLoadingArtists = false
|
||||||
private(set) var isLoadingAlbums = false
|
private(set) var isLoadingAlbums = false
|
||||||
private(set) var isLoadingPlaylists = false
|
private(set) var isLoadingPlaylists = false
|
||||||
|
|
||||||
|
// Last refresh timestamps (persisted in UserDefaults)
|
||||||
|
private(set) var lastArtistsRefresh: Date?
|
||||||
|
private(set) var lastAlbumsRefresh: Date?
|
||||||
|
private(set) var lastPlaylistsRefresh: Date?
|
||||||
|
|
||||||
|
// MARK: - Disk Cache
|
||||||
|
|
||||||
|
private let cacheDirectory: URL = {
|
||||||
|
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||||
|
let dir = caches.appendingPathComponent("MMLibrary", isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir
|
||||||
|
}()
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
init(service: MAService?) {
|
init(service: MAService?) {
|
||||||
self.service = service
|
self.service = service
|
||||||
|
loadFromDisk()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setService(_ service: MAService) {
|
func setService(_ service: MAService) {
|
||||||
self.service = service
|
self.service = service
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Disk Persistence
|
||||||
|
|
||||||
|
/// Loads all cached library data from disk (called synchronously on init).
|
||||||
|
func loadFromDisk() {
|
||||||
|
if let cached: [MAArtist] = load("artists.json") {
|
||||||
|
artists = cached
|
||||||
|
artistsOffset = cached.count
|
||||||
|
logger.info("Loaded \(cached.count) artists from disk cache")
|
||||||
|
}
|
||||||
|
if let cached: [MAAlbum] = load("albums.json") {
|
||||||
|
albums = cached
|
||||||
|
albumsOffset = cached.count
|
||||||
|
logger.info("Loaded \(cached.count) albums from disk cache")
|
||||||
|
}
|
||||||
|
if let cached: [MAPlaylist] = load("playlists.json") {
|
||||||
|
playlists = cached
|
||||||
|
logger.info("Loaded \(cached.count) playlists from disk cache")
|
||||||
|
}
|
||||||
|
|
||||||
|
let ud = UserDefaults.standard
|
||||||
|
lastArtistsRefresh = ud.object(forKey: "lib.lastArtistsRefresh") as? Date
|
||||||
|
lastAlbumsRefresh = ud.object(forKey: "lib.lastAlbumsRefresh") as? Date
|
||||||
|
lastPlaylistsRefresh = ud.object(forKey: "lib.lastPlaylistsRefresh") as? Date
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save<T: Encodable>(_ value: T, _ filename: String) {
|
||||||
|
guard let data = try? JSONEncoder().encode(value) else { return }
|
||||||
|
let url = cacheDirectory.appendingPathComponent(filename)
|
||||||
|
Task.detached(priority: .background) {
|
||||||
|
try? data.write(to: url, options: .atomic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load<T: Decodable>(_ filename: String) -> T? {
|
||||||
|
let url = cacheDirectory.appendingPathComponent(filename)
|
||||||
|
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||||
|
return try? JSONDecoder().decode(T.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func markRefreshed(_ key: String) -> Date {
|
||||||
|
let now = Date()
|
||||||
|
UserDefaults.standard.set(now, forKey: key)
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Artists
|
// MARK: - Artists
|
||||||
|
|
||||||
/// Load initial artists
|
|
||||||
func loadArtists(refresh: Bool = false) async throws {
|
func loadArtists(refresh: Bool = false) async throws {
|
||||||
guard !isLoadingArtists else { return }
|
guard !isLoadingArtists else { return }
|
||||||
guard let service else {
|
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
|
||||||
throw MAWebSocketClient.ClientError.notConnected
|
|
||||||
}
|
|
||||||
|
|
||||||
if refresh {
|
if refresh {
|
||||||
artistsOffset = 0
|
artistsOffset = 0
|
||||||
hasMoreArtists = true
|
hasMoreArtists = true
|
||||||
await MainActor.run {
|
artists = []
|
||||||
self.artists = []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
guard hasMoreArtists else { return }
|
guard hasMoreArtists else { return }
|
||||||
|
|
||||||
isLoadingArtists = true
|
isLoadingArtists = true
|
||||||
defer { isLoadingArtists = false }
|
defer { isLoadingArtists = false }
|
||||||
|
|
||||||
logger.info("Loading artists (offset: \(self.artistsOffset))")
|
logger.info("Loading artists (offset: \(self.artistsOffset))")
|
||||||
|
|
||||||
do {
|
let newArtists = try await service.getArtists(limit: pageSize, offset: artistsOffset)
|
||||||
let newArtists = try await service.getArtists(
|
|
||||||
limit: pageSize,
|
if refresh {
|
||||||
offset: artistsOffset
|
artists = newArtists
|
||||||
)
|
} else {
|
||||||
|
artists.append(contentsOf: newArtists)
|
||||||
await MainActor.run {
|
|
||||||
if refresh {
|
|
||||||
self.artists = newArtists
|
|
||||||
} else {
|
|
||||||
self.artists.append(contentsOf: newArtists)
|
|
||||||
}
|
|
||||||
|
|
||||||
self.artistsOffset += newArtists.count
|
|
||||||
self.hasMoreArtists = newArtists.count >= self.pageSize
|
|
||||||
|
|
||||||
logger.info("Loaded \(newArtists.count) artists, total: \(self.artists.count)")
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
logger.error("Failed to load artists: \(error.localizedDescription)")
|
|
||||||
throw error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
artistsOffset += newArtists.count
|
||||||
|
hasMoreArtists = newArtists.count >= pageSize
|
||||||
|
|
||||||
|
// Persist to disk after a full load or first page of refresh
|
||||||
|
if refresh || artistsOffset <= pageSize {
|
||||||
|
save(artists, "artists.json")
|
||||||
|
lastArtistsRefresh = markRefreshed("lib.lastArtistsRefresh")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Loaded \(newArtists.count) artists, total: \(self.artists.count)")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load more artists (pagination)
|
/// Persist updated artist list after pagination completes.
|
||||||
func loadMoreArtistsIfNeeded(currentItem: MAArtist?) async throws {
|
func loadMoreArtistsIfNeeded(currentItem: MAArtist?) async throws {
|
||||||
guard let currentItem else { return }
|
guard let currentItem else { return }
|
||||||
|
|
||||||
let thresholdIndex = artists.index(artists.endIndex, offsetBy: -10)
|
let thresholdIndex = artists.index(artists.endIndex, offsetBy: -10)
|
||||||
if let itemIndex = artists.firstIndex(where: { $0.id == currentItem.id }),
|
if let idx = artists.firstIndex(where: { $0.id == currentItem.id }), idx >= thresholdIndex {
|
||||||
itemIndex >= thresholdIndex {
|
|
||||||
try await loadArtists(refresh: false)
|
try await loadArtists(refresh: false)
|
||||||
|
save(artists, "artists.json")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Albums
|
// MARK: - Albums
|
||||||
|
|
||||||
/// Load initial albums
|
|
||||||
func loadAlbums(refresh: Bool = false) async throws {
|
func loadAlbums(refresh: Bool = false) async throws {
|
||||||
guard !isLoadingAlbums else { return }
|
guard !isLoadingAlbums else { return }
|
||||||
guard let service else {
|
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
|
||||||
throw MAWebSocketClient.ClientError.notConnected
|
|
||||||
}
|
|
||||||
|
|
||||||
if refresh {
|
if refresh {
|
||||||
albumsOffset = 0
|
albumsOffset = 0
|
||||||
hasMoreAlbums = true
|
hasMoreAlbums = true
|
||||||
await MainActor.run {
|
albums = []
|
||||||
self.albums = []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
guard hasMoreAlbums else { return }
|
guard hasMoreAlbums else { return }
|
||||||
|
|
||||||
isLoadingAlbums = true
|
isLoadingAlbums = true
|
||||||
defer { isLoadingAlbums = false }
|
defer { isLoadingAlbums = false }
|
||||||
|
|
||||||
logger.info("Loading albums (offset: \(self.albumsOffset))")
|
logger.info("Loading albums (offset: \(self.albumsOffset))")
|
||||||
|
|
||||||
do {
|
let newAlbums = try await service.getAlbums(limit: pageSize, offset: albumsOffset)
|
||||||
let newAlbums = try await service.getAlbums(
|
|
||||||
limit: pageSize,
|
if refresh {
|
||||||
offset: albumsOffset
|
albums = newAlbums
|
||||||
)
|
} else {
|
||||||
|
albums.append(contentsOf: newAlbums)
|
||||||
await MainActor.run {
|
|
||||||
if refresh {
|
|
||||||
self.albums = newAlbums
|
|
||||||
} else {
|
|
||||||
self.albums.append(contentsOf: newAlbums)
|
|
||||||
}
|
|
||||||
|
|
||||||
self.albumsOffset += newAlbums.count
|
|
||||||
self.hasMoreAlbums = newAlbums.count >= self.pageSize
|
|
||||||
|
|
||||||
logger.info("Loaded \(newAlbums.count) albums, total: \(self.albums.count)")
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
logger.error("Failed to load albums: \(error.localizedDescription)")
|
|
||||||
throw error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
albumsOffset += newAlbums.count
|
||||||
|
hasMoreAlbums = newAlbums.count >= pageSize
|
||||||
|
|
||||||
|
if refresh || albumsOffset <= pageSize {
|
||||||
|
save(albums, "albums.json")
|
||||||
|
lastAlbumsRefresh = markRefreshed("lib.lastAlbumsRefresh")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Loaded \(newAlbums.count) albums, total: \(self.albums.count)")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load more albums (pagination)
|
|
||||||
func loadMoreAlbumsIfNeeded(currentItem: MAAlbum?) async throws {
|
func loadMoreAlbumsIfNeeded(currentItem: MAAlbum?) async throws {
|
||||||
guard let currentItem else { return }
|
guard let currentItem else { return }
|
||||||
|
|
||||||
let thresholdIndex = albums.index(albums.endIndex, offsetBy: -10)
|
let thresholdIndex = albums.index(albums.endIndex, offsetBy: -10)
|
||||||
if let itemIndex = albums.firstIndex(where: { $0.id == currentItem.id }),
|
if let idx = albums.firstIndex(where: { $0.id == currentItem.id }), idx >= thresholdIndex {
|
||||||
itemIndex >= thresholdIndex {
|
|
||||||
try await loadAlbums(refresh: false)
|
try await loadAlbums(refresh: false)
|
||||||
|
save(albums, "albums.json")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Playlists
|
// MARK: - Playlists
|
||||||
|
|
||||||
/// Load playlists
|
|
||||||
func loadPlaylists(refresh: Bool = false) async throws {
|
func loadPlaylists(refresh: Bool = false) async throws {
|
||||||
guard !isLoadingPlaylists else { return }
|
guard !isLoadingPlaylists else { return }
|
||||||
guard let service else {
|
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
|
||||||
throw MAWebSocketClient.ClientError.notConnected
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoadingPlaylists = true
|
isLoadingPlaylists = true
|
||||||
defer { isLoadingPlaylists = false }
|
defer { isLoadingPlaylists = false }
|
||||||
|
|
||||||
logger.info("Loading playlists")
|
logger.info("Loading playlists")
|
||||||
|
|
||||||
do {
|
let loaded = try await service.getPlaylists()
|
||||||
let loadedPlaylists = try await service.getPlaylists()
|
playlists = loaded
|
||||||
|
save(playlists, "playlists.json")
|
||||||
await MainActor.run {
|
lastPlaylistsRefresh = markRefreshed("lib.lastPlaylistsRefresh")
|
||||||
self.playlists = loadedPlaylists
|
|
||||||
logger.info("Loaded \(loadedPlaylists.count) playlists")
|
logger.info("Loaded \(loaded.count) playlists")
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
logger.error("Failed to load playlists: \(error.localizedDescription)")
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Album Tracks
|
// MARK: - Artist Albums & Tracks (not cached — fetched on demand)
|
||||||
|
|
||||||
/// Get tracks for an album
|
func getArtistAlbums(artistUri: String) async throws -> [MAAlbum] {
|
||||||
|
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
|
||||||
|
logger.info("Loading albums for artist \(artistUri)")
|
||||||
|
return try await service.getArtistAlbums(artistUri: artistUri)
|
||||||
|
}
|
||||||
|
|
||||||
func getAlbumTracks(albumUri: String) async throws -> [MAMediaItem] {
|
func getAlbumTracks(albumUri: String) async throws -> [MAMediaItem] {
|
||||||
guard let service else {
|
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
|
||||||
throw MAWebSocketClient.ClientError.notConnected
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("Loading tracks for album \(albumUri)")
|
logger.info("Loading tracks for album \(albumUri)")
|
||||||
return try await service.getAlbumTracks(albumUri: albumUri)
|
return try await service.getAlbumTracks(albumUri: albumUri)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Search
|
// MARK: - Search
|
||||||
|
|
||||||
/// Search library
|
|
||||||
func search(query: String, mediaTypes: [MediaType]? = nil) async throws -> [MAMediaItem] {
|
func search(query: String, mediaTypes: [MediaType]? = nil) async throws -> [MAMediaItem] {
|
||||||
guard !query.isEmpty else { return [] }
|
guard !query.isEmpty else { return [] }
|
||||||
guard let service else {
|
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
|
||||||
throw MAWebSocketClient.ClientError.notConnected
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("Searching for '\(query)'")
|
logger.info("Searching for '\(query)'")
|
||||||
return try await service.search(query: query, mediaTypes: mediaTypes)
|
return try await service.search(query: query, mediaTypes: mediaTypes)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,82 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
/// AsyncImage with URLCache support for album covers
|
// MARK: - Image Cache
|
||||||
|
|
||||||
|
final class ImageCache: @unchecked Sendable {
|
||||||
|
static let shared = ImageCache()
|
||||||
|
|
||||||
|
private let memory = NSCache<NSString, UIImage>()
|
||||||
|
private let directory: URL
|
||||||
|
private let fileManager = FileManager.default
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||||
|
directory = caches.appendingPathComponent("MMArtwork", isDirectory: true)
|
||||||
|
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
memory.totalCostLimit = 75 * 1024 * 1024 // 75 MB in-memory
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable SHA-256 hex string used as a filename for disk storage.
|
||||||
|
func cacheKey(for url: URL) -> String {
|
||||||
|
let hash = SHA256.hash(data: Data(url.absoluteString.utf8))
|
||||||
|
return hash.map { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
|
|
||||||
|
func image(for key: String) -> UIImage? {
|
||||||
|
// 1. Memory
|
||||||
|
if let img = memory.object(forKey: key as NSString) { return img }
|
||||||
|
// 2. Disk
|
||||||
|
let file = directory.appendingPathComponent(key)
|
||||||
|
guard let data = try? Data(contentsOf: file),
|
||||||
|
let img = UIImage(data: data) else { return nil }
|
||||||
|
memory.setObject(img, forKey: key as NSString, cost: data.count)
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
func store(_ image: UIImage, data: Data, for key: String) {
|
||||||
|
memory.setObject(image, forKey: key as NSString, cost: data.count)
|
||||||
|
let file = directory.appendingPathComponent(key)
|
||||||
|
try? data.write(to: file, options: .atomic)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total bytes currently stored on disk.
|
||||||
|
var diskUsageBytes: Int {
|
||||||
|
guard let enumerator = fileManager.enumerator(
|
||||||
|
at: directory,
|
||||||
|
includingPropertiesForKeys: [.fileSizeKey]
|
||||||
|
) else { return 0 }
|
||||||
|
return enumerator.reduce(0) { total, item in
|
||||||
|
guard let url = item as? URL,
|
||||||
|
let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize
|
||||||
|
else { return total }
|
||||||
|
return total + size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove all cached artwork from disk and memory.
|
||||||
|
func clearAll() {
|
||||||
|
memory.removeAllObjects()
|
||||||
|
try? fileManager.removeItem(at: directory)
|
||||||
|
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CachedAsyncImage
|
||||||
|
|
||||||
|
/// Async image view that caches to both memory (NSCache) and disk (FileManager).
|
||||||
|
/// Sends the MA auth token in the Authorization header so the image proxy responds.
|
||||||
struct CachedAsyncImage<Content: View, Placeholder: View>: View {
|
struct CachedAsyncImage<Content: View, Placeholder: View>: View {
|
||||||
|
@Environment(MAService.self) private var service
|
||||||
|
|
||||||
let url: URL?
|
let url: URL?
|
||||||
let content: (Image) -> Content
|
let content: (Image) -> Content
|
||||||
let placeholder: () -> Placeholder
|
let placeholder: () -> Placeholder
|
||||||
|
|
||||||
@State private var image: UIImage?
|
@State private var image: UIImage?
|
||||||
@State private var isLoading = false
|
|
||||||
|
|
||||||
init(
|
init(
|
||||||
url: URL?,
|
url: URL?,
|
||||||
@ViewBuilder content: @escaping (Image) -> Content,
|
@ViewBuilder content: @escaping (Image) -> Content,
|
||||||
@@ -25,53 +91,52 @@ struct CachedAsyncImage<Content: View, Placeholder: View>: View {
|
|||||||
self.content = content
|
self.content = content
|
||||||
self.placeholder = placeholder
|
self.placeholder = placeholder
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if let image {
|
if let image {
|
||||||
content(Image(uiImage: image))
|
content(Image(uiImage: image))
|
||||||
} else {
|
} else {
|
||||||
placeholder()
|
placeholder()
|
||||||
.task {
|
|
||||||
await loadImage()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// task(id:) automatically cancels + restarts when the URL changes
|
||||||
|
.task(id: url) {
|
||||||
|
await loadImage()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadImage() async {
|
private func loadImage() async {
|
||||||
guard let url, !isLoading else { return }
|
guard let url else { return }
|
||||||
|
|
||||||
isLoading = true
|
let key = ImageCache.shared.cacheKey(for: url)
|
||||||
defer { isLoading = false }
|
|
||||||
|
// Serve from cache instantly if available
|
||||||
// Configure URLCache if needed
|
if let cached = ImageCache.shared.image(for: key) {
|
||||||
configureURLCache()
|
image = cached
|
||||||
|
return
|
||||||
do {
|
|
||||||
let (data, _) = try await URLSession.shared.data(from: url)
|
|
||||||
if let uiImage = UIImage(data: data) {
|
|
||||||
await MainActor.run {
|
|
||||||
image = uiImage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
print("Failed to load image: \(error.localizedDescription)")
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Build request with auth header
|
||||||
private func configureURLCache() {
|
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData)
|
||||||
let cache = URLCache.shared
|
if let token = service.authManager.currentToken {
|
||||||
if cache.diskCapacity < 50_000_000 {
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
URLCache.shared = URLCache(
|
}
|
||||||
memoryCapacity: 10_000_000, // 10 MB
|
|
||||||
diskCapacity: 50_000_000 // 50 MB
|
do {
|
||||||
)
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
// Only cache successful responses
|
||||||
|
guard (response as? HTTPURLResponse)?.statusCode == 200 else { return }
|
||||||
|
guard let uiImage = UIImage(data: data) else { return }
|
||||||
|
ImageCache.shared.store(uiImage, data: data, for: key)
|
||||||
|
image = uiImage
|
||||||
|
} catch {
|
||||||
|
// Network errors are silent — placeholder stays visible
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Convenience Initializers
|
// MARK: - Convenience Initializer
|
||||||
|
|
||||||
extension CachedAsyncImage where Content == Image, Placeholder == Color {
|
extension CachedAsyncImage where Content == Image, Placeholder == Color {
|
||||||
init(url: URL?) {
|
init(url: URL?) {
|
||||||
|
|||||||
@@ -11,47 +11,166 @@ struct ArtistDetailView: View {
|
|||||||
@Environment(MAService.self) private var service
|
@Environment(MAService.self) private var service
|
||||||
let artist: MAArtist
|
let artist: MAArtist
|
||||||
|
|
||||||
|
@State private var albums: [MAAlbum] = []
|
||||||
|
@State private var isLoading = true
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
@State private var showError = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 24) {
|
VStack(spacing: 24) {
|
||||||
// Artist Header
|
// Artist Header
|
||||||
VStack(spacing: 16) {
|
artistHeader
|
||||||
// Artist Image
|
|
||||||
if let imageUrl = artist.imageUrl {
|
|
||||||
let coverURL = service.imageProxyURL(path: imageUrl, size: 512)
|
|
||||||
|
|
||||||
CachedAsyncImage(url: coverURL) { image in
|
|
||||||
image
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
} placeholder: {
|
|
||||||
Circle()
|
|
||||||
.fill(Color.gray.opacity(0.2))
|
|
||||||
}
|
|
||||||
.frame(width: 250, height: 250)
|
|
||||||
.clipShape(Circle())
|
|
||||||
.shadow(radius: 10)
|
|
||||||
} else {
|
|
||||||
Circle()
|
|
||||||
.fill(Color.gray.opacity(0.2))
|
|
||||||
.frame(width: 250, height: 250)
|
|
||||||
.overlay {
|
|
||||||
Image(systemName: "music.mic")
|
|
||||||
.font(.system(size: 60))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.top)
|
|
||||||
|
|
||||||
// TODO: Load artist albums, top tracks, etc.
|
Divider()
|
||||||
Text("Artist details coming soon")
|
|
||||||
.foregroundStyle(.secondary)
|
// Albums Section
|
||||||
.padding()
|
if isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.padding()
|
||||||
|
} else if albums.isEmpty {
|
||||||
|
Text("No albums found")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding()
|
||||||
|
} else {
|
||||||
|
albumGrid
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(artist.name)
|
.navigationTitle(artist.name)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.task {
|
||||||
|
await loadAlbums()
|
||||||
|
}
|
||||||
|
.alert("Error", isPresented: $showError) {
|
||||||
|
Button("OK", role: .cancel) { }
|
||||||
|
} message: {
|
||||||
|
if let errorMessage {
|
||||||
|
Text(errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Artist Header
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var artistHeader: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
if let imageUrl = artist.imageUrl {
|
||||||
|
let coverURL = service.imageProxyURL(path: imageUrl, size: 512)
|
||||||
|
CachedAsyncImage(url: coverURL) { image in
|
||||||
|
image
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
} placeholder: {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.gray.opacity(0.2))
|
||||||
|
}
|
||||||
|
.frame(width: 200, height: 200)
|
||||||
|
.clipShape(Circle())
|
||||||
|
.shadow(radius: 10)
|
||||||
|
} else {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.gray.opacity(0.2))
|
||||||
|
.frame(width: 200, height: 200)
|
||||||
|
.overlay {
|
||||||
|
Image(systemName: "music.mic")
|
||||||
|
.font(.system(size: 60))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !albums.isEmpty {
|
||||||
|
Text("\(albums.count) albums")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Album Grid
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var albumGrid: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Albums")
|
||||||
|
.font(.title2.bold())
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
LazyVGrid(
|
||||||
|
columns: [GridItem(.adaptive(minimum: 160), spacing: 16)],
|
||||||
|
spacing: 16
|
||||||
|
) {
|
||||||
|
ForEach(albums) { album in
|
||||||
|
NavigationLink(destination: AlbumDetailView(album: album)) {
|
||||||
|
ArtistAlbumCard(album: album, service: service)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func loadAlbums() async {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
do {
|
||||||
|
albums = try await service.libraryManager.getArtistAlbums(artistUri: artist.uri)
|
||||||
|
isLoading = false
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
showError = true
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Artist Album Card
|
||||||
|
|
||||||
|
private struct ArtistAlbumCard: View {
|
||||||
|
let album: MAAlbum
|
||||||
|
let service: MAService
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
if let imageUrl = album.imageUrl {
|
||||||
|
let coverURL = service.imageProxyURL(path: imageUrl, size: 256)
|
||||||
|
CachedAsyncImage(url: coverURL) { image in
|
||||||
|
image
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
} placeholder: {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.gray.opacity(0.2))
|
||||||
|
}
|
||||||
|
.aspectRatio(1, contentMode: .fit)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
} else {
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(Color.gray.opacity(0.2))
|
||||||
|
.aspectRatio(1, contentMode: .fit)
|
||||||
|
.overlay {
|
||||||
|
Image(systemName: "opticaldisc")
|
||||||
|
.font(.system(size: 36))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(album.name)
|
||||||
|
.font(.caption.bold())
|
||||||
|
.lineLimit(2)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
|
||||||
|
if let year = album.year {
|
||||||
|
Text(String(year))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,27 +7,73 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
enum LibraryTab: String, CaseIterable {
|
||||||
|
case artists = "Artists"
|
||||||
|
case albums = "Albums"
|
||||||
|
case playlists = "Playlists"
|
||||||
|
case radio = "Radio"
|
||||||
|
}
|
||||||
|
|
||||||
struct LibraryView: View {
|
struct LibraryView: View {
|
||||||
@Environment(MAService.self) private var service
|
@Environment(MAService.self) private var service
|
||||||
|
@State private var selectedTab: LibraryTab = .artists
|
||||||
|
@State private var refreshError: String?
|
||||||
|
@State private var showError = false
|
||||||
|
|
||||||
|
private var isRefreshing: Bool {
|
||||||
|
switch selectedTab {
|
||||||
|
case .artists: return service.libraryManager.isLoadingArtists
|
||||||
|
case .albums: return service.libraryManager.isLoadingAlbums
|
||||||
|
case .playlists: return service.libraryManager.isLoadingPlaylists
|
||||||
|
case .radio: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var lastRefresh: Date? {
|
||||||
|
switch selectedTab {
|
||||||
|
case .artists: return service.libraryManager.lastArtistsRefresh
|
||||||
|
case .albums: return service.libraryManager.lastAlbumsRefresh
|
||||||
|
case .playlists: return service.libraryManager.lastPlaylistsRefresh
|
||||||
|
case .radio: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
TabView {
|
Group {
|
||||||
Tab("Artists", systemImage: "music.mic") {
|
switch selectedTab {
|
||||||
ArtistsView()
|
case .artists: ArtistsView()
|
||||||
}
|
case .albums: AlbumsView()
|
||||||
|
case .playlists: PlaylistsView()
|
||||||
Tab("Albums", systemImage: "square.stack") {
|
case .radio: RadiosView()
|
||||||
AlbumsView()
|
|
||||||
}
|
|
||||||
|
|
||||||
Tab("Playlists", systemImage: "music.note.list") {
|
|
||||||
PlaylistsView()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tabViewStyle(.page(indexDisplayMode: .always))
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.navigationTitle("Library")
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button {
|
||||||
|
Task { await refresh() }
|
||||||
|
} label: {
|
||||||
|
if isRefreshing {
|
||||||
|
ProgressView()
|
||||||
|
} else {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isRefreshing || selectedTab == .radio)
|
||||||
|
.help(lastRefreshLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItem(placement: .principal) {
|
||||||
|
Picker("Library", selection: $selectedTab) {
|
||||||
|
ForEach(LibraryTab.allCases, id: \.self) { tab in
|
||||||
|
Text(tab.rawValue).tag(tab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.frame(maxWidth: 360)
|
||||||
|
}
|
||||||
|
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
SearchView()
|
SearchView()
|
||||||
@@ -36,6 +82,37 @@ struct LibraryView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.navigationDestination(for: MAArtist.self) { ArtistDetailView(artist: $0) }
|
||||||
|
.navigationDestination(for: MAAlbum.self) { AlbumDetailView(album: $0) }
|
||||||
|
.navigationDestination(for: MAPlaylist.self) { PlaylistDetailView(playlist: $0) }
|
||||||
|
.alert("Refresh Failed", isPresented: $showError) {
|
||||||
|
Button("OK", role: .cancel) { }
|
||||||
|
} message: {
|
||||||
|
if let refreshError { Text(refreshError) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private var lastRefreshLabel: String {
|
||||||
|
guard let date = lastRefresh else { return "Never refreshed" }
|
||||||
|
let formatter = RelativeDateTimeFormatter()
|
||||||
|
formatter.unitsStyle = .full
|
||||||
|
return "Last refreshed \(formatter.localizedString(for: date, relativeTo: .now))"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refresh() async {
|
||||||
|
do {
|
||||||
|
switch selectedTab {
|
||||||
|
case .artists: try await service.libraryManager.loadArtists(refresh: true)
|
||||||
|
case .albums: try await service.libraryManager.loadAlbums(refresh: true)
|
||||||
|
case .playlists: try await service.libraryManager.loadPlaylists(refresh: true)
|
||||||
|
case .radio: break
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
refreshError = error.localizedDescription
|
||||||
|
showError = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
//
|
||||||
|
// RadiosView.swift
|
||||||
|
// Mobile Music Assistant
|
||||||
|
//
|
||||||
|
// Created by Sven Hanold on 26.03.26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct RadiosView: View {
|
||||||
|
@Environment(MAService.self) private var service
|
||||||
|
|
||||||
|
@State private var radios: [MAMediaItem] = []
|
||||||
|
@State private var isLoading = true
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
@State private var showError = false
|
||||||
|
@State private var showPlayerPicker = false
|
||||||
|
@State private var selectedRadio: MAMediaItem?
|
||||||
|
|
||||||
|
private var players: [MAPlayer] {
|
||||||
|
Array(service.playerManager.players.values).sorted { $0.name < $1.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List(radios) { radio in
|
||||||
|
RadioRow(radio: radio, service: service)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
selectedRadio = radio
|
||||||
|
showPlayerPicker = true
|
||||||
|
}
|
||||||
|
.listRowSeparator(.visible)
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.overlay {
|
||||||
|
if isLoading {
|
||||||
|
ProgressView()
|
||||||
|
} else if radios.isEmpty && errorMessage == nil {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No Radio Stations",
|
||||||
|
systemImage: "antenna.radiowaves.left.and.right",
|
||||||
|
description: Text("No radio stations found in your library.")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await loadRadios()
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
await loadRadios()
|
||||||
|
}
|
||||||
|
.alert("Error", isPresented: $showError) {
|
||||||
|
Button("OK", role: .cancel) { }
|
||||||
|
} message: {
|
||||||
|
if let errorMessage { Text(errorMessage) }
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showPlayerPicker) {
|
||||||
|
if let radio = selectedRadio {
|
||||||
|
PlayerPickerView(players: players) { player in
|
||||||
|
Task { await playRadio(radio, on: player) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadRadios() async {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
do {
|
||||||
|
radios = try await service.getRadios()
|
||||||
|
isLoading = false
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
showError = true
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func playRadio(_ radio: MAMediaItem, on player: MAPlayer) async {
|
||||||
|
do {
|
||||||
|
try await service.playerManager.playMedia(playerId: player.playerId, uri: radio.uri)
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
showError = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Radio Row
|
||||||
|
|
||||||
|
private struct RadioRow: View {
|
||||||
|
let radio: MAMediaItem
|
||||||
|
let service: MAService
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
if let imageUrl = radio.imageUrl {
|
||||||
|
CachedAsyncImage(url: service.imageProxyURL(path: imageUrl, size: 128)) { image in
|
||||||
|
image.resizable().aspectRatio(contentMode: .fill)
|
||||||
|
} placeholder: {
|
||||||
|
RoundedRectangle(cornerRadius: 8).fill(Color.gray.opacity(0.2))
|
||||||
|
}
|
||||||
|
.frame(width: 50, height: 50)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
} else {
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(Color.gray.opacity(0.2))
|
||||||
|
.frame(width: 50, height: 50)
|
||||||
|
.overlay {
|
||||||
|
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(radio.name)
|
||||||
|
.font(.body)
|
||||||
|
.lineLimit(2)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "play.circle")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationStack {
|
||||||
|
RadiosView()
|
||||||
|
.environment(MAService())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user