// // 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 { 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) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } } .withMANavigation() .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 { // Group results by media type let groupedResults = Dictionary(grouping: searchResults) { $0.mediaType ?? .unknown } // Artists if let artists = groupedResults[.artist], !artists.isEmpty { Section { ForEach(artists) { item in NavigationLink(value: convertToArtist(item)) { SearchResultRow(item: item) } } } header: { Label("Artists", systemImage: "music.mic") .font(.headline) .foregroundStyle(.primary) } } // Albums if let albums = groupedResults[.album], !albums.isEmpty { Section { ForEach(albums) { item in NavigationLink(value: convertToAlbum(item)) { SearchResultRow(item: item) } } } header: { Label("Albums", systemImage: "opticaldisc") .font(.headline) .foregroundStyle(.primary) } } // Tracks if let tracks = groupedResults[.track], !tracks.isEmpty { Section { ForEach(tracks) { item in SearchResultRow(item: item) .contentShape(Rectangle()) .onTapGesture { print("🎵 Tapped track: \(item.name)") // TODO: Play track } } } header: { Label("Tracks", systemImage: "music.note") .font(.headline) .foregroundStyle(.primary) } } // Playlists if let playlists = groupedResults[.playlist], !playlists.isEmpty { Section { ForEach(playlists) { item in NavigationLink(value: convertToPlaylist(item)) { SearchResultRow(item: item) } } } header: { Label("Playlists", systemImage: "music.note.list") .font(.headline) .foregroundStyle(.primary) } } // Podcasts if let podcasts = groupedResults[.podcast], !podcasts.isEmpty { Section { ForEach(podcasts) { item in NavigationLink(value: convertToPodcast(item)) { SearchResultRow(item: item) } } } header: { Label("Podcasts", systemImage: "mic.fill") .font(.headline) .foregroundStyle(.primary) } } // Radios if let radios = groupedResults[.radio], !radios.isEmpty { Section { ForEach(radios) { item in SearchResultRow(item: item) .contentShape(Rectangle()) .onTapGesture { print("📻 Tapped radio: \(item.name)") // TODO: Play radio } } } header: { Label("Radio Stations", systemImage: "antenna.radiowaves.left.and.right") .font(.headline) .foregroundStyle(.primary) } } } .listStyle(.plain) } // MARK: - Navigation Helper private func convertToAlbum(_ item: MAMediaItem) -> MAAlbum { print("🔄 Converting to album: \(item.name) (URI: \(item.uri))") return MAAlbum( uri: item.uri, name: item.name, artists: item.artists, imageUrl: item.imageUrl, imageProvider: item.imageProvider, year: nil ) } private func convertToPlaylist(_ item: MAMediaItem) -> MAPlaylist { return MAPlaylist( uri: item.uri, name: item.name, imageUrl: item.imageUrl ) } private func convertToPodcast(_ item: MAMediaItem) -> MAPodcast { return MAPodcast( uri: item.uri, name: item.name, publisher: item.artists?.first?.name, imageUrl: item.imageUrl ) } private func convertToArtist(_ item: MAMediaItem) -> MAArtist { print("🔄 Converting to artist: \(item.name) (URI: \(item.uri))") // If the item itself is an artist, use its data if item.mediaType == .artist { return MAArtist( uri: item.uri, name: item.name, imageUrl: item.imageUrl, imageProvider: item.imageProvider, sortName: nil, musicbrainzId: nil ) } // Otherwise try to use the first artist from the artists array if let firstArtist = item.artists?.first { return firstArtist } // Fallback return MAArtist( uri: item.uri, name: item.name, imageUrl: item.imageUrl, imageProvider: item.imageProvider, sortName: nil, musicbrainzId: nil ) } // 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 { print("🔍 Starting search for: '\(query)'") isSearching = true errorMessage = nil do { let results = try await service.libraryManager.search(query: query) print("✅ Search returned \(results.count) results") // Debug: Print first result if let first = results.first { print("📦 First result: \(first.name) (type: \(first.mediaType?.rawValue ?? "nil"))") } await MainActor.run { searchResults = results isSearching = false print("✅ UI updated with \(searchResults.count) results") } } catch { await MainActor.run { errorMessage = error.localizedDescription showError = true searchResults = [] isSearching = false print("❌ Search Error: \(error)") } } } } // MARK: - Search Result Row struct SearchResultRow: View { @Environment(MAService.self) private var service let item: MAMediaItem var body: some View { HStack(spacing: 12) { // Thumbnail CachedAsyncImage(url: service.imageProxyURL(path: item.imageUrl, provider: item.imageProvider, size: 128)) { image in image .resizable() .aspectRatio(contentMode: .fill) } placeholder: { thumbnailShape .fill(Color.gray.opacity(0.2)) .overlay { Image(systemName: mediaTypeIcon) .foregroundStyle(.secondary) } } .frame(width: 60, height: 60) .clipShape(thumbnailShape) // 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 ?? "unknown").capitalized, systemImage: mediaTypeIcon) .font(.caption2) .foregroundStyle(.tertiary) } Spacer() } .padding(.vertical, 4) } private var thumbnailShape: some Shape { if item.mediaType == .artist { return AnyShape(Circle()) } 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" case .podcast: return "mic.fill" case .podcastEpisode: return "mic" default: return "questionmark" } } } // 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()) }