425 lines
18 KiB
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: 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)
|
|
}
|
|
}
|
|
}
|