Pull to reload, Umplatzierung Search-Button, Album-Artist

This commit is contained in:
2026-04-06 14:59:32 +02:00
parent 56199db301
commit 040917479e
9 changed files with 247 additions and 187 deletions
@@ -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,10 +112,12 @@ 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
lastAlbumArtistsRefresh = ud.object(forKey: "lib.lastAlbumArtistsRefresh") as? Date
lastAlbumsRefresh = ud.object(forKey: "lib.lastAlbumsRefresh") as? Date
lastPlaylistsRefresh = ud.object(forKey: "lib.lastPlaylistsRefresh") as? Date
}
@@ -136,46 +148,50 @@ 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 }
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 }
isLoadingArtists = true
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)
artistsOffset += newArtists.count
}
for a in newArtists where a.favorite { favoriteURIs.insert(a.uri) }
hasMoreArtists = newArtists.count >= pageSize
if refresh || artistsOffset <= 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.
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
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 }
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 }
isLoadingAlbums = true
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)
albumsOffset += newAlbums.count
}
for a in newAlbums where a.favorite { favoriteURIs.insert(a.uri) }
hasMoreAlbums = newAlbums.count >= pageSize
if refresh || albumsOffset <= 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 {
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)
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)
}
@@ -48,11 +50,14 @@ struct AlbumsView: View {
}
.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 {
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 {
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,33 +18,20 @@ 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 .albumArtists: ArtistsView(albumArtistsOnly: true)
case .artists: ArtistsView()
case .albums: AlbumsView()
case .playlists: PlaylistsView()
@@ -51,20 +40,6 @@ struct LibraryView: View {
}
.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
+10 -9
View File
@@ -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)