import SwiftUI import FlixyDesign import MediaKit /// Der Knopf, der zur Suche führt. Steht in mehreren Werkzeugleisten. struct SearchToolbarItem: ToolbarContent { var body: some ToolbarContent { ToolbarItem(placement: .topBarTrailing) { NavigationLink(value: SearchRoute()) { Image(systemName: "magnifyingglass") } .accessibilityLabel(Text("search.title")) } } } struct SearchRoute: Hashable {} /// Sucht über alle Mediatheken des Servers. struct SearchView: View { @Environment(FlixySession.self) private var session @State private var text = "" @State private var hubs: [MediaRowGroup] = [] @State private var isSearching = false @State private var recent: [String] = UserDefaults.standard.stringArray(forKey: recentKey) ?? [] private static let recentKey = "flixy.search.recent" private static let recentLimit = 8 var body: some View { Group { if text.isEmpty { recentSearches } else if hubs.isEmpty && !isSearching { ContentUnavailableView.search(text: text) } else { results } } .background(Color.flixyCanvas) .navigationTitle(Text("search.title")) .navigationBarTitleDisplayMode(.inline) .searchable(text: $text, placement: .navigationBarDrawer(displayMode: .always)) .navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) } // Jeder Tastendruck löst eine Anfrage aus — aber erst nach einer // kurzen Pause. Ohne sie schickt „Blade Runner" zwölf Anfragen los, // von denen elf verworfen werden. .task(id: text) { await search() } } private var results: some View { ScrollView { LazyVStack(alignment: .leading, spacing: FlixySpacing.section) { ForEach(hubs) { hub in MediaRow(row: hub) } } .padding(.vertical, FlixySpacing.loose) } } @ViewBuilder private var recentSearches: some View { if recent.isEmpty { ContentUnavailableView { Label("search.title", systemImage: "magnifyingglass") } description: { Text("search.hint") } } else { List { Section { ForEach(recent, id: \.self) { entry in Button { text = entry } label: { Label(entry, systemImage: "clock.arrow.circlepath") .foregroundStyle(Color.flixyTextSecondary) } } .onDelete { offsets in recent.remove(atOffsets: offsets) persistRecent() } } header: { Text("search.recent") } .listRowBackground(Color.flixySurface) } .scrollContentBackground(.hidden) } } private func search() async { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard let backend = session.backend, trimmed.count >= 2 else { hubs = [] return } do { try await Task.sleep(for: .milliseconds(300)) } catch { // Ein neuer Tastendruck hat diese Aufgabe abgebrochen — genau der // Zweck der Pause. return } isSearching = true defer { isSearching = false } do { hubs = try await backend.search(trimmed, limit: 30) if !hubs.isEmpty { remember(trimmed) } } catch is CancellationError { return } catch { hubs = [] } } private func remember(_ entry: String) { recent.removeAll { $0.caseInsensitiveCompare(entry) == .orderedSame } recent.insert(entry, at: 0) recent = Array(recent.prefix(Self.recentLimit)) persistRecent() } private func persistRecent() { UserDefaults.standard.set(recent, forKey: Self.recentKey) } }