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" : [
{
"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())
}
}