Pull to reload, Umplatzierung Search-Button, Album-Artist
This commit is contained in:
@@ -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<T: Encodable>(_ 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 {
|
||||
|
||||
@@ -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))")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) { }
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user