Pull to reload, Umplatzierung Search-Button, Album-Artist
This commit is contained in:
@@ -19,18 +19,22 @@ final class MALibraryManager {
|
|||||||
|
|
||||||
// Published library data
|
// Published library data
|
||||||
private(set) var artists: [MAArtist] = []
|
private(set) var artists: [MAArtist] = []
|
||||||
|
private(set) var albumArtists: [MAArtist] = []
|
||||||
private(set) var albums: [MAAlbum] = []
|
private(set) var albums: [MAAlbum] = []
|
||||||
private(set) var playlists: [MAPlaylist] = []
|
private(set) var playlists: [MAPlaylist] = []
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
private var artistsOffset = 0
|
private var artistsOffset = 0
|
||||||
|
private var albumArtistsOffset = 0
|
||||||
private var albumsOffset = 0
|
private var albumsOffset = 0
|
||||||
private var hasMoreArtists = true
|
private var hasMoreArtists = true
|
||||||
|
private var hasMoreAlbumArtists = true
|
||||||
private var hasMoreAlbums = true
|
private var hasMoreAlbums = true
|
||||||
private let pageSize = 50
|
private let pageSize = 50
|
||||||
|
|
||||||
// Loading states
|
// Loading states
|
||||||
private(set) var isLoadingArtists = false
|
private(set) var isLoadingArtists = false
|
||||||
|
private(set) var isLoadingAlbumArtists = false
|
||||||
private(set) var isLoadingAlbums = false
|
private(set) var isLoadingAlbums = false
|
||||||
private(set) var isLoadingPlaylists = false
|
private(set) var isLoadingPlaylists = false
|
||||||
|
|
||||||
@@ -40,6 +44,7 @@ final class MALibraryManager {
|
|||||||
|
|
||||||
// Last refresh timestamps (persisted in UserDefaults)
|
// Last refresh timestamps (persisted in UserDefaults)
|
||||||
private(set) var lastArtistsRefresh: Date?
|
private(set) var lastArtistsRefresh: Date?
|
||||||
|
private(set) var lastAlbumArtistsRefresh: Date?
|
||||||
private(set) var lastAlbumsRefresh: Date?
|
private(set) var lastAlbumsRefresh: Date?
|
||||||
private(set) var lastPlaylistsRefresh: Date?
|
private(set) var lastPlaylistsRefresh: Date?
|
||||||
|
|
||||||
@@ -71,7 +76,7 @@ final class MALibraryManager {
|
|||||||
logger.info("Cache version mismatch (\(storedVersion) → \(Self.cacheVersion)), clearing library cache")
|
logger.info("Cache version mismatch (\(storedVersion) → \(Self.cacheVersion)), clearing library cache")
|
||||||
try? FileManager.default.removeItem(at: cacheDirectory)
|
try? FileManager.default.removeItem(at: cacheDirectory)
|
||||||
try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
|
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.removeObject(forKey: key)
|
||||||
}
|
}
|
||||||
UserDefaults.standard.set(Self.cacheVersion, forKey: "lib.cacheVersion")
|
UserDefaults.standard.set(Self.cacheVersion, forKey: "lib.cacheVersion")
|
||||||
@@ -90,6 +95,11 @@ final class MALibraryManager {
|
|||||||
artistsOffset = cached.count
|
artistsOffset = cached.count
|
||||||
logger.info("Loaded \(cached.count) artists from disk cache")
|
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") {
|
if let cached: [MAAlbum] = load("albums.json") {
|
||||||
albums = cached
|
albums = cached
|
||||||
albumsOffset = cached.count
|
albumsOffset = cached.count
|
||||||
@@ -102,10 +112,12 @@ final class MALibraryManager {
|
|||||||
|
|
||||||
// Seed favorite URIs from cached data
|
// Seed favorite URIs from cached data
|
||||||
for artist in artists where artist.favorite { favoriteURIs.insert(artist.uri) }
|
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) }
|
for album in albums where album.favorite { favoriteURIs.insert(album.uri) }
|
||||||
|
|
||||||
let ud = UserDefaults.standard
|
let ud = UserDefaults.standard
|
||||||
lastArtistsRefresh = ud.object(forKey: "lib.lastArtistsRefresh") 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
|
lastAlbumsRefresh = ud.object(forKey: "lib.lastAlbumsRefresh") as? Date
|
||||||
lastPlaylistsRefresh = ud.object(forKey: "lib.lastPlaylistsRefresh") as? Date
|
lastPlaylistsRefresh = ud.object(forKey: "lib.lastPlaylistsRefresh") as? Date
|
||||||
}
|
}
|
||||||
@@ -136,46 +148,50 @@ final class MALibraryManager {
|
|||||||
guard !isLoadingArtists else { return }
|
guard !isLoadingArtists else { return }
|
||||||
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
|
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 {
|
if refresh {
|
||||||
hasMoreArtists = true
|
// Full refresh: load ALL pages from scratch so we never lose data
|
||||||
}
|
isLoadingArtists = true
|
||||||
|
defer { isLoadingArtists = false }
|
||||||
|
|
||||||
|
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 }
|
guard hasMoreArtists else { return }
|
||||||
|
|
||||||
isLoadingArtists = true
|
isLoadingArtists = true
|
||||||
defer { isLoadingArtists = false }
|
defer { isLoadingArtists = false }
|
||||||
|
|
||||||
logger.info("Loading artists (offset: \(fetchOffset), refresh: \(refresh))")
|
logger.info("Loading artists page (offset: \(self.artistsOffset))")
|
||||||
|
let newArtists = try await service.getArtists(limit: pageSize, offset: self.artistsOffset)
|
||||||
|
|
||||||
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)
|
artists.append(contentsOf: newArtists)
|
||||||
artistsOffset += newArtists.count
|
artistsOffset += newArtists.count
|
||||||
}
|
|
||||||
for a in newArtists where a.favorite { favoriteURIs.insert(a.uri) }
|
for a in newArtists where a.favorite { favoriteURIs.insert(a.uri) }
|
||||||
hasMoreArtists = newArtists.count >= pageSize
|
hasMoreArtists = newArtists.count >= pageSize
|
||||||
|
|
||||||
if refresh || artistsOffset <= pageSize {
|
if !hasMoreArtists || artistsOffset <= pageSize {
|
||||||
save(artists, "artists.json")
|
save(artists, "artists.json")
|
||||||
lastArtistsRefresh = markRefreshed("lib.lastArtistsRefresh")
|
lastArtistsRefresh = markRefreshed("lib.lastArtistsRefresh")
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("Loaded \(newArtists.count) artists, total: \(self.artists.count)")
|
logger.info("Loaded \(newArtists.count) artists, total: \(self.artists.count)")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Persist updated artist list after pagination completes.
|
/// Persist updated artist list after pagination completes.
|
||||||
func loadMoreArtistsIfNeeded(currentItem: MAArtist?) async throws {
|
func loadMoreArtistsIfNeeded(currentItem: MAArtist?) async throws {
|
||||||
@@ -187,44 +203,112 @@ 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
|
// MARK: - Albums
|
||||||
|
|
||||||
func loadAlbums(refresh: Bool = false) async throws {
|
func loadAlbums(refresh: Bool = false) async throws {
|
||||||
guard !isLoadingAlbums else { return }
|
guard !isLoadingAlbums else { return }
|
||||||
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
|
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
|
||||||
|
|
||||||
let fetchOffset = refresh ? 0 : albumsOffset
|
|
||||||
if refresh {
|
if refresh {
|
||||||
hasMoreAlbums = true
|
isLoadingAlbums = true
|
||||||
}
|
defer { isLoadingAlbums = false }
|
||||||
|
|
||||||
|
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 }
|
guard hasMoreAlbums else { return }
|
||||||
|
|
||||||
isLoadingAlbums = true
|
isLoadingAlbums = true
|
||||||
defer { isLoadingAlbums = false }
|
defer { isLoadingAlbums = false }
|
||||||
|
|
||||||
logger.info("Loading albums (offset: \(fetchOffset), refresh: \(refresh))")
|
logger.info("Loading albums page (offset: \(self.albumsOffset))")
|
||||||
|
let newAlbums = try await service.getAlbums(limit: pageSize, offset: self.albumsOffset)
|
||||||
|
|
||||||
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)
|
albums.append(contentsOf: newAlbums)
|
||||||
albumsOffset += newAlbums.count
|
albumsOffset += newAlbums.count
|
||||||
}
|
|
||||||
for a in newAlbums where a.favorite { favoriteURIs.insert(a.uri) }
|
for a in newAlbums where a.favorite { favoriteURIs.insert(a.uri) }
|
||||||
hasMoreAlbums = newAlbums.count >= pageSize
|
hasMoreAlbums = newAlbums.count >= pageSize
|
||||||
|
|
||||||
if refresh || albumsOffset <= pageSize {
|
if !hasMoreAlbums || albumsOffset <= pageSize {
|
||||||
save(albums, "albums.json")
|
save(albums, "albums.json")
|
||||||
lastAlbumsRefresh = markRefreshed("lib.lastAlbumsRefresh")
|
lastAlbumsRefresh = markRefreshed("lib.lastAlbumsRefresh")
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("Loaded \(newAlbums.count) albums, total: \(self.albums.count)")
|
logger.info("Loaded \(newAlbums.count) albums, total: \(self.albums.count)")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func loadMoreAlbumsIfNeeded(currentItem: MAAlbum?) async throws {
|
func loadMoreAlbumsIfNeeded(currentItem: MAAlbum?) async throws {
|
||||||
guard let currentItem else { return }
|
guard let currentItem else { return }
|
||||||
|
|||||||
@@ -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)
|
/// Get albums (with pagination)
|
||||||
func getAlbums(limit: Int = 50, offset: Int = 0) async throws -> [MAAlbum] {
|
func getAlbums(limit: Int = 50, offset: Int = 0) async throws -> [MAAlbum] {
|
||||||
logger.debug("Fetching albums (limit: \(limit), offset: \(offset))")
|
logger.debug("Fetching albums (limit: \(limit), offset: \(offset))")
|
||||||
|
|||||||
@@ -103,10 +103,13 @@ struct AlbumDetailView: View {
|
|||||||
FavoriteButton(uri: album.uri, size: 22, showInLight: true)
|
FavoriteButton(uri: album.uri, size: 22, showInLight: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task {
|
.task(id: "tracks-\(album.uri)") {
|
||||||
async let tracksLoad: () = loadTracks()
|
await loadTracks()
|
||||||
async let detailLoad: () = loadAlbumDetail()
|
}
|
||||||
_ = await (tracksLoad, detailLoad)
|
.task(id: "detail-\(album.uri)") {
|
||||||
|
await loadAlbumDetail()
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
withAnimation(.linear(duration: 20).repeatForever(autoreverses: true)) {
|
withAnimation(.linear(duration: 20).repeatForever(autoreverses: true)) {
|
||||||
kenBurnsScale = 1.15
|
kenBurnsScale = 1.15
|
||||||
}
|
}
|
||||||
@@ -375,17 +378,17 @@ struct AlbumDetailView: View {
|
|||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
|
|
||||||
private func loadTracks() async {
|
private func loadTracks() async {
|
||||||
print("🔵 AlbumDetailView: Loading tracks for album: \(album.name)")
|
|
||||||
print("🔵 AlbumDetailView: Album URI: \(album.uri)")
|
|
||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
do {
|
do {
|
||||||
tracks = try await service.libraryManager.getAlbumTracks(albumUri: album.uri)
|
tracks = try await service.libraryManager.getAlbumTracks(albumUri: album.uri)
|
||||||
print("✅ AlbumDetailView: Loaded \(tracks.count) tracks")
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
|
} catch is CancellationError {
|
||||||
|
// View disappeared during load — leave isLoading true so a retry
|
||||||
|
// happens automatically when the view reappears.
|
||||||
|
return
|
||||||
} catch {
|
} catch {
|
||||||
print("❌ AlbumDetailView: Failed to load tracks: \(error)")
|
|
||||||
errorMessage = error.localizedDescription
|
errorMessage = error.localizedDescription
|
||||||
showError = true
|
showError = true
|
||||||
isLoading = false
|
isLoading = false
|
||||||
@@ -408,6 +411,8 @@ struct AlbumDetailView: View {
|
|||||||
if let desc = detail.metadata?.description, !desc.isEmpty {
|
if let desc = detail.metadata?.description, !desc.isEmpty {
|
||||||
albumDescription = desc
|
albumDescription = desc
|
||||||
}
|
}
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
} catch {
|
} catch {
|
||||||
// Description is optional — silently ignore if unavailable
|
// Description is optional — silently ignore if unavailable
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ struct AlbumsView: View {
|
|||||||
@Environment(MAService.self) private var service
|
@Environment(MAService.self) private var service
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
@State private var showError = false
|
@State private var showError = false
|
||||||
|
@State private var scrollPosition: String?
|
||||||
|
|
||||||
private var albums: [MAAlbum] {
|
private var albums: [MAAlbum] {
|
||||||
service.libraryManager.albums
|
service.libraryManager.albums
|
||||||
@@ -34,6 +35,7 @@ struct AlbumsView: View {
|
|||||||
AlbumGridItem(album: album)
|
AlbumGridItem(album: album)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.id(album.uri)
|
||||||
.task {
|
.task {
|
||||||
await loadMoreIfNeeded(currentItem: album)
|
await loadMoreIfNeeded(currentItem: album)
|
||||||
}
|
}
|
||||||
@@ -48,11 +50,14 @@ struct AlbumsView: View {
|
|||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
.scrollPosition(id: $scrollPosition)
|
||||||
.refreshable {
|
.refreshable {
|
||||||
await loadAlbums(refresh: true)
|
await loadAlbums(refresh: true)
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
await loadAlbums(refresh: !albums.isEmpty)
|
if albums.isEmpty {
|
||||||
|
await loadAlbums(refresh: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.alert("Error", isPresented: $showError) {
|
.alert("Error", isPresented: $showError) {
|
||||||
Button("OK", role: .cancel) { }
|
Button("OK", role: .cancel) { }
|
||||||
|
|||||||
@@ -61,11 +61,13 @@ struct ArtistDetailView: View {
|
|||||||
FavoriteButton(uri: artist.uri, size: 22, showInLight: true)
|
FavoriteButton(uri: artist.uri, size: 22, showInLight: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task {
|
.task(id: "albums-\(artist.uri)") {
|
||||||
async let albumsLoad: () = loadAlbums()
|
await loadAlbums()
|
||||||
async let detailLoad: () = loadArtistDetail()
|
}
|
||||||
_ = await (albumsLoad, detailLoad)
|
.task(id: "detail-\(artist.uri)") {
|
||||||
// Start Ken Burns animation
|
await loadArtistDetail()
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
withAnimation(.linear(duration: 20).repeatForever(autoreverses: true)) {
|
withAnimation(.linear(duration: 20).repeatForever(autoreverses: true)) {
|
||||||
kenBurnsScale = 1.15
|
kenBurnsScale = 1.15
|
||||||
}
|
}
|
||||||
@@ -218,16 +220,14 @@ struct ArtistDetailView: View {
|
|||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
|
|
||||||
private func loadAlbums() async {
|
private func loadAlbums() async {
|
||||||
print("🔵 ArtistDetailView: Loading albums for artist: \(artist.name)")
|
|
||||||
print("🔵 ArtistDetailView: Artist URI: \(artist.uri)")
|
|
||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
do {
|
do {
|
||||||
albums = try await service.libraryManager.getArtistAlbums(artistUri: artist.uri)
|
albums = try await service.libraryManager.getArtistAlbums(artistUri: artist.uri)
|
||||||
print("✅ ArtistDetailView: Loaded \(albums.count) albums")
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
} catch {
|
} catch {
|
||||||
print("❌ ArtistDetailView: Failed to load albums: \(error)")
|
|
||||||
errorMessage = error.localizedDescription
|
errorMessage = error.localizedDescription
|
||||||
showError = true
|
showError = true
|
||||||
isLoading = false
|
isLoading = false
|
||||||
@@ -240,9 +240,10 @@ struct ArtistDetailView: View {
|
|||||||
if let desc = detail.metadata?.description, !desc.isEmpty {
|
if let desc = detail.metadata?.description, !desc.isEmpty {
|
||||||
biography = desc
|
biography = desc
|
||||||
}
|
}
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
} catch {
|
} catch {
|
||||||
// Biography is optional — silently ignore if unavailable
|
// Biography is optional — silently ignore if unavailable
|
||||||
print("ℹ️ ArtistDetailView: Could not load artist detail: \(error)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,15 +10,17 @@ import UIKit
|
|||||||
|
|
||||||
struct ArtistsView: View {
|
struct ArtistsView: View {
|
||||||
@Environment(MAService.self) private var service
|
@Environment(MAService.self) private var service
|
||||||
|
var albumArtistsOnly: Bool = false
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
@State private var showError = false
|
@State private var showError = false
|
||||||
|
@State private var scrollPosition: String?
|
||||||
|
|
||||||
private var artists: [MAArtist] {
|
private var artists: [MAArtist] {
|
||||||
service.libraryManager.artists
|
albumArtistsOnly ? service.libraryManager.albumArtists : service.libraryManager.artists
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isLoading: Bool {
|
private var isLoading: Bool {
|
||||||
service.libraryManager.isLoadingArtists
|
albumArtistsOnly ? service.libraryManager.isLoadingAlbumArtists : service.libraryManager.isLoadingArtists
|
||||||
}
|
}
|
||||||
|
|
||||||
private let columns = [
|
private let columns = [
|
||||||
@@ -60,7 +62,7 @@ struct ArtistsView: View {
|
|||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.top, 10)
|
.padding(.top, 10)
|
||||||
.padding(.bottom, 4)
|
.padding(.bottom, 4)
|
||||||
.id(letter)
|
.id("section-\(letter)")
|
||||||
|
|
||||||
// Grid of artists in this section
|
// Grid of artists in this section
|
||||||
LazyVGrid(columns: columns, spacing: 8) {
|
LazyVGrid(columns: columns, spacing: 8) {
|
||||||
@@ -69,6 +71,7 @@ struct ArtistsView: View {
|
|||||||
ArtistGridItem(artist: artist)
|
ArtistGridItem(artist: artist)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.id(artist.uri)
|
||||||
.task {
|
.task {
|
||||||
await loadMoreIfNeeded(currentItem: artist)
|
await loadMoreIfNeeded(currentItem: artist)
|
||||||
}
|
}
|
||||||
@@ -86,14 +89,15 @@ struct ArtistsView: View {
|
|||||||
}
|
}
|
||||||
.padding(.trailing, 28)
|
.padding(.trailing, 28)
|
||||||
}
|
}
|
||||||
|
.scrollPosition(id: $scrollPosition)
|
||||||
.overlay(alignment: .trailing) {
|
.overlay(alignment: .trailing) {
|
||||||
AlphabetIndexView(
|
AlphabetIndexView(
|
||||||
letters: allLetters,
|
letters: allLetters,
|
||||||
itemHeight: 17,
|
itemHeight: 17,
|
||||||
onSelect: { letter in
|
onSelect: { letter in
|
||||||
// Scroll to this letter's section, or the nearest one after it
|
let target = "section-\(letter)"
|
||||||
let target = availableLetters.first { $0 >= letter } ?? availableLetters.last
|
scrollPosition = target
|
||||||
if let target { proxy.scrollTo(target, anchor: .top) }
|
proxy.scrollTo(target, anchor: .top)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
@@ -104,7 +108,9 @@ struct ArtistsView: View {
|
|||||||
await loadArtists(refresh: true)
|
await loadArtists(refresh: true)
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
await loadArtists(refresh: !artists.isEmpty)
|
if artists.isEmpty {
|
||||||
|
await loadArtists(refresh: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.alert("Error", isPresented: $showError) {
|
.alert("Error", isPresented: $showError) {
|
||||||
Button("OK", role: .cancel) { }
|
Button("OK", role: .cancel) { }
|
||||||
@@ -116,9 +122,11 @@ struct ArtistsView: View {
|
|||||||
.overlay {
|
.overlay {
|
||||||
if artists.isEmpty && !isLoading {
|
if artists.isEmpty && !isLoading {
|
||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
"No Artists",
|
albumArtistsOnly ? "No Album Artists" : "No Artists",
|
||||||
systemImage: "music.mic",
|
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 {
|
private func loadArtists(refresh: Bool) async {
|
||||||
do {
|
do {
|
||||||
|
if albumArtistsOnly {
|
||||||
|
try await service.libraryManager.loadAlbumArtists(refresh: refresh)
|
||||||
|
} else {
|
||||||
try await service.libraryManager.loadArtists(refresh: refresh)
|
try await service.libraryManager.loadArtists(refresh: refresh)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = error.localizedDescription
|
errorMessage = error.localizedDescription
|
||||||
showError = true
|
showError = true
|
||||||
@@ -135,7 +147,11 @@ struct ArtistsView: View {
|
|||||||
|
|
||||||
private func loadMoreIfNeeded(currentItem: MAArtist) async {
|
private func loadMoreIfNeeded(currentItem: MAArtist) async {
|
||||||
do {
|
do {
|
||||||
|
if albumArtistsOnly {
|
||||||
|
try await service.libraryManager.loadMoreAlbumArtistsIfNeeded(currentItem: currentItem)
|
||||||
|
} else {
|
||||||
try await service.libraryManager.loadMoreArtistsIfNeeded(currentItem: currentItem)
|
try await service.libraryManager.loadMoreArtistsIfNeeded(currentItem: currentItem)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = error.localizedDescription
|
errorMessage = error.localizedDescription
|
||||||
showError = true
|
showError = true
|
||||||
|
|||||||
@@ -6,8 +6,10 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
enum LibraryTab: String, CaseIterable {
|
enum LibraryTab: String, CaseIterable {
|
||||||
|
case albumArtists = "Album Artists"
|
||||||
case artists = "Artists"
|
case artists = "Artists"
|
||||||
case albums = "Albums"
|
case albums = "Albums"
|
||||||
case playlists = "Playlists"
|
case playlists = "Playlists"
|
||||||
@@ -16,33 +18,20 @@ enum LibraryTab: String, CaseIterable {
|
|||||||
|
|
||||||
struct LibraryView: View {
|
struct LibraryView: View {
|
||||||
@Environment(MAService.self) private var service
|
@Environment(MAService.self) private var service
|
||||||
@State private var selectedTab: LibraryTab = .artists
|
@State private var selectedTab: LibraryTab = .albumArtists
|
||||||
@State private var showSearch = false
|
|
||||||
@State private var refreshError: String?
|
|
||||||
@State private var showError = false
|
|
||||||
|
|
||||||
private var isRefreshing: Bool {
|
init() {
|
||||||
switch selectedTab {
|
UISegmentedControl.appearance().setTitleTextAttributes(
|
||||||
case .artists: return service.libraryManager.isLoadingArtists
|
[.font: UIFont.systemFont(ofSize: 11, weight: .medium)],
|
||||||
case .albums: return service.libraryManager.isLoadingAlbums
|
for: .normal
|
||||||
case .playlists: return service.libraryManager.isLoadingPlaylists
|
)
|
||||||
case .radio: return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var lastRefresh: Date? {
|
|
||||||
switch selectedTab {
|
|
||||||
case .artists: return service.libraryManager.lastArtistsRefresh
|
|
||||||
case .albums: return service.libraryManager.lastAlbumsRefresh
|
|
||||||
case .playlists: return service.libraryManager.lastPlaylistsRefresh
|
|
||||||
case .radio: return nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Group {
|
Group {
|
||||||
switch selectedTab {
|
switch selectedTab {
|
||||||
|
case .albumArtists: ArtistsView(albumArtistsOnly: true)
|
||||||
case .artists: ArtistsView()
|
case .artists: ArtistsView()
|
||||||
case .albums: AlbumsView()
|
case .albums: AlbumsView()
|
||||||
case .playlists: PlaylistsView()
|
case .playlists: PlaylistsView()
|
||||||
@@ -51,20 +40,6 @@ struct LibraryView: View {
|
|||||||
}
|
}
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
|
||||||
Button {
|
|
||||||
Task { await refresh() }
|
|
||||||
} label: {
|
|
||||||
if isRefreshing {
|
|
||||||
ProgressView()
|
|
||||||
} else {
|
|
||||||
Image(systemName: "arrow.clockwise")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(isRefreshing || selectedTab == .radio)
|
|
||||||
.help(lastRefreshLabel)
|
|
||||||
}
|
|
||||||
|
|
||||||
ToolbarItem(placement: .principal) {
|
ToolbarItem(placement: .principal) {
|
||||||
Picker("Library", selection: $selectedTab) {
|
Picker("Library", selection: $selectedTab) {
|
||||||
ForEach(LibraryTab.allCases, id: \.self) { tab in
|
ForEach(LibraryTab.allCases, id: \.self) { tab in
|
||||||
@@ -74,52 +49,8 @@ struct LibraryView: View {
|
|||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
.frame(maxWidth: 360)
|
.frame(maxWidth: 360)
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItem(placement: .primaryAction) {
|
|
||||||
Button {
|
|
||||||
showSearch = true
|
|
||||||
} label: {
|
|
||||||
Label("Search", systemImage: "magnifyingglass")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.withMANavigation()
|
.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)
|
FavoriteButton(uri: playlist.uri, size: 22, showInLight: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task {
|
.task(id: playlist.uri) {
|
||||||
await loadTracks()
|
await loadTracks()
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
withAnimation(.linear(duration: 20).repeatForever(autoreverses: true)) {
|
withAnimation(.linear(duration: 20).repeatForever(autoreverses: true)) {
|
||||||
kenBurnsScale = 1.15
|
kenBurnsScale = 1.15
|
||||||
}
|
}
|
||||||
@@ -313,6 +314,8 @@ struct PlaylistDetailView: View {
|
|||||||
do {
|
do {
|
||||||
tracks = try await service.libraryManager.getPlaylistTracks(playlistUri: playlist.uri)
|
tracks = try await service.libraryManager.getPlaylistTracks(playlistUri: playlist.uri)
|
||||||
isLoading = false
|
isLoading = false
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = error.localizedDescription
|
errorMessage = error.localizedDescription
|
||||||
showError = true
|
showError = true
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ struct MainTabView: View {
|
|||||||
LibraryView()
|
LibraryView()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Tab("Search", systemImage: "magnifyingglass") {
|
||||||
|
NavigationStack {
|
||||||
|
SearchView()
|
||||||
|
.withMANavigation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Tab("Players", systemImage: "speaker.wave.2.fill") {
|
Tab("Players", systemImage: "speaker.wave.2.fill") {
|
||||||
PlayerListView()
|
PlayerListView()
|
||||||
}
|
}
|
||||||
@@ -114,19 +121,13 @@ struct PlayerListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Players")
|
.navigationTitle("Players")
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .primaryAction) {
|
|
||||||
Button {
|
|
||||||
Task { await loadPlayers() }
|
|
||||||
} label: {
|
|
||||||
Label("Refresh", systemImage: "arrow.clockwise")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.withMANavigation()
|
.withMANavigation()
|
||||||
.task {
|
.task {
|
||||||
await loadPlayers()
|
await loadPlayers()
|
||||||
}
|
}
|
||||||
|
.refreshable {
|
||||||
|
await loadPlayers()
|
||||||
|
}
|
||||||
.sheet(item: $nowPlayingPlayer) { selectedPlayer in
|
.sheet(item: $nowPlayingPlayer) { selectedPlayer in
|
||||||
PlayerNowPlayingView(playerId: selectedPlayer.playerId)
|
PlayerNowPlayingView(playerId: selectedPlayer.playerId)
|
||||||
.environment(service)
|
.environment(service)
|
||||||
|
|||||||
Reference in New Issue
Block a user