NavidromeApp/iOS/Views/Library/SearchView.swift

425 lines
18 KiB
Swift

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: 80)
}
}
}
}
}
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: 80)
}
}
}
}
}
// 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)
}
}
}