Files
Flaro/Flixy/Views/Player/PlayerView.swift
T
ScarriffleandClaude Opus 5 fc812f5a25 Zwei Schlüssel ohne Platzhalter, zwei Kreisel übereinander
**Die Schlüssel.** Im Quelltext steht `"season.number \(season)"`, der
Schlüssel heißt damit `season.number %lld`. Im Katalog stand er ohne
Platzhalter — also traf er nie, und die App zeigte den Schlüssel selbst:
„The 50 · season.number 1 · episode.number 1". Beide umbenannt. Ein
Überbleibsel `episode.number %@` ohne eine einzige Übersetzung ist
mitgegangen, und aus „Episode" wurde „Folge", wie überall sonst im Haus.

**Die Kreisel.** Die Abdeckung beim Wiederherstellen der Stelle ist eine
ganze Seite, kein Schleier — nur lag das Übrige weiter darüber. Zu sehen
waren Bedienelemente mitten im Satz, ein Kreisel in der Abdeckung und ein
zweiter im Abspielknopf, den man in dem Moment ohnehin nicht drücken
soll. Jetzt ist die Abdeckung ausschließlich. Die Kopfzeile bleibt: sie
ist der Weg zurück.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 21:52:08 +02:00

718 lines
30 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
/// 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)
.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:
// Wartet auf Daten: ohne Anzeige sieht das aus wie ein
// Standbild, und man weiß nicht, ob noch etwas kommt.
//
// Nur, solange die Bedienelemente aus sind. Sind sie
// an, steht der Kreisel **im** Abspielknopf — dort, wo
// sonst das Symbol ist. Vorher lag er dahinter und das
// Dreieck davor: es sah pausiert aus, war es nicht.
if model.isBuffering && !showsControls {
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.
.onTapGesture { primaryTap() }
// **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 der Kreisel
// — kein Dreieck. Ein Dreieck heißt „angehalten, tipp
// mich", und beides stimmte nicht: es lief bereits, und
// der Tipp bewirkte nichts.
if model.isBuffering {
ProgressView()
.tint(.white)
.scaleEffect(1.4)
} else {
Image(systemName: model.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 52))
}
}
// 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.
private func primaryTap() {
guard isMac else {
toggleControls()
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 {
withAnimation(.easeInOut(duration: 0.2)) { 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)
}
}