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>
145 lines
6.1 KiB
Swift
145 lines
6.1 KiB
Swift
import SwiftUI
|
|
import FlixyDesign
|
|
import MediaKit
|
|
|
|
/// Eine Kachel, die zur Detailseite führt — mit einem Knopf, der sofort
|
|
/// startet.
|
|
///
|
|
/// **Der Umweg, den es spart.** Wer weiß, was er sehen will, klickte bisher
|
|
/// auf die Kachel, wartete auf die Detailseite und drückte dort „Abspielen".
|
|
/// Zwei Schritte für eine Absicht.
|
|
///
|
|
/// **Warum eine Hülle und keine Änderung an der Karte.** `PosterCard` liegt
|
|
/// in `FlixyDesign`, und das Paket ist ohne Abhängigkeiten deklariert — es
|
|
/// kennt weder `MediaItem` noch `PlaybackCoordinator`. Ein Überzug **in** der
|
|
/// Karte fiele ausserdem unter ihr `.accessibilityElement(children: .combine)`
|
|
/// und wäre für VoiceOver gar nicht mehr da.
|
|
struct QuickPlayTile<Card: View>: View {
|
|
let item: MediaItem
|
|
@ViewBuilder let card: () -> Card
|
|
|
|
@Environment(FlixySession.self) private var session
|
|
@Environment(PlaybackCoordinator.self) private var playback
|
|
@Environment(QuickPlay.self) private var quickPlay
|
|
@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 }
|
|
|
|
/// Ob es überhaupt etwas zu starten gibt.
|
|
///
|
|
/// Eine Sammlung ist kein Titel — dort wäre der Knopf eine Behauptung.
|
|
private var isPlayable: Bool { item.kind != .collection }
|
|
|
|
var body: some View {
|
|
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
|
|
card()
|
|
}
|
|
.buttonStyle(.plain)
|
|
// **Als Überzug auf dem Link, nicht als Kind darin.**
|
|
//
|
|
// Ein `Button` im *Label* eines `NavigationLink` löst auf iOS nie aus:
|
|
// der Link macht sein Label zu einer einzigen Fläche, verschachtelte
|
|
// Bedienelemente werden nicht eigens getroffen — und `.plain` macht
|
|
// das Versagen lautlos. Als Geschwister darüber gewinnt er.
|
|
.overlay(alignment: .topTrailing) {
|
|
if isPlayable, isMac { playButton }
|
|
}
|
|
.onContinuousHover { phase in
|
|
// Zur Laufzeit, nicht beim Übersetzen: „für iPad angepasst" ist
|
|
// der iOS-Bau und läuft trotzdem auf dem Mac.
|
|
guard isMac else { return }
|
|
if case .active = phase { isPointerInside = true } else { isPointerInside = false }
|
|
}
|
|
.animation(.easeOut(duration: 0.15), value: isPointerInside)
|
|
// 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.
|
|
private var playButton: some View {
|
|
Button(action: trigger) {
|
|
Group {
|
|
if quickPlay.resolvingID == item.id {
|
|
ProgressView().controlSize(.small).tint(.white)
|
|
} else {
|
|
Image(systemName: "play.fill")
|
|
.font(.system(size: 11, weight: .bold))
|
|
.foregroundStyle(.white)
|
|
}
|
|
}
|
|
// Feste Grösse, damit der Kreisel den Knopf nicht wachsen lässt
|
|
// und nichts springt, wenn nachgeschlagen wird.
|
|
.frame(width: 28, height: 28)
|
|
.background(accent.opacity(0.92), in: .circle)
|
|
.shadow(color: .black.opacity(0.35), radius: 3, y: 1)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.padding(FlixySpacing.snug)
|
|
.opacity(isPointerInside ? 1 : 0)
|
|
// Er ist eine Zeigergeste; für VoiceOver gibt es die benannte Aktion
|
|
// oben. Beides anzubieten hiesse, jede Kachel zu verdoppeln.
|
|
.accessibilityHidden(true)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|