Files
Flaro/Flixy/Views/Player/PlayerView.swift
T
ScarriffleandClaude Opus 5 24d80e5c95 Offline fragte die Spurauswahl den Server — den es dort nicht gibt
Deine Beobachtung, Schritt für Schritt nachgemessen: der Untertitel-Knopf war
da, das Sheet zeigte nur „Aus".

**Zwei Quellen für dieselbe Frage.** Der Knopf richtete sich nach den Spuren,
die der Player in der Datei findet (`subtitleTracks`); die Liste nach den
Spuren, die der Server gemeldet hat (`subtitleStreams`). Die kommen aus
`chooseAudioStream`, und das läuft nur auf dem Online-Weg. Offline war die
Liste deshalb leer — Knopf da, nichts dahinter.

Offline listet das Sheet jetzt die Spuren der Datei und schaltet sie
unmittelbar in der Engine um. Online bleibt es beim bisherigen Weg: dort
heisst Umstellen „neu bestellen", weil ein umgerechneter Stream genau eine
Spur mitbringt.

**Der Tonspur-Knopf verschwindet, wo es nichts zu wählen gibt.** Eine Spur
ist keine Wahl — ein Angebot, das nach dem Antippen nichts anbietet. Bei den
Untertiteln genügt dagegen eine: „an oder aus" ist auch eine Wahl.

**Belegt, dass die Spur wirklich ankommt.** Die heruntergeladene Datei direkt
mit AVFoundation gelesen:

    Spuren in der Datei: 3
      vide / soun / sbtl   (sbtl = tx3g, also mov_text)
    Gruppe AVMediaCharacteristicLegible: 2 Auswahlmöglichkeiten

Der Untertitel ist drin — er wurde nur nie angeboten.

**Was dabei auffällt und offen bleibt:** alle Spuren der umgepackten Datei
tragen die Sprache `und`. Jellyfin reicht die Sprachkennung beim Umpacken
nicht durch, also heissen sie „Unknown language". Das ist eine eigene Sache;
die App weiss, welche Sprache sie bestellt hat, und könnte sie beschriften.

iOS und tvOS bauen.

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

1065 lines
48 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import FlixyDesign
import FlixyDownloads
import FlixyPlayback
import PlexKit
/// Der Vollbildplayer.
struct PlayerView: View {
/// Das Modell gehört der Wurzel, nicht dieser Ansicht.
///
/// Vorher wurde es hier gebaut und beim Verschwinden abgeräumt — und
/// genau daran starb die Wiedergabe, sobald man zurückging. Jetzt
/// überdauert es die Ansicht, und das Beenden ist eine Entscheidung des
/// Nutzers statt eine Nebenwirkung der Navigation.
let model: PlayerModel
@Environment(FlixySession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback
@Environment(PlaybackSettings.self) private var settings
@Environment(SubtitleAppearance.self) private var subtitleAppearance
@Environment(NetworkConditions.self) private var network
@Environment(DownloadCoordinator.self) private var downloads
@Environment(PlaybackDiagnostics.self) private var diagnostics
@Environment(\.flixyAccent) private var accent
/// Woran sich Quer- und Hochformat unterscheiden lassen.
///
/// Auf jedem iPhone ist das Querformat senkrecht kompakt und das
/// Hochformat nicht. Billiger als die Szene zu befragen, und es
/// aktualisiert sich von selbst.
@Environment(\.verticalSizeClass) private var verticalSizeClass
/// Wie hoch die Fensterleiste auf dem Mac ist.
///
/// Fest, weil es keine Auskunft darüber gibt, die hier ankäme: die
/// Sicherheitsränder einer Szene kennen die Fensterleiste nicht, sie
/// gehört dem System. Achtundzwanzig Punkte sind das Maß eines
/// gewöhnlichen Fensters.
private static let titleBarHeight: CGFloat = 28
/// Was der Bildschirm gerade verdeckt.
///
/// **Aus dem Fenster geholt, nicht von SwiftUI.** Diese Ansicht ist
/// bildfüllend — sie muss es sein, sonst wäre das Bild im Querformat um
/// die Notch-Breite schmaler — und eine bildfüllende Ansicht bekommt von
/// SwiftUI keine Auskunft mehr darüber, wo der Schirm aufhört. Siehe
/// `ScreenSafeArea`.
///
/// Neu geholt wird er immer dann, wenn sich die Fläche ändert: das ist
/// genau der Moment des Drehens, und genau dann stimmen die alten Werte
/// nicht mehr.
@State private var safeArea = EdgeInsets()
@State private var showsControls = true
@State private var trackPicker: TrackPickerSheet.Kind?
@State private var showsQualityChoice = false
@State private var hideTask: Task<Void, Never>?
@State private var scrubbing: Double?
/// Welche Richtung gerade als Hinweis eingeblendet ist.
@State private var seekHint: Double?
@State private var hintTask: Task<Void, Never>?
/// Ob die Tastatur bei diesem Bildschirm ankommt. Nur auf dem Mac gesetzt.
@FocusState private var keyboardFocus: Bool
/// Wann die App zuletzt nach vorn geholt wurde.
@State private var becameActive: ContinuousClock.Instant?
/// Wo der Zeiger zuletzt war — um Bewegung von Neuzeichnen zu trennen.
@State private var lastPointerLocation: CGPoint = .zero
/// Ob das Fenster gerade im Vollbild ist — soweit wir es wissen können.
///
/// **Selbst mitgezählt, nicht abgefragt.** `UIWindowScene.isFullScreen`
/// gibt es für eine „für iPad angepasste" App nicht. Wer mit dem grünen
/// Knopf oder ⌃⌘F umschaltet, bringt das Symbol also aus dem Tritt — das
/// Umschalten selbst funktioniert trotzdem, es zeigt nur kurz das falsche
/// Bild. Ein falsches Symbol ist der kleinere Preis gegenüber gar keinem
/// Knopf.
@State private var isFullScreen = false
/// Ob Zeiger und Tastatur gelten. Einmal beim Start ermittelt.
private var isMac: Bool { FlixyPlatform.isMac }
/// Ob gedreht und mit zwei Fingern gezoomt werden kann.
///
/// Ein iPad dreht sich von selbst und braucht keinen Knopf dafür; der
/// Mac dreht sich gar nicht. Bleibt das Telefon.
private var isPhone: Bool {
!isMac && UIDevice.current.userInterfaceIdiom == .phone
}
/// Ob das Gerät gerade quer liegt.
private var isLandscape: Bool { verticalSizeClass == .compact }
/// Wann der Zeiger zuletzt die Bedienelemente geholt hat.
@State private var lastPointerReveal: Date = .distantPast
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
Group {
// Bei „nur Ton" bleibt der Schirm dunkel — das ist der halbe
// Zweck der Stufe.
if model.showsVideo {
// **Die Bildfläche ist genau so gross wie das Bild.**
//
// Vorher deckte sie das ganze Fenster ab, und die Ebene
// liess oben und unten schwarz. Damit gehörten die Balken
// zur Bildfläche, und für SwiftUI war ein Klick darauf ein
// Klick aufs Bild. Wo das Bild aufhört, mussten wir
// ausrechnen — aus Fenstergrösse, Sicherheitsrändern und
// Seitenverhältnis. Diese Rechnung lag zweimal daneben.
//
// Jetzt rechnet das Layout. Der schwarze Rand ist schlicht
// der Hintergrund dahinter, und wer ihn anklickt, klickt
// nicht aufs Bild — das muss niemand mehr feststellen.
Color.clear
.ignoresSafeArea()
.overlay {
surface(for: model)
.contentShape(.rect)
.onTapGesture { primaryTap() }
}
}
// **Unter** den Bedienelementen, nicht darüber. Als Überzug
// über dem ganzen Player lag hier eine durchsichtige Fläche
// vor jedem Knopf und hat sämtliche Tipper verschluckt —
// Pause, Scrubber, Qualität, alles tot. Ein Überzug liegt in
// SwiftUI zwangsläufig obenauf; hier im Stapel entscheidet
// die Reihenfolge, und alles Spätere liegt darüber.
seekZones
// Deckt den Umstieg ab. Ohne sie sieht man den Filmanfang,
// während der neue Stream anläuft — und denkt, die Stelle sei
// weg. Die Abdeckung nennt sie ausdrücklich.
//
// **Und dann nichts anderes.** Die Abdeckung ist eine ganze
// Seite, kein Schleier: legt sich das Übrige darüber, stehen
// Bedienelemente und Kreisel mitten im Satz „Stelle wird
// wiederhergestellt" — zwei Ladeanzeigen übereinander, eine
// davon in einem Knopf, den man in dem Moment gar nicht
// drücken soll. Die Kopfzeile bleibt: sie ist der Weg zurück.
if let target = model.resumeTarget {
restoring(to: target)
} else {
switch model.phase {
case .preparing:
preparing(for: model)
case .failed(let message):
failure(message: message)
case .playing:
// **Der Kreisel gilt immer**, mit oder ohne
// Bedienelemente.
//
// Er ist keine Verzierung der Bedienung, sondern die
// Auskunft „es kommt gleich weiter" — und die braucht
// man gerade dann, wenn man nichts eingeblendet hat
// und vor einem stehenden Bild sitzt. Die Knöpfe
// darüber lassen an seiner Stelle Platz, damit er
// hindurchscheint und nicht zwei Anzeigen
// übereinanderliegen.
if model.isBuffering {
ProgressView()
.tint(.white)
.scaleEffect(1.4)
.transition(.opacity)
}
// **Deckkraft statt Einfügen und Entfernen.**
//
// Eine Übergangsanimation beim Einfügen hängt daran,
// dass SwiftUI die Ansicht als dieselbe wiedererkennt
// und der Einfügevorgang in einer animierten
// Transaktion steckt. Bleibt eine der beiden
// Bedingungen aus, erscheint die Bedienung schlagartig
// — und genau so sah es aus. Deckkraft hat diese
// Bedingungen nicht: sie ist ein Wert, und Werte
// lassen sich immer überblenden.
controls(for: model)
.opacity(showsControls ? 1 : 0)
// Unsichtbar heisst auch unantastbar. Sonst läge
// der Scrubber weiterhin über dem Bild und
// schluckte jeden Klick.
.allowsHitTesting(showsControls)
// Überspringen und „Nächste Folge" liegen außerhalb
// der Bedienelemente: sie sollen auch dann erscheinen,
// wenn die ausgeblendet sind — sonst müsste man erst
// antippen, um das Intro überspringen zu können.
overlayActions(for: model)
}
}
// Die Kopfzeile liegt über allem und erscheint auch während
// des Ladens. Ohne sie sitzt man bei langsamer Verbindung
// fest: kein Zurück, kein Abbrechen, nur ein Ladekreis.
// Sie hatte bisher gar keine Überblendung: sie sprang
// herein, während die Bedienung darunter verblasste. Zwei
// Hälften derselben Oberfläche, die sich verschieden
// verhalten, lesen sich als „es fadet nicht".
do {
let zeigt = showsControls || !isPlayingPhase(model)
VStack {
topBar(for: model)
Spacer()
}
.opacity(zeigt ? 1 : 0)
.allowsHitTesting(zeigt)
.padding(FlixySpacing.loose)
// Siehe `controls(for:)`: die Kopfzeile lag sonst hinter
// der Dynamic Island, und der Zurück-Pfeil war tot.
.padding(.top, safeArea.top)
.padding(.leading, safeArea.leading)
.padding(.trailing, safeArea.trailing)
// **Unter der Fensterleiste beginnen.**
//
// Der Player deckt das ganze Fenster ab, und auf dem Mac
// zeichnet das System seine Ampelknöpfe darüber. Die
// Kopfzeile lag dadurch nicht nur optisch unter ihnen: der
// ganze Streifen gehört der Fensterleiste, und die
// schluckt jeden Klick. Verkleinern, Schließen, Tonspur,
// Untertitel und die Qualitätsstufe waren dort tot — und
// ein zweiter Klick auf dieselbe Stelle legte das Fenster
// ins Dock, weil das der Doppelklick auf eine
// Fensterleiste tut.
.padding(.top, FlixyPlatform.isMac ? Self.titleBarHeight : 0)
}
}
}
// **Auseinander füllt, zusammen passt ein.**
//
// Kein stufenloses Zoomen: es gibt genau zwei sinnvolle Zustände, und
// alles dazwischen wäre ein Bild, das weder ganz zu sehen ist noch den
// Schirm füllt. Die Geste wählt aus zweien aus, statt einen Faktor zu
// setzen — und der Schwellenwert liegt weit vom Ruhepunkt weg, damit
// eine wackelige Hand nichts umschaltet.
//
// **Am ganzen Player, nicht an der Bildfläche.** Dort hing sie bisher
// und griff deshalb fast nie: über dem Bild 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.
//
// `simultaneousGesture`, damit Tippen, Doppeltippen und der Scrubber
// unberührt bleiben: die Geste erkennt mit, statt dazwischenzugehen.
.simultaneousGesture(zoomGesture(for: model), including: isMac ? .none : .all)
// Immer aus, nicht nur wenn die Bedienelemente ausgeblendet sind.
// Die Uhrzeit über einem laufenden Film hat dort nichts zu suchen —
// und im Querformat erst recht nicht.
.onGeometryChange(for: CGSize.self) { proxy in
proxy.size
} action: { _ in
safeArea = ScreenSafeArea.current
}
.onAppear { safeArea = ScreenSafeArea.current }
// **Auch beim Zurückholen der App.** Eine Drehung im Hintergrund
// ändert die Fläche, ohne dass `onGeometryChange` hier feuert — die
// Ansicht ist dann gar nicht sichtbar. Wer die App danach quer
// wiederfindet, hätte sonst die Ränder von vorhin.
.onReceive(
NotificationCenter.default.publisher(
for: UIApplication.didBecomeActiveNotification
)
) { _ in
safeArea = ScreenSafeArea.current
}
.animation(.easeInOut(duration: 0.2), value: model.isBuffering)
// **Herein sanft, hinaus zügig.** Das Einblenden begleitet eine
// Bewegung und soll der Hand entgegenkommen; das Ausblenden ist ein
// Aufräumen, und Aufräumen darf man nicht zusehen müssen.
//
// Hier, nicht an jeder einzelnen Umschaltstelle: die gab es sechsmal,
// mit vier verschiedenen Dauern, und eine davon stand an einer Stelle,
// die gar nicht animiert wurde.
.animation(
showsControls ? .easeOut(duration: 0.45) : .easeIn(duration: 0.28),
value: showsControls
)
#if os(iOS)
// Die Statusleiste gehört nicht über einen Film. Auf dem Mac gibt es
// keine.
.statusBarHidden(true)
#endif
.persistentSystemOverlays(showsControls ? .automatic : .hidden)
.landscapeWhileVisible(settings.locksLandscape)
.contentShape(.rect)
// Für alles ausserhalb von Bild und Sprungflächen — also den
// schwarzen Rand. Ein Film füllt das Fenster fast nie ganz aus, und
// die Balken gehören nicht zum Film: dort zu klicken ist die
// natürliche Geste für „zeig mir die Bedienung".
//
// **Ohne Ort.** Wo geklickt wurde, spielt keine Rolle mehr: die
// Bildfläche fängt ihre eigenen Klicks ab, hier kommt nur noch an,
// was daneben ging.
.onTapGesture { toggleControls() }
.onReceive(
NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)
) { _ in
becameActive = .now
}
// **Tastatur und Zeiger — nur auf dem Mac.**
//
// Dort ist beides selbstverständlich: Leertaste hält an, die Pfeile
// springen, und die Bedienelemente kommen von allein, sobald sich der
// Zeiger regt. Auf dem iPhone gibt es keines von beidem.
//
// Zur Laufzeit entschieden, nicht beim Übersetzen: „für iPad
// angepasst" ist der iOS-Bau und läuft trotzdem auf dem Mac. Ein
// `#if` hätte das Ganze genau dort abgeschaltet. `focusable(_:)`
// nimmt den Schalter entgegen, die Tastenhandler geben sonst
// `.ignored` zurück — so bleibt es **eine** Ansicht, und der Player
// wird nicht neu aufgebaut, nur weil die Frage vom Gerät abhängt.
.focusable(isMac)
.focusEffectDisabled()
.focused($keyboardFocus)
// Von Hand gesetzt, weil sonst niemand den Fokus hat: der Player
// besteht aus Knöpfen, und solange keiner angeklickt wurde, ginge
// jeder Tastendruck ins Leere.
.onAppear { if isMac { keyboardFocus = true } }
.onKeyPress(.space) { onMac { model.togglePlayPause() } }
.onKeyPress(.leftArrow) { onMac { seek(by: -Double(settings.rewindSeconds)) } }
.onKeyPress(.rightArrow) { onMac { seek(by: Double(settings.forwardSeconds)) } }
.onContinuousHover { phase in
guard isMac, case .active(let ort) = phase else { return }
pointerMoved(to: ort)
}
// **Die Umgebung wird den Blättern mitgegeben, nicht vererbt.**
//
// Ein Sheet hängt an einem eigenen Darstellungsbaum. Auf dem iPad kam
// die Umgebung des Players dort bisher an, auf dem Mac nicht — und
// `@Environment(PlaybackSettings.self)` ist kein Wert mit
// Ersatzangabe, sondern eine Behauptung: fehlt der Gegenstand, hält
// Swift die Anwendung an. Sichtbar war davon nichts, weil der Player
// in diesem Augenblick mitten in einem Layoutdurchlauf steckt: die
// App fror ein, statt zu beenden.
//
// Hier weitergereicht ist es keine Frage des Baums mehr. Der Akzent
// steht daneben, weil er sonst still auf die Vorgabe zurückfiele —
// kein Absturz, aber die falsche Farbe.
.sheet(item: $trackPicker) { kind in
TrackPickerSheet(model: model, kind: kind)
.environment(\.flixyAccent, accent)
}
.sheet(isPresented: $showsQualityChoice) {
QualitySheet(model: model)
.environment(settings)
.environment(\.flixyAccent, accent)
}
.onChange(of: showsQualityChoice) { _, isShown in
// Nach dem Schließen die Bedienelemente stehen lassen und die
// Ausblendfrist neu starten. Ohne das verschwänden sie sofort,
// weil die alte Frist während des Dialogs abgelaufen ist.
guard !isShown else { return }
showsControls = true
scheduleControlsHide()
}
// **Mitten im Film.** Eine Einstellung, die erst beim nächsten
// Titel wirkt, hält jeder für kaputt — man stellt um und sieht
// nichts. `SubtitleStyle` ist vergleichbar, das feuert nur bei einer
// echten Änderung.
.onChange(of: subtitleAppearance.style) { _, neu in
model.engine?.applySubtitleStyle(neu)
}
.task { scheduleControlsHide() }
.onDisappear { hideTask?.cancel() }
}
// MARK: - Bedienelemente
private func controls(for model: PlayerModel) -> some View {
VStack {
// Platz für die Kopfzeile, die als eigene Ebene darüber liegt.
Spacer().frame(height: 44)
Spacer()
transport(for: model)
Spacer()
scrubber(for: model)
}
.padding(FlixySpacing.loose)
// **Zusätzlich zum Polster, nicht statt.** Der Sicherheitsrand sagt,
// wo der Bildschirm aufhört; das Polster sagt, wie luftig es aussehen
// soll. Ersetzte eines das andere, klebte der Schieberegler im
// Querformat am Home-Indikator.
.padding(.leading, safeArea.leading)
.padding(.trailing, safeArea.trailing)
.padding(.bottom, safeArea.bottom)
.background {
// Nur ein Hauch Abdunklung: genug, damit weiße Schrift auf hellen
// Szenen lesbar bleibt, wenig genug, dass das Bild nicht
// verfälscht wirkt.
//
// **Und unantastbar.** Er deckt das ganze Fenster ab und lag damit
// über dem Bild: solange die Bedienung zu sehen war, landete jeder
// Klick auf ihm statt auf dem Film. Zu merken war das an genau
// einer Stelle — nach dem Pausieren bleibt die Bedienung stehen,
// und der Klick, der wieder starten sollte, blendete nur aus.
// Anhalten ging, fortsetzen nicht.
Color.black.opacity(0.35).ignoresSafeArea().allowsHitTesting(false)
}
}
private func topBar(for model: PlayerModel) -> some View {
HStack(alignment: .top, spacing: FlixySpacing.regular) {
// Der Pfeil verkleinert, er beendet nicht. Das ist der ganze
// Zweck der Übung: weiterschauen, während man stöbert.
Button {
playback.minimize()
} label: {
Image(systemName: "chevron.down")
.font(.title3)
.padding(FlixySpacing.snug)
}
.accessibilityLabel(Text("player.minimize"))
// Und daneben der Weg, wirklich Schluss zu machen — sonst gäbe es
// keinen mehr, seit der Pfeil nur noch verkleinert.
Button {
playback.close()
} label: {
Image(systemName: "xmark")
.font(.callout.weight(.semibold))
.padding(FlixySpacing.snug)
}
.accessibilityLabel(Text("player.stop"))
VStack(alignment: .leading, spacing: 2) {
Text(model.title)
.font(.flixyCardTitle)
.lineLimit(1)
if let subtitle = model.subtitle {
Text(subtitle)
.font(.flixyCaption)
.foregroundStyle(.white.opacity(0.7))
.lineLimit(1)
}
}
Spacer()
// **Vollbild — nur auf dem Mac, und nur wenn es dort geht.**
//
// Ob die Aktion jemand entgegennimmt, steht nicht fest; siehe
// `MacFullScreen`. Gefragt wird vorher, damit kein Knopf dasteht,
// der nichts tut — das lässt den Nutzer an sich zweifeln statt an
// der Plattform.
if isMac, MacFullScreen.isAvailable {
Button {
if MacFullScreen.toggle() { isFullScreen.toggle() }
revealControls()
} label: {
Image(systemName: isFullScreen
? "arrow.down.right.and.arrow.up.left"
: "arrow.up.left.and.arrow.down.right")
.font(.callout.weight(.semibold))
.padding(FlixySpacing.snug)
}
.accessibilityLabel(Text(isFullScreen ? "player.exitFullScreen" : "player.fullScreen"))
.keyboardShortcut("f", modifiers: [.command, .control])
}
// **Einpassen oder füllen — überall ausser auf dem Mac.**
//
// Dort gibt es den Vollbildknopf darüber, und zwei Knöpfe, die
// beide „grösser" bedeuten, nebeneinander sind eine Zumutung.
if !isMac {
Button {
setFit(model.videoFit == .fill ? .fit : .fill, on: model)
} label: {
Image(systemName: model.videoFit == .fill
? "arrow.down.right.and.arrow.up.left"
: "arrow.up.left.and.arrow.down.right")
.font(.title3)
.padding(FlixySpacing.snug)
}
.accessibilityLabel(Text(model.videoFit == .fill ? "player.fitVideo" : "player.fillVideo"))
}
// **Drehen — nur am Telefon.**
//
// Ein iPad dreht sich von selbst, sobald man es dreht; ein Telefon
// im Player auch, seit die Sperre weg ist. Der Knopf ist für den
// Fall, dass die Ausrichtungssperre des Systems an ist — dann
// dreht das Gerät nicht mehr von allein, und das ist meistens
// genau dann, wenn man liegt.
if isPhone {
Button {
OrientationLock.shared.rotate(to: isLandscape ? .portrait : .landscape)
revealControls()
} label: {
// **Ein Symbol für beide Richtungen, nicht zwei.** Das
// Telefon-Symbol zeigte die Lage, in die es ginge — und
// las sich damit als Zustandsanzeige statt als Knopf.
// Zwei Pfeile im Kreis sagen, was passiert: es dreht.
Image(systemName: "arrow.triangle.2.circlepath")
.font(.title3)
.padding(FlixySpacing.snug)
}
.accessibilityLabel(Text("player.rotate"))
}
// Getrennte Knöpfe, weil es zwei verschiedene Fragen sind: die
// Tonspur stellt man einmal am Anfang um, die Untertitel mitten im
// Film. Ein gemeinsames Sheet bedeutete jedes Mal Scrollen an der
// anderen Liste vorbei.
if model.hasAudioChoice {
trackButton(
symbol: "waveform",
label: "player.audioTracks",
kind: .audio
)
}
if model.hasSubtitleChoice {
trackButton(
symbol: model.selectedSubtitleTrack == nil
? "captions.bubble"
: "captions.bubble.fill",
label: "player.subtitleTracks",
kind: .subtitles
)
}
// **Bild-in-Bild von Hand.** Automatisch geht es nur beim
// Verlassen der App — und auf dem Mac gar nicht. Der Knopf
// erscheint nur, wenn es wirklich ginge.
if model.canUsePictureInPicture {
Button {
model.togglePictureInPicture()
} label: {
Image(systemName: model.isPictureInPictureActive ? "pip.exit" : "pip.enter")
.font(.title3)
.padding(FlixySpacing.snug)
}
.accessibilityLabel(Text("player.pictureInPicture"))
}
if model.engine?.supportsAirPlay == true {
AirPlayButton(tint: accent)
.frame(width: 28, height: 28)
.padding(FlixySpacing.snug)
.accessibilityLabel(Text("player.airPlay"))
}
if !model.isOffline {
qualityButton(for: model)
}
}
.foregroundStyle(.white)
}
private func trackButton(
symbol: String,
label: LocalizedStringKey,
kind: TrackPickerSheet.Kind
) -> some View {
Button {
trackPicker = kind
hideTask?.cancel()
} label: {
Image(systemName: symbol)
.font(.title3)
.padding(FlixySpacing.snug)
}
.accessibilityLabel(Text(label))
}
/// Der Knopf für die Qualitätsstufe.
///
/// Ein `Menu` stand hier vorher — und war der Grund dafür, dass sich der
/// Player aufhängte: schloss man es durch einen Tipp ins Bild, blieb eine
/// unsichtbare Ebene zurück, die alle weiteren Tipps schluckte. Die
/// Bedienelemente kamen dann nicht mehr, und pausieren ging nicht mehr.
/// Ein `confirmationDialog` räumt sich selbst weg.
private func qualityButton(for model: PlayerModel) -> some View {
Button {
showsQualityChoice = true
hideTask?.cancel()
} label: {
if model.isChangingQuality {
ProgressView().controlSize(.small).tint(.white)
} else {
// Die Stufe **als Text**, nicht als Symbol. Drei Schieberegler
// sagen nicht, was eingestellt ist; „1080p" schon — und genau
// das ist die Frage, wegen der man hier hintippt.
Text(model.qualityLabel)
.font(.flixyCaption.weight(.semibold))
.monospacedDigit()
.padding(.horizontal, FlixySpacing.snug)
.padding(.vertical, FlixySpacing.tight)
.overlay {
RoundedRectangle(cornerRadius: FlixyRadius.card)
.strokeBorder(.white.opacity(0.5), lineWidth: 1)
}
.padding(FlixySpacing.snug)
}
}
.disabled(model.isChangingQuality)
.accessibilityLabel(Text("player.quality"))
}
private func transport(for model: PlayerModel) -> some View {
HStack(spacing: FlixySpacing.section * 2) {
Button {
model.skip(by: .seconds(-settings.rewindSeconds))
scheduleControlsHide()
} label: {
Image(systemName: "gobackward.\(settings.rewindSeconds)").font(.largeTitle)
}
.accessibilityLabel(Text("player.back10"))
Button {
model.togglePlayPause()
scheduleControlsHide()
} label: {
ZStack {
// Solange auf Daten gewartet wird, steht hier **nichts**.
//
// Kein Dreieck: das hieße „angehalten, tipp mich", und
// beides stimmt nicht. Aber auch kein zweiter Kreisel —
// der eine liegt schon darunter und scheint durch diese
// Lücke hindurch. Die Fläche bleibt tippbar, damit man
// ein endloses Laden auch anhalten kann.
if model.isBuffering {
Color.clear
} else {
Image(systemName: model.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 52))
}
}
.contentShape(.rect)
// Feste Größe, damit die beiden Knöpfe daneben stehen
// bleiben. Sie wanderten bisher bei jedem Wechsel ein Stück,
// weil Dreieck und Doppelbalken verschieden breit sind.
.frame(width: 64, height: 64)
}
.accessibilityLabel(Text(playPauseLabel(for: model)))
.animation(.easeInOut(duration: 0.2), value: model.isBuffering)
Button {
model.skip(by: .seconds(settings.forwardSeconds))
scheduleControlsHide()
} label: {
Image(systemName: "goforward.\(settings.forwardSeconds)").font(.largeTitle)
}
.accessibilityLabel(Text("player.forward10"))
}
.foregroundStyle(.white)
}
/// Was die Sprachausgabe zu dem Knopf sagt.
///
/// Beim Warten weder „Abspielen" noch „Pause": beides wäre eine Auskunft
/// über einen Zustand, in dem der Player gerade nicht ist.
private func playPauseLabel(for model: PlayerModel) -> LocalizedStringKey {
if model.isBuffering { return "player.buffering" }
return model.isPlaying ? "player.pause" : "player.play"
}
private func scrubber(for model: PlayerModel) -> some View {
VStack(spacing: FlixySpacing.tight) {
PlaybackScrubber(
position: model.currentTime,
duration: model.duration ?? .seconds(1),
buffered: model.bufferedUntil,
scrubbing: $scrubbing,
// Erst beim Loslassen springen: bei jedem Zwischenwert zu
// springen lässt das Bild flackern und hält bei großen
// Dateien den Puffer dauerhaft leer.
onCommit: { model.seek(to: $0) },
onActivity: { scheduleControlsHide() }
)
HStack {
Text(displayTime(for: model).timecode)
Spacer()
if let duration = model.duration {
Text("−" + (duration - displayTime(for: model)).timecode)
}
}
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.white.opacity(0.8))
}
}
private func displayTime(for model: PlayerModel) -> Duration {
scrubbing.map(Duration.seconds) ?? model.currentTime
}
/// Überspringen-Knopf und „Nächste Folge".
@ViewBuilder
private func overlayActions(for model: PlayerModel) -> some View {
VStack {
Spacer()
HStack {
Spacer()
if model.hasReachedEnd, model.nextEpisodeRequest() != nil {
Button {
if let next = model.nextEpisodeRequest() {
playback.play(next)
}
} label: {
Label("player.nextEpisode", systemImage: "forward.end.fill")
.font(.flixyCardTitle)
.padding(.horizontal, FlixySpacing.loose)
.padding(.vertical, FlixySpacing.regular)
}
.buttonStyle(.borderedProminent)
.tint(accent)
} else if let marker = model.activeMarker {
Button {
model.skipActiveMarker()
} label: {
Text(marker.isIntro ? "player.skipIntro" : "player.skipCredits")
.font(.flixyCardTitle)
.padding(.horizontal, FlixySpacing.loose)
.padding(.vertical, FlixySpacing.regular)
}
.buttonStyle(.bordered)
.tint(.white)
.transition(.move(edge: .trailing).combined(with: .opacity))
}
}
.padding(.horizontal, FlixySpacing.section)
.padding(.leading, safeArea.leading)
.padding(.trailing, safeArea.trailing)
// Über dem Schieberegler, damit sich beides nicht überdeckt, wenn
// die Bedienelemente eingeblendet sind.
.padding(.bottom, (showsControls ? 110 : FlixySpacing.section) + safeArea.bottom)
}
.animation(.snappy, value: model.activeMarker?.id)
.animation(.snappy, value: model.hasReachedEnd)
}
/// Die Abdeckung während des Umstellens.
private func restoring(to target: Duration) -> some View {
ZStack {
Color.black.ignoresSafeArea()
VStack(spacing: FlixySpacing.regular) {
ProgressView().tint(.white)
Text("player.restoring")
.font(.flixyCardTitle)
.foregroundStyle(.white)
// Die Stelle ausdrücklich nennen: der ganze Zweck ist, dass
// niemand denkt, er müsse sie wiederfinden.
Text(target.timecode)
.font(.system(.title3, design: .monospaced))
.foregroundStyle(.white.opacity(0.7))
}
}
.transition(.opacity)
}
private func preparing(for model: PlayerModel) -> some View {
VStack(spacing: FlixySpacing.loose) {
ProgressView().tint(.white)
Text(model.title)
.font(.flixyCardTitle)
.foregroundStyle(.white)
.multilineTextAlignment(.center)
Text("player.preparing")
.font(.flixyCaption)
.foregroundStyle(.white.opacity(0.6))
// Über die Vermittlung von Plex ist die Leitung auf wenige Mbit
// gedrosselt. Ein großer Film läuft darüber nicht an, und ohne
// diesen Hinweis sucht man den Fehler in der App statt in der
// Verbindung.
if session.server?.connection.relay == true {
Label("player.relayWarning", systemImage: "exclamationmark.triangle")
.font(.flixyCaption)
.foregroundStyle(.white.opacity(0.8))
.multilineTextAlignment(.center)
.padding(.top, FlixySpacing.snug)
}
}
.padding(FlixySpacing.section)
}
private func failure(message: String) -> some View {
VStack(spacing: FlixySpacing.loose) {
Image(systemName: "exclamationmark.triangle")
.font(.largeTitle)
.foregroundStyle(.white.opacity(0.8))
Text(message)
.font(.flixyBody)
.foregroundStyle(.white)
.multilineTextAlignment(.center)
Button("action.close") { playback.close() }
.buttonStyle(.borderedProminent)
.tint(accent)
}
.padding(FlixySpacing.section)
}
/// Die beiden Flächen zum Springen.
///
/// Bewusst nicht die ganzen Bildschirmhälften: in der Mitte sitzt der
/// Pause-Knopf, oben die Kopfzeile, unten der Scrubber. Deckten die
/// Flächen alles ab, träfe man beim Zielen auf einen Knopf zuerst die
/// Fläche — und der Knopf bliebe wirkungslos.
private var seekZones: some View {
GeometryReader { geometry in
let width = geometry.size.width * 0.3
let height = geometry.size.height * 0.45
HStack(spacing: 0) {
seekZone(
seconds: Double(-settings.rewindSeconds),
symbol: "gobackward.\(settings.rewindSeconds)"
).frame(width: width)
Spacer(minLength: 0)
seekZone(
seconds: Double(settings.forwardSeconds),
symbol: "goforward.\(settings.forwardSeconds)"
).frame(width: width)
}
.frame(height: height)
// Etwas unterhalb der Mitte: oben bleibt die Kopfzeile frei,
// unten der Scrubber.
.position(x: geometry.size.width / 2, y: geometry.size.height * 0.5)
}
}
/// Eine Fläche zum Springen.
///
/// Ohne sichtbare Begrenzung: die Fläche soll sich nicht aufdrängen. Der
/// kurze Hinweis beim Doppeltippen sagt genug.
private func seekZone(seconds: Double, symbol: String) -> some View {
Color.clear
.contentShape(.rect)
.overlay {
if seekHint == seconds {
Label(
seconds < 0 ? "player.back10" : "player.forward10",
systemImage: symbol
)
.labelStyle(.iconOnly)
.font(.system(size: 44))
.foregroundStyle(.white)
.padding(FlixySpacing.section)
.background(.black.opacity(0.35), in: .circle)
.transition(.opacity.combined(with: .scale))
}
}
// Das doppelte zuerst: sonst gewinnt das einfache und man käme nie
// zum Springen.
.onTapGesture(count: 2) { seek(by: seconds) }
.onTapGesture { primaryTap() }
}
private func seek(by seconds: Double) {
guard let current = model.duration != nil || model.isLive ? model.currentTime : nil
else { return }
model.seek(to: current + .seconds(seconds))
withAnimation(.easeOut(duration: 0.15)) { seekHint = seconds }
hintTask?.cancel()
hintTask = Task {
try? await Task.sleep(for: .milliseconds(600))
guard !Task.isCancelled else { return }
withAnimation(.easeOut(duration: 0.2)) { seekHint = nil }
}
}
/// Die Bildfläche, auf das Seitenverhältnis zugeschnitten.
///
/// Solange nichts ankommt, ist keines bekannt — dann füllt sie alles. Das
/// ist richtig so: ohne Bild gibt es auch keinen schwarzen Rand daneben,
/// den man von ihm unterscheiden müsste.
@ViewBuilder
private func surface(for model: PlayerModel) -> some View {
let fläche = VideoSurface(
engine: model.engine,
surface: model.surface,
isActive: playback.presentation == .full,
// **Nur im Füllmodus, und nur wenn ein Untertitel läuft.**
//
// Füllend reicht das Bild über die untere Bildschirmkante hinaus,
// und genau dort zeichnet AVFoundation die Untertitel; die Ebene
// unten einzurücken rettet die Schrift. Läuft aber keiner, rettet
// es nichts — dann bleibt bloss ein schwarzer Streifen unter
// einem Bild, das gerade füllen soll. Das ist das Gegenteil
// dessen, wofür der Knopf da ist.
bottomInset: model.videoFit == .fill && model.selectedSubtitleTrack != nil
? safeArea.bottom
: 0
)
// **Im Füllmodus kein Seitenverhältnis.** Die Fläche soll den Schirm
// decken — das Einpassen ist genau das, was hier abgeschaltet wird.
// Dass der schwarze Rand dann keine Klicks mehr abfängt, ist kein
// Verlust: es gibt keinen.
if model.videoFit == .fill {
fläche
} else if let breite = model.statistics?.width,
let höhe = model.statistics?.height,
breite > 0, höhe > 0 {
// **Eingepasst heisst: der ganze Schirm, nur das Verhältnis
// bleibt.** Kein Sicherheitsrand — hier stand einer, und er war
// falsch.
//
// Nachgerechnet, Querformat auf einem Pro Max: 956 × 440 Punkt.
// Ein Bild in 16∶9 wird bei voller Höhe 782 Punkt breit, es
// bleiben also 87 Punkte Balken auf jeder Seite — und die Dynamic
// Island braucht 62. Das Bild kommt ihr von selbst nicht nahe.
// Das Polster schützte vor nichts und kostete vierzig Punkte
// Höhe, also jedes zehnte Bildpunktband.
//
// Erst was breiter ist als der Schirm (2,17∶1) läuft an die
// Kanten — bei 21∶9 über die ganze Breite, und dann liegt ein
// Streifen hinter der Island. Dafür gibt es den Zoomknopf; ein
// Film, der nie an den Rand darf, wäre der schlechtere Tausch.
fläche
.aspectRatio(CGFloat(breite) / CGFloat(höhe), contentMode: .fit)
} else {
fläche
}
}
/// Die Zweifingergeste, die zwischen einpassen und füllen umlegt.
private func zoomGesture(for model: PlayerModel) -> some Gesture {
MagnifyGesture(minimumScaleDelta: 0.1)
.onEnded { wert in
if wert.magnification > 1.1 {
setFit(.fill, on: model)
} else if wert.magnification < 0.9 {
setFit(.fit, on: model)
}
}
}
/// Legt die Skalierung um und zeigt dabei die Bedienelemente.
///
/// Das Einblenden gehört dazu: der Knopf wechselt sein Symbol, und wer
/// nach der Geste nicht sieht, welcher Zustand nun gilt, probiert sie
/// ein zweites Mal.
private func setFit(_ neu: PlayerModel.VideoFit, on model: PlayerModel) {
guard model.videoFit != neu else { return }
model.videoFit = neu
// Ausdrücklich: die Ansicht baut ihr Seitenverhältnis von selbst ab,
// aber das Bild in der Engine liegt weiter, wie es lag.
model.applyVideoFit()
revealControls()
}
private func isPlayingPhase(_ model: PlayerModel) -> Bool {
if case .playing = model.phase { return true }
return false
}
// MARK: - Ein- und Ausblenden
/// Was ein einfacher Klick oder Tipp ins Bild auslöst.
///
/// **Auf dem Mac: anhalten.** Dort liegt ein Zeiger auf dem Bild, und die
/// Bedienelemente kommen von selbst, sobald er sich bewegt — ein Klick
/// muss sie nicht erst holen. Erst aufdecken und dann den Pausenknopf
/// suchen sind zwei Handgriffe für eine Sache.
///
/// **Mit dem Finger nicht.** Dort gibt es keine Bewegung ohne Berührung:
/// ein Tipp, der sofort anhält, nähme den einzigen Weg zu Scrubber,
/// Tonspur und Qualität.
private func primaryTap() {
guard isMac else {
toggleControls()
return
}
// **Der Klick, der das Fenster nach vorn holt, zählt nicht.**
//
// Auf dem Mac pausiert ein Klick ins Bild. Wer aus einem anderen
// Fenster zurückkommt, klickt aber zuerst einmal, um dieses Fenster
// zu aktivieren — und genau dieser Klick kam ebenfalls hier an. Die
// Wiedergabe sprang dadurch von selbst wieder an, als hätte man auf
// Play gedrückt. Hat man ja auch, nur nicht absichtlich.
//
// Eine Viertelsekunde reicht: länger braucht niemand vom Aktivieren
// bis zum ersten gemeinten Klick, und kürzer erwischt den Fall nicht.
if let becameActive, ContinuousClock.now - becameActive < .milliseconds(250) {
self.becameActive = nil
return
}
model.togglePlayPause()
revealControls()
}
/// Führt eine Tastenaktion aus — aber nur dort, wo es eine Tastatur gibt.
private func onMac(_ action: () -> Void) -> KeyPress.Result {
guard isMac else { return .ignored }
action()
revealControls()
return .handled
}
private func toggleControls() {
if showsControls {
showsControls = false
} else {
revealControls()
}
}
/// Zeigt die Bedienelemente und setzt die Ausblendfrist neu.
private func revealControls() {
if !showsControls {
// Wie überblendet wird, steht am Player selbst — siehe dort.
showsControls = true
// Beim Aufdecken nachsehen, was inzwischen läuft — genau dann
// will man es wissen.
if model.isLive {
Task { await model.refreshLiveProgrammeIfNeeded() }
}
}
scheduleControlsHide()
}
/// Der Zeiger hat sich bewegt.
///
/// Gedrosselt, weil die Meldung bei jeder Bewegung kommt — bis zu
/// hundertmal je Sekunde. Die Ausblendfrist jedes Mal neu aufzusetzen
/// hieße, hundertmal je Sekunde eine Aufgabe abzubrechen und eine neue zu
/// starten. Fünfmal reicht vollkommen.
private func pointerMoved(to ort: CGPoint) {
// **Gleiche Stelle heisst: der Zeiger hat sich nicht bewegt.**
//
// Die Meldung kommt nicht nur bei Bewegung, sondern auch bei jedem
// Neuzeichnen — und seit die Bedienung dauerhaft montiert ist, zeichnet
// der Scrubber im Sekundentakt neu. Ohne diese Prüfung setzte die
// Ausblendfrist sich endlos selbst zurück: die Bedienung blieb für
// immer stehen, und die Überblendung, die nie stattfand, sah aus wie
// eine kaputte Animation. Sie war nie das Problem.
guard ort != lastPointerLocation else { return }
lastPointerLocation = ort
let now = Date.now
guard !showsControls || now.timeIntervalSince(lastPointerReveal) > 0.2 else { return }
lastPointerReveal = now
revealControls()
}
/// Blendet die Bedienelemente nach kurzer Ruhe aus — aber nur, solange
/// tatsächlich etwas läuft. Im pausierten Zustand wäre ein leerer
/// Bildschirm ohne jede Bedienmöglichkeit das Ergebnis.
private func scheduleControlsHide() {
hideTask?.cancel()
hideTask = Task {
try? await Task.sleep(for: .seconds(3.5))
guard !Task.isCancelled, model.isPlaying else { return }
showsControls = false
}
}
}
extension Duration {
/// Als „1:23:45" bzw. „23:45".
var timecode: String {
let total = max(0, Int(components.seconds))
let hours = total / 3600
let minutes = (total % 3600) / 60
let seconds = total % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
}
return String(format: "%d:%02d", minutes, seconds)
}
}