Und dein Argument dafür war das beste: auf dem Mac kann ich selbst hinsehen. Kein Simulator-Tunnel, der mitten im Prüfen abstürzt — `screencapture` auf das Fenster der App, und zwar nur auf ihres, nicht auf den Bildschirm. **Ein eigenes Ziel, nicht ein gemeinsames.** Die Info.plist der iOS-App ist voller iOS-Angaben: Startbildschirm, erlaubte Ausrichtungen, Live Activities. Die alle bedingt zu machen hieße, dass jede spätere Änderung an der iOS-Seite über eine Stelle geht, die auch den Mac betrifft. Zwei Ziele sind ein paar Zeilen mehr und eine Sorge weniger. **Was den Unterschied ausmacht, steht an einer Stelle.** Ein neuer `PlatformShims` in FlixyDesign übersetzt, was auf beiden Seiten verschieden heißt: schmale Titelzeile, halbhohes Blatt, Eingabefelder ohne Großschreibung, die Plätze in der Werkzeugleiste, die Zwischenablage. Zwanzig Ansichten benutzen ihn, statt zwanzigmal `#if os(iOS)` zu tragen — sonst wandert die Fallunterscheidung in jede neue Datei und wird beim übernächsten Bildschirm vergessen. Wirklich verschieden sind nur fünf Stellen: die Bildfläche (`UIView` gegen `NSView`), der AirPlay-Knopf, die Ausrichtungssperre (auf dem Mac gibt es nichts zu sperren), der App-Delegat für Hintergrund-Downloads und das Anmeldefenster, das ein anderes Fenster als Anker braucht. **Das Karussell blättert jetzt mit einem einrastenden Streifen** statt mit `TabView(.page)` — den Seitenstil gibt es auf dem Mac nicht. Das ist nebenbei die bessere Lösung: das verschachtelte TabView hatte die Eigenschaft, beim Wechsel kurz zwei Seiten gleichzeitig zu zeigen. **Was auf dem Mac fehlt und warum:** VLC. Das Binärpaket ist auf iOS festgelegt; sein xcframework enthält zwar Mac-Teile, aber das ist eine eigene Runde. Solange fällt die Wiedergabe dort auf den Systemplayer zurück — MKV und DTS gehen also noch nicht. Live Activities gibt es auf dem Mac nicht, Bild-in- Bild startet von Hand statt beim Verlassen der App. Gesehen: die App startet, der Anmeldebildschirm steht, beide Wege sind da. Angemeldet habe ich mich auf dem Mac **nicht** — das ginge nur, indem ich einen Token in deinen Schlüsselbund schreibe, und dann fragt macOS dich beim ersten Zugriff nach dem Passwort. Das machst du mit einem Klick selbst. iOS baut unverändert, 263 Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
562 lines
22 KiB
Swift
562 lines
22 KiB
Swift
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
|
||
|
||
@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>?
|
||
|
||
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.
|
||
if let target = model.resumeTarget {
|
||
restoring(to: target)
|
||
}
|
||
|
||
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.
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
// 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: Tippen zeigt und verbirgt
|
||
// die Bedienelemente wie bisher.
|
||
.onTapGesture { toggleControls() }
|
||
.sheet(item: $trackPicker) { kind in
|
||
TrackPickerSheet(model: model, kind: kind)
|
||
}
|
||
.sheet(isPresented: $showsQualityChoice) {
|
||
QualitySheet(model: model)
|
||
}
|
||
.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: {
|
||
Image(systemName: model.isPlaying ? "pause.fill" : "play.fill")
|
||
.font(.system(size: 52))
|
||
}
|
||
.accessibilityLabel(Text(model.isPlaying ? "player.pause" : "player.play"))
|
||
|
||
Button {
|
||
model.skip(by: .seconds(settings.forwardSeconds))
|
||
scheduleControlsHide()
|
||
} label: {
|
||
Image(systemName: "goforward.\(settings.forwardSeconds)").font(.largeTitle)
|
||
}
|
||
.accessibilityLabel(Text("player.forward10"))
|
||
}
|
||
.foregroundStyle(.white)
|
||
}
|
||
|
||
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 { toggleControls() }
|
||
}
|
||
|
||
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
|
||
|
||
private func toggleControls() {
|
||
withAnimation(.easeInOut(duration: 0.2)) { showsControls.toggle() }
|
||
// Beim Aufdecken nachsehen, was inzwischen läuft — genau dann will
|
||
// man es wissen.
|
||
if showsControls, model.isLive {
|
||
Task { await model.refreshLiveProgrammeIfNeeded() }
|
||
}
|
||
if showsControls { scheduleControlsHide() }
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
}
|