Songs Tab (SearchView.swift) Default state now loads all songs alphabetically from the library via getAlbumList2 → per-album song fetch, cached under "all_songs_sorted" so subsequent opens are instant. The Download All banner shows song count + already-downloaded count and queues only non-downloaded songs. Every row uses .contextMenu (the long-press menu) with Play Now, Play Next, Add to Queue, Download/Remove, Send to Watch, and Add to Playlist — same pattern as Favourites. Watch and download badges appear on each row. Searching ≥2 chars runs the server search and shows artists/albums/songs in sections, then clears back to the full list when the field is empty. Keyboard Done Button A single keyboardDoneButton() View extension in AsyncCoverArt.swift calls UIApplication.shared.sendAction(resignFirstResponder:...) globally — no @FocusState needed. Applied to: LoginView (all 4 fields), CompanionSettingsView (host/port), TrackEditorView (checkField helper covers all tag fields), BatchAlbumEditorSheet (editField helper), RadioView (name/URL), PlaylistsView (name fields), MyMusicView (search), SearchView (via @FocusState + toolbar directly). ShazamKit MTAudioProcessingTap Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix — works for HLS, radio, and any AVPlayer stream without touching the microphone. The prepare callback captures the source format and builds an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free function (required by the API) calls MTAudioProcessingTapGetSourceAudio then dispatches to a serial analysisQueue — the render thread is never blocked. convertAndMatch wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and feeds SHSession.matchStreamingBuffer. Fallback to microphone (AVAudioEngine) is kept for the local engine path where no AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if the mic fallback is ever hit.
418 lines
18 KiB
Swift
418 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
|
|
|
|
// Try loading from cache first for instant display
|
|
if let cached = libraryCache.load([Song].self, key: "all_songs_sorted") {
|
|
await MainActor.run {
|
|
allSongs = cached
|
|
isLoadingSongs = false
|
|
songsLoaded = true
|
|
}
|
|
return
|
|
}
|
|
|
|
// Fetch all albums, then their songs, sorted alphabetically by title
|
|
do {
|
|
var offset = 0
|
|
var collected: [Song] = []
|
|
while true {
|
|
let albums = try await serverManager.client.getAlbumList2(
|
|
type: "alphabeticalByName", size: 500, offset: offset)
|
|
for album in albums {
|
|
if let detail = try? await serverManager.client.getAlbum(id: album.id) {
|
|
collected.append(contentsOf: detail.song ?? [])
|
|
}
|
|
}
|
|
if albums.count < 500 { break }
|
|
offset += 500
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|