Features: - Dual-AVPlayer Smart DJ crossfade with LUFS normalization - Mitsuha-style FFT visualizer (real-time + offline pre-computed) - Companion API integration (Smart DJ, tag editing, vis frames) - Offline-first SyncEngine with delta sync and album detail pre-caching - Audio pre-fetcher for gapless queue playback - Optimistic action queue (star/unstar with background retry) - ShazamKit recognition with MusicKit preview playback - Radio streaming with HLS/PLS/M3U support and buffer seek - Watch app with Crown Sequencer and Ultra speaker support - Batch metadata editing with album_artist fix for split albums - Cache-first UI pattern across all views - NWPathMonitor offline detection with reactive song greying
210 lines
8.4 KiB
Swift
210 lines
8.4 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.isOffline
|
|
Button(action: {
|
|
if available {
|
|
audioPlayer.play(song: song, fromQueue: songs)
|
|
}
|
|
}) {
|
|
HStack(spacing: 12) {
|
|
AsyncCoverArt(coverArtId: song.coverArt, size: 44)
|
|
.frame(width: 44, height: 44)
|
|
.cornerRadius(3)
|
|
.opacity(available ? 1.0 : 0.4)
|
|
|
|
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)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
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 }
|
|
}
|
|
}
|
|
}
|
|
}
|