Gesehen und Favorit direkt an der Kachel — und die Pinch-Geste greift wieder

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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-01 08:19:19 +02:00
co-authored by Claude Opus 5
parent 46b0117984
commit 3f680c7426
6 changed files with 172 additions and 62 deletions
@@ -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<BackendCapability>
/// 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 = {}
}