import SwiftUI import FlixyDesign import MediaKit /// 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")) .flixyInlineTitle() .searchable(text: $text) // 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) } .flixyRevealsTopChrome() } @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) .flixyRevealsTopChrome() } } 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 { // Abgebrochen, weil weitergetippt oder der Bildschirm verlassen // wurde: `Task.isCancelled` statt `catch is CancellationError` — // eine abgebrochene Netzanfrage kommt als Übertragungsfehler // zurück, nicht als Abbruch, und die alte Prüfung lief deshalb // ins Leere. if Task.isCancelled { return } 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) } }