Files
Flaro/Flixy/Views/Player/PlayerView.swift
T
ScarriffleandClaude Opus 5 279d8e36ae Zwei Fehler um dieselbe Bildfläche
**Die Wiedergabe sprang von selbst wieder an.** Auf dem Mac pausiert ein
Klick ins Bild — und der Klick, mit dem man ein Fenster nach vorn holt,
kommt ebenfalls dort an. Man aktiviert das Fenster und drückt dabei auf
Play. Klicks innerhalb einer Viertelsekunde nach dem Aktivieren zählen
deshalb nicht: länger braucht niemand bis zum ersten gemeinten Klick,
kürzer erwischt den Fall nicht.

**Schwarzbild mit Ton nach dem Vergrössern.** Kachel und Vollbild
bestehen beim Wechsel kurz gleichzeitig, und beide holten sich dieselbe
Bildfläche in ihre Hülle. Eine Ansicht hat aber nur einen Vorgesetzten —
wer zuletzt aktualisiert, gewinnt, und das war nicht verlässlich die
richtige. Blieb die Fläche bei der Kachel, die gleich darauf verschwand,
lief im Vollbild der Ton weiter und das Bild blieb schwarz.

Die Hülle allein genügte nicht: sie verhindert, dass das Abräumen der
einen Ansicht der anderen etwas wegnimmt, entscheidet aber nicht, wer
die Fläche bekommen **soll**. Das sagt jetzt `isActive` — abgeleitet aus
`playback.presentation`, also aus derselben Quelle, die auch bestimmt,
welche Ansicht überhaupt zu sehen ist.

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

750 lines
31 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?
/// 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.
.onTapGesture { primaryTap() }
.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.
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 {
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)
}
}