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