NavidromeApp/iOS/Views/Library/SearchView.swift
Dallas Groot 2bdac607b4 bug fixes
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.
2026-04-10 16:55:09 -07:00

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)
}
}
}