Updated API calls and Library View
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
@@ -12,6 +13,7 @@
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
@@ -23,6 +25,7 @@
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
|
||||
@@ -10,207 +10,234 @@ import OSLog
|
||||
|
||||
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
|
||||
final class MALibraryManager {
|
||||
// MARK: - Properties
|
||||
|
||||
|
||||
private weak var service: MAService?
|
||||
|
||||
// Cache
|
||||
|
||||
// Published library data
|
||||
private(set) var artists: [MAArtist] = []
|
||||
private(set) var albums: [MAAlbum] = []
|
||||
private(set) var playlists: [MAPlaylist] = []
|
||||
|
||||
|
||||
// Pagination
|
||||
private var artistsOffset = 0
|
||||
private var albumsOffset = 0
|
||||
private var hasMoreArtists = true
|
||||
private var hasMoreAlbums = true
|
||||
|
||||
private let pageSize = 50
|
||||
|
||||
|
||||
// Loading states
|
||||
private(set) var isLoadingArtists = false
|
||||
private(set) var isLoadingAlbums = 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
|
||||
|
||||
|
||||
init(service: MAService?) {
|
||||
self.service = service
|
||||
loadFromDisk()
|
||||
}
|
||||
|
||||
|
||||
func setService(_ service: MAService) {
|
||||
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
|
||||
|
||||
/// Load initial artists
|
||||
|
||||
func loadArtists(refresh: Bool = false) async throws {
|
||||
guard !isLoadingArtists else { return }
|
||||
guard let service else {
|
||||
throw MAWebSocketClient.ClientError.notConnected
|
||||
}
|
||||
|
||||
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
|
||||
|
||||
if refresh {
|
||||
artistsOffset = 0
|
||||
hasMoreArtists = true
|
||||
await MainActor.run {
|
||||
self.artists = []
|
||||
}
|
||||
artists = []
|
||||
}
|
||||
|
||||
|
||||
guard hasMoreArtists else { return }
|
||||
|
||||
|
||||
isLoadingArtists = true
|
||||
defer { isLoadingArtists = false }
|
||||
|
||||
|
||||
logger.info("Loading artists (offset: \(self.artistsOffset))")
|
||||
|
||||
do {
|
||||
let newArtists = try await service.getArtists(
|
||||
limit: pageSize,
|
||||
offset: artistsOffset
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
let newArtists = try await service.getArtists(limit: pageSize, offset: artistsOffset)
|
||||
|
||||
if refresh {
|
||||
artists = newArtists
|
||||
} else {
|
||||
artists.append(contentsOf: newArtists)
|
||||
}
|
||||
|
||||
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 {
|
||||
guard let currentItem else { return }
|
||||
|
||||
let thresholdIndex = artists.index(artists.endIndex, offsetBy: -10)
|
||||
if let itemIndex = artists.firstIndex(where: { $0.id == currentItem.id }),
|
||||
itemIndex >= thresholdIndex {
|
||||
if let idx = artists.firstIndex(where: { $0.id == currentItem.id }), idx >= thresholdIndex {
|
||||
try await loadArtists(refresh: false)
|
||||
save(artists, "artists.json")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Albums
|
||||
|
||||
/// Load initial albums
|
||||
|
||||
func loadAlbums(refresh: Bool = false) async throws {
|
||||
guard !isLoadingAlbums else { return }
|
||||
guard let service else {
|
||||
throw MAWebSocketClient.ClientError.notConnected
|
||||
}
|
||||
|
||||
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
|
||||
|
||||
if refresh {
|
||||
albumsOffset = 0
|
||||
hasMoreAlbums = true
|
||||
await MainActor.run {
|
||||
self.albums = []
|
||||
}
|
||||
albums = []
|
||||
}
|
||||
|
||||
|
||||
guard hasMoreAlbums else { return }
|
||||
|
||||
|
||||
isLoadingAlbums = true
|
||||
defer { isLoadingAlbums = false }
|
||||
|
||||
|
||||
logger.info("Loading albums (offset: \(self.albumsOffset))")
|
||||
|
||||
do {
|
||||
let newAlbums = try await service.getAlbums(
|
||||
limit: pageSize,
|
||||
offset: albumsOffset
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
let newAlbums = try await service.getAlbums(limit: pageSize, offset: albumsOffset)
|
||||
|
||||
if refresh {
|
||||
albums = newAlbums
|
||||
} else {
|
||||
albums.append(contentsOf: newAlbums)
|
||||
}
|
||||
|
||||
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 {
|
||||
guard let currentItem else { return }
|
||||
|
||||
let thresholdIndex = albums.index(albums.endIndex, offsetBy: -10)
|
||||
if let itemIndex = albums.firstIndex(where: { $0.id == currentItem.id }),
|
||||
itemIndex >= thresholdIndex {
|
||||
if let idx = albums.firstIndex(where: { $0.id == currentItem.id }), idx >= thresholdIndex {
|
||||
try await loadAlbums(refresh: false)
|
||||
save(albums, "albums.json")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Playlists
|
||||
|
||||
/// Load playlists
|
||||
|
||||
func loadPlaylists(refresh: Bool = false) async throws {
|
||||
guard !isLoadingPlaylists else { return }
|
||||
guard let service else {
|
||||
throw MAWebSocketClient.ClientError.notConnected
|
||||
}
|
||||
|
||||
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
|
||||
|
||||
isLoadingPlaylists = true
|
||||
defer { isLoadingPlaylists = false }
|
||||
|
||||
|
||||
logger.info("Loading playlists")
|
||||
|
||||
do {
|
||||
let loadedPlaylists = try await service.getPlaylists()
|
||||
|
||||
await MainActor.run {
|
||||
self.playlists = loadedPlaylists
|
||||
logger.info("Loaded \(loadedPlaylists.count) playlists")
|
||||
}
|
||||
} catch {
|
||||
logger.error("Failed to load playlists: \(error.localizedDescription)")
|
||||
throw error
|
||||
}
|
||||
|
||||
let loaded = try await service.getPlaylists()
|
||||
playlists = loaded
|
||||
save(playlists, "playlists.json")
|
||||
lastPlaylistsRefresh = markRefreshed("lib.lastPlaylistsRefresh")
|
||||
|
||||
logger.info("Loaded \(loaded.count) playlists")
|
||||
}
|
||||
|
||||
// MARK: - Album Tracks
|
||||
|
||||
/// Get tracks for an album
|
||||
|
||||
// MARK: - Artist Albums & Tracks (not cached — fetched on demand)
|
||||
|
||||
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] {
|
||||
guard let service else {
|
||||
throw MAWebSocketClient.ClientError.notConnected
|
||||
}
|
||||
|
||||
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
|
||||
logger.info("Loading tracks for album \(albumUri)")
|
||||
return try await service.getAlbumTracks(albumUri: albumUri)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Search
|
||||
|
||||
/// Search library
|
||||
|
||||
func search(query: String, mediaTypes: [MediaType]? = nil) async throws -> [MAMediaItem] {
|
||||
guard !query.isEmpty else { return [] }
|
||||
guard let service else {
|
||||
throw MAWebSocketClient.ClientError.notConnected
|
||||
}
|
||||
|
||||
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
|
||||
logger.info("Searching for '\(query)'")
|
||||
return try await service.search(query: query, mediaTypes: mediaTypes)
|
||||
}
|
||||
|
||||
@@ -6,16 +6,82 @@
|
||||
//
|
||||
|
||||
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 {
|
||||
@Environment(MAService.self) private var service
|
||||
|
||||
let url: URL?
|
||||
let content: (Image) -> Content
|
||||
let placeholder: () -> Placeholder
|
||||
|
||||
|
||||
@State private var image: UIImage?
|
||||
@State private var isLoading = false
|
||||
|
||||
|
||||
init(
|
||||
url: URL?,
|
||||
@ViewBuilder content: @escaping (Image) -> Content,
|
||||
@@ -25,53 +91,52 @@ struct CachedAsyncImage<Content: View, Placeholder: View>: View {
|
||||
self.content = content
|
||||
self.placeholder = placeholder
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let image {
|
||||
content(Image(uiImage: image))
|
||||
} else {
|
||||
placeholder()
|
||||
.task {
|
||||
await loadImage()
|
||||
}
|
||||
}
|
||||
}
|
||||
// task(id:) automatically cancels + restarts when the URL changes
|
||||
.task(id: url) {
|
||||
await loadImage()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func loadImage() async {
|
||||
guard let url, !isLoading else { return }
|
||||
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
// Configure URLCache if needed
|
||||
configureURLCache()
|
||||
|
||||
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)")
|
||||
guard let url else { return }
|
||||
|
||||
let key = ImageCache.shared.cacheKey(for: url)
|
||||
|
||||
// Serve from cache instantly if available
|
||||
if let cached = ImageCache.shared.image(for: key) {
|
||||
image = cached
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func configureURLCache() {
|
||||
let cache = URLCache.shared
|
||||
if cache.diskCapacity < 50_000_000 {
|
||||
URLCache.shared = URLCache(
|
||||
memoryCapacity: 10_000_000, // 10 MB
|
||||
diskCapacity: 50_000_000 // 50 MB
|
||||
)
|
||||
|
||||
// Build request with auth header
|
||||
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData)
|
||||
if let token = service.authManager.currentToken {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
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 {
|
||||
init(url: URL?) {
|
||||
|
||||
@@ -11,47 +11,166 @@ struct ArtistDetailView: View {
|
||||
@Environment(MAService.self) private var service
|
||||
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 {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
// Artist Header
|
||||
VStack(spacing: 16) {
|
||||
// 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)
|
||||
artistHeader
|
||||
|
||||
// TODO: Load artist albums, top tracks, etc.
|
||||
Text("Artist details coming soon")
|
||||
.foregroundStyle(.secondary)
|
||||
.padding()
|
||||
Divider()
|
||||
|
||||
// Albums Section
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.padding()
|
||||
} else if albums.isEmpty {
|
||||
Text("No albums found")
|
||||
.foregroundStyle(.secondary)
|
||||
.padding()
|
||||
} else {
|
||||
albumGrid
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(artist.name)
|
||||
.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
|
||||
|
||||
enum LibraryTab: String, CaseIterable {
|
||||
case artists = "Artists"
|
||||
case albums = "Albums"
|
||||
case playlists = "Playlists"
|
||||
case radio = "Radio"
|
||||
}
|
||||
|
||||
struct LibraryView: View {
|
||||
@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 {
|
||||
NavigationStack {
|
||||
TabView {
|
||||
Tab("Artists", systemImage: "music.mic") {
|
||||
ArtistsView()
|
||||
}
|
||||
|
||||
Tab("Albums", systemImage: "square.stack") {
|
||||
AlbumsView()
|
||||
}
|
||||
|
||||
Tab("Playlists", systemImage: "music.note.list") {
|
||||
PlaylistsView()
|
||||
Group {
|
||||
switch selectedTab {
|
||||
case .artists: ArtistsView()
|
||||
case .albums: AlbumsView()
|
||||
case .playlists: PlaylistsView()
|
||||
case .radio: RadiosView()
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .always))
|
||||
.navigationTitle("Library")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.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) {
|
||||
NavigationLink {
|
||||
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