Files
Flaro/Flixy/Views/Components/QuickPlayTile.swift
T
ScarriffleandClaude Opus 5 93695dc332 Beim Umpacken wird nach der Tonspur gefragt, nicht geraten
Mein Fehler, und ein vermeidbarer. Beim Umpacken legt der Server genau eine
Tonspur in die Datei, und welche, hat bisher allein die Abwägung entschieden
— bei einer Aufnahme mit Audiodeskription die falsche. Nach dem Herunterladen
gibt es nichts mehr zu korrigieren; drei Gigabyte Datenvolumen waren weg.

Wo eine Spur verlorengeht, wird jetzt gefragt. Nur dort: bei einer Datei, die
der Systemplayer ohnehin öffnet, kommt alles mit, und eine Frage ohne Folgen
ist nur ein Klick mehr.

**Der Vorschlag kommt aus dem, was schon bekannt ist.** Die gemerkte Sprache
der Serie zuerst, dann die Serieneinstellung, dann die allgemeine — dieselbe
Abwägung wie beim Abspielen (`AudioTrackChooser`), über denselben Weg. Sie
ist jetzt Vorschlag statt Entscheidung und steht als „· empfohlen" dabei.

Beim Stapel aus der Mehrfachauswahl wird einmal gefragt, nicht je Folge: die
Folgen einer Staffel sind gleich aufgebaut, und zwanzigmal dieselbe Frage
wäre keine Wahl, sondern eine Prüfung der Geduld.

**Was am Server gemessen wurde, und warum es nicht besser geht:**

- `static=true` liefert die Datei unverändert — alle Spuren, aber Matroska;
  die Magie am Dateianfang ist `1a45dfa3`, auch wenn im Pfad `.mp4` steht.
- `stream.mp4` bildet genau eine Tonspur ab.
- Die HLS-Liste des Servers führt **eine** Variante und **keine**
  `EXT-X-MEDIA`-Tonspuren.

Mehrere Tonspuren offline gibt dieser Server auf keinem seiner Wege her. Das
liegt nicht an MP4 — das kann mehrere — und nicht an AVFoundation, sondern am
Transcoder, der je Sitzung eine Spur abbildet.

iOS und tvOS bauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 10:14:14 +02:00

309 lines
13 KiB
Swift

import SwiftUI
import FlixyDesign
import FlixyDownloads
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
/// 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(DownloadCoordinator.self) private var downloads
@Environment(LanguagePreferences.self) private var languages
@Environment(\.flixyAccent) 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(\.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() }
// **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(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)
}
/// 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)
}
}
}
}