import SwiftUI import PhotosUI struct AlbumDetailView: View { @EnvironmentObject var serverManager: ServerManager @EnvironmentObject var audioPlayer: AudioPlayer @EnvironmentObject var offlineManager: OfflineManager @ObservedObject private var watchManager = WatchConnectivityManager.shared @ObservedObject private var albumCoverStore = AlbumCoverStore.shared @ObservedObject private var libraryCache = LibraryCache.shared let albumId: String @State private var album: AlbumWithSongs? @State private var isLoading = true @State private var isDownloading = false @State private var showPlaylistPicker = false @State private var playlistPickerSongId: String? @State private var availablePlaylists: [Playlist] = [] @State private var showGetInfo = false @State private var getInfoSong: Song? @State private var showCoverPicker = false @State private var selectedCoverPhoto: PhotosPickerItem? @State private var showTrackEditor = false @State private var trackEditorSong: Song? @State private var showBatchEditor = false @State private var loadFailed = false private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) var body: some View { ScrollView { if let album = album { VStack(spacing: 0) { // Album header - iOS 8 style albumHeader(album) // Song list songList(album) // Bottom spacing Color.clear.frame(height: 120) } } else if loadFailed { VStack(spacing: 16) { Image(systemName: "wifi.slash") .font(.system(size: 32)) .foregroundColor(.gray) Text("Couldn't load album") .font(.system(size: 15)) .foregroundColor(.gray) Button(action: { Task { await loadAlbum() } }) { Text("Retry") .font(.system(size: 14, weight: .medium)) .foregroundColor(accentPink) } } .padding(.top, 100) } else if isLoading { ProgressView() .tint(accentPink) .padding(.top, 100) } } .background(Color(white: 0.06)) .navigationBarTitleDisplayMode(.inline) .sheet(isPresented: $showPlaylistPicker) { AddToPlaylistSheet(songId: playlistPickerSongId, playlists: availablePlaylists) } .sheet(isPresented: $showGetInfo) { if let song = getInfoSong { SongInfoSheet(song: song) } } .sheet(isPresented: $showTrackEditor) { if let song = trackEditorSong { TrackEditorView(song: song) } } .sheet(isPresented: $showBatchEditor) { if let album = album { BatchAlbumEditorSheet(album: album) } } .photosPicker( isPresented: $showCoverPicker, selection: $selectedCoverPhoto, matching: .images, photoLibrary: .shared() ) .onChange(of: selectedCoverPhoto) { _, newItem in guard let item = newItem, let coverArtId = album?.coverArt else { return } Task { if let data = try? await item.loadTransferable(type: Data.self), let image = UIImage(data: data) { let resized = resizeAlbumCover(image, maxSize: 600) albumCoverStore.saveCover(resized, for: coverArtId) } selectedCoverPhoto = nil } } .task { await loadAlbum() } } // MARK: - Album Header private func albumHeader(_ album: AlbumWithSongs) -> some View { VStack(spacing: 12) { // Album art — long press for cover options AsyncCoverArt( coverArtId: album.coverArt, size: 260 ) .frame(width: 220, height: 220) .cornerRadius(6) .shadow(color: .black.opacity(0.4), radius: 12, y: 6) .padding(.top, 20) .contextMenu { Button(action: { showCoverPicker = true }) { Label("Choose Cover Art...", systemImage: "photo.on.rectangle") } if CompanionSettings.shared.isEnabled { Button(action: { showBatchEditor = true }) { Label("Edit Album Tags...", systemImage: "tag") } } if let coverArtId = album.coverArt, albumCoverStore.hasCover(for: coverArtId) { Button(role: .destructive, action: { albumCoverStore.removeCover(for: coverArtId) }) { Label("Restore Original Cover", systemImage: "arrow.uturn.backward") } } } // Album info Text(album.name) .font(.system(size: 20, weight: .bold)) .foregroundColor(.white) .multilineTextAlignment(.center) Text(album.artist ?? "Unknown Artist") .font(.system(size: 15)) .foregroundColor(accentPink) HStack(spacing: 4) { Text(album.genre ?? "") if album.year != nil { Text("•") Text(String(album.year!)) } } .font(.system(size: 13)) .foregroundColor(.gray) // Action buttons row HStack(spacing: 10) { // Play button Button(action: { playAlbum(album, shuffle: false) }) { HStack(spacing: 6) { Image(systemName: "play.fill") Text("Play") } .font(.system(size: 14, weight: .semibold)) .frame(maxWidth: .infinity) .padding(.vertical, 10) .background(accentPink) .foregroundColor(.white) .cornerRadius(8) } // Shuffle button Button(action: { playAlbum(album, shuffle: true) }) { HStack(spacing: 6) { Image(systemName: "shuffle") Text("Shuffle") } .font(.system(size: 14, weight: .semibold)) .frame(maxWidth: .infinity) .padding(.vertical, 10) .background(Color.white.opacity(0.12)) .foregroundColor(.white) .cornerRadius(8) } // Download button (red = not downloaded, green ✓ = downloaded) Button(action: { downloadAlbum(album) }) { Image(systemName: isAlbumDownloaded(album) ? "checkmark.circle.fill" : "arrow.down.circle") .font(.system(size: 20)) .foregroundColor(isAlbumDownloaded(album) ? .green : .red) } .frame(width: 38, height: 38) .background(Color.white.opacity(0.12)) .cornerRadius(8) // Watch button (red = not synced, green = synced) if WatchConnectivityManager.shared.isWatchAvailable { Button(action: { sendAlbumToWatch(album) }) { Image(systemName: "applewatch") .font(.system(size: 17)) .foregroundColor(isAlbumOnWatch(album) ? .green : .red) } .frame(width: 38, height: 38) .background(Color.white.opacity(0.12)) .cornerRadius(8) } } .padding(.horizontal, 20) .padding(.top, 8) Divider() .background(Color.white.opacity(0.1)) .padding(.top, 12) } } // MARK: - Song List private func songList(_ album: AlbumWithSongs) -> some View { LazyVStack(spacing: 0) { ForEach(Array((album.song ?? []).enumerated()), id: \.element.id) { index, song in let available = isSongAvailable(song) let dlState = offlineManager.downloads[song.id] let isDownloaded = offlineManager.isSongDownloaded(song.id) let isOnWatch = WatchConnectivityManager.shared.isSongOnWatch(song.id) HStack(spacing: 14) { // Track number with download overlay ZStack { Text("\(song.track ?? (index + 1))") .font(.system(size: 15)) .foregroundColor(available ? .gray : .gray.opacity(0.3)) if case .downloading(let progress) = dlState { Circle() .stroke(Color.white.opacity(0.1), lineWidth: 2) .frame(width: 22, height: 22) Circle() .trim(from: 0, to: progress) .stroke(accentPink, style: StrokeStyle(lineWidth: 2, lineCap: .round)) .frame(width: 22, height: 22) .rotationEffect(.degrees(-90)) } else if case .queued = dlState { Circle() .stroke(accentPink.opacity(0.3), lineWidth: 2) .frame(width: 22, height: 22) } } .frame(width: 24, alignment: .center) // Song title + progress bar (tappable area) VStack(alignment: .leading, spacing: 3) { Text(song.title) .font(.system(size: 16)) .foregroundColor( !available ? .gray.opacity(0.35) : audioPlayer.currentSong?.id == song.id ? accentPink : .white ) .lineLimit(1) if let artist = song.artist, artist != album.artist { Text(artist) .font(.system(size: 12)) .foregroundColor(.gray) } 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) } } .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) .onTapGesture { if available { playSong(song, from: album) } } // Status indicators HStack(spacing: 6) { if isOnWatch { Image(systemName: "applewatch") .font(.system(size: 10)) .foregroundColor(.blue.opacity(0.7)) } if isDownloaded { Image(systemName: "arrow.down.circle.fill") .font(.system(size: 12)) .foregroundColor(.green.opacity(0.6)) } } // Duration Text(song.durationFormatted) .font(.system(size: 14)) .foregroundColor(.gray) // More button — standalone Menu, NOT inside a Button Menu { 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") } Button(action: { playlistPickerSongId = song.id Task { availablePlaylists = (try? await serverManager.client.getPlaylists()) ?? [] showPlaylistPicker = true } }) { Label("Add to Playlist...", systemImage: "text.badge.plus") } Divider() if offlineManager.isSongDownloaded(song.id) { Button(role: .destructive, action: { offlineManager.removeSong(song.id) }) { Label("Remove Download", systemImage: "trash") } if WatchConnectivityManager.shared.isWatchAvailable { Button(action: { _ = WatchConnectivityManager.shared.sendSongToWatch(song) }) { Label("Send to Watch", systemImage: "applewatch.and.arrow.forward") } } } else { Button(action: { if let server = serverManager.activeServer { offlineManager.downloadSong(song, server: server) } }) { Label("Download", systemImage: "arrow.down.circle") } } Button(action: { Task { if song.starred != nil { try? await serverManager.client.unstar(id: song.id) } else { try? await serverManager.client.star(id: song.id) } } }) { Label(song.starred != nil ? "Unstar" : "Star", systemImage: song.starred != nil ? "heart.slash" : "heart") } Divider() Button(action: { getInfoSong = song showGetInfo = true }) { Label("Get Info", systemImage: "info.circle") } if CompanionSettings.shared.isEnabled { Button(action: { trackEditorSong = song showTrackEditor = true }) { Label("Edit Tags", systemImage: "tag") } } } label: { Image(systemName: "ellipsis") .foregroundColor(.gray) .frame(width: 30, height: 30) .contentShape(Rectangle()) } } .padding(.horizontal, 16) .padding(.vertical, 10) if index < (album.song?.count ?? 0) - 1 { Divider() .background(Color.white.opacity(0.08)) .padding(.leading, 54) } } } } // MARK: - Actions private func playAlbum(_ album: AlbumWithSongs, shuffle: Bool) { guard let songs = album.song, !songs.isEmpty else { return } if shuffle { audioPlayer.shuffleEnabled = true } audioPlayer.play(song: songs[0], fromQueue: songs) } private func playSong(_ song: Song, from album: AlbumWithSongs) { guard let songs = album.song else { return } let idx = songs.firstIndex(where: { $0.id == song.id }) ?? 0 audioPlayer.play(song: song, fromQueue: songs, at: idx) } private func downloadAlbum(_ album: AlbumWithSongs) { guard let server = serverManager.activeServer else { return } offlineManager.downloadAlbum(album, server: server) } private func isAlbumDownloaded(_ album: AlbumWithSongs) -> Bool { guard let songs = album.song else { return false } return songs.allSatisfy { offlineManager.isSongDownloaded($0.id) } } private func isAlbumOnWatch(_ album: AlbumWithSongs) -> Bool { guard let songs = album.song, !songs.isEmpty else { return false } return songs.allSatisfy { WatchConnectivityManager.shared.isSongOnWatch($0.id) } } private func sendAlbumToWatch(_ album: AlbumWithSongs) { guard let songs = album.song else { return } // Download first if needed, then send each song for song in songs { if offlineManager.isSongDownloaded(song.id) { _ = WatchConnectivityManager.shared.sendSongToWatch(song) } else if let server = serverManager.activeServer { offlineManager.downloadSong(song, server: server) // Queue watch send after download completes DispatchQueue.main.asyncAfter(deadline: .now() + 2) { if self.offlineManager.isSongDownloaded(song.id) { _ = WatchConnectivityManager.shared.sendSongToWatch(song) } } } } } private func loadAlbum() async { let cache = LibraryCache.shared loadFailed = false // 1. Show cached version instantly — no spinner if we have data if album == nil, let cached = cache.loadAlbumDetail(id: albumId) { album = cached isLoading = false } // 2. Fetch from server in background do { if let result = try await serverManager.client.getAlbum(id: albumId) { cache.cacheAlbumDetail(result) await MainActor.run { self.album = result self.isLoading = false } } else if album == nil { await retryAlbumLoad() } else { await MainActor.run { self.isLoading = false } } } catch { if album == nil { await retryAlbumLoad() } else { await MainActor.run { self.isLoading = false } } } } private func retryAlbumLoad() async { // Wait for server connection to establish for _ in 0..<10 { if serverManager.connectionState == .connected { break } try? await Task.sleep(for: .milliseconds(500)) } do { if let result = try await serverManager.client.getAlbum(id: albumId) { LibraryCache.shared.cacheAlbumDetail(result) await MainActor.run { self.album = result self.isLoading = false } return } } catch { } await MainActor.run { self.isLoading = false self.loadFailed = true } } /// Whether a song is available (downloaded or online) private func isSongAvailable(_ song: Song) -> Bool { if offlineManager.isSongDownloaded(song.id) { return true } return !libraryCache.isOffline } /// Resize image to max dimension while preserving aspect ratio private func resizeAlbumCover(_ image: UIImage, maxSize: CGFloat) -> UIImage { let size = image.size let scale = min(maxSize / size.width, maxSize / size.height) guard scale < 1 else { return image } let newSize = CGSize(width: size.width * scale, height: size.height * scale) let renderer = UIGraphicsImageRenderer(size: newSize) return renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: newSize)) } } } // MARK: - Album Grid View (for "more" navigation) struct AlbumGridView: View { @EnvironmentObject var serverManager: ServerManager let title: String let type: String var genre: String? = nil @State private var albums: [Album] = [] @State private var isLoading = true @State private var searchText = "" private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) private let columns = [ GridItem(.adaptive(minimum: 160), spacing: 14) ] private var filtered: [Album] { let deduped = deduplicateAlbums(albums) return searchText.isEmpty ? deduped : deduped.filter { $0.name.localizedCaseInsensitiveContains(searchText) || ($0.artist ?? "").localizedCaseInsensitiveContains(searchText) } } 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) let sameAlbum = albums.filter { "\($0.name)|\($0.coverArt ?? "")" == key } if sameAlbum.count > 1 { result.append(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 )) } else { result.append(album) } } return result } var body: some View { VStack(spacing: 0) { // Search HStack(spacing: 8) { Image(systemName: "magnifyingglass").foregroundColor(.gray).font(.system(size: 14)) TextField("Search albums...", 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(.vertical, 8) ScrollView { LazyVGrid(columns: columns, spacing: 18) { ForEach(filtered) { album in NavigationLink(destination: AlbumDetailView(albumId: album.id)) { 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) } } } } .padding(16) } } .background(Color(white: 0.06)) .navigationTitle(title) .task { do { let result = try await serverManager.client.getAlbumList2(type: type, size: 200, genre: genre) await MainActor.run { albums = result isLoading = false } } catch { isLoading = false } } } }