import SwiftUI 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 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 = [] @State private var showBatchAlbumEditor = false @State private var singleEditAlbumId: String? @State private var showSingleAlbumEditor = false private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) enum SortMode: String, CaseIterable { case recentlyAdded = "Recently Added" 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 = "" }) { Text(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 .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: - 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) { 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) } Divider() .background(Color.white.opacity(0.1)) .padding(.leading, 78) } if filtered.isEmpty && !isLoading { emptyState("No artists found") } } .padding(.top, 4) } // 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() 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 } 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) let (albums, playlistList, artistList, albumsFull, genreList, searchResult) = try await ( albumsReq, playlistsReq, artistsReq, allAlbumsReq, genresReq, songsReq ) cache.cacheAlbums(albums) cache.cachePlaylists(playlistList) cache.cacheArtists(artistList) cache.save(genreList, key: "genres") cache.save(albumsFull, key: "all_albums") 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 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) } } } } }