import SwiftUI struct SearchView: View { @EnvironmentObject var serverManager: ServerManager @EnvironmentObject var audioPlayer: AudioPlayer @EnvironmentObject var offlineManager: OfflineManager @ObservedObject private var libraryCache = LibraryCache.shared @ObservedObject private var watchManager = WatchConnectivityManager.shared @State private var searchText = "" @State private var searchArtists: [Artist] = [] @State private var searchAlbums: [Album] = [] @State private var searchSongs: [Song] = [] @State private var isSearching = false // All-songs browse state @State private var allSongs: [Song] = [] @State private var isLoadingSongs = false @State private var songsLoaded = false // Long-press menu @State private var menuSong: Song? = nil @State private var showMenu = false @State private var playlistPickerSongId: String? = nil @State private var availablePlaylists: [Playlist] = [] @State private var showPlaylistPicker = false @FocusState private var searchFocused: Bool private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) private var isSearchActive: Bool { !searchText.isEmpty } // MARK: - Body var body: some View { NavigationStack { VStack(spacing: 0) { // Search bar HStack(spacing: 10) { Image(systemName: "magnifyingglass").foregroundColor(.gray) TextField("Artists, Songs, Albums", text: $searchText) .foregroundColor(.white) .autocapitalization(.none) .disableAutocorrection(true) .focused($searchFocused) .onSubmit { performSearch() } .onChange(of: searchText) { _, new in if new.count >= 2 { performSearch() } else if new.isEmpty { clearSearch() } } .toolbar { ToolbarItemGroup(placement: .keyboard) { Spacer() Button("Done") { searchFocused = false } .foregroundColor(accentPink) } } if !searchText.isEmpty { Button(action: { searchText = ""; clearSearch() }) { Image(systemName: "xmark.circle.fill").foregroundColor(.gray) } } } .padding(10) .background(Color.white.opacity(0.08)) .cornerRadius(10) .padding(.horizontal, 16) .padding(.top, 10) .padding(.bottom, 8) if isSearchActive { searchResultsView } else { allSongsView } } .background(Color(white: 0.06)) .navigationTitle("Songs") .sheet(isPresented: $showPlaylistPicker) { AddToPlaylistSheet(songId: playlistPickerSongId, playlists: availablePlaylists) } .task { await loadAllSongs() } } } // MARK: - All Songs Browse private var allSongsView: some View { Group { if isLoadingSongs { Spacer() ProgressView().tint(accentPink) Spacer() } else if allSongs.isEmpty { Spacer() VStack(spacing: 12) { Image(systemName: "music.note.list") .font(.system(size: 40)).foregroundColor(.gray) Text("No songs found") .font(.system(size: 16)).foregroundColor(.gray) } Spacer() } else { ScrollView { LazyVStack(spacing: 0) { // Download All banner (offline only — explicit user action) downloadAllBanner ForEach(allSongs) { song in songRow(song, queue: allSongs) Divider() .background(Color.white.opacity(0.06)) .padding(.leading, 72) } Color.clear.frame(height: 120) } } } } } private var downloadAllBanner: some View { HStack { VStack(alignment: .leading, spacing: 2) { Text("\(allSongs.count) Songs") .font(.system(size: 13, weight: .semibold)) .foregroundColor(.white) let downloaded = allSongs.filter { offlineManager.isSongDownloaded($0.id) }.count if downloaded > 0 { Text("\(downloaded) downloaded") .font(.system(size: 11)).foregroundColor(.gray) } } Spacer() Button(action: downloadAll) { HStack(spacing: 5) { Image(systemName: "arrow.down.circle") .font(.system(size: 14)) Text("Download All") .font(.system(size: 13, weight: .medium)) } .foregroundColor(accentPink) .padding(.horizontal, 12).padding(.vertical, 6) .background(accentPink.opacity(0.12)) .cornerRadius(16) } } .padding(.horizontal, 16) .padding(.vertical, 10) .background(Color.white.opacity(0.03)) } // MARK: - Search Results private var searchResultsView: some View { Group { if isSearching { Spacer() ProgressView().tint(accentPink) Spacer() } else if searchArtists.isEmpty && searchAlbums.isEmpty && searchSongs.isEmpty { Spacer() VStack(spacing: 12) { Image(systemName: "magnifyingglass") .font(.system(size: 36)).foregroundColor(.gray) Text("No results for \"\(searchText)\"") .font(.system(size: 15)).foregroundColor(.gray) } Spacer() } else { ScrollView { LazyVStack(alignment: .leading, spacing: 0) { if !searchArtists.isEmpty { sectionHeader("Artists") ForEach(searchArtists) { artist in NavigationLink(destination: ArtistDetailView(artistId: artist.id)) { HStack(spacing: 12) { AsyncCoverArt(coverArtId: artist.coverArt, size: 44) .frame(width: 44, height: 44).clipShape(Circle()) Text(artist.name) .font(.system(size: 15)).foregroundColor(.white) Spacer() Image(systemName: "chevron.right") .font(.system(size: 12)).foregroundColor(.gray) } .padding(.horizontal, 16).padding(.vertical, 8) } } } if !searchAlbums.isEmpty { sectionHeader("Albums") ForEach(searchAlbums) { album in NavigationLink(destination: AlbumDetailView(albumId: album.id)) { HStack(spacing: 12) { AsyncCoverArt(coverArtId: album.coverArt, size: 48) .frame(width: 48, height: 48).cornerRadius(4) VStack(alignment: .leading, spacing: 2) { Text(album.name) .font(.system(size: 15)).foregroundColor(.white).lineLimit(1) Text(album.artist ?? "") .font(.system(size: 12)).foregroundColor(.gray) } Spacer() Image(systemName: "chevron.right") .font(.system(size: 12)).foregroundColor(.gray) } .padding(.horizontal, 16).padding(.vertical, 8) } } } if !searchSongs.isEmpty { sectionHeader("Songs") ForEach(searchSongs) { song in songRow(song, queue: searchSongs) Divider() .background(Color.white.opacity(0.06)) .padding(.leading, 72) } } Color.clear.frame(height: 120) } } } } } // MARK: - Song Row (shared, with long-press menu) private func songRow(_ song: Song, queue: [Song]) -> some View { let available = offlineManager.isSongDownloaded(song.id) || libraryCache.isServerAvailable let isDownloaded = offlineManager.isSongDownloaded(song.id) let isOnWatch = WatchConnectivityManager.shared.isSongOnWatch(song.id) let dlState = offlineManager.downloads[song.id] let isCurrent = audioPlayer.currentSong?.id == song.id return Button(action: { if available { audioPlayer.play(song: song, fromQueue: queue) } }) { HStack(spacing: 12) { // Artwork + download progress overlay ZStack { AsyncCoverArt(coverArtId: song.coverArt, size: 44) .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) : isCurrent ? accentPink : .white) .lineLimit(1) Text("\(song.artist ?? "") • \(song.album ?? "")") .font(.system(size: 12)) .foregroundColor(available ? .gray : .gray.opacity(0.3)) .lineLimit(1) 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 badges HStack(spacing: 4) { if isOnWatch { Image(systemName: "applewatch") .font(.system(size: 9)).foregroundColor(.blue.opacity(0.7)) } 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(available ? .gray : .gray.opacity(0.3)) } .padding(.horizontal, 16).padding(.vertical, 9) } .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("Add to Queue", systemImage: "text.line.last.and.arrowtriangle.forward") } Divider() 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") } } Button(action: { if !isOnWatch { _ = WatchConnectivityManager.shared.sendSongToWatch(song) } }) { Label(isOnWatch ? "On Watch ✓" : "Send to Watch", systemImage: isOnWatch ? "applewatch.checkmark" : "applewatch.and.arrow.forward") } .disabled(isOnWatch) Divider() Button(action: { playlistPickerSongId = song.id Task { availablePlaylists = (try? await serverManager.client.getPlaylists()) ?? [] showPlaylistPicker = true } }) { Label("Add to Playlist...", systemImage: "text.badge.plus") } } } // MARK: - Helpers private func sectionHeader(_ title: String) -> some View { Text(title) .font(.system(size: 20, weight: .bold)).foregroundColor(.white) .padding(.horizontal, 16).padding(.top, 20).padding(.bottom, 8) } private func clearSearch() { searchArtists = []; searchAlbums = []; searchSongs = [] } private func performSearch() { guard searchText.count >= 2 else { return } isSearching = true Task { do { let result = try await serverManager.client.search3(query: searchText) await MainActor.run { searchArtists = result?.artist ?? [] searchAlbums = result?.album ?? [] searchSongs = result?.song ?? [] isSearching = false } } catch { await MainActor.run { isSearching = false } } } } private func loadAllSongs() async { guard !songsLoaded else { return } isLoadingSongs = true // Cache hit → instant display if let cached = libraryCache.load([Song].self, key: "all_songs_sorted") { await MainActor.run { allSongs = cached isLoadingSongs = false songsLoaded = true } return } // Use search3 with empty query to fetch all songs directly. // This is a single paginated call per 500 songs vs. 1 call per album // (144 albums = 144 sequential requests, many of which fail silently). do { var collected: [Song] = [] var offset = 0 let pageSize = 500 while true { let result = try await serverManager.client.search3( query: "", artistCount: 0, artistOffset: 0, albumCount: 0, albumOffset: 0, songCount: pageSize, songOffset: offset ) let page = result?.song ?? [] collected.append(contentsOf: page) if page.count < pageSize { break } offset += pageSize } let sorted = collected.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending } libraryCache.save(sorted, key: "all_songs_sorted") await MainActor.run { allSongs = sorted isLoadingSongs = false songsLoaded = true } } catch { await MainActor.run { isLoadingSongs = false } } } private func downloadAll() { guard let server = serverManager.activeServer else { return } for song in allSongs where !offlineManager.isSongDownloaded(song.id) { offlineManager.downloadSong(song, server: server) } } }