NavidromeApp/iOS/Views/Library/SearchView.swift

255 lines
11 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
@State private var searchText = ""
@State private var artists: [Artist] = []
@State private var albums: [Album] = []
@State private var songs: [Song] = []
@State private var isSearching = false
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
NavigationStack {
ScrollView {
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)
.onSubmit { performSearch() }
.onChange(of: searchText) { oldValue, newValue in
if newValue.count >= 2 {
performSearch()
}
}
if !searchText.isEmpty {
Button(action: {
searchText = ""
artists = []; albums = []; songs = []
}) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.gray)
}
}
}
.padding(10)
.background(Color.white.opacity(0.08))
.cornerRadius(10)
.padding(.horizontal, 16)
.padding(.top, 10)
if isSearching {
ProgressView().tint(accentPink).padding(.top, 40)
} else if !searchText.isEmpty {
searchResults
} else {
emptyState
}
Color.clear.frame(height: 120)
}
}
.background(Color(white: 0.06))
.navigationTitle("Search")
}
}
private var emptyState: some View {
VStack(spacing: 12) {
Image(systemName: "magnifyingglass")
.font(.system(size: 40))
.foregroundColor(.gray)
Text("Search your library")
.font(.system(size: 16))
.foregroundColor(.gray)
}
.padding(.top, 80)
}
private var searchResults: some View {
LazyVStack(alignment: .leading, spacing: 0) {
// Artists
if !artists.isEmpty {
sectionHeader("Artists")
ForEach(artists) { 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)
}
}
}
// Albums
if !albums.isEmpty {
sectionHeader("Albums")
ForEach(albums) { 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)
}
}
}
// Songs
if !songs.isEmpty {
sectionHeader("Songs")
ForEach(songs) { song in
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]
Button(action: {
if available {
audioPlayer.play(song: song, fromQueue: songs)
}
}) {
HStack(spacing: 12) {
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) :
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)
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()
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, 8)
}
}
}
}
}
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 performSearch() {
guard searchText.count >= 2 else { return }
isSearching = true
Task {
do {
let result = try await serverManager.client.search3(query: searchText)
await MainActor.run {
artists = result?.artist ?? []
albums = result?.album ?? []
songs = result?.song ?? []
isSearching = false
}
} catch {
await MainActor.run { isSearching = false }
}
}
}
}