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: View { let item: MediaItem /// Ob ein Tipp auf die Detailseite führt, statt abzuspielen. /// /// **Für die Suche.** Dort weiß man gerade *nicht*, was man sehen will — /// sonst hätte man nicht gesucht. Ein Tipp, der sofort abspielt, ist auf /// der Startseite richtig (die Kachel stand schon da, man kennt sie) und /// in einer Trefferliste falsch: der erste Treffer ist eine Vermutung des /// Servers, keine Entscheidung des Nutzers. var opensDetails: Bool = false @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 /// Der Weg auf die Detailseite — siehe `MediaStack`. @Environment(\.flixyPush) private var push 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 } private var route: MediaRoute { MediaRoute(ratingKey: item.id, title: item.title) } /// Wohin ein Tipp führt. /// /// Drei Gründe, derselbe Ausgang: am Mac ist der Klick seit jeher der Weg /// auf die Seite (zum Starten gibt es den Knopf beim Überfahren), eine /// Sammlung hat nichts abzuspielen, und in der Suche ist es gewollt. private var tapOpensDetails: Bool { isMac || opensDetails || !isPlayable } var body: some View { kachel .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 { // **Am Telefon und am iPad führt der Weg zur Detailseite // hierher.** Antippen spielt dort ab; wer stattdessen // nachsehen will, was das überhaupt ist, drückt lang. Am Mac // ist es umgekehrt: dort öffnet der Klick die Seite, und zum // Starten gibt es den Knopf beim Überfahren. if !tapOpensDetails { Button("hero.details", systemImage: "info.circle") { push(route) } Divider() } 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() } // Für VoiceOver der zweite Weg: am Telefon führt das Antippen in den // Film, und die Detailseite wäre sonst nur über ein Kontextmenü zu // erreichen, das eine Geste verlangt. .accessibilityAction(named: Text("hero.details")) { if tapOpensDetails { return } push(route) } // **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) } } } /// Die Kachel selbst — und was ein Antippen bedeutet. /// /// **Am Telefon und am iPad spielt es ab.** Wer eine Kachel antippt, weiss /// meistens schon, was er sehen will; der Umweg über die Detailseite /// kostete zwei Schritte für eine Absicht. Die Seite ist weiter da — über /// langes Drücken, wo auch „gesehen" und „entfernen" stehen. /// /// **Am Mac bleibt der Klick die Detailseite.** Dort gibt es den /// Abspielknopf beim Überfahren, und langes Drücken ist keine übliche /// Geste — ein Rechtsklick ist der Weg ins Menü, nicht in den Film. /// /// Ein `NavigationLink` schiebt selbst; ein Knopf braucht dafür den Weg /// aus der Umgebung. Deshalb zwei Fassungen und nicht eine mit `if` im /// Inneren: die Kachel soll am Mac ein Link **sein**, nicht einer /// aussehen. @ViewBuilder private var kachel: some View { if tapOpensDetails { NavigationLink(value: route) { card() } } else { Button { trigger() } label: { card() } } } /// 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) } } } }