Files
Flaro/Flixy/Views/Player/PlayerView.swift
T
ScarriffleandClaude Opus 5 10caa5fdd1 Platzhalter statt Ratespiel, und zwei Griffe im Player
**Das Raster sagt jetzt, ob noch etwas kommt.** Eine Mediathek lädt
seitenweise nach, und wer unten ankam, sah ein Raster, das aufhört — ohne
unterscheiden zu können, ob das alles war oder gerade nachgeladen wird.
Beides sah gleich aus.

Beide Server nennen die Gesamtzahl (Jellyfin `TotalRecordCount`, Plex
`totalSize`), und wir lesen sie längst. Also stehen die nächsten Kacheln
schon da, in der Form, die sie haben werden — gedeckelt auf ein Dutzend,
weil zweihundert Platzhalter niemandem helfen. Ist wirklich alles da,
steht es am Ende ausdrücklich: „Alle 252 Titel geladen".

Gegen die Attrappe mit 252 Titeln durchgeblättert und nachgesehen: die
Zeile steht da, und alle 252 sind angekommen.

**Herein sanft, hinaus zügig.** Das Einblenden der Bedienung begleitet
eine Bewegung des Zeigers und soll wirken, als käme sie der Hand
entgegen; zwei Zehntel wirkten wie ein Aufblitzen. Das Ausblenden
dagegen ist ein Aufräumen, und dabei muss niemand zusehen.

**Ein Klick ins Schwarze blendet um, statt anzuhalten.** Ein Film füllt
das Fenster fast nie ganz aus — die Balken daneben gehören nicht zum
Film, und dort zu klicken ist die natürliche Geste für „zeig mir die
Bedienung". Wo das Bild aufhört, wird aus `presentationSize` gerechnet,
also aus dem, was wirklich ankommt. Fehlen die Maße, verhält es sich wie
bisher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 18:50:26 +02:00

818 lines
34 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(NetworkConditions.self) private var network
@Environment(DownloadCoordinator.self) private var downloads
@Environment(PlaybackDiagnostics.self) private var diagnostics
@Environment(\.flixyAccent) private var accent
/// 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
@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?
/// Wie gross die Player-Fläche gerade ist — für die Frage, wo das Bild
/// aufhört und das Schwarze anfängt.
@State private var playerSize: CGSize = .zero
/// Ob Zeiger und Tastatur gelten. Einmal beim Start ermittelt.
private var isMac: Bool { FlixyPlatform.isMac }
/// 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 {
VideoSurface(
engine: model.engine,
surface: model.surface,
isActive: playback.presentation == .full
)
.ignoresSafeArea()
}
// **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)
}
if showsControls {
controls(for: model).transition(.opacity)
}
// Ü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.
if showsControls || !isPlayingPhase(model) {
VStack {
topBar(for: model)
Spacer()
}
.padding(FlixySpacing.loose)
// **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.
.animation(.easeInOut(duration: 0.2), value: model.isBuffering)
#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 außerhalb der Sprungflächen. Mit Ort, weil es einen
// Unterschied macht, **wo** geklickt wurde — siehe `tap(at:)`.
.onGeometryChange(for: CGSize.self) { $0.size } action: { playerSize = $0 }
.onTapGesture(coordinateSpace: .local) { tap(at: $0) }
.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 = phase else { return }
pointerMoved()
}
// **Die Umgebung wird den Blättern mitgegeben, nicht vererbt.**
//
// Ein Blatt 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 }
withAnimation(.easeInOut(duration: 0.2)) { showsControls = true }
scheduleControlsHide()
}
.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)
.background {
// Nur ein Hauch Abdunklung: genug, damit weiße Schrift auf hellen
// Szenen lesbar bleibt, wenig genug, dass das Bild nicht
// verfälscht wirkt.
Color.black.opacity(0.35).ignoresSafeArea()
}
}
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()
// Getrennte Knöpfe, weil es zwei verschiedene Fragen sind: die
// Tonspur stellt man einmal am Anfang um, die Untertitel mitten im
// Film. Ein gemeinsames Blatt 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) {
Slider(
value: Binding(
get: { scrubbing ?? model.currentTime.seconds },
set: { scrubbing = $0 }
),
in: 0...max(model.duration?.seconds ?? 1, 1),
onEditingChanged: { editing in
// 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.
if !editing, let target = scrubbing {
model.seek(to: .seconds(target))
scrubbing = nil
}
scheduleControlsHide()
}
)
.tint(accent)
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)
// Über dem Schieberegler, damit sich beides nicht überdeckt, wenn
// die Bedienelemente eingeblendet sind.
.padding(.bottom, showsControls ? 110 : FlixySpacing.section)
}
.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 }
}
}
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.
/// Ein Klick — und die Frage, worauf.
///
/// **Auf das Bild** heißt anhalten: das ist der Griff, den man aus jedem
/// Player kennt.
///
/// **Ins Schwarze daneben** heißt ein- und ausblenden. Ein Film füllt das
/// Fenster fast nie ganz aus — oben und unten oder links und rechts
/// bleiben Balken, und die gehören nicht zum Film. Dort zu klicken ist
/// die natürliche Geste für „zeig mir die Bedienung" und war bisher
/// dasselbe wie ein Griff mitten ins Bild.
private func tap(at point: CGPoint) {
guard isMac, !videoRect.contains(point) else {
primaryTap()
return
}
toggleControls()
}
/// Wo das Bild tatsächlich liegt.
///
/// Der Player füllt das ganze Fenster, das Bild aber nur den Teil, der
/// zum Seitenverhältnis passt — der Rest ist schwarz. Die Maße kommen
/// aus `presentationSize`, also von dem, was wirklich ankommt, nicht aus
/// den Angaben der Datei.
///
/// Ohne Maße die ganze Fläche: dann verhält es sich wie bisher, statt
/// aus Unkenntnis überall das Falsche zu tun.
private var videoRect: CGRect {
let ganzes = CGRect(origin: .zero, size: playerSize)
guard let breite = model.statistics?.width,
let höhe = model.statistics?.height,
breite > 0, höhe > 0,
playerSize.width > 0, playerSize.height > 0
else { return ganzes }
let verhältnisBild = CGFloat(breite) / CGFloat(höhe)
let verhältnisFläche = playerSize.width / playerSize.height
if verhältnisBild > verhältnisFläche {
// Breiter als das Fenster: Balken oben und unten.
let sichtbar = playerSize.width / verhältnisBild
return CGRect(
x: 0,
y: (playerSize.height - sichtbar) / 2,
width: playerSize.width,
height: sichtbar
)
}
// Höher als das Fenster: Balken links und rechts.
let sichtbar = playerSize.height * verhältnisBild
return CGRect(
x: (playerSize.width - sichtbar) / 2,
y: 0,
width: sichtbar,
height: playerSize.height
)
}
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 {
withAnimation(.easeInOut(duration: 0.2)) { showsControls = false }
} else {
revealControls()
}
}
/// Zeigt die Bedienelemente und setzt die Ausblendfrist neu.
private func revealControls() {
if !showsControls {
// **Herein sanft, hinaus zügig.** Das Einblenden begleitet eine
// Bewegung des Zeigers und soll sich anfühlen, als käme die
// Bedienung der Hand entgegen — zwei Zehntel wirken dabei wie ein
// Aufblitzen. Das Ausblenden dagegen ist ein Aufräumen, und
// Aufräumen darf man nicht zusehen müssen.
withAnimation(.easeOut(duration: 0.45)) { 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() {
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 }
withAnimation(.easeInOut(duration: 0.3)) { 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)
}
}