NavidromeApp/iOS/Views/Library/MyMusicView.swift
2026-04-03 15:47:48 -07:00

1065 lines
43 KiB
Swift

import SwiftUI
import PhotosUI
struct MyMusicView: View {
@EnvironmentObject var serverManager: ServerManager
@EnvironmentObject var audioPlayer: AudioPlayer
@EnvironmentObject var offlineManager: OfflineManager
@Binding var navigateToPlaylistId: String?
@Binding var navigateToAlbumId: String?
@Binding var navigateToArtistId: String?
@State private var recentAlbums: [Album] = []
@State private var allAlbums: [Album] = []
@State private var allSongs: [Song] = []
@State private var playlists: [Playlist] = []
@State private var artists: [ArtistIndex] = []
@State private var genres: [Genre] = []
@State private var favouriteSongs: [Song] = []
@State private var isLoading = true
@State private var sortMode: SortMode = .recentlyAdded
@State private var showServerPicker = false
@State private var showCreatePlaylist = false
@State private var newPlaylistName = ""
@State private var searchText = ""
// Pagination for songs
@State private var songOffset = 0
@State private var hasMoreSongs = true
@State private var albumOffset = 0
@State private var hasMoreAlbums = true
// Album selection mode for batch editing
@State private var isSelectingAlbums = false
@State private var selectedAlbumIds: Set<String> = []
@State private var showBatchAlbumEditor = false
@State private var singleEditAlbumId: String?
@State private var showSingleAlbumEditor = false
// Artist cover picker
@State private var showArtistCoverPicker = false
@State private var artistCoverPickerItem: PhotosPickerItem?
@State private var artistCoverTargetId: String?
@ObservedObject private var artistCoverStore = ArtistCoverStore.shared
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
enum SortMode: String, CaseIterable {
case recentlyAdded = "Recently Added"
case favourites = "Favourites"
case artists = "Artists"
case albums = "Albums"
case songs = "Songs"
case genres = "Genres"
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Tab pills
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(SortMode.allCases, id: \.self) { mode in
Button(action: {
sortMode = mode
searchText = ""
}) {
HStack(spacing: 4) {
if mode == .favourites {
Image(systemName: "heart.fill")
.font(.system(size: 11))
}
Text(mode == .favourites ? "Favourites" : mode.rawValue)
.font(.system(size: 13, weight: .medium))
}
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(sortMode == mode ? accentPink : Color.white.opacity(0.1))
.foregroundColor(sortMode == mode ? .white : .gray)
.cornerRadius(16)
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
// Search bar (for all tabs except Recently Added)
if sortMode != .recentlyAdded {
searchBar
}
// Content
ScrollView {
VStack(alignment: .leading, spacing: 0) {
switch sortMode {
case .recentlyAdded: recentlyAddedTab
case .favourites: favouritesTab
case .artists: artistsTab
case .albums: albumsTab
case .songs: songsTab
case .genres: genresTab
}
Color.clear.frame(height: 100)
}
}
}
.background(Color(white: 0.06))
.navigationTitle("My Music")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
if isSelectingAlbums {
Button(action: {
isSelectingAlbums = false
selectedAlbumIds.removeAll()
}) {
Text("Cancel")
.foregroundColor(.gray)
}
}
}
ToolbarItem(placement: .principal) {
if isSelectingAlbums {
Text("\(selectedAlbumIds.count) selected")
.font(.system(size: 15, weight: .semibold))
.foregroundColor(.white)
}
}
ToolbarItem(placement: .navigationBarTrailing) {
if isSelectingAlbums {
Button(action: { showBatchAlbumEditor = true }) {
HStack(spacing: 4) {
Image(systemName: "tag")
Text("Edit")
}
.font(.system(size: 14, weight: .semibold))
.foregroundColor(selectedAlbumIds.isEmpty ? .gray : accentPink)
}
.disabled(selectedAlbumIds.isEmpty)
} else {
Button(action: { showServerPicker = true }) {
Image(systemName: "server.rack")
.foregroundColor(accentPink)
}
}
}
}
.sheet(isPresented: $showServerPicker) {
ServerPickerSheet(isPresented: $showServerPicker)
}
.alert("New Playlist", isPresented: $showCreatePlaylist) {
TextField("Playlist name", text: $newPlaylistName)
Button("Create") {
let name = newPlaylistName
newPlaylistName = ""
Task {
try? await serverManager.client.createPlaylist(name: name)
await loadData()
}
}
Button("Cancel", role: .cancel) { newPlaylistName = "" }
}
.task { await loadData() }
.refreshable { await loadData() }
.sheet(isPresented: $showBatchAlbumEditor) {
MultiAlbumEditorSheet(albumIds: Array(selectedAlbumIds)) {
isSelectingAlbums = false
selectedAlbumIds.removeAll()
}
}
.sheet(isPresented: $showSingleAlbumEditor) {
if let albumId = singleEditAlbumId {
MultiAlbumEditorSheet(albumIds: [albumId])
}
}
.navigationDestination(isPresented: Binding(
get: { navigateToPlaylistId != nil },
set: { if !$0 { navigateToPlaylistId = nil } }
)) {
if let pid = navigateToPlaylistId {
PlaylistDetailView(playlistId: pid)
}
}
.navigationDestination(isPresented: Binding(
get: { navigateToAlbumId != nil },
set: { if !$0 { navigateToAlbumId = nil } }
)) {
if let aid = navigateToAlbumId {
AlbumDetailView(albumId: aid)
}
}
.navigationDestination(isPresented: Binding(
get: { navigateToArtistId != nil },
set: { if !$0 { navigateToArtistId = nil } }
)) {
if let aid = navigateToArtistId {
ArtistDetailView(artistId: aid)
}
}
}
}
// MARK: - Search Bar
private var searchBar: some View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundColor(.gray)
.font(.system(size: 14))
TextField("Search \(sortMode.rawValue.lowercased())...", text: $searchText)
.font(.system(size: 15))
.foregroundColor(.white)
.autocorrectionDisabled()
if !searchText.isEmpty {
Button(action: { searchText = "" }) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.gray)
.font(.system(size: 14))
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.white.opacity(0.08))
.cornerRadius(10)
.padding(.horizontal, 16)
.padding(.bottom, 8)
}
// MARK: - Recently Added Tab
private var recentlyAddedTab: some View {
VStack(alignment: .leading, spacing: 0) {
recentlyAddedSection
playlistsSection
}
}
private var recentlyAddedSection: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Recently Added")
.font(.system(size: 22, weight: .bold))
.foregroundColor(.white)
Spacer()
NavigationLink(destination: AlbumGridView(title: "Recently Added", type: "newest")) {
Text("more")
.font(.system(size: 15))
.foregroundColor(accentPink)
}
}
.padding(.horizontal, 16)
.padding(.top, 16)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 14) {
ForEach(deduplicateAlbums(Array(recentAlbums.prefix(20)))) { album in
VStack(alignment: .leading, spacing: 6) {
ZStack(alignment: .bottomTrailing) {
NavigationLink(destination: AlbumDetailView(albumId: album.id)) {
AsyncCoverArt(coverArtId: album.coverArt, size: 160)
.frame(width: 160, height: 160)
.cornerRadius(4)
.shadow(color: .black.opacity(0.3), radius: 4, y: 2)
}
Button(action: { playAlbum(album) }) {
Image(systemName: "play.circle.fill")
.font(.system(size: 32))
.foregroundColor(.white)
.shadow(radius: 4)
}
.padding(6)
}
NavigationLink(destination: AlbumDetailView(albumId: album.id)) {
VStack(alignment: .leading, spacing: 2) {
Text(album.name)
.font(.system(size: 12, weight: .medium))
.foregroundColor(.white)
.lineLimit(1)
Text(album.artist ?? "")
.font(.system(size: 11))
.foregroundColor(.gray)
.lineLimit(1)
}
}
}
.frame(width: 160)
.contextMenu {
Button(action: { playAlbum(album) }) {
Label("Play", systemImage: "play.fill")
}
if CompanionSettings.shared.isEnabled {
Divider()
Button(action: {
singleEditAlbumId = album.id
showSingleAlbumEditor = true
}) {
Label("Edit Album", systemImage: "tag")
}
Button(action: {
selectedAlbumIds = [album.id]
isSelectingAlbums = true
sortMode = .albums
}) {
Label("Select Albums", systemImage: "checkmark.circle")
}
}
}
}
}
.padding(.horizontal, 16)
}
}
}
// MARK: - Playlists Section
private var playlistsSection: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("Playlists")
.font(.system(size: 22, weight: .bold))
.foregroundColor(.white)
Spacer()
Button(action: { showCreatePlaylist = true }) {
Image(systemName: "plus.circle.fill")
.font(.system(size: 22))
.foregroundColor(accentPink)
}
}
.padding(.horizontal, 16)
.padding(.top, 24)
.padding(.bottom, 12)
LazyVStack(spacing: 0) {
ForEach(playlists) { playlist in
NavigationLink(destination: PlaylistDetailView(playlistId: playlist.id)) {
HStack(spacing: 12) {
AsyncCoverArt(coverArtId: playlist.coverArt, size: 56)
.frame(width: 52, height: 52)
.cornerRadius(4)
VStack(alignment: .leading, spacing: 3) {
Text(playlist.name)
.font(.system(size: 15))
.foregroundColor(.white)
.lineLimit(1)
Text("\(playlist.songCount ?? 0) songs")
.font(.system(size: 12))
.foregroundColor(.gray)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12))
.foregroundColor(.gray)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
.contextMenu {
Button(role: .destructive, action: {
Task {
try? await serverManager.client.deletePlaylist(id: playlist.id)
await loadData()
}
}) {
Label("Delete Playlist", systemImage: "trash")
}
}
Divider()
.background(Color.white.opacity(0.1))
.padding(.leading, 86)
}
}
}
}
// MARK: - Favourites Tab
private var favouritesTab: some View {
let filtered = searchText.isEmpty ? favouriteSongs : favouriteSongs.filter {
$0.title.localizedCaseInsensitiveContains(searchText) ||
($0.artist ?? "").localizedCaseInsensitiveContains(searchText)
}
return VStack(spacing: 0) {
// Header buttons
if !favouriteSongs.isEmpty {
HStack(spacing: 12) {
Button(action: downloadAllFavourites) {
HStack(spacing: 4) {
Image(systemName: "arrow.down.circle")
Text("Download All")
}
.font(.system(size: 13, weight: .medium))
.foregroundColor(accentPink)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(accentPink.opacity(0.15))
.cornerRadius(8)
}
if WatchConnectivityManager.shared.isWatchAvailable {
Button(action: sendAllFavouritesToWatch) {
HStack(spacing: 4) {
Image(systemName: "applewatch.and.arrow.forward")
Text("Send to Watch")
}
.font(.system(size: 13, weight: .medium))
.foregroundColor(accentPink)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(accentPink.opacity(0.15))
.cornerRadius(8)
}
}
Spacer()
Text("\(favouriteSongs.count) songs")
.font(.system(size: 12))
.foregroundColor(.gray)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
// Song list with swipe-to-delete
LazyVStack(spacing: 0) {
ForEach(Array(filtered.enumerated()), id: \.element.id) { index, song in
songRow(song: song, index: index, allSongs: filtered)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
removeFavourite(song)
} label: {
Label("Unfavourite", systemImage: "heart.slash")
}
.tint(.red)
}
Divider()
.background(Color.white.opacity(0.08))
.padding(.leading, 72)
}
if filtered.isEmpty && !isLoading {
emptyState(searchText.isEmpty ? "No favourites yet — Heart songs to see them here" : "No matches")
}
}
.padding(.top, 4)
}
}
private func removeFavourite(_ song: Song) {
Task {
try? await serverManager.client.unstar(id: song.id)
await MainActor.run {
favouriteSongs.removeAll { $0.id == song.id }
LibraryCache.shared.save(favouriteSongs, key: "starred_songs")
}
}
}
private func downloadAllFavourites() {
guard let server = serverManager.activeServer else { return }
for song in favouriteSongs {
if !offlineManager.isSongDownloaded(song.id) {
offlineManager.downloadSong(song, server: server)
}
}
}
private func sendAllFavouritesToWatch() {
for song in favouriteSongs {
_ = WatchConnectivityManager.shared.sendSongToWatch(song)
}
}
// MARK: - Artists Tab
private var artistsTab: some View {
let flatArtists = artists.flatMap { $0.artist ?? [] }
let filtered = searchText.isEmpty ? flatArtists : flatArtists.filter {
$0.name.localizedCaseInsensitiveContains(searchText)
}
return LazyVStack(spacing: 0) {
ForEach(filtered) { artist in
NavigationLink(destination: ArtistDetailView(artistId: artist.id)) {
HStack(spacing: 14) {
if let customImage = artistCoverStore.loadCover(for: artist.id) {
Image(uiImage: customImage)
.resizable()
.scaledToFill()
.frame(width: 48, height: 48)
.clipShape(Circle())
} else {
AsyncCoverArt(coverArtId: artist.coverArt, size: 48)
.frame(width: 48, height: 48)
.clipShape(Circle())
}
Text(artist.name)
.font(.system(size: 16))
.foregroundColor(.white)
Spacer()
if let count = artist.albumCount {
Text("\(count) albums")
.font(.system(size: 12))
.foregroundColor(.gray)
}
Image(systemName: "chevron.right")
.font(.system(size: 12))
.foregroundColor(.gray)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
.contextMenu {
Button(action: {
artistCoverTargetId = artist.id
showArtistCoverPicker = true
}) {
Label("Change Artist Cover", systemImage: "photo.on.rectangle")
}
if artistCoverStore.hasCover(for: artist.id) {
Button(role: .destructive, action: {
artistCoverStore.removeCover(for: artist.id)
}) {
Label("Remove Custom Cover", systemImage: "xmark.circle")
}
}
}
Divider()
.background(Color.white.opacity(0.1))
.padding(.leading, 78)
}
if filtered.isEmpty && !isLoading {
emptyState("No artists found")
}
}
.padding(.top, 4)
.photosPicker(
isPresented: $showArtistCoverPicker,
selection: $artistCoverPickerItem,
matching: .images
)
.onChange(of: artistCoverPickerItem) { _, newItem in
guard let item = newItem, let targetId = artistCoverTargetId else { return }
Task {
if let data = try? await item.loadTransferable(type: Data.self),
let image = UIImage(data: data) {
artistCoverStore.saveCover(image, for: targetId)
}
artistCoverPickerItem = nil
artistCoverTargetId = nil
}
}
}
// MARK: - Albums Tab (grid with selection)
private var albumsTab: some View {
let deduped = deduplicateAlbums(allAlbums)
let filtered = searchText.isEmpty ? deduped : deduped.filter {
$0.name.localizedCaseInsensitiveContains(searchText) ||
($0.artist ?? "").localizedCaseInsensitiveContains(searchText)
}
let columns = [GridItem(.adaptive(minimum: 160), spacing: 14)]
return VStack(spacing: 0) {
// Quick actions bar in selection mode
if isSelectingAlbums {
HStack(spacing: 16) {
Button(action: {
if selectedAlbumIds.count == filtered.count {
selectedAlbumIds.removeAll()
} else {
selectedAlbumIds = Set(filtered.map { $0.id })
}
}) {
HStack(spacing: 4) {
Image(systemName: selectedAlbumIds.count == filtered.count ? "checkmark.circle.fill" : "circle")
.font(.system(size: 14))
Text(selectedAlbumIds.count == filtered.count ? "Deselect All" : "Select All")
.font(.system(size: 13, weight: .medium))
}
.foregroundColor(accentPink)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color.white.opacity(0.03))
}
LazyVGrid(columns: columns, spacing: 18) {
ForEach(filtered) { album in
if isSelectingAlbums {
// Selection mode: tap to toggle selection
albumTile(album)
.overlay(alignment: .topLeading) {
Image(systemName: selectedAlbumIds.contains(album.id) ? "checkmark.circle.fill" : "circle")
.font(.system(size: 22))
.foregroundColor(selectedAlbumIds.contains(album.id) ? accentPink : .white.opacity(0.5))
.shadow(radius: 4)
.padding(6)
}
.opacity(selectedAlbumIds.isEmpty || selectedAlbumIds.contains(album.id) ? 1 : 0.5)
.onTapGesture {
if selectedAlbumIds.contains(album.id) {
selectedAlbumIds.remove(album.id)
} else {
selectedAlbumIds.insert(album.id)
}
}
} else {
// Normal mode: navigate + context menu
NavigationLink(destination: AlbumDetailView(albumId: album.id)) {
albumTile(album)
}
.contextMenu {
Button(action: { playAlbum(album) }) {
Label("Play", systemImage: "play.fill")
}
if CompanionSettings.shared.isEnabled {
Divider()
Button(action: {
singleEditAlbumId = album.id
showSingleAlbumEditor = true
}) {
Label("Edit Album", systemImage: "tag")
}
Button(action: {
selectedAlbumIds = [album.id]
isSelectingAlbums = true
}) {
Label("Select Albums", systemImage: "checkmark.circle")
}
}
}
}
}
}
.padding(16)
if hasMoreAlbums && searchText.isEmpty {
Button(action: { Task { await loadMoreAlbums() } }) {
Text("Load More")
.font(.system(size: 14, weight: .medium))
.foregroundColor(accentPink)
.padding(.vertical, 12)
.frame(maxWidth: .infinity)
}
}
if filtered.isEmpty && !isLoading {
emptyState("No albums found")
}
}
}
/// Reusable album tile (cover + title + artist)
private func albumTile(_ album: Album) -> some View {
VStack(alignment: .leading, spacing: 6) {
AsyncCoverArt(coverArtId: album.coverArt, size: 180)
.frame(height: 160)
.cornerRadius(4)
Text(album.name)
.font(.system(size: 13, weight: .medium))
.foregroundColor(.white)
.lineLimit(1)
Text(album.artist ?? "")
.font(.system(size: 11))
.foregroundColor(.gray)
.lineLimit(1)
}
}
// MARK: - Songs Tab (list)
private var songsTab: some View {
let filtered = searchText.isEmpty ? allSongs : allSongs.filter {
$0.title.localizedCaseInsensitiveContains(searchText) ||
($0.artist ?? "").localizedCaseInsensitiveContains(searchText) ||
($0.album ?? "").localizedCaseInsensitiveContains(searchText)
}
return LazyVStack(spacing: 0) {
ForEach(Array(filtered.enumerated()), id: \.element.id) { index, song in
songRow(song: song, index: index, allSongs: filtered)
Divider()
.background(Color.white.opacity(0.08))
.padding(.leading, 72)
}
if hasMoreSongs && searchText.isEmpty {
Button(action: { Task { await loadMoreSongs() } }) {
Text("Load More")
.font(.system(size: 14, weight: .medium))
.foregroundColor(accentPink)
.padding(.vertical, 12)
.frame(maxWidth: .infinity)
}
}
if filtered.isEmpty && !isLoading {
emptyState("No songs found")
}
}
.padding(.top, 4)
}
@ViewBuilder
private func songRow(song: Song, index: Int, allSongs: [Song]) -> some View {
Button(action: {
audioPlayer.play(song: song, fromQueue: Array(allSongs), at: index)
}) {
HStack(spacing: 12) {
AsyncCoverArt(coverArtId: song.coverArt, size: 48)
.frame(width: 44, height: 44)
.cornerRadius(3)
VStack(alignment: .leading, spacing: 2) {
Text(song.title)
.font(.system(size: 15))
.foregroundColor(audioPlayer.currentSong?.id == song.id ? accentPink : .white)
.lineLimit(1)
Text("\(song.artist ?? "") · \(song.album ?? "")")
.font(.system(size: 12))
.foregroundColor(.gray)
.lineLimit(1)
}
Spacer()
Text(song.durationFormatted)
.font(.system(size: 13))
.foregroundColor(.gray)
}
.padding(.horizontal, 16)
.padding(.vertical, 6)
}
.contextMenu {
Button(action: { audioPlayer.playNow(song) }) {
Label("Play Now", systemImage: "play.fill")
}
Button(action: { audioPlayer.playNext(song) }) {
Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward")
}
Button(action: { audioPlayer.playLater(song) }) {
Label("Play Later", systemImage: "text.line.last.and.arrowtriangle.forward")
}
Divider()
Button(action: { audioPlayer.playInstantMix(basedOn: song) }) {
Label("Instant Mix", systemImage: "wand.and.stars")
}
Divider()
if offlineManager.isSongDownloaded(song.id) {
Button(role: .destructive, action: { offlineManager.removeSong(song.id) }) {
Label("Remove Download", systemImage: "trash")
}
} else {
Button(action: {
if let server = serverManager.activeServer {
offlineManager.downloadSong(song, server: server)
}
}) {
Label("Download", systemImage: "arrow.down.circle")
}
}
}
}
// MARK: - Genres Tab
private var genresTab: some View {
let filtered = searchText.isEmpty ? genres : genres.filter {
$0.value.localizedCaseInsensitiveContains(searchText)
}
return LazyVStack(spacing: 0) {
ForEach(filtered) { genre in
NavigationLink(destination: AlbumGridView(title: genre.value, type: "byGenre", genre: genre.value)) {
HStack(spacing: 14) {
Image(systemName: "music.note.list")
.font(.system(size: 18))
.foregroundColor(accentPink)
.frame(width: 36, height: 36)
.background(Color.white.opacity(0.08))
.cornerRadius(8)
VStack(alignment: .leading, spacing: 2) {
Text(genre.value)
.font(.system(size: 16))
.foregroundColor(.white)
HStack(spacing: 8) {
if let sc = genre.songCount {
Text("\(sc) songs")
.font(.system(size: 12))
.foregroundColor(.gray)
}
if let ac = genre.albumCount {
Text("\(ac) albums")
.font(.system(size: 12))
.foregroundColor(.gray)
}
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12))
.foregroundColor(.gray)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
Divider()
.background(Color.white.opacity(0.1))
.padding(.leading, 66)
}
if filtered.isEmpty && !isLoading {
emptyState("No genres found")
}
}
.padding(.top, 4)
}
// MARK: - Empty State
private func emptyState(_ text: String) -> some View {
VStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.font(.system(size: 28))
.foregroundColor(.gray)
Text(text)
.font(.system(size: 14))
.foregroundColor(.gray)
}
.frame(maxWidth: .infinity)
.padding(.top, 60)
}
// MARK: - Play Album
private func playAlbum(_ album: Album) {
Task {
do {
let albumDetail = try await serverManager.client.getAlbum(id: album.id)
if let songs = albumDetail?.song, !songs.isEmpty {
await MainActor.run {
audioPlayer.play(song: songs[0], fromQueue: songs, at: 0)
}
}
} catch {
// Fallback: navigate to album detail
}
}
}
// MARK: - Album Deduplication
// Navidrome returns one album entry per artist for compilations.
// Group by name + coverArt to show each album once.
private func deduplicateAlbums(_ albums: [Album]) -> [Album] {
var seen = Set<String>()
var result: [Album] = []
for album in albums {
let key = "\(album.name)|\(album.coverArt ?? "")"
if seen.contains(key) { continue }
seen.insert(key)
// Check if multiple artists share this album
let sameAlbum = albums.filter { "\($0.name)|\($0.coverArt ?? "")" == key }
if sameAlbum.count > 1 {
// Replace artist with "Various Artists"
let grouped = Album(
id: album.id, name: album.name,
artist: "Various Artists", artistId: nil,
coverArt: album.coverArt, songCount: album.songCount,
duration: album.duration, playCount: album.playCount,
created: album.created, starred: album.starred,
year: album.year, genre: album.genre
)
result.append(grouped)
} else {
result.append(album)
}
}
return result
}
// MARK: - Data Loading
private func loadData() async {
let client = serverManager.client
let cache = LibraryCache.shared
// Load cached data FIRST show instantly, never spinner on warm launch
let hadCache: Bool
if recentAlbums.isEmpty, let cached = cache.loadAlbums() { recentAlbums = cached }
if playlists.isEmpty, let cached = cache.loadPlaylists() { playlists = cached }
if artists.isEmpty, let cached = cache.loadArtists() { artists = cached }
if let cached = cache.load([Genre].self, key: "genres") { genres = cached }
if let cached = cache.load([Album].self, key: "all_albums") { allAlbums = cached }
if favouriteSongs.isEmpty, let cached = cache.load([Song].self, key: "starred_songs") { favouriteSongs = cached }
hadCache = !recentAlbums.isEmpty || !playlists.isEmpty || !artists.isEmpty
// If we have cache, stop showing spinner immediately
if hadCache { isLoading = false }
// Then refresh from server in background
do {
async let albumsReq = client.getAlbumList2(type: "newest", size: 30)
async let playlistsReq = client.getPlaylists()
async let artistsReq = client.getArtists()
async let allAlbumsReq = client.getAlbumList2(type: "alphabeticalByName", size: 100, offset: 0)
async let genresReq = client.getGenres()
async let songsReq = client.search3(query: "", songCount: 100, songOffset: 0)
async let starredReq = client.getStarred2()
let (albums, playlistList, artistList, albumsFull, genreList, searchResult, starred) = try await (
albumsReq, playlistsReq, artistsReq, allAlbumsReq, genresReq, songsReq, starredReq
)
cache.cacheAlbums(albums)
cache.cachePlaylists(playlistList)
cache.cacheArtists(artistList)
cache.save(genreList, key: "genres")
cache.save(albumsFull, key: "all_albums")
if let starredSongs = starred?.song {
cache.save(starredSongs, key: "starred_songs")
}
await MainActor.run {
self.recentAlbums = albums
self.playlists = playlistList
self.artists = artistList
self.allAlbums = albumsFull
self.albumOffset = 100
self.hasMoreAlbums = albumsFull.count >= 100
self.genres = genreList
self.allSongs = searchResult?.song ?? []
self.songOffset = 100
self.hasMoreSongs = (searchResult?.song?.count ?? 0) >= 100
if let starredSongs = starred?.song {
self.favouriteSongs = starredSongs
}
self.isLoading = false
}
} catch {
serverManager.handleConnectionFailure()
await MainActor.run { self.isLoading = false }
}
}
private func loadMoreAlbums() async {
do {
let more = try await serverManager.client.getAlbumList2(type: "alphabeticalByName", size: 100, offset: albumOffset)
await MainActor.run {
allAlbums.append(contentsOf: more)
albumOffset += 100
hasMoreAlbums = more.count >= 100
}
} catch { }
}
private func loadMoreSongs() async {
do {
let result = try await serverManager.client.search3(query: "", songCount: 100, songOffset: songOffset)
let moreSongs = result?.song ?? []
await MainActor.run {
allSongs.append(contentsOf: moreSongs)
songOffset += 100
hasMoreSongs = moreSongs.count >= 100
}
} catch { }
}
}
// MARK: - Server Picker Sheet
struct ServerPickerSheet: View {
@EnvironmentObject var serverManager: ServerManager
@Binding var isPresented: Bool
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
NavigationStack {
List {
Section("Active Server") {
ForEach(serverManager.servers) { server in
Button(action: {
Task {
_ = await serverManager.switchServer(server)
isPresented = false
}
}) {
HStack {
VStack(alignment: .leading) {
Text(server.name)
.foregroundColor(.white)
Text(server.url)
.font(.caption)
.foregroundColor(.gray)
}
Spacer()
if serverManager.activeServer?.id == server.id {
Image(systemName: "checkmark")
.foregroundColor(accentPink)
}
}
}
}
}
if let msg = serverManager.lastFailoverMessage {
Section {
Text(msg)
.font(.caption)
.foregroundColor(.gray)
}
}
}
.navigationTitle("Servers")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") { isPresented = false }
.foregroundColor(accentPink)
}
}
}
}
}