import SwiftUI import HaloDesign import HaloDownloads 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 `HaloDesign`, 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(HaloSession.self) private var session @Environment(PlaybackCoordinator.self) private var playback @Environment(QuickPlay.self) private var quickPlay @Environment(DownloadCoordinator.self) private var downloads @Environment(LanguagePreferences.self) private var languages @Environment(\.haloAccent) private var accent @State private var isPointerInside = false @State private var errorMessage: String? /// Der nachgeladene Titel, sobald die Qualitätsauswahl offen sein soll. /// /// **Zwei Dinge in einem Zustand**, und das mit Absicht: die Kachel kennt /// nur eine Zusammenfassung ohne Dateiangaben, also muss vor der Frage /// nachgeladen werden. Ein eigenes `Bool` daneben wäre ein zweiter /// Zustand, der mit diesem gleichziehen müsste. @State private var downloadChoice: MediaItem? /// Stufe steht, Tonspur fehlt — siehe `DownloadButton`. @State private var pendingQuality: (item: MediaItem, quality: DownloadQuality)? /// Was die umgebende Seite tun soll, wenn sich am Server etwas geändert /// hat. Siehe `ItemActionEntries`. @Environment(\.haloItemChanged) private var itemChanged /// Der Weg auf die Detailseite — siehe `MediaStack`. @Environment(\.haloPush) private var push private var isMac: Bool { HaloPlatform.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() } // **Herunterladen gehört hierher.** Bisher führte der Weg // über die Detailseite — zwei Schritte für etwas, das man // beim Vorbeiscrollen entscheidet. if isDownloadable { Button("downloads.download", systemImage: "arrow.down.circle") { Task { await prepareDownload() } } } 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. .confirmationDialog( Text("downloads.quality.title"), isPresented: Binding( get: { downloadChoice != nil }, set: { if !$0 { downloadChoice = nil } } ), titleVisibility: .visible ) { if let ziel = downloadChoice { let wahl = DownloadQualityChoice(item: ziel, backend: session.backend) ForEach(wahl.available) { quality in Button(wahl.label(for: quality)) { chose(ziel, quality: quality) } } } Button("action.cancel", role: .cancel) {} } message: { Text("downloads.quality.hint") } .confirmationDialog( Text("downloads.audio.title"), isPresented: Binding( get: { pendingQuality != nil }, set: { if !$0 { pendingQuality = nil } } ), titleVisibility: .visible ) { if let offen = pendingQuality { let spuren = offen.item.preferredVersion?.files.first?.audioStreams ?? [] let empfohlen = offen.item.preferredVersion?.files.first.flatMap { service.preferredAudioStream(in: $0, of: offen.item) } ForEach(spuren) { stream in Button( stream == empfohlen ? String(localized: "downloads.audio.recommended \(stream.name)") : stream.name ) { enqueue(offen.item, quality: offen.quality, audioStream: stream) } } } Button("action.cancel", role: .cancel) {} } message: { Text("downloads.audio.hint") } .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(HaloSpacing.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) } /// Ob es etwas herunterzuladen gibt — und ob es nicht schon da ist. /// /// Sammlungen, Serien und Staffeln fallen heraus: dort lädt man nicht /// *den* Titel, sondern seine Folgen, und dafür gibt es auf der Seite /// „Alle Folgen laden". private var isDownloadable: Bool { guard item.kind == .movie || item.kind == .episode else { return false } return !downloads.items.contains { $0.ratingKey == item.id } } /// Holt die Dateiangaben nach und öffnet dann die Auswahl. /// /// Erst beim Tippen, nicht beim Zeichnen: eine Reihe aus zwanzig Kacheln /// wären sonst zwanzig Anfragen für ein Menü, das selten aufgeht. private func prepareDownload() async { downloadChoice = (try? await session.backend?.item(id: item.id)) ?? item } private var service: DownloadService { DownloadService(backend: session.backend, coordinator: downloads, languages: languages) } /// Nach der Stufe: fragen, wenn beim Umpacken eine Tonspur verlorenginge. private func chose(_ ziel: MediaItem, quality: DownloadQuality) { downloadChoice = nil if service.needsAudioChoice(for: ziel, quality: quality) { pendingQuality = (ziel, quality) } else { enqueue(ziel, quality: quality, audioStream: nil) } } private func enqueue(_ ziel: MediaItem, quality: DownloadQuality, audioStream: MediaStream?) { pendingQuality = nil do { _ = try service.download(ziel, quality: quality, audioStream: audioStream) } catch { errorMessage = error.explanation } } 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) } } } }