From 3f680c74268d32adac0aaa98c5545c5e24bd4d7f Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 1 Oct 2026 08:19:19 +0200 Subject: [PATCH] =?UTF-8?q?Gesehen=20und=20Favorit=20direkt=20an=20der=20K?= =?UTF-8?q?achel=20=E2=80=94=20und=20die=20Pinch-Geste=20greift=20wieder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drei Dinge aus dem Gebrauch. **Das Kontextmenü einer Kachel trug genau einen Eintrag.** „Abspielen", sonst nichts; der Kommentar daneben nannte es seit Längerem den natürlichen Ort für „Als gesehen markieren". Jetzt steht es dort — zusammen mit Favorit und Sternen, soweit der Server sie kennt. Es sind dieselben Einträge wie im Menü der Detailseite: `ItemActionEntries` wird von beiden benutzt, statt sie zweimal zu beschreiben. Zwei Dinge daran sind nicht offensichtlich. Der Inhalt eines Kontextmenüs lebt nur, solange das Menü offen ist — ein `@State` darin könnte nichts anzeigen, was nach dem Tippen passiert. Ausgeführt wird deshalb von der Kachel, die länger lebt, und dort hängt auch die Fehlermeldung. Und danach muss die Seite neu laden: eine abgehakte Folge gehört nicht mehr in „Weiterschauen", und der Fortschrittsbalken stimmte sonst nicht mehr. Dafür gibt es `flixyItemChanged` in der Umgebung — sonst müsste der Rückweg durch jede Zwischenebene gereicht werden. **Die Pinch-Geste griff fast nie.** Sie hing an der Bildfläche, und über der liegen die Sprungflächen — und sobald man einmal getippt hat, die ganze Bedienung. Eine Zwei-Finger-Geste bildet sich aber nur, wenn **beide** Finger auf der Ansicht landen, die sie trägt; in dem schmalen Streifen dazwischen gelang das kaum. Sie hängt jetzt als `simultaneousGesture` am ganzen Player: sie erkennt mit, statt dazwischenzugehen, und Tippen, Doppeltippen und Scrubber bleiben unberührt. Gemessen im Simulator, Querformat auf einem Pro Max, mit einer echten Zwei-Finger-Geste: auseinander 956 × 440 pt, Rand 0,0 / 0,0 zusammen 782 × 440 pt, Rand 87,0 / 87,0 **Der Drehknopf zeigte ein Telefon.** Das las sich als Zustandsanzeige — „so liegt es" — statt als Knopf. Zwei Pfeile im Kreis sagen, was passiert. iOS und tvOS bauen, 463 Tests grün. Co-Authored-By: Claude Opus 5 --- .../Views/Components/ItemActionEntries.swift | 92 +++++++++++++++++++ Flixy/Views/Components/QuickPlayTile.swift | 50 +++++++++- Flixy/Views/Detail/ItemActionsMenu.swift | 52 +---------- Flixy/Views/Home/HomeView.swift | 3 + Flixy/Views/Library/LibraryView.swift | 2 + Flixy/Views/Player/PlayerView.swift | 35 ++++--- 6 files changed, 172 insertions(+), 62 deletions(-) create mode 100644 Flixy/Views/Components/ItemActionEntries.swift diff --git a/Flixy/Views/Components/ItemActionEntries.swift b/Flixy/Views/Components/ItemActionEntries.swift new file mode 100644 index 0000000..be676e7 --- /dev/null +++ b/Flixy/Views/Components/ItemActionEntries.swift @@ -0,0 +1,92 @@ +import SwiftUI +import MediaKit + +/// Die Einträge, die am Server etwas ändern — ohne Hülle darum. +/// +/// **Gesehen, Favorit, Sterne gehören nicht nur auf die Detailseite.** Wer in +/// einer Reihe steht und weiss, dass er die Folge schon kennt, will sie dort +/// abhaken und nicht erst eine Seite tiefer gehen. Dieselben Einträge, zwei +/// Orte — und deshalb an einer Stelle beschrieben. +/// +/// **Warum sie keinen eigenen Zustand tragen.** Der Inhalt eines +/// Kontextmenüs lebt nur, solange das Menü offen ist: SwiftUI baut ihn beim +/// Öffnen und wirft ihn beim Schliessen weg. Ein `@State` darin könnte also +/// nichts anzeigen, was **nach** dem Tippen passiert — weder Kreisel noch +/// Fehlermeldung, denn da ist das Menü längst zu. Ausgeführt wird deshalb vom +/// Aufrufer, der länger lebt als das Menü. +/// +/// Aus demselben Grund kommen `capabilities` als Wert herein, statt aus der +/// Umgebung gelesen zu werden: was in ein Kontextmenü durchgereicht wird, ist +/// von SwiftUI-Fassung zu SwiftUI-Fassung verschieden — ein Wert, den der +/// Aufrufer übergibt, ist es nicht. +struct ItemActionEntries: View { + let item: MediaItem + let capabilities: Set + /// Führt die Serveraktion aus und räumt hinterher auf. + let run: (@escaping (any MediaBackend) async throws -> Void) -> Void + + var body: some View { + Button { + run { backend in + if item.isWatched { + try await backend.markUnwatched(id: item.id) + } else { + try await backend.markWatched(id: item.id) + } + } + } label: { + Label( + item.isWatched ? "action.markUnwatched" : "action.markWatched", + systemImage: item.isWatched ? "eye.slash" : "checkmark.circle" + ) + } + + if capabilities.contains(.favourites) { + Button { + run { try await $0.setFavourite(!item.isFavourite, id: item.id) } + } label: { + Label( + item.isFavourite ? "action.removeFavourite" : "action.addFavourite", + systemImage: item.isFavourite ? "heart.slash" : "heart" + ) + } + } + + if capabilities.contains(.ratings) { + Menu { + ForEach((1...5).reversed(), id: \.self) { stars in + Button { + // In Zehnteln: fünf Sterne sind eine 10. + run { try await $0.rate(id: item.id, rating: stars * 2) } + } label: { + Label(String(repeating: "★", count: stars), systemImage: "star") + } + } + if item.rating != nil { + Divider() + Button(role: .destructive) { + run { try await $0.rate(id: item.id, rating: 0) } + } label: { + Label("action.clearRating", systemImage: "star.slash") + } + } + } label: { + Label("action.rate", systemImage: "star") + } + } + } +} + +extension EnvironmentValues { + /// Was zu tun ist, wenn ein Titel am Server verändert wurde. + /// + /// **Über die Umgebung, weil der Weg sonst lang ist.** Eine Kachel steckt + /// in einer Reihe, die in der Startseite steckt — und nur die Startseite + /// weiss, wie man ihre Reihen neu lädt. Diesen Rückweg durch jede + /// Zwischenebene zu reichen hiesse, drei Ansichten um ein Argument zu + /// erweitern, das sie nur weitergeben. + /// + /// Die Voreinstellung tut nichts: wo niemand etwas hinterlegt, gibt es + /// auch nichts neu zu laden. + @Entry var flixyItemChanged: () async -> Void = {} +} diff --git a/Flixy/Views/Components/QuickPlayTile.swift b/Flixy/Views/Components/QuickPlayTile.swift index 41452a0..9252d8d 100644 --- a/Flixy/Views/Components/QuickPlayTile.swift +++ b/Flixy/Views/Components/QuickPlayTile.swift @@ -24,6 +24,11 @@ struct QuickPlayTile: View { @Environment(\.flixyAccent) private var accent @State private var isPointerInside = false + @State private var errorMessage: String? + + /// Was die umgebende Seite tun soll, wenn sich am Server etwas geändert + /// hat. Siehe `ItemActionEntries`. + @Environment(\.flixyItemChanged) private var itemChanged private var isMac: Bool { FlixyPlatform.isMac } @@ -53,17 +58,40 @@ struct QuickPlayTile: View { if case .active = phase { isPointerInside = true } else { isPointerInside = false } } .animation(.easeOut(duration: 0.15), value: isPointerInside) - // Der Weg ohne Zeiger: langes Drücken auf dem iPad, Rechtsklick auf - // dem Mac. Später der natürliche Ort für „Als gesehen markieren". + // Langes Drücken am Telefon und am iPad, Rechtsklick am Mac. + // + // **Hier und nicht nur auf der Detailseite.** Eine Folge abzuhaken, + // die man schon kennt, war bisher zwei Schritte weit weg: Kachel + // antippen, warten, Menü oben rechts. Jetzt steht es dort, wo die + // Kachel steht. .contextMenu { if isPlayable { Button("tile.play", systemImage: "play.fill") { trigger() } + Divider() } + ItemActionEntries( + item: item, + capabilities: session.backend?.capabilities ?? [], + run: run + ) } // Für VoiceOver eine **Aktion**, kein zweites Element: die Kachel // bleibt einer, und im Aktionen-Rotor steht „Abspielen". Zwanzig // Kacheln mit je zwei Elementen wären sechzig Wischer. .accessibilityAction(named: Text("tile.play")) { trigger() } + // **Nur der Fehler, kein Kreisel.** Wer im Kontextmenü tippt, sieht + // das Menü verschwinden; ein Kreisel auf der Kachel käme für eine + // Aktion, die Millisekunden dauert, zu spät und ginge zu früh. Geht + // sie dagegen schief, muss man es erfahren — sonst bliebe der Haken + // einfach aus, und das sieht aus wie ein Fehler der App. + .alert( + Text("error.title"), + isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } }) + ) { + Button("action.close") { errorMessage = nil } + } message: { + if let errorMessage { Text(errorMessage) } + } } /// Oben rechts, weil der Fortschrittsbalken unten liegt. @@ -95,4 +123,22 @@ struct QuickPlayTile: View { private func trigger() { quickPlay.start(item, backend: session.backend, coordinator: playback) } + + /// Führt eine Serveraktion aus und lässt die Seite danach neu laden. + /// + /// Das Neuladen gehört dazu: eine Folge, die als gesehen abgehakt wurde, + /// gehört nicht mehr in „Weiterschauen", und der Fortschrittsbalken unter + /// der Kachel stimmt sonst nicht mehr. Ohne diesen Schritt sähe es aus, + /// als wäre nichts passiert. + private func run(_ action: @escaping (any MediaBackend) async throws -> Void) { + guard let backend = session.backend else { return } + Task { + do { + try await action(backend) + await itemChanged() + } catch { + errorMessage = ErrorText.describe(error) + } + } + } } diff --git a/Flixy/Views/Detail/ItemActionsMenu.swift b/Flixy/Views/Detail/ItemActionsMenu.swift index 0271a05..85d6912 100644 --- a/Flixy/Views/Detail/ItemActionsMenu.swift +++ b/Flixy/Views/Detail/ItemActionsMenu.swift @@ -27,54 +27,10 @@ struct ItemActionsMenu: View { var body: some View { Menu { - Button { - run { backend in - if item.isWatched { - try await backend.markUnwatched(id: item.id) - } else { - try await backend.markWatched(id: item.id) - } - } - } label: { - Label( - item.isWatched ? "action.markUnwatched" : "action.markWatched", - systemImage: item.isWatched ? "eye.slash" : "checkmark.circle" - ) - } - - if capabilities.contains(.favourites) { - Button { - run { try await $0.setFavourite(!item.isFavourite, id: item.id) } - } label: { - Label( - item.isFavourite ? "action.removeFavourite" : "action.addFavourite", - systemImage: item.isFavourite ? "heart.slash" : "heart" - ) - } - } - - if capabilities.contains(.ratings) { - Menu { - ForEach((1...5).reversed(), id: \.self) { stars in - Button { - // In Zehnteln: fünf Sterne sind eine 10. - run { try await $0.rate(id: item.id, rating: stars * 2) } - } label: { - Label(String(repeating: "★", count: stars), systemImage: "star") - } - } - if item.rating != nil { - Divider() - Button(role: .destructive) { - run { try await $0.rate(id: item.id, rating: 0) } - } label: { - Label("action.clearRating", systemImage: "star.slash") - } - } - } label: { - Label("action.rate", systemImage: "star") - } - } + // Dieselben Einträge wie im Kontextmenü einer Kachel — siehe + // `ItemActionEntries`. Hier mit Kreisel und Fehlermeldung, weil + // diese Ansicht lange genug lebt, um beides zu zeigen. + ItemActionEntries(item: item, capabilities: capabilities, run: run) } label: { if isWorking { ProgressView().controlSize(.small) diff --git a/Flixy/Views/Home/HomeView.swift b/Flixy/Views/Home/HomeView.swift index 6d3fcdd..c9d48b1 100644 --- a/Flixy/Views/Home/HomeView.swift +++ b/Flixy/Views/Home/HomeView.swift @@ -61,6 +61,9 @@ struct HomeView: View { .toolbar(.hidden, for: .navigationBar) .task(id: session.serverName) { await feed.load(from: session.backend) } .refreshable { await feed.load(from: session.backend) } + // Was eine Kachel am Server ändert, ändert auch, was hier steht: + // eine abgehakte Folge gehört nicht mehr in „Weiterschauen". + .environment(\.flixyItemChanged, { await feed.load(from: session.backend) }) } private func content(hubs: [MediaRowGroup]) -> some View { diff --git a/Flixy/Views/Library/LibraryView.swift b/Flixy/Views/Library/LibraryView.swift index 6f1f7cd..475df56 100644 --- a/Flixy/Views/Library/LibraryView.swift +++ b/Flixy/Views/Library/LibraryView.swift @@ -79,6 +79,8 @@ struct LibraryView: View { // verschiedenen Abfragen vermischen. .onChange(of: query) { _, _ in Task { await reload() } } .refreshable { await reload() } + // Siehe `HomeView`: was eine Kachel ändert, muss im Raster ankommen. + .environment(\.flixyItemChanged, { await reload() }) } @ViewBuilder diff --git a/Flixy/Views/Player/PlayerView.swift b/Flixy/Views/Player/PlayerView.swift index 26c05fc..16545ba 100644 --- a/Flixy/Views/Player/PlayerView.swift +++ b/Flixy/Views/Player/PlayerView.swift @@ -113,17 +113,6 @@ struct PlayerView: View { surface(for: model) .contentShape(.rect) .onTapGesture { primaryTap() } - // **Auseinander füllt, zusammen passt ein.** - // - // Kein stufenloses Zoomen: es gibt genau zwei - // sinnvolle Zustände, und alles dazwischen - // wäre ein Bild, das weder ganz zu sehen ist - // noch den Schirm füllt. Die Geste wählt also - // aus zweien aus, statt einen Faktor zu - // setzen — und der Schwellenwert liegt bewusst - // weit vom Ruhepunkt weg, damit eine wackelige - // Hand nichts umschaltet. - .gesture(zoomGesture(for: model), including: isMac ? .none : .all) } } @@ -233,6 +222,24 @@ struct PlayerView: View { } } } + // **Auseinander füllt, zusammen passt ein.** + // + // Kein stufenloses Zoomen: es gibt genau zwei sinnvolle Zustände, und + // alles dazwischen wäre ein Bild, das weder ganz zu sehen ist noch den + // Schirm füllt. Die Geste wählt aus zweien aus, statt einen Faktor zu + // setzen — und der Schwellenwert liegt weit vom Ruhepunkt weg, damit + // eine wackelige Hand nichts umschaltet. + // + // **Am ganzen Player, nicht an der Bildfläche.** Dort hing sie bisher + // und griff deshalb fast nie: über dem Bild liegen die Sprungflächen, + // und sobald man einmal getippt hat, die ganze Bedienung. Eine + // Zwei-Finger-Geste bildet sich aber nur, wenn **beide** Finger auf + // der Ansicht landen, die sie trägt — in dem schmalen Streifen + // dazwischen gelang das kaum. + // + // `simultaneousGesture`, damit Tippen, Doppeltippen und der Scrubber + // unberührt bleiben: die Geste erkennt mit, statt dazwischenzugehen. + .simultaneousGesture(zoomGesture(for: model), including: isMac ? .none : .all) // Immer aus, nicht nur wenn die Bedienelemente ausgeblendet sind. // Die Uhrzeit über einem laufenden Film hat dort nichts zu suchen — // und im Querformat erst recht nicht. @@ -476,7 +483,11 @@ struct PlayerView: View { OrientationLock.shared.rotate(to: isLandscape ? .portrait : .landscape) revealControls() } label: { - Image(systemName: isLandscape ? "iphone" : "iphone.landscape") + // **Ein Symbol für beide Richtungen, nicht zwei.** Das + // Telefon-Symbol zeigte die Lage, in die es ginge — und + // las sich damit als Zustandsanzeige statt als Knopf. + // Zwei Pfeile im Kreis sagen, was passiert: es dreht. + Image(systemName: "arrow.triangle.2.circlepath") .font(.title3) .padding(FlixySpacing.snug) }