From 6373566d1c4ee50d603f878a399d6a37d35940ee Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 29 Sep 2026 20:30:55 +0200 Subject: [PATCH] =?UTF-8?q?Suchen=20am=20Fernseher=20=E2=80=94=20und=20die?= =?UTF-8?q?=20Leiste,=20die=20es=20daf=C3=BCr=20braucht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Das Backend konnte die Suche längst; es fehlte die Oberfläche. **`.searchable`, keine eigene Tastatur.** tvOS macht daraus einen eigenen Bildschirm mit Rastertastatur oben und Ergebnissen darunter — die Bedienung, die jeder von Apples Apps kennt. Etwas Eigenes zu bauen hiesse, sie nachzuahmen und dabei die Diktierfunktion zu verlieren. **Herausgelöst statt kopiert**, wie schon bei Start und Mediathek: `SearchFeed` trägt das Entprellen, die Mindestlänge und die zuletzt gesuchten Begriffe. Drei Stellen, an denen es nicht offensichtlich ist — und die dritte ist die wichtigste: geprüft wird auf `Task.isCancelled` und nicht auf `CancellationError`, weil eine abgebrochene Netzanfrage als Übertragungsfehler zurückkommt. Die naheliegende Prüfung lief ins Leere, und jeder Tastendruck leerte kurz die Liste. **Und erst jetzt eine Reiterleiste.** Bis hierher gab es genau einen Bildschirm, und eine Leiste mit einem Eintrag wäre Zierrat gewesen. Der Suchreiter trägt `role: .search`: tvOS stellt ihn dadurch ans Ende und gibt ihm die Lupe, dieselbe Stelle wie überall sonst. Keinen dritten Reiter für die Mediatheken — die stehen als Reihe auf der Startseite, und ein Reiter verspräche einen eigenen Bereich, hinter dem dasselbe Raster läge. Der offene Reiter wird ausdrücklich gehalten, nicht dem System überlassen: ein Link aus dem Regal über der App führt zur Startseite, und stünde gerade die Suche offen, passierte sichtbar nichts. Nebenbei bekommt die iOS-Suche einen Knopf zum Vergessen des Verlaufs. Wischen zum Löschen gab es dort, am Fernseher gibt es das nicht — und eine Liste, die sich nur auf einer Plattform leeren lässt, ist keine. Co-Authored-By: Claude Opus 5 --- Flixy/Resources/Localizable.xcstrings | 34 ++++++++ Flixy/Session/SearchFeed.swift | 80 ++++++++++++++++++ Flixy/Views/Search/SearchView.swift | 73 ++++------------- FlixyTV/Views/Search/TVSearchView.swift | 104 ++++++++++++++++++++++++ FlixyTV/Views/TVRootView.swift | 2 +- FlixyTV/Views/TVShell.swift | 48 +++++++++++ Packages/FlixyVLC/Package.resolved | 15 ---- 7 files changed, 282 insertions(+), 74 deletions(-) create mode 100644 Flixy/Session/SearchFeed.swift create mode 100644 FlixyTV/Views/Search/TVSearchView.swift create mode 100644 FlixyTV/Views/TVShell.swift delete mode 100644 Packages/FlixyVLC/Package.resolved diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index 6ebc49c..04d6d4e 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -4758,6 +4758,23 @@ } } }, + "search.forgetRecent": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verlauf löschen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear history" + } + } + } + }, "search.hint": { "localizations": { "de": { @@ -4774,6 +4791,23 @@ } } }, + "search.noResults": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nichts gefunden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Nothing found." + } + } + } + }, "search.recent": { "localizations": { "de": { diff --git a/Flixy/Session/SearchFeed.swift b/Flixy/Session/SearchFeed.swift new file mode 100644 index 0000000..b66838e --- /dev/null +++ b/Flixy/Session/SearchFeed.swift @@ -0,0 +1,80 @@ +import Foundation +import MediaKit +import Observation + +/// Die Suche als Modell: entprellen, fragen, merken. +/// +/// **Herausgelöst wie `HomeFeed` und `LibraryFeed`**, und aus demselben +/// Grund: der Fernseher stellt dieselben Fragen an denselben Server, nur ganz +/// anders dargestellt. Kopiert wären dabei die drei Stellen, an denen es +/// nicht offensichtlich ist — die Pause vor der Anfrage, die Mindestlänge und +/// vor allem die Prüfung auf `Task.isCancelled`, ohne die jeder Tastendruck +/// die Ergebnisse des vorigen löscht. +@MainActor +@Observable +final class SearchFeed { + /// Wie viele zuletzt gesuchte Begriffe gemerkt werden. + private static let recentLimit = 8 + private static let recentKey = "flixy.search.recent" + + private(set) var hubs: [MediaRowGroup] = [] + private(set) var isSearching = false + private(set) var recent: [String] + + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + self.recent = defaults.stringArray(forKey: Self.recentKey) ?? [] + } + + /// Sucht — nach einer kurzen Pause. + /// + /// Die Pause ist der Kern: aufgerufen wird das bei **jedem** Tastendruck, + /// und ohne sie liefe für „Nachtzug" eine Anfrage je Buchstabe. Ein + /// neuer Tastendruck bricht die laufende Aufgabe ab, und der Abbruch + /// landet im `catch` der Pause — deshalb steht dort kein Fehler, sondern + /// ein `return`. + func search(_ text: String, from backend: (any MediaBackend)?) async { + let bereinigt = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard let backend, bereinigt.count >= 2 else { + hubs = [] + return + } + + do { + try await Task.sleep(for: .milliseconds(300)) + } catch { + return + } + + isSearching = true + defer { isSearching = false } + + do { + hubs = try await backend.search(bereinigt, limit: 30) + if !hubs.isEmpty { remember(bereinigt) } + } catch { + // **`Task.isCancelled` statt `catch is CancellationError`.** Eine + // abgebrochene Netzanfrage kommt als Übertragungsfehler zurück, + // nicht als Abbruch; die naheliegende Prüfung lief deshalb ins + // Leere, und jeder Tastendruck leerte kurz die Liste. + if Task.isCancelled { return } + hubs = [] + } + } + + // MARK: - Zuletzt gesucht + + private func remember(_ eintrag: String) { + recent.removeAll { $0.caseInsensitiveCompare(eintrag) == .orderedSame } + recent.insert(eintrag, at: 0) + recent = Array(recent.prefix(Self.recentLimit)) + defaults.set(recent, forKey: Self.recentKey) + } + + func forgetRecent() { + recent = [] + defaults.removeObject(forKey: Self.recentKey) + } +} diff --git a/Flixy/Views/Search/SearchView.swift b/Flixy/Views/Search/SearchView.swift index 7efc2e2..a0d51be 100644 --- a/Flixy/Views/Search/SearchView.swift +++ b/Flixy/Views/Search/SearchView.swift @@ -3,22 +3,21 @@ import FlixyDesign import MediaKit /// Sucht über alle Mediatheken des Servers. +/// +/// **Nur noch Darstellung.** Entprellen, fragen und merken liegen in +/// `SearchFeed`, weil der Fernseher dieselben Fragen an denselben Server +/// stellt. Siehe dort, warum das kein Beiwerk ist. 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 + @State private var feed = SearchFeed() var body: some View { Group { if text.isEmpty { recentSearches - } else if hubs.isEmpty && !isSearching { + } else if feed.hubs.isEmpty && !feed.isSearching { ContentUnavailableView.search(text: text) } else { results @@ -31,13 +30,13 @@ struct SearchView: View { // 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() } + .task(id: text) { await feed.search(text, from: session.backend) } } private var results: some View { ScrollView { LazyVStack(alignment: .leading, spacing: FlixySpacing.section) { - ForEach(hubs) { hub in + ForEach(feed.hubs) { hub in MediaRow(row: hub) } } @@ -48,7 +47,7 @@ struct SearchView: View { @ViewBuilder private var recentSearches: some View { - if recent.isEmpty { + if feed.recent.isEmpty { ContentUnavailableView { Label("search.title", systemImage: "magnifyingglass") } description: { @@ -57,7 +56,7 @@ struct SearchView: View { } else { List { Section { - ForEach(recent, id: \.self) { entry in + ForEach(feed.recent, id: \.self) { entry in Button { text = entry } label: { @@ -65,12 +64,13 @@ struct SearchView: View { .foregroundStyle(Color.flixyTextSecondary) } } - .onDelete { offsets in - recent.remove(atOffsets: offsets) - persistRecent() - } } header: { Text("search.recent") + } footer: { + Button("search.forgetRecent", role: .destructive) { + feed.forgetRecent() + } + .font(.flixyCaption) } .listRowBackground(Color.flixySurface) } @@ -78,47 +78,4 @@ struct SearchView: View { .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) - } } diff --git a/FlixyTV/Views/Search/TVSearchView.swift b/FlixyTV/Views/Search/TVSearchView.swift new file mode 100644 index 0000000..915b5c9 --- /dev/null +++ b/FlixyTV/Views/Search/TVSearchView.swift @@ -0,0 +1,104 @@ +import SwiftUI +import FlixyDesign +import MediaKit + +/// Die Suche am Fernseher. +/// +/// **`.searchable` und keine eigene Tastatur.** tvOS macht daraus einen +/// eigenen Bildschirm mit Rastertastatur links und Ergebnissen rechts — die +/// Bedienung, die jeder von Apples eigenen Apps kennt. Etwas Eigenes zu bauen +/// hiesse, sie nachzuahmen und dabei die Diktierfunktion zu verlieren. +/// +/// Das Modell dahinter ist dasselbe wie am Telefon: `SearchFeed` entprellt, +/// fragt und merkt sich die letzten Begriffe. +struct TVSearchView: View { + @Environment(FlixySession.self) private var session + @Environment(PlaybackCoordinator.self) private var playback + @Environment(QuickPlay.self) private var quickPlay + + @State private var text = "" + @State private var feed = SearchFeed() + @State private var pfad = NavigationPath() + + var body: some View { + NavigationStack(path: $pfad) { + inhalt + .navigationDestination(for: MediaRoute.self) { route in + TVDetailView(route: route) + } + } + .searchable(text: $text, prompt: Text("search.hint")) + // Jeder Tastendruck löst eine Anfrage aus — aber erst nach einer + // kurzen Pause. Siehe `SearchFeed`. + .task(id: text) { await feed.search(text, from: session.backend) } + } + + @ViewBuilder + private var inhalt: some View { + if text.isEmpty { + zuletzt + } else if feed.hubs.isEmpty { + // Kein Unterschied zwischen „sucht noch" und „nichts gefunden": + // auf dem Fernseher tippt man langsam, und ein Text, der bei + // jedem Buchstaben zwischen beiden wechselt, flackert nur. + leer + } else { + ergebnisse + } + } + + private var ergebnisse: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: FlixySpacing.section) { + ForEach(feed.hubs) { reihe in + TVMediaRow( + row: reihe, + onSelect: { quickPlay.start($0, backend: session.backend, coordinator: playback) } + ) + } + } + .padding(.vertical, FlixySpacing.section) + } + .scrollClipDisabled() + .scrollIndicators(.never) + } + + /// Die letzten Begriffe — als Knöpfe, nicht als Liste. + /// + /// Am Fernseher ist das der eigentliche Wert der Suche: einmal getippt, + /// danach ein Druck. Eine Zeile mit Wischen zum Löschen gibt es hier + /// nicht, deshalb steht das Vergessen als eigener Knopf daneben. + @ViewBuilder + private var zuletzt: some View { + if feed.recent.isEmpty { + hinweis("search.hint") + } else { + VStack(alignment: .leading, spacing: FlixySpacing.regular) { + Text("search.recent") + .font(.flixyRowHeader) + .foregroundStyle(Color.flixyTextPrimary) + + ForEach(feed.recent, id: \.self) { eintrag in + Button(eintrag) { text = eintrag } + } + + Button("search.forgetRecent", role: .destructive) { feed.forgetRecent() } + .padding(.top, FlixySpacing.regular) + } + .frame(maxWidth: 700, alignment: .leading) + .padding(FlixySpacing.section) + } + } + + private var leer: some View { + hinweis("search.noResults") + } + + private func hinweis(_ text: LocalizedStringKey) -> some View { + Text(text) + .font(.flixyBody) + .foregroundStyle(Color.flixyTextMuted) + .frame(maxWidth: 700) + .padding(FlixySpacing.section) + } +} diff --git a/FlixyTV/Views/TVRootView.swift b/FlixyTV/Views/TVRootView.swift index 275b055..f780606 100644 --- a/FlixyTV/Views/TVRootView.swift +++ b/FlixyTV/Views/TVRootView.swift @@ -39,7 +39,7 @@ struct TVRootView: View { fehlgeschlagen(meldung) case .ready: - TVHomeView(deepLinkTarget: $ziel) + TVShell(deepLinkTarget: $ziel) } } .task { await session.restore() } diff --git a/FlixyTV/Views/TVShell.swift b/FlixyTV/Views/TVShell.swift new file mode 100644 index 0000000..9e21ec9 --- /dev/null +++ b/FlixyTV/Views/TVShell.swift @@ -0,0 +1,48 @@ +import SwiftUI +import FlixyDesign + +/// Start und Suche nebeneinander. +/// +/// **Ein `TabView`, und erst jetzt.** Bis hierher gab es genau einen +/// Bildschirm, und eine Leiste mit einem einzigen Eintrag wäre Zierrat +/// gewesen. Mit der Suche gibt es zwei Orte, zwischen denen man wechselt — +/// und dafür ist die Leiste oben die Bedienung, die jeder vom Fernseher +/// kennt: sie erscheint, wenn man vom obersten Element aus nach oben wischt, +/// und verschwindet wieder. +/// +/// **Kein Reiter für die Mediatheken.** Die stehen als Reihe auf der +/// Startseite. Ein dritter Reiter dafür verspräche einen eigenen Bereich, und +/// dahinter läge dasselbe Raster, das eine Reihe tiefer ohnehin erreichbar +/// ist. +struct TVShell: View { + @Binding var deepLinkTarget: MediaRoute? + + /// Welcher Reiter offen ist. + /// + /// Ausdrücklich gehalten, nicht dem System überlassen: ein Link aus dem + /// Regal über der App führt immer zur Startseite, und stünde gerade die + /// Suche offen, passierte sichtbar nichts. + @State private var reiter = Reiter.start + + private enum Reiter: Hashable { case start, suche } + + var body: some View { + TabView(selection: $reiter) { + Tab(value: Reiter.start) { + TVHomeView(deepLinkTarget: $deepLinkTarget) + } label: { + Text("sidebar.home") + } + + // **`role: .search` und nicht ein gewöhnlicher Reiter.** tvOS + // stellt ihn dadurch ans Ende der Leiste und gibt ihm das + // Lupensymbol — dieselbe Stelle wie in Apples eigenen Apps. + Tab(value: Reiter.suche, role: .search) { + TVSearchView() + } + } + .onChange(of: deepLinkTarget) { _, neu in + if neu != nil { reiter = .start } + } + } +} diff --git a/Packages/FlixyVLC/Package.resolved b/Packages/FlixyVLC/Package.resolved deleted file mode 100644 index 613e4ba..0000000 --- a/Packages/FlixyVLC/Package.resolved +++ /dev/null @@ -1,15 +0,0 @@ -{ - "originHash" : "8b778ccd4dce9d26be9db57d4587b19c4a4366fcdaf8b6017e1df36fa1eddb4f", - "pins" : [ - { - "identity" : "vlckit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/videolan/vlckit.git", - "state" : { - "revision" : "6cbc4e7b248aa51ec0906697abb30ecae47194b7", - "version" : "4.0.0-a24" - } - } - ], - "version" : 3 -}