Updated API calls and Library View

This commit is contained in:
2026-03-27 15:44:33 +01:00
parent e9b6412d71
commit f931c92d94
6 changed files with 632 additions and 207 deletions
@@ -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,14 +10,14 @@ 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] = []
@@ -27,7 +27,6 @@ final class MALibraryManager {
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
@@ -35,31 +34,86 @@ final class MALibraryManager {
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 }
@@ -69,56 +123,46 @@ final class MALibraryManager {
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,
offset: artistsOffset
)
await MainActor.run {
if refresh { if refresh {
self.artists = newArtists artists = newArtists
} else { } else {
self.artists.append(contentsOf: newArtists) artists.append(contentsOf: newArtists)
} }
self.artistsOffset += newArtists.count artistsOffset += newArtists.count
self.hasMoreArtists = newArtists.count >= self.pageSize 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)") logger.info("Loaded \(newArtists.count) artists, total: \(self.artists.count)")
} }
} catch {
logger.error("Failed to load artists: \(error.localizedDescription)")
throw error
}
}
/// 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 }
@@ -128,89 +172,72 @@ final class MALibraryManager {
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,
offset: albumsOffset
)
await MainActor.run {
if refresh { if refresh {
self.albums = newAlbums albums = newAlbums
} else { } else {
self.albums.append(contentsOf: newAlbums) albums.append(contentsOf: newAlbums)
} }
self.albumsOffset += newAlbums.count albumsOffset += newAlbums.count
self.hasMoreAlbums = newAlbums.count >= self.pageSize 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)") logger.info("Loaded \(newAlbums.count) albums, total: \(self.albums.count)")
} }
} catch {
logger.error("Failed to load albums: \(error.localizedDescription)")
throw error
}
}
/// 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")
lastPlaylistsRefresh = markRefreshed("lib.lastPlaylistsRefresh")
await MainActor.run { logger.info("Loaded \(loaded.count) playlists")
self.playlists = loadedPlaylists
logger.info("Loaded \(loadedPlaylists.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)
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)
}
/// Get tracks for an album
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,15 +6,81 @@
// //
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?,
@@ -32,46 +98,45 @@ struct CachedAsyncImage<Content: View, Placeholder: View>: View {
content(Image(uiImage: image)) content(Image(uiImage: image))
} else { } else {
placeholder() placeholder()
.task { }
}
// task(id:) automatically cancels + restarts when the URL changes
.task(id: url) {
await loadImage() 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 }
// Configure URLCache if needed // Serve from cache instantly if available
configureURLCache() if let cached = ImageCache.shared.image(for: key) {
image = cached
return
}
// 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 { do {
let (data, _) = try await URLSession.shared.data(from: url) let (data, response) = try await URLSession.shared.data(for: request)
if let uiImage = UIImage(data: data) { // Only cache successful responses
await MainActor.run { 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 image = uiImage
}
}
} catch { } catch {
print("Failed to load image: \(error.localizedDescription)") // Network errors are silent placeholder stays visible
}
}
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
)
} }
} }
} }
// 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,15 +11,53 @@ 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
artistHeader
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) { VStack(spacing: 16) {
// Artist Image
if let imageUrl = artist.imageUrl { if let imageUrl = artist.imageUrl {
let coverURL = service.imageProxyURL(path: imageUrl, size: 512) let coverURL = service.imageProxyURL(path: imageUrl, size: 512)
CachedAsyncImage(url: coverURL) { image in CachedAsyncImage(url: coverURL) { image in
image image
.resizable() .resizable()
@@ -28,30 +66,111 @@ struct ArtistDetailView: View {
Circle() Circle()
.fill(Color.gray.opacity(0.2)) .fill(Color.gray.opacity(0.2))
} }
.frame(width: 250, height: 250) .frame(width: 200, height: 200)
.clipShape(Circle()) .clipShape(Circle())
.shadow(radius: 10) .shadow(radius: 10)
} else { } else {
Circle() Circle()
.fill(Color.gray.opacity(0.2)) .fill(Color.gray.opacity(0.2))
.frame(width: 250, height: 250) .frame(width: 200, height: 200)
.overlay { .overlay {
Image(systemName: "music.mic") Image(systemName: "music.mic")
.font(.system(size: 60)) .font(.system(size: 60))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
if !albums.isEmpty {
Text("\(albums.count) albums")
.font(.subheadline)
.foregroundStyle(.secondary)
}
} }
.padding(.top) .padding(.top)
}
// TODO: Load artist albums, top tracks, etc. // MARK: - Album Grid
Text("Artist details coming soon")
@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) .foregroundStyle(.secondary)
.padding()
} }
} }
.navigationTitle(artist.name)
.navigationBarTitleDisplayMode(.inline)
} }
} }
@@ -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())
}
}