Der Simulator läuft wieder. Ich habe alles durchgeklickt, was ich dir in den letzten beiden Runden ungeprüft gegeben habe. Es war nicht in Ordnung. **Zwei Navigationsleisten übereinander.** Auf dem iPhone hat die Leiste unten fünf Plätze; alles darüber schiebt das System in ein „Mehr"-Verzeichnis — und dessen Einträge bekamen zwei Zurück-Pfeile, einen vom System und einen von uns. Statt das zu flicken, gibt es jetzt die **Bibliothek**, die du ohnehin wolltest: auf dem iPhone ein Eintrag, hinter dem alle Mediatheken stehen, auf dem iPad weiter jede einzeln in der Seitenleiste. Damit bleibt die Leiste bei fünf Einträgen, und das „Mehr" entfällt. **Kacheln in der Mediathek öffneten nichts.** Kein Fehler, keine Meldung — Antippen führte einfach ins Leere. Der Grund: in einem Stapel dürfen nicht beide Arten von Verknüpfung nebeneinander vorkommen. Sobald ein Bildschirm über eine **Ansichts**-Verknüpfung geschoben wurde, fanden die **Wert**-Verknüpfungen darin ihr Ziel nicht mehr. Jetzt ist alles wertbasiert, und das Ziel für Detailseiten steht **einmal je Stapel** an dessen Wurzel statt in jedem Bildschirm einzeln. **Der Lupenknopf in der Kopfzeile zeigte auf ein Ziel, das es nie gab.** Kein Bildschirm hat je ein `SearchRoute`-Ziel angemeldet — der Knopf tat seit jeher nichts. Er ist weg; die Suche hat ihren eigenen Platz in der Leiste. **Der Download-Dialog zeigte Schlüssel statt Namen** — „quality.hd" statt „720p". Die drei Übersetzungen fehlten schlicht. **Die Kontokarte wuchs aus dem Bild heraus.** Die Leiste unten in der Seitenleiste hat eine feste Höhe; meine aufklappende Karte schob die Zeile mit dem Namen nach unten hinaus. Jetzt hängt sie als Popover darüber — die Zeile bleibt stehen, wie bei JellyTV. Verkleinerte Downloads bei Jellyfin laufen: Stufen erscheinen, die Adresse stimmt, die Datei kommt an. Dass die Attrappe nur 28 kB liefert und die App das als „keine Videodatei" zurückweist, ist richtig so — die Prüfung tut, was sie soll. Zwei Gruben in der Attrappe nebenbei gestopft: sie kannte `/stream.mp4` nicht und meldete ihr Testbild als 360p, womit es gar keine kleineren Stufen geben konnte. 263 Tests grün. Diesmal alles angesehen: iPhone und iPad, Einstellungen, Bibliothek, Detailseite, Download-Dialog, Kontokarte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
123 lines
4.0 KiB
Swift
123 lines
4.0 KiB
Swift
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"))
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.searchable(text: $text, placement: .navigationBarDrawer(displayMode: .always))
|
|
// 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 {
|
|
// 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)
|
|
}
|
|
}
|