Files
Flaro/Flixy/Views/Search/SearchView.swift
T
ScarriffleandClaude Opus 5 fe2d730c00 Die Ansichten sprechen nicht mehr mit Plex
Start, Suche, Mediathek, Detailseiten und Downloads arbeiten jetzt mit
`MediaItem` und dem `MediaBackend`-Vertrag. Damit sind sie fertig für
einen zweiten Server, ohne dass an ihnen noch etwas zu tun wäre.

Dabei sind ein paar Namen ehrlicher geworden: aus `grandparentTitle`
wurde `seriesTitle`, aus `viewedLeafCount` `playedEpisodeCount`. Und
`TagStrip` nahm bisher Plex-Marken mit Kennung und Rolle entgegen, um
davon nur den Namen anzuzeigen — Genres sind jetzt schlicht Zeichenketten.

`Arrangement` ist aus PlexKit heraus und generisch: dieselbe Rechnung
gilt für jede Liste, die jemand selbst zusammenstellt. Phase 3 baut die
konfigurierbare Seitenleiste darauf, statt sie ein zweites Mal — und ein
zweites Mal falsch — zu schreiben.

Die Fehlerübersetzung gibt es nur noch einmal. Es waren zwei, und sie
waren bereits auseinandergelaufen: derselbe Fehler bekam je nach
Bildschirm eine andere Erklärung.

Das Nachladen der Mediathek bleibt unangetastet: eine lange Liste, die
fünf Einträge vor dem Ende nachlädt. `MediaPage` heißt zwar Seite, ist
aber eine technische Portion — Blättern mit „weiter zu Seite 2" gehört
nicht in eine Mediathek, in der man stöbert. Der Kommentar dort sagt das
jetzt ausdrücklich, damit es niemand später „aufräumt".

Player, Live-TV und die Download-Engine sprechen noch unmittelbar mit
Plex. Sie folgen als Nächstes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 12:46:52 +02:00

134 lines
4.2 KiB
Swift

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