// // SearchView.swift // Mobile Music Assistant // // Created by Sven Hanold on 26.03.26. // import SwiftUI struct SearchView: View { @Environment(MAService.self) private var service @Environment(\.dismiss) private var dismiss @State private var searchText = "" @State private var searchResults: [MAMediaItem] = [] @State private var isSearching = false @State private var errorMessage: String? @State private var showError = false // Debounce timer @State private var searchTask: Task? var body: some View { NavigationStack { Group { if searchResults.isEmpty && !isSearching { if searchText.isEmpty { ContentUnavailableView( "Search Library", systemImage: "magnifyingglass", description: Text("Find artists, albums, tracks, and playlists") ) } else { ContentUnavailableView( "No Results", systemImage: "magnifyingglass", description: Text("No results found for '\(searchText)'") ) } } else if isSearching { ProgressView() } else { searchResultsList } } .navigationTitle("Search") .navigationBarTitleDisplayMode(.inline) .searchable(text: $searchText, prompt: "Artists, albums, tracks...") .onChange(of: searchText) { _, newValue in performSearch(query: newValue) } .alert("Error", isPresented: $showError) { Button("OK", role: .cancel) { } } message: { if let errorMessage { Text(errorMessage) } } } } // MARK: - Search Results List @ViewBuilder private var searchResultsList: some View { List { ForEach(searchResults) { item in SearchResultRow(item: item) .contentShape(Rectangle()) .onTapGesture { // TODO: Navigate to detail view based on media type } } } .listStyle(.plain) } // MARK: - Search private func performSearch(query: String) { // Cancel previous search searchTask?.cancel() guard !query.isEmpty else { searchResults = [] return } // Debounce search - wait 500ms after user stops typing searchTask = Task { try? await Task.sleep(for: .milliseconds(500)) guard !Task.isCancelled else { return } await executeSearch(query: query) } } private func executeSearch(query: String) async { isSearching = true errorMessage = nil do { let results = try await service.libraryManager.search(query: query) await MainActor.run { searchResults = results isSearching = false } } catch { await MainActor.run { errorMessage = error.localizedDescription showError = true isSearching = false } } } } // MARK: - Search Result Row struct SearchResultRow: View { @Environment(MAService.self) private var service let item: MAMediaItem var body: some View { HStack(spacing: 12) { // Thumbnail if let imageUrl = item.imageUrl { let coverURL = service.imageProxyURL(path: imageUrl, size: 128) CachedAsyncImage(url: coverURL) { image in image .resizable() .aspectRatio(contentMode: .fill) } placeholder: { Rectangle() .fill(Color.gray.opacity(0.2)) } .frame(width: 60, height: 60) .clipShape(thumbnailShape) } else { thumbnailShape .fill(Color.gray.opacity(0.2)) .frame(width: 60, height: 60) .overlay { Image(systemName: mediaTypeIcon) .foregroundStyle(.secondary) } } // Item Info VStack(alignment: .leading, spacing: 4) { Text(item.name) .font(.body) .lineLimit(1) if let artists = item.artists, !artists.isEmpty { Text(artists.map { $0.name }.joined(separator: ", ")) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } else if let album = item.album { Text(album.name) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } Label(item.mediaType.rawValue.capitalized, systemImage: mediaTypeIcon) .font(.caption2) .foregroundStyle(.tertiary) } Spacer() } .padding(.vertical, 4) } private var thumbnailShape: some Shape { switch item.mediaType { case .artist: return AnyShape(Circle()) default: return AnyShape(RoundedRectangle(cornerRadius: 8)) } } private var mediaTypeIcon: String { switch item.mediaType { case .track: return "music.note" case .album: return "opticaldisc" case .artist: return "music.mic" case .playlist: return "music.note.list" case .radio: return "antenna.radiowaves.left.and.right" } } } // MARK: - AnyShape Helper struct AnyShape: Shape { private let _path: (CGRect) -> Path init(_ shape: S) { _path = { rect in shape.path(in: rect) } } func path(in rect: CGRect) -> Path { _path(rect) } } #Preview { SearchView() .environment(MAService()) }