diff --git a/Mobile Music Assistant/Assets.xcassets/AppIcon.appiconset/Contents.json b/Mobile Music Assistant/Assets.xcassets/AppIcon.appiconset/Contents.json index 2305880..87d4015 100644 --- a/Mobile Music Assistant/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Mobile Music Assistant/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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" diff --git a/Mobile Music Assistant/ServicesMALibraryManager.swift b/Mobile Music Assistant/ServicesMALibraryManager.swift index c5ef8b2..8f3e584 100644 --- a/Mobile Music Assistant/ServicesMALibraryManager.swift +++ b/Mobile Music Assistant/ServicesMALibraryManager.swift @@ -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(_ 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(_ 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) } diff --git a/Mobile Music Assistant/ViewsComponentsCachedAsyncImage.swift b/Mobile Music Assistant/ViewsComponentsCachedAsyncImage.swift index cfe5d1e..6a07849 100644 --- a/Mobile Music Assistant/ViewsComponentsCachedAsyncImage.swift +++ b/Mobile Music Assistant/ViewsComponentsCachedAsyncImage.swift @@ -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() + 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: 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: 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?) { diff --git a/Mobile Music Assistant/ViewsLibraryArtistDetailView.swift b/Mobile Music Assistant/ViewsLibraryArtistDetailView.swift index 88e7362..f12afca 100644 --- a/Mobile Music Assistant/ViewsLibraryArtistDetailView.swift +++ b/Mobile Music Assistant/ViewsLibraryArtistDetailView.swift @@ -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) + } + } } } diff --git a/Mobile Music Assistant/ViewsLibraryLibraryView.swift b/Mobile Music Assistant/ViewsLibraryLibraryView.swift index 86ad6c7..0cfd8b2 100644 --- a/Mobile Music Assistant/ViewsLibraryLibraryView.swift +++ b/Mobile Music Assistant/ViewsLibraryLibraryView.swift @@ -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 } } } diff --git a/Mobile Music Assistant/ViewsLibraryRadiosView.swift b/Mobile Music Assistant/ViewsLibraryRadiosView.swift new file mode 100644 index 0000000..e56bf1f --- /dev/null +++ b/Mobile Music Assistant/ViewsLibraryRadiosView.swift @@ -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()) + } +}