Files
Flaro/Flixy/Views/Player/PlayerView.swift
T
ScarriffleandClaude Opus 5 22f824b000 Die Ränder kommen jetzt von der Stelle, die sie kennt
Mein voriger Versuch war an der Quelle falsch, nicht an der Rechnung.

**Was ich gebaut hatte:** die Sicherheitsränder in `RootView` messen und
über die Umgebung an den Player reichen. Gemessen in einer Ansicht,
angewendet in einer anderen, über eine Drehung hinweg — drei
Gelegenheiten, daneben zu liegen. Im Simulator ging es gut, auf dem
Gerät nicht: dort standen im Querformat links 75 und rechts 28 Punkt
Rand, und der Untertitel wurde von der Bildschirmkante abgeschnitten.

**Warum SwiftUI hier nicht hilft.** Der Sicherheitsrand ist dort kein
Wert, den man erfragt, sondern einer, den man verbraucht: wer ihn
respektiert, sieht null, und wer ihn mit `ignoresSafeArea` überschreitet,
sieht danach ebenfalls null. Beide Auswege habe ich durchgespielt und
beide gemessen. Nimmt man der Ansicht das `ignoresSafeArea`, sitzen die
Knöpfe richtig — aber die Bildfläche verliert es mit, und das Bild ist im
Querformat um die Notch-Breite schmaler. Schiebt man einen
`GeometryReader` dazwischen, passiert dasselbe.

**Das Fenster weiß es immer**, in der richtigen Ausrichtung und in dem
Moment, in dem gefragt wird. `ScreenSafeArea` ist die eine Stelle in
dieser App, die so fragt, und die eine, an der es keinen SwiftUI-Weg
gibt. Nachgeholt wird der Wert bei jeder Änderung der Fläche — das ist
genau der Moment des Drehens.

Nachgemessen im Simulator gegen die Jellyfin-Attrappe: im Querformat
meldet das Fenster 0/62/62/20, und damit stehen Kopfzeile, Schieberegler
und Zeitangaben vollständig im sichtbaren Bereich, während das Bild bis
an die Kanten reicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-29 17:32:50 +02:00

1003 lines
44 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() }
// **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 also
// aus zweien aus, statt einen Faktor zu
// setzen — und der Schwellenwert liegt bewusst
// weit vom Ruhepunkt weg, damit eine wackelige
// Hand nichts umschaltet.
.gesture(zoomGesture(for: model), including: isMac ? .none : .all)
}
}
// **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)
}
}
}
// 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 }
.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: {
Image(systemName: isLandscape ? "iphone" : "iphone.landscape")
.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.audioTracks.isEmpty {
trackButton(
symbol: "waveform",
label: "player.audioTracks",
kind: .audio
)
}
if !model.subtitleTracks.isEmpty {
trackButton(
symbol: model.selectedSubtitleTrack == nil
? "captions.bubble"
: "captions.bubble.fill",
label: "player.subtitleTracks",
kind: .subtitles
)
}
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.** Eingepasst liegen unten ohnehin schwarze
// Balken, in denen der Untertitel Platz hat; dort etwas
// auszusparen nähme Bild, ohne etwas zu retten.
bottomInset: model.videoFit == .fill ? 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 {
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
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)
}
}