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