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 songsLoaded = false @State private var isLoadingSongs = false @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 = "" // Album selection mode for batch editing @State private var isSelectingAlbums = false @State private var selectedAlbumIds: Set = [] @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 @ObservedObject private var libraryCache = LibraryCache.shared // Song info / edit @State private var getInfoSong: Song? @State private var trackEditorSong: Song? // Playlist rename @State private var showRenamePlaylist = false @State private var renamePlaylistId: String? @State private var renamePlaylistName = "" 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 = "" } } .alert("Rename Playlist", isPresented: $showRenamePlaylist) { TextField("Name", text: $renamePlaylistName) Button("Save") { let newName = renamePlaylistName guard let pid = renamePlaylistId else { return } Task { try? await serverManager.client.updatePlaylist(id: pid, name: newName) await loadData() } } Button("Cancel", role: .cancel) { } } .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]) } } .sheet(item: $getInfoSong) { song in SongInfoSheet(song: song) } .sheet(item: $trackEditorSong) { song in TrackEditorView(song: song) } .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() .keyboardDoneButton() 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") } Button(action: { playAlbumNext(album) }) { Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward") } Button(action: { playAlbumLater(album) }) { Label("Play Later", systemImage: "text.line.last.and.arrowtriangle.forward") } 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(action: { renamePlaylistId = playlist.id renamePlaylistName = playlist.name showRenamePlaylist = true }) { Label("Rename Playlist", systemImage: "pencil") } 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 — star 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") } Button(action: { playAlbumNext(album) }) { Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward") } Button(action: { playAlbumLater(album) }) { Label("Play Later", systemImage: "text.line.last.and.arrowtriangle.forward") } 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 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) /// First letter of a song title for section headers, collapsing digits/symbols to "#" private func sectionKey(for song: Song) -> String { let first = song.title.first ?? "#" return first.isLetter ? String(first).uppercased() : "#" } private var songsTab: some View { let filtered: [Song] = { let base = allSongs guard !searchText.isEmpty else { return base } return base.filter { $0.title.localizedCaseInsensitiveContains(searchText) || ($0.artist ?? "").localizedCaseInsensitiveContains(searchText) || ($0.album ?? "").localizedCaseInsensitiveContains(searchText) } }() // Group into alphabetical sections let grouped: [(String, [Song])] = { var dict: [String: [Song]] = [:] for song in filtered { let key = sectionKey(for: song) dict[key, default: []].append(song) } // Letters A-Z then # for digits/symbols let letters = dict.keys.filter { $0 != "#" }.sorted() let keys = dict["#"] != nil ? letters + ["#"] : letters return keys.compactMap { k in dict[k].map { (k, $0) } } }() return LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) { if isLoadingSongs { ProgressView() .tint(accentPink) .frame(maxWidth: .infinity) .padding(.top, 60) } else if filtered.isEmpty { emptyState("No songs found") } else { ForEach(grouped, id: \.0) { (letter, songs) in Section { ForEach(Array(songs.enumerated()), id: \.element.id) { idx, song in songRow(song: song, index: idx, allSongs: Array(filtered)) Divider() .background(Color.white.opacity(0.08)) .padding(.leading, 72) } } header: { Text(letter) .font(.system(size: 13, weight: .semibold)) .foregroundColor(.gray) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 16) .padding(.vertical, 6) .background(Color.black.opacity(0.85)) } } } } .padding(.top, 4) .task { guard !songsLoaded, !isLoadingSongs else { return } await loadAllSongs() } } private func loadAllSongs() async { // Cache hit → instant display if let cached = LibraryCache.shared.load([Song].self, key: "all_songs_sorted") { allSongs = cached songsLoaded = true return } isLoadingSongs = true do { var collected: [Song] = [] var offset = 0 while true { let result = try await serverManager.client.search3( query: "", artistCount: 0, artistOffset: 0, albumCount: 0, albumOffset: 0, songCount: 500, songOffset: offset) let page = result?.song ?? [] collected.append(contentsOf: page) if page.count < 500 { break } offset += 500 } let sorted = collected.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending } LibraryCache.shared.save(sorted, key: "all_songs_sorted") allSongs = sorted songsLoaded = true isLoadingSongs = false } catch { isLoadingSongs = false } } @ViewBuilder private func songRow(song: Song, index: Int, allSongs: [Song]) -> some View { let isDownloaded = offlineManager.isSongDownloaded(song.id) let isOnWatch = WatchConnectivityManager.shared.isSongOnWatch(song.id) let dlState = offlineManager.downloads[song.id] let available = isDownloaded || libraryCache.isServerAvailable Button(action: { if available { audioPlayer.play(song: song, fromQueue: Array(allSongs), at: index) } }) { HStack(spacing: 12) { // Cover art with download progress overlay ZStack { AsyncCoverArt(coverArtId: song.coverArt, size: 48) .frame(width: 44, height: 44) .cornerRadius(3) .opacity(available ? 1.0 : 0.4) if case .downloading(let progress) = dlState { RoundedRectangle(cornerRadius: 3) .fill(Color.black.opacity(0.5)) .frame(width: 44, height: 44) Circle() .trim(from: 0, to: progress) .stroke(accentPink, style: StrokeStyle(lineWidth: 2.5, lineCap: .round)) .frame(width: 24, height: 24) .rotationEffect(.degrees(-90)) } else if case .queued = dlState { RoundedRectangle(cornerRadius: 3) .fill(Color.black.opacity(0.4)) .frame(width: 44, height: 44) ProgressView().tint(accentPink).scaleEffect(0.6) } } VStack(alignment: .leading, spacing: 2) { Text(song.title) .font(.system(size: 15)) .foregroundColor( !available ? .gray.opacity(0.35) : audioPlayer.currentSong?.id == song.id ? accentPink : .white ) .lineLimit(1) Text("\(song.artist ?? "") · \(song.album ?? "")") .font(.system(size: 12)) .foregroundColor(available ? .gray : .gray.opacity(0.3)) .lineLimit(1) // Download progress bar if case .downloading(let progress) = dlState { GeometryReader { geo in ZStack(alignment: .leading) { Capsule().fill(Color.white.opacity(0.08)).frame(height: 2) Capsule().fill(accentPink) .frame(width: geo.size.width * progress, height: 2) } } .frame(height: 2) } } Spacer() // Status icons HStack(spacing: 4) { if isOnWatch { Image(systemName: "applewatch") .font(.system(size: 9)) .foregroundColor(.green) } if isDownloaded { Image(systemName: "arrow.down.circle.fill") .font(.system(size: 11)) .foregroundColor(.green.opacity(0.6)) } } 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() // Favourite toggle — routes through OptimisticActionQueue for offline resilience. // Direct client calls with try? silently lose the action when Tailscale is reconnecting. Button(action: { if song.starred != nil { OptimisticActionQueue.shared.unstar(songId: song.id) favouriteSongs.removeAll { $0.id == song.id } LibraryCache.shared.save(favouriteSongs, key: "starred_songs") } else { OptimisticActionQueue.shared.star(songId: song.id) if !favouriteSongs.contains(where: { $0.id == song.id }) { favouriteSongs.append(song) LibraryCache.shared.save(favouriteSongs, key: "starred_songs") } } }) { Label(song.starred != nil ? "Unfavourite" : "Favourite", systemImage: song.starred != nil ? "heart.slash.fill" : "heart") } Divider() // Download if isDownloaded { 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") } } // Send to Watch (always visible) Button(action: { _ = WatchConnectivityManager.shared.sendSongToWatch(song) }) { Label( isOnWatch ? "On Watch ✓" : "Send to Watch", systemImage: isOnWatch ? "applewatch.checkmark" : "applewatch.and.arrow.forward" ) } .disabled(isOnWatch) Divider() Button(action: { getInfoSong = song }) { Label("Get Info", systemImage: "info.circle") } if CompanionSettings.shared.isEnabled { Button(action: { trackEditorSong = song }) { Label("Edit Tags", systemImage: "tag") } } } } // 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 { } } } private func playAlbumNext(_ album: Album) { Task { if let detail = try? await serverManager.client.getAlbum(id: album.id), let songs = detail.song { await MainActor.run { for song in songs.reversed() { audioPlayer.playNext(song) } } } } } private func playAlbumLater(_ album: Album) { Task { if let detail = try? await serverManager.client.getAlbum(id: album.id), let songs = detail.song { await MainActor.run { for song in songs { audioPlayer.playLater(song) } } } } } // MARK: - Album Deduplication // Navidrome returns one album entry per artist for compilations. // Group by name + coverArt to show each album once. // O(n) implementation: build frequency map in one pass, deduplicate in second pass. private func deduplicateAlbums(_ albums: [Album]) -> [Album] { // Count how many distinct artist entries share the same name+cover key var keyCount: [String: Int] = [:] for album in albums { let key = "\(album.name)|\(album.coverArt ?? "")" keyCount[key, default: 0] += 1 } var seen = Set() var result: [Album] = [] result.reserveCapacity(keyCount.count) for album in albums { let key = "\(album.name)|\(album.coverArt ?? "")" guard !seen.contains(key) else { continue } seen.insert(key) if keyCount[key, default: 1] > 1 { // Multiple artist entries — replace with "Various Artists" let grouped = Album( id: album.id, name: album.name, artist: "Various Artists", artistId: nil, albumArtist: "Various Artists", 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 genresReq = client.getGenres() async let starredReq = client.getStarred2() let (albums, playlistList, artistList, genreList, starred) = try await ( albumsReq, playlistsReq, artistsReq, genresReq, starredReq ) // Fetch ALL albums (paginated) let albumsFull = try await fetchAllAlbums(client: client) 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.genres = genreList if let starredSongs = starred?.song { self.favouriteSongs = starredSongs } self.isLoading = false } } catch { serverManager.handleConnectionFailure() await MainActor.run { self.isLoading = false } } } /// Paginate all albums from the server private func fetchAllAlbums(client: SubsonicClient) async throws -> [Album] { var all: [Album] = [] var offset = 0 let pageSize = 500 while true { let page = try await client.getAlbumList2(type: "alphabeticalByName", size: pageSize, offset: offset) all.append(contentsOf: page) if page.count < pageSize { break } offset += pageSize } // Sort: alpha first, then numerals, then special characters return all.sorted { a, b in let aFirst = a.name.first ?? Character("\0") let bFirst = b.name.first ?? Character("\0") let aIsLetter = aFirst.isLetter let bIsLetter = bFirst.isLetter if aIsLetter && !bIsLetter { return true } if !aIsLetter && bIsLetter { return false } return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending } } } // 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) } } } } }