diff --git a/Mobile Music Assistant/ServicesMALibraryManager.swift b/Mobile Music Assistant/ServicesMALibraryManager.swift index e032cd8..8c109c3 100644 --- a/Mobile Music Assistant/ServicesMALibraryManager.swift +++ b/Mobile Music Assistant/ServicesMALibraryManager.swift @@ -19,18 +19,22 @@ final class MALibraryManager { // Published library data private(set) var artists: [MAArtist] = [] + private(set) var albumArtists: [MAArtist] = [] private(set) var albums: [MAAlbum] = [] private(set) var playlists: [MAPlaylist] = [] // Pagination private var artistsOffset = 0 + private var albumArtistsOffset = 0 private var albumsOffset = 0 private var hasMoreArtists = true + private var hasMoreAlbumArtists = true private var hasMoreAlbums = true private let pageSize = 50 // Loading states private(set) var isLoadingArtists = false + private(set) var isLoadingAlbumArtists = false private(set) var isLoadingAlbums = false private(set) var isLoadingPlaylists = false @@ -40,6 +44,7 @@ final class MALibraryManager { // Last refresh timestamps (persisted in UserDefaults) private(set) var lastArtistsRefresh: Date? + private(set) var lastAlbumArtistsRefresh: Date? private(set) var lastAlbumsRefresh: Date? private(set) var lastPlaylistsRefresh: Date? @@ -71,7 +76,7 @@ final class MALibraryManager { logger.info("Cache version mismatch (\(storedVersion) → \(Self.cacheVersion)), clearing library cache") try? FileManager.default.removeItem(at: cacheDirectory) try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true) - for key in ["lib.lastArtistsRefresh", "lib.lastAlbumsRefresh", "lib.lastPlaylistsRefresh"] { + for key in ["lib.lastArtistsRefresh", "lib.lastAlbumArtistsRefresh", "lib.lastAlbumsRefresh", "lib.lastPlaylistsRefresh"] { UserDefaults.standard.removeObject(forKey: key) } UserDefaults.standard.set(Self.cacheVersion, forKey: "lib.cacheVersion") @@ -90,6 +95,11 @@ final class MALibraryManager { artistsOffset = cached.count logger.info("Loaded \(cached.count) artists from disk cache") } + if let cached: [MAArtist] = load("albumartists.json") { + albumArtists = cached + albumArtistsOffset = cached.count + logger.info("Loaded \(cached.count) album artists from disk cache") + } if let cached: [MAAlbum] = load("albums.json") { albums = cached albumsOffset = cached.count @@ -102,12 +112,14 @@ final class MALibraryManager { // Seed favorite URIs from cached data for artist in artists where artist.favorite { favoriteURIs.insert(artist.uri) } + for artist in albumArtists where artist.favorite { favoriteURIs.insert(artist.uri) } for album in albums where album.favorite { favoriteURIs.insert(album.uri) } 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 + lastArtistsRefresh = ud.object(forKey: "lib.lastArtistsRefresh") as? Date + lastAlbumArtistsRefresh = ud.object(forKey: "lib.lastAlbumArtistsRefresh") 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) { @@ -136,45 +148,49 @@ final class MALibraryManager { guard !isLoadingArtists else { return } guard let service else { throw MAWebSocketClient.ClientError.notConnected } - // For refresh, reset pagination counters but keep existing data visible until new data arrives - let fetchOffset = refresh ? 0 : artistsOffset if refresh { - hasMoreArtists = true - } + // Full refresh: load ALL pages from scratch so we never lose data + isLoadingArtists = true + defer { isLoadingArtists = false } - guard hasMoreArtists else { return } - - isLoadingArtists = true - defer { isLoadingArtists = false } - - logger.info("Loading artists (offset: \(fetchOffset), refresh: \(refresh))") - - let newArtists = try await service.getArtists(limit: pageSize, offset: fetchOffset) - - // DEBUG: log first artist's image state so we can trace artwork loading - if let a = newArtists.first { - logger.debug("DEBUG Artist[0] name=\(a.name) metadata=\(String(describing: a.metadata)) imageUrl=\(a.imageUrl ?? "nil") imageProvider=\(a.imageProvider ?? "nil")") - } - - // Replace or append atomically — no intermediate empty state - if refresh { - artists = newArtists - artistsOffset = newArtists.count - // Reset and repopulate artist favorites on refresh - for a in artists where a.favorite { favoriteURIs.insert(a.uri) } - } else { - artists.append(contentsOf: newArtists) - artistsOffset += newArtists.count - } - for a in newArtists where a.favorite { favoriteURIs.insert(a.uri) } - hasMoreArtists = newArtists.count >= pageSize - - if refresh || artistsOffset <= pageSize { + logger.info("Refreshing all artists") + var allArtists: [MAArtist] = [] + var offset = 0 + var hasMore = true + while hasMore { + let page = try await service.getArtists(limit: pageSize, offset: offset) + allArtists.append(contentsOf: page) + offset += page.count + hasMore = page.count >= pageSize + } + artists = allArtists + artistsOffset = allArtists.count + hasMoreArtists = false + for a in allArtists where a.favorite { favoriteURIs.insert(a.uri) } save(artists, "artists.json") lastArtistsRefresh = markRefreshed("lib.lastArtistsRefresh") - } + logger.info("Refreshed all artists, total: \(allArtists.count)") + } else { + // Pagination: load next page + guard hasMoreArtists else { return } - logger.info("Loaded \(newArtists.count) artists, total: \(self.artists.count)") + isLoadingArtists = true + defer { isLoadingArtists = false } + + logger.info("Loading artists page (offset: \(self.artistsOffset))") + let newArtists = try await service.getArtists(limit: pageSize, offset: self.artistsOffset) + + artists.append(contentsOf: newArtists) + artistsOffset += newArtists.count + for a in newArtists where a.favorite { favoriteURIs.insert(a.uri) } + hasMoreArtists = newArtists.count >= pageSize + + if !hasMoreArtists || artistsOffset <= pageSize { + save(artists, "artists.json") + lastArtistsRefresh = markRefreshed("lib.lastArtistsRefresh") + } + logger.info("Loaded \(newArtists.count) artists, total: \(self.artists.count)") + } } /// Persist updated artist list after pagination completes. @@ -187,43 +203,111 @@ final class MALibraryManager { } } + // MARK: - Album Artists + + func loadAlbumArtists(refresh: Bool = false) async throws { + guard !isLoadingAlbumArtists else { return } + guard let service else { throw MAWebSocketClient.ClientError.notConnected } + + if refresh { + isLoadingAlbumArtists = true + defer { isLoadingAlbumArtists = false } + + logger.info("Refreshing all album artists") + var allArtists: [MAArtist] = [] + var offset = 0 + var hasMore = true + while hasMore { + let page = try await service.getAlbumArtists(limit: pageSize, offset: offset) + allArtists.append(contentsOf: page) + offset += page.count + hasMore = page.count >= pageSize + } + albumArtists = allArtists + albumArtistsOffset = allArtists.count + hasMoreAlbumArtists = false + for a in allArtists where a.favorite { favoriteURIs.insert(a.uri) } + save(albumArtists, "albumartists.json") + lastAlbumArtistsRefresh = markRefreshed("lib.lastAlbumArtistsRefresh") + logger.info("Refreshed all album artists, total: \(allArtists.count)") + } else { + guard hasMoreAlbumArtists else { return } + + isLoadingAlbumArtists = true + defer { isLoadingAlbumArtists = false } + + logger.info("Loading album artists page (offset: \(self.albumArtistsOffset))") + let newArtists = try await service.getAlbumArtists(limit: pageSize, offset: self.albumArtistsOffset) + + albumArtists.append(contentsOf: newArtists) + albumArtistsOffset += newArtists.count + for a in newArtists where a.favorite { favoriteURIs.insert(a.uri) } + hasMoreAlbumArtists = newArtists.count >= pageSize + + if !hasMoreAlbumArtists || albumArtistsOffset <= pageSize { + save(albumArtists, "albumartists.json") + lastAlbumArtistsRefresh = markRefreshed("lib.lastAlbumArtistsRefresh") + } + logger.info("Loaded \(newArtists.count) album artists, total: \(self.albumArtists.count)") + } + } + + func loadMoreAlbumArtistsIfNeeded(currentItem: MAArtist?) async throws { + guard let currentItem else { return } + let thresholdIndex = albumArtists.index(albumArtists.endIndex, offsetBy: -10) + if let idx = albumArtists.firstIndex(where: { $0.id == currentItem.id }), idx >= thresholdIndex { + try await loadAlbumArtists(refresh: false) + save(albumArtists, "albumartists.json") + } + } + // MARK: - Albums func loadAlbums(refresh: Bool = false) async throws { guard !isLoadingAlbums else { return } guard let service else { throw MAWebSocketClient.ClientError.notConnected } - let fetchOffset = refresh ? 0 : albumsOffset if refresh { - hasMoreAlbums = true - } + isLoadingAlbums = true + defer { isLoadingAlbums = false } - guard hasMoreAlbums else { return } - - isLoadingAlbums = true - defer { isLoadingAlbums = false } - - logger.info("Loading albums (offset: \(fetchOffset), refresh: \(refresh))") - - let newAlbums = try await service.getAlbums(limit: pageSize, offset: fetchOffset) - - if refresh { - albums = newAlbums - albumsOffset = newAlbums.count - for a in albums where a.favorite { favoriteURIs.insert(a.uri) } - } else { - albums.append(contentsOf: newAlbums) - albumsOffset += newAlbums.count - } - for a in newAlbums where a.favorite { favoriteURIs.insert(a.uri) } - hasMoreAlbums = newAlbums.count >= pageSize - - if refresh || albumsOffset <= pageSize { + logger.info("Refreshing all albums") + var allAlbums: [MAAlbum] = [] + var offset = 0 + var hasMore = true + while hasMore { + let page = try await service.getAlbums(limit: pageSize, offset: offset) + allAlbums.append(contentsOf: page) + offset += page.count + hasMore = page.count >= pageSize + } + albums = allAlbums + albumsOffset = allAlbums.count + hasMoreAlbums = false + for a in allAlbums where a.favorite { favoriteURIs.insert(a.uri) } save(albums, "albums.json") lastAlbumsRefresh = markRefreshed("lib.lastAlbumsRefresh") - } + logger.info("Refreshed all albums, total: \(allAlbums.count)") + } else { + guard hasMoreAlbums else { return } - logger.info("Loaded \(newAlbums.count) albums, total: \(self.albums.count)") + isLoadingAlbums = true + defer { isLoadingAlbums = false } + + logger.info("Loading albums page (offset: \(self.albumsOffset))") + let newAlbums = try await service.getAlbums(limit: pageSize, offset: self.albumsOffset) + + albums.append(contentsOf: newAlbums) + albumsOffset += newAlbums.count + for a in newAlbums where a.favorite { favoriteURIs.insert(a.uri) } + hasMoreAlbums = newAlbums.count >= pageSize + + if !hasMoreAlbums || albumsOffset <= pageSize { + save(albums, "albums.json") + lastAlbumsRefresh = markRefreshed("lib.lastAlbumsRefresh") + } + logger.info("Loaded \(newAlbums.count) albums, total: \(self.albums.count)") + } } func loadMoreAlbumsIfNeeded(currentItem: MAAlbum?) async throws { diff --git a/Mobile Music Assistant/ServicesMAService.swift b/Mobile Music Assistant/ServicesMAService.swift index c3a5d3a..e56bd5c 100644 --- a/Mobile Music Assistant/ServicesMAService.swift +++ b/Mobile Music Assistant/ServicesMAService.swift @@ -246,6 +246,20 @@ final class MAService { ) } + /// Get album artists (with pagination) — only artists that appear as primary album artists + func getAlbumArtists(limit: Int = 50, offset: Int = 0) async throws -> [MAArtist] { + logger.debug("Fetching album artists (limit: \(limit), offset: \(offset))") + return try await webSocketClient.sendCommand( + "music/artists/library_items", + args: [ + "limit": limit, + "offset": offset, + "album_artists_only": true + ], + resultType: [MAArtist].self + ) + } + /// Get albums (with pagination) func getAlbums(limit: Int = 50, offset: Int = 0) async throws -> [MAAlbum] { logger.debug("Fetching albums (limit: \(limit), offset: \(offset))") diff --git a/Mobile Music Assistant/ViewsLibraryAlbumDetailView.swift b/Mobile Music Assistant/ViewsLibraryAlbumDetailView.swift index 4b93190..4dafbc1 100644 --- a/Mobile Music Assistant/ViewsLibraryAlbumDetailView.swift +++ b/Mobile Music Assistant/ViewsLibraryAlbumDetailView.swift @@ -103,10 +103,13 @@ struct AlbumDetailView: View { FavoriteButton(uri: album.uri, size: 22, showInLight: true) } } - .task { - async let tracksLoad: () = loadTracks() - async let detailLoad: () = loadAlbumDetail() - _ = await (tracksLoad, detailLoad) + .task(id: "tracks-\(album.uri)") { + await loadTracks() + } + .task(id: "detail-\(album.uri)") { + await loadAlbumDetail() + } + .onAppear { withAnimation(.linear(duration: 20).repeatForever(autoreverses: true)) { kenBurnsScale = 1.15 } @@ -375,17 +378,17 @@ struct AlbumDetailView: View { // MARK: - Actions private func loadTracks() async { - print("đŸ”ĩ AlbumDetailView: Loading tracks for album: \(album.name)") - print("đŸ”ĩ AlbumDetailView: Album URI: \(album.uri)") isLoading = true errorMessage = nil do { tracks = try await service.libraryManager.getAlbumTracks(albumUri: album.uri) - print("✅ AlbumDetailView: Loaded \(tracks.count) tracks") isLoading = false + } catch is CancellationError { + // View disappeared during load — leave isLoading true so a retry + // happens automatically when the view reappears. + return } catch { - print("❌ AlbumDetailView: Failed to load tracks: \(error)") errorMessage = error.localizedDescription showError = true isLoading = false @@ -408,6 +411,8 @@ struct AlbumDetailView: View { if let desc = detail.metadata?.description, !desc.isEmpty { albumDescription = desc } + } catch is CancellationError { + return } catch { // Description is optional — silently ignore if unavailable } diff --git a/Mobile Music Assistant/ViewsLibraryAlbumsView.swift b/Mobile Music Assistant/ViewsLibraryAlbumsView.swift index f3efc18..7c9d662 100644 --- a/Mobile Music Assistant/ViewsLibraryAlbumsView.swift +++ b/Mobile Music Assistant/ViewsLibraryAlbumsView.swift @@ -11,6 +11,7 @@ struct AlbumsView: View { @Environment(MAService.self) private var service @State private var errorMessage: String? @State private var showError = false + @State private var scrollPosition: String? private var albums: [MAAlbum] { service.libraryManager.albums @@ -34,6 +35,7 @@ struct AlbumsView: View { AlbumGridItem(album: album) } .buttonStyle(.plain) + .id(album.uri) .task { await loadMoreIfNeeded(currentItem: album) } @@ -43,16 +45,19 @@ struct AlbumsView: View { ProgressView() .gridCellColumns(columns.count) .padding(.horizontal, 12) - .padding(.vertical, 8) + .padding(.vertical, 8) } } .padding() } + .scrollPosition(id: $scrollPosition) .refreshable { await loadAlbums(refresh: true) } .task { - await loadAlbums(refresh: !albums.isEmpty) + if albums.isEmpty { + await loadAlbums(refresh: true) + } } .alert("Error", isPresented: $showError) { Button("OK", role: .cancel) { } diff --git a/Mobile Music Assistant/ViewsLibraryArtistDetailView.swift b/Mobile Music Assistant/ViewsLibraryArtistDetailView.swift index f93a461..19dfd4f 100644 --- a/Mobile Music Assistant/ViewsLibraryArtistDetailView.swift +++ b/Mobile Music Assistant/ViewsLibraryArtistDetailView.swift @@ -61,11 +61,13 @@ struct ArtistDetailView: View { FavoriteButton(uri: artist.uri, size: 22, showInLight: true) } } - .task { - async let albumsLoad: () = loadAlbums() - async let detailLoad: () = loadArtistDetail() - _ = await (albumsLoad, detailLoad) - // Start Ken Burns animation + .task(id: "albums-\(artist.uri)") { + await loadAlbums() + } + .task(id: "detail-\(artist.uri)") { + await loadArtistDetail() + } + .onAppear { withAnimation(.linear(duration: 20).repeatForever(autoreverses: true)) { kenBurnsScale = 1.15 } @@ -218,16 +220,14 @@ struct ArtistDetailView: View { // MARK: - Actions private func loadAlbums() async { - print("đŸ”ĩ ArtistDetailView: Loading albums for artist: \(artist.name)") - print("đŸ”ĩ ArtistDetailView: Artist URI: \(artist.uri)") isLoading = true errorMessage = nil do { albums = try await service.libraryManager.getArtistAlbums(artistUri: artist.uri) - print("✅ ArtistDetailView: Loaded \(albums.count) albums") isLoading = false + } catch is CancellationError { + return } catch { - print("❌ ArtistDetailView: Failed to load albums: \(error)") errorMessage = error.localizedDescription showError = true isLoading = false @@ -240,9 +240,10 @@ struct ArtistDetailView: View { if let desc = detail.metadata?.description, !desc.isEmpty { biography = desc } + } catch is CancellationError { + return } catch { // Biography is optional — silently ignore if unavailable - print("â„šī¸ ArtistDetailView: Could not load artist detail: \(error)") } } } diff --git a/Mobile Music Assistant/ViewsLibraryArtistsView.swift b/Mobile Music Assistant/ViewsLibraryArtistsView.swift index 5cf37ec..3b0a338 100644 --- a/Mobile Music Assistant/ViewsLibraryArtistsView.swift +++ b/Mobile Music Assistant/ViewsLibraryArtistsView.swift @@ -10,15 +10,17 @@ import UIKit struct ArtistsView: View { @Environment(MAService.self) private var service + var albumArtistsOnly: Bool = false @State private var errorMessage: String? @State private var showError = false + @State private var scrollPosition: String? private var artists: [MAArtist] { - service.libraryManager.artists + albumArtistsOnly ? service.libraryManager.albumArtists : service.libraryManager.artists } private var isLoading: Bool { - service.libraryManager.isLoadingArtists + albumArtistsOnly ? service.libraryManager.isLoadingAlbumArtists : service.libraryManager.isLoadingArtists } private let columns = [ @@ -60,7 +62,7 @@ struct ArtistsView: View { .padding(.horizontal, 12) .padding(.top, 10) .padding(.bottom, 4) - .id(letter) + .id("section-\(letter)") // Grid of artists in this section LazyVGrid(columns: columns, spacing: 8) { @@ -69,6 +71,7 @@ struct ArtistsView: View { ArtistGridItem(artist: artist) } .buttonStyle(.plain) + .id(artist.uri) .task { await loadMoreIfNeeded(currentItem: artist) } @@ -86,14 +89,15 @@ struct ArtistsView: View { } .padding(.trailing, 28) } + .scrollPosition(id: $scrollPosition) .overlay(alignment: .trailing) { AlphabetIndexView( letters: allLetters, itemHeight: 17, onSelect: { letter in - // Scroll to this letter's section, or the nearest one after it - let target = availableLetters.first { $0 >= letter } ?? availableLetters.last - if let target { proxy.scrollTo(target, anchor: .top) } + let target = "section-\(letter)" + scrollPosition = target + proxy.scrollTo(target, anchor: .top) } ) .padding(.vertical, 8) @@ -104,7 +108,9 @@ struct ArtistsView: View { await loadArtists(refresh: true) } .task { - await loadArtists(refresh: !artists.isEmpty) + if artists.isEmpty { + await loadArtists(refresh: true) + } } .alert("Error", isPresented: $showError) { Button("OK", role: .cancel) { } @@ -116,9 +122,11 @@ struct ArtistsView: View { .overlay { if artists.isEmpty && !isLoading { ContentUnavailableView( - "No Artists", + albumArtistsOnly ? "No Album Artists" : "No Artists", systemImage: "music.mic", - description: Text("Your library doesn't contain any artists yet") + description: Text(albumArtistsOnly + ? "Your library doesn't contain any album artists yet" + : "Your library doesn't contain any artists yet") ) } } @@ -126,7 +134,11 @@ struct ArtistsView: View { private func loadArtists(refresh: Bool) async { do { - try await service.libraryManager.loadArtists(refresh: refresh) + if albumArtistsOnly { + try await service.libraryManager.loadAlbumArtists(refresh: refresh) + } else { + try await service.libraryManager.loadArtists(refresh: refresh) + } } catch { errorMessage = error.localizedDescription showError = true @@ -135,7 +147,11 @@ struct ArtistsView: View { private func loadMoreIfNeeded(currentItem: MAArtist) async { do { - try await service.libraryManager.loadMoreArtistsIfNeeded(currentItem: currentItem) + if albumArtistsOnly { + try await service.libraryManager.loadMoreAlbumArtistsIfNeeded(currentItem: currentItem) + } else { + try await service.libraryManager.loadMoreArtistsIfNeeded(currentItem: currentItem) + } } catch { errorMessage = error.localizedDescription showError = true diff --git a/Mobile Music Assistant/ViewsLibraryLibraryView.swift b/Mobile Music Assistant/ViewsLibraryLibraryView.swift index 99007f0..5bebbe0 100644 --- a/Mobile Music Assistant/ViewsLibraryLibraryView.swift +++ b/Mobile Music Assistant/ViewsLibraryLibraryView.swift @@ -6,8 +6,10 @@ // import SwiftUI +import UIKit enum LibraryTab: String, CaseIterable { + case albumArtists = "Album Artists" case artists = "Artists" case albums = "Albums" case playlists = "Playlists" @@ -16,55 +18,28 @@ enum LibraryTab: String, CaseIterable { struct LibraryView: View { @Environment(MAService.self) private var service - @State private var selectedTab: LibraryTab = .artists - @State private var showSearch = false - @State private var refreshError: String? - @State private var showError = false + @State private var selectedTab: LibraryTab = .albumArtists - 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 - } + init() { + UISegmentedControl.appearance().setTitleTextAttributes( + [.font: UIFont.systemFont(ofSize: 11, weight: .medium)], + for: .normal + ) } var body: some View { NavigationStack { Group { switch selectedTab { - case .artists: ArtistsView() - case .albums: AlbumsView() - case .playlists: PlaylistsView() - case .radio: RadiosView() + case .albumArtists: ArtistsView(albumArtistsOnly: true) + case .artists: ArtistsView() + case .albums: AlbumsView() + case .playlists: PlaylistsView() + case .radio: RadiosView() } } .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 @@ -74,52 +49,8 @@ struct LibraryView: View { .pickerStyle(.segmented) .frame(maxWidth: 360) } - - ToolbarItem(placement: .primaryAction) { - Button { - showSearch = true - } label: { - Label("Search", systemImage: "magnifyingglass") - } - } } .withMANavigation() - .alert("Refresh Failed", isPresented: $showError) { - Button("OK", role: .cancel) { } - } message: { - if let refreshError { Text(refreshError) } - } - } - // Search presented as isolated sheet with its own NavigationStack. - // This prevents the main LibraryView stack from being affected by - // search-internal navigation (artist/album/playlist detail). - .sheet(isPresented: $showSearch) { - NavigationStack { - SearchView() - } - } - } - - // 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/ViewsLibraryPlaylistDetailView.swift b/Mobile Music Assistant/ViewsLibraryPlaylistDetailView.swift index ede92d4..f3e3ddf 100644 --- a/Mobile Music Assistant/ViewsLibraryPlaylistDetailView.swift +++ b/Mobile Music Assistant/ViewsLibraryPlaylistDetailView.swift @@ -71,8 +71,9 @@ struct PlaylistDetailView: View { FavoriteButton(uri: playlist.uri, size: 22, showInLight: true) } } - .task { + .task(id: playlist.uri) { await loadTracks() + guard !Task.isCancelled else { return } withAnimation(.linear(duration: 20).repeatForever(autoreverses: true)) { kenBurnsScale = 1.15 } @@ -313,6 +314,8 @@ struct PlaylistDetailView: View { do { tracks = try await service.libraryManager.getPlaylistTracks(playlistUri: playlist.uri) isLoading = false + } catch is CancellationError { + return } catch { errorMessage = error.localizedDescription showError = true diff --git a/Mobile Music Assistant/ViewsMainTabView.swift b/Mobile Music Assistant/ViewsMainTabView.swift index 99e7f4b..599ddd2 100644 --- a/Mobile Music Assistant/ViewsMainTabView.swift +++ b/Mobile Music Assistant/ViewsMainTabView.swift @@ -16,6 +16,13 @@ struct MainTabView: View { LibraryView() } + Tab("Search", systemImage: "magnifyingglass") { + NavigationStack { + SearchView() + .withMANavigation() + } + } + Tab("Players", systemImage: "speaker.wave.2.fill") { PlayerListView() } @@ -114,19 +121,13 @@ struct PlayerListView: View { } } .navigationTitle("Players") - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - Task { await loadPlayers() } - } label: { - Label("Refresh", systemImage: "arrow.clockwise") - } - } - } .withMANavigation() .task { await loadPlayers() } + .refreshable { + await loadPlayers() + } .sheet(item: $nowPlayingPlayer) { selectedPlayer in PlayerNowPlayingView(playerId: selectedPlayer.playerId) .environment(service)