Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit, damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind. 712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25 Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit `HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`, `JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben. `docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben hiesse, die Vergangenheit zu glätten. **Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis: `com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container; Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen. **Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter `…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet. Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht statt umbenannt — sie steht ohnehin nicht im Repo. Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist ein Handgriff ausserhalb des Projekts. 368 Tests grün. iOS und tvOS bauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1052 lines
47 KiB
Swift
1052 lines
47 KiB
Swift
import SwiftUI
|
||
import HaloDesign
|
||
import HaloDownloads
|
||
import HaloPlayback
|
||
|
||
/// 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(HaloSession.self) private var session
|
||
@Environment(PlaybackCoordinator.self) private var playback
|
||
@Environment(PlaybackSettings.self) private var settings
|
||
@Environment(SubtitleAppearance.self) private var subtitleAppearance
|
||
@Environment(NetworkConditions.self) private var network
|
||
@Environment(DownloadCoordinator.self) private var downloads
|
||
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
||
@Environment(\.haloAccent) private var accent
|
||
/// Woran sich Quer- und Hochformat unterscheiden lassen.
|
||
///
|
||
/// Auf jedem iPhone ist das Querformat senkrecht kompakt und das
|
||
/// Hochformat nicht. Billiger als die Szene zu befragen, und es
|
||
/// aktualisiert sich von selbst.
|
||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||
|
||
/// Wie hoch die Fensterleiste auf dem Mac ist.
|
||
///
|
||
/// Fest, weil es keine Auskunft darüber gibt, die hier ankäme: die
|
||
/// Sicherheitsränder einer Szene kennen die Fensterleiste nicht, sie
|
||
/// gehört dem System. Achtundzwanzig Punkte sind das Maß eines
|
||
/// gewöhnlichen Fensters.
|
||
private static let titleBarHeight: CGFloat = 28
|
||
|
||
/// Was der Bildschirm gerade verdeckt.
|
||
///
|
||
/// **Aus dem Fenster geholt, nicht von SwiftUI.** Diese Ansicht ist
|
||
/// bildfüllend — sie muss es sein, sonst wäre das Bild im Querformat um
|
||
/// die Notch-Breite schmaler — und eine bildfüllende Ansicht bekommt von
|
||
/// SwiftUI keine Auskunft mehr darüber, wo der Schirm aufhört. Siehe
|
||
/// `ScreenSafeArea`.
|
||
///
|
||
/// Neu geholt wird er immer dann, wenn sich die Fläche ändert: das ist
|
||
/// genau der Moment des Drehens, und genau dann stimmen die alten Werte
|
||
/// nicht mehr.
|
||
@State private var safeArea = EdgeInsets()
|
||
@State private var showsControls = true
|
||
@State private var trackPicker: TrackPickerSheet.Kind?
|
||
@State private var showsQualityChoice = false
|
||
@State private var hideTask: Task<Void, Never>?
|
||
@State private var scrubbing: Double?
|
||
/// Welche Richtung gerade als Hinweis eingeblendet ist.
|
||
@State private var seekHint: Double?
|
||
@State private var hintTask: Task<Void, Never>?
|
||
/// Ob die Tastatur bei diesem Bildschirm ankommt. Nur auf dem Mac gesetzt.
|
||
@FocusState private var keyboardFocus: Bool
|
||
/// Wann die App zuletzt nach vorn geholt wurde.
|
||
@State private var becameActive: ContinuousClock.Instant?
|
||
/// Wo der Zeiger zuletzt war — um Bewegung von Neuzeichnen zu trennen.
|
||
@State private var lastPointerLocation: CGPoint = .zero
|
||
/// Ob das Fenster gerade im Vollbild ist — soweit wir es wissen können.
|
||
///
|
||
/// **Selbst mitgezählt, nicht abgefragt.** `UIWindowScene.isFullScreen`
|
||
/// gibt es für eine „für iPad angepasste" App nicht. Wer mit dem grünen
|
||
/// Knopf oder ⌃⌘F umschaltet, bringt das Symbol also aus dem Tritt — das
|
||
/// Umschalten selbst funktioniert trotzdem, es zeigt nur kurz das falsche
|
||
/// Bild. Ein falsches Symbol ist der kleinere Preis gegenüber gar keinem
|
||
/// Knopf.
|
||
@State private var isFullScreen = false
|
||
/// Ob Zeiger und Tastatur gelten. Einmal beim Start ermittelt.
|
||
private var isMac: Bool { HaloPlatform.isMac }
|
||
/// Ob gedreht und mit zwei Fingern gezoomt werden kann.
|
||
///
|
||
/// Ein iPad dreht sich von selbst und braucht keinen Knopf dafür; der
|
||
/// Mac dreht sich gar nicht. Bleibt das Telefon.
|
||
private var isPhone: Bool {
|
||
!isMac && UIDevice.current.userInterfaceIdiom == .phone
|
||
}
|
||
/// Ob das Gerät gerade quer liegt.
|
||
private var isLandscape: Bool { verticalSizeClass == .compact }
|
||
|
||
/// Wann der Zeiger zuletzt die Bedienelemente geholt hat.
|
||
@State private var lastPointerReveal: Date = .distantPast
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
Color.black.ignoresSafeArea()
|
||
|
||
Group {
|
||
// Bei „nur Ton" bleibt der Schirm dunkel — das ist der halbe
|
||
// Zweck der Stufe.
|
||
if model.showsVideo {
|
||
// **Die Bildfläche ist genau so gross wie das Bild.**
|
||
//
|
||
// Vorher deckte sie das ganze Fenster ab, und die Ebene
|
||
// liess oben und unten schwarz. Damit gehörten die Balken
|
||
// zur Bildfläche, und für SwiftUI war ein Klick darauf ein
|
||
// Klick aufs Bild. Wo das Bild aufhört, mussten wir
|
||
// ausrechnen — aus Fenstergrösse, Sicherheitsrändern und
|
||
// Seitenverhältnis. Diese Rechnung lag zweimal daneben.
|
||
//
|
||
// Jetzt rechnet das Layout. Der schwarze Rand ist schlicht
|
||
// der Hintergrund dahinter, und wer ihn anklickt, klickt
|
||
// nicht aufs Bild — das muss niemand mehr feststellen.
|
||
Color.clear
|
||
.ignoresSafeArea()
|
||
.overlay {
|
||
surface(for: model)
|
||
.contentShape(.rect)
|
||
.onTapGesture { primaryTap() }
|
||
}
|
||
}
|
||
|
||
// **Unter** den Bedienelementen, nicht darüber. Als Überzug
|
||
// über dem ganzen Player lag hier eine durchsichtige Fläche
|
||
// vor jedem Knopf und hat sämtliche Tipper verschluckt —
|
||
// Pause, Scrubber, Qualität, alles tot. Ein Überzug liegt in
|
||
// SwiftUI zwangsläufig obenauf; hier im Stapel entscheidet
|
||
// die Reihenfolge, und alles Spätere liegt darüber.
|
||
seekZones
|
||
|
||
// Deckt den Umstieg ab. Ohne sie sieht man den Filmanfang,
|
||
// während der neue Stream anläuft — und denkt, die Stelle sei
|
||
// weg. Die Abdeckung nennt sie ausdrücklich.
|
||
//
|
||
// **Und dann nichts anderes.** Die Abdeckung ist eine ganze
|
||
// Seite, kein Schleier: legt sich das Übrige darüber, stehen
|
||
// Bedienelemente und Kreisel mitten im Satz „Stelle wird
|
||
// wiederhergestellt" — zwei Ladeanzeigen übereinander, eine
|
||
// davon in einem Knopf, den man in dem Moment gar nicht
|
||
// drücken soll. Die Kopfzeile bleibt: sie ist der Weg zurück.
|
||
if let target = model.resumeTarget {
|
||
restoring(to: target)
|
||
} else {
|
||
switch model.phase {
|
||
case .preparing:
|
||
preparing(for: model)
|
||
|
||
case .failed(let message):
|
||
failure(message: message)
|
||
|
||
case .playing:
|
||
// **Der Kreisel gilt immer**, mit oder ohne
|
||
// Bedienelemente.
|
||
//
|
||
// Er ist keine Verzierung der Bedienung, sondern die
|
||
// Auskunft „es kommt gleich weiter" — und die braucht
|
||
// man gerade dann, wenn man nichts eingeblendet hat
|
||
// und vor einem stehenden Bild sitzt. Die Knöpfe
|
||
// darüber lassen an seiner Stelle Platz, damit er
|
||
// hindurchscheint und nicht zwei Anzeigen
|
||
// übereinanderliegen.
|
||
if model.isBuffering {
|
||
ProgressView()
|
||
.tint(.white)
|
||
.scaleEffect(1.4)
|
||
.transition(.opacity)
|
||
}
|
||
|
||
// **Deckkraft statt Einfügen und Entfernen.**
|
||
//
|
||
// Eine Übergangsanimation beim Einfügen hängt daran,
|
||
// dass SwiftUI die Ansicht als dieselbe wiedererkennt
|
||
// und der Einfügevorgang in einer animierten
|
||
// Transaktion steckt. Bleibt eine der beiden
|
||
// Bedingungen aus, erscheint die Bedienung schlagartig
|
||
// — und genau so sah es aus. Deckkraft hat diese
|
||
// Bedingungen nicht: sie ist ein Wert, und Werte
|
||
// lassen sich immer überblenden.
|
||
controls(for: model)
|
||
.opacity(showsControls ? 1 : 0)
|
||
// Unsichtbar heisst auch unantastbar. Sonst läge
|
||
// der Scrubber weiterhin über dem Bild und
|
||
// schluckte jeden Klick.
|
||
.allowsHitTesting(showsControls)
|
||
// Überspringen und „Nächste Folge" liegen außerhalb
|
||
// der Bedienelemente: sie sollen auch dann erscheinen,
|
||
// wenn die ausgeblendet sind — sonst müsste man erst
|
||
// antippen, um das Intro überspringen zu können.
|
||
overlayActions(for: model)
|
||
}
|
||
}
|
||
|
||
// Die Kopfzeile liegt über allem und erscheint auch während
|
||
// des Ladens. Ohne sie sitzt man bei langsamer Verbindung
|
||
// fest: kein Zurück, kein Abbrechen, nur ein Ladekreis.
|
||
// Sie hatte bisher gar keine Überblendung: sie sprang
|
||
// herein, während die Bedienung darunter verblasste. Zwei
|
||
// Hälften derselben Oberfläche, die sich verschieden
|
||
// verhalten, lesen sich als „es fadet nicht".
|
||
do {
|
||
let zeigt = showsControls || !isPlayingPhase(model)
|
||
VStack {
|
||
topBar(for: model)
|
||
Spacer()
|
||
}
|
||
.opacity(zeigt ? 1 : 0)
|
||
.allowsHitTesting(zeigt)
|
||
.padding(HaloSpacing.loose)
|
||
// Siehe `controls(for:)`: die Kopfzeile lag sonst hinter
|
||
// der Dynamic Island, und der Zurück-Pfeil war tot.
|
||
.padding(.top, safeArea.top)
|
||
.padding(.leading, safeArea.leading)
|
||
.padding(.trailing, safeArea.trailing)
|
||
// **Unter der Fensterleiste beginnen.**
|
||
//
|
||
// Der Player deckt das ganze Fenster ab, und auf dem Mac
|
||
// zeichnet das System seine Ampelknöpfe darüber. Die
|
||
// Kopfzeile lag dadurch nicht nur optisch unter ihnen: der
|
||
// ganze Streifen gehört der Fensterleiste, und die
|
||
// schluckt jeden Klick. Verkleinern, Schließen, Tonspur,
|
||
// Untertitel und die Qualitätsstufe waren dort tot — und
|
||
// ein zweiter Klick auf dieselbe Stelle legte das Fenster
|
||
// ins Dock, weil das der Doppelklick auf eine
|
||
// Fensterleiste tut.
|
||
.padding(.top, HaloPlatform.isMac ? Self.titleBarHeight : 0)
|
||
}
|
||
}
|
||
}
|
||
// **Auseinander füllt, zusammen passt ein.**
|
||
//
|
||
// Kein stufenloses Zoomen: es gibt genau zwei sinnvolle Zustände, und
|
||
// alles dazwischen wäre ein Bild, das weder ganz zu sehen ist noch den
|
||
// Schirm füllt. Die Geste wählt aus zweien aus, statt einen Faktor zu
|
||
// setzen — und der Schwellenwert liegt weit vom Ruhepunkt weg, damit
|
||
// eine wackelige Hand nichts umschaltet.
|
||
//
|
||
// **Am ganzen Player, nicht an der Bildfläche.** Dort hing sie bisher
|
||
// und griff deshalb fast nie: über dem Bild liegen die Sprungflächen,
|
||
// und sobald man einmal getippt hat, die ganze Bedienung. Eine
|
||
// Zwei-Finger-Geste bildet sich aber nur, wenn **beide** Finger auf
|
||
// der Ansicht landen, die sie trägt — in dem schmalen Streifen
|
||
// dazwischen gelang das kaum.
|
||
//
|
||
// `simultaneousGesture`, damit Tippen, Doppeltippen und der Scrubber
|
||
// unberührt bleiben: die Geste erkennt mit, statt dazwischenzugehen.
|
||
.simultaneousGesture(zoomGesture(for: model), including: isMac ? .none : .all)
|
||
// Immer aus, nicht nur wenn die Bedienelemente ausgeblendet sind.
|
||
// Die Uhrzeit über einem laufenden Film hat dort nichts zu suchen —
|
||
// und im Querformat erst recht nicht.
|
||
.onGeometryChange(for: CGSize.self) { proxy in
|
||
proxy.size
|
||
} action: { _ in
|
||
safeArea = ScreenSafeArea.current
|
||
}
|
||
.onAppear { safeArea = ScreenSafeArea.current }
|
||
// **Auch beim Zurückholen der App.** Eine Drehung im Hintergrund
|
||
// ändert die Fläche, ohne dass `onGeometryChange` hier feuert — die
|
||
// Ansicht ist dann gar nicht sichtbar. Wer die App danach quer
|
||
// wiederfindet, hätte sonst die Ränder von vorhin.
|
||
.onReceive(
|
||
NotificationCenter.default.publisher(
|
||
for: UIApplication.didBecomeActiveNotification
|
||
)
|
||
) { _ in
|
||
safeArea = ScreenSafeArea.current
|
||
}
|
||
.animation(.easeInOut(duration: 0.2), value: model.isBuffering)
|
||
// **Herein sanft, hinaus zügig.** Das Einblenden begleitet eine
|
||
// Bewegung und soll der Hand entgegenkommen; das Ausblenden ist ein
|
||
// Aufräumen, und Aufräumen darf man nicht zusehen müssen.
|
||
//
|
||
// Hier, nicht an jeder einzelnen Umschaltstelle: die gab es sechsmal,
|
||
// mit vier verschiedenen Dauern, und eine davon stand an einer Stelle,
|
||
// die gar nicht animiert wurde.
|
||
.animation(
|
||
showsControls ? .easeOut(duration: 0.45) : .easeIn(duration: 0.28),
|
||
value: showsControls
|
||
)
|
||
#if os(iOS)
|
||
// Die Statusleiste gehört nicht über einen Film. Auf dem Mac gibt es
|
||
// keine.
|
||
.statusBarHidden(true)
|
||
#endif
|
||
.persistentSystemOverlays(showsControls ? .automatic : .hidden)
|
||
.landscapeWhileVisible(settings.locksLandscape)
|
||
.contentShape(.rect)
|
||
// Für alles ausserhalb von Bild und Sprungflächen — also den
|
||
// schwarzen Rand. Ein Film füllt das Fenster fast nie ganz aus, und
|
||
// die Balken gehören nicht zum Film: dort zu klicken ist die
|
||
// natürliche Geste für „zeig mir die Bedienung".
|
||
//
|
||
// **Ohne Ort.** Wo geklickt wurde, spielt keine Rolle mehr: die
|
||
// Bildfläche fängt ihre eigenen Klicks ab, hier kommt nur noch an,
|
||
// was daneben ging.
|
||
.onTapGesture { toggleControls() }
|
||
.onReceive(
|
||
NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)
|
||
) { _ in
|
||
becameActive = .now
|
||
}
|
||
// **Tastatur und Zeiger — nur auf dem Mac.**
|
||
//
|
||
// Dort ist beides selbstverständlich: Leertaste hält an, die Pfeile
|
||
// springen, und die Bedienelemente kommen von allein, sobald sich der
|
||
// Zeiger regt. Auf dem iPhone gibt es keines von beidem.
|
||
//
|
||
// Zur Laufzeit entschieden, nicht beim Übersetzen: „für iPad
|
||
// angepasst" ist der iOS-Bau und läuft trotzdem auf dem Mac. Ein
|
||
// `#if` hätte das Ganze genau dort abgeschaltet. `focusable(_:)`
|
||
// nimmt den Schalter entgegen, die Tastenhandler geben sonst
|
||
// `.ignored` zurück — so bleibt es **eine** Ansicht, und der Player
|
||
// wird nicht neu aufgebaut, nur weil die Frage vom Gerät abhängt.
|
||
.focusable(isMac)
|
||
.focusEffectDisabled()
|
||
.focused($keyboardFocus)
|
||
// Von Hand gesetzt, weil sonst niemand den Fokus hat: der Player
|
||
// besteht aus Knöpfen, und solange keiner angeklickt wurde, ginge
|
||
// jeder Tastendruck ins Leere.
|
||
.onAppear { if isMac { keyboardFocus = true } }
|
||
.onKeyPress(.space) { onMac { model.togglePlayPause() } }
|
||
.onKeyPress(.leftArrow) { onMac { seek(by: -Double(settings.rewindSeconds)) } }
|
||
.onKeyPress(.rightArrow) { onMac { seek(by: Double(settings.forwardSeconds)) } }
|
||
.onContinuousHover { phase in
|
||
guard isMac, case .active(let ort) = phase else { return }
|
||
pointerMoved(to: ort)
|
||
}
|
||
// **Die Umgebung wird den Blättern mitgegeben, nicht vererbt.**
|
||
//
|
||
// Ein Sheet hängt an einem eigenen Darstellungsbaum. Auf dem iPad kam
|
||
// die Umgebung des Players dort bisher an, auf dem Mac nicht — und
|
||
// `@Environment(PlaybackSettings.self)` ist kein Wert mit
|
||
// Ersatzangabe, sondern eine Behauptung: fehlt der Gegenstand, hält
|
||
// Swift die Anwendung an. Sichtbar war davon nichts, weil der Player
|
||
// in diesem Augenblick mitten in einem Layoutdurchlauf steckt: die
|
||
// App fror ein, statt zu beenden.
|
||
//
|
||
// Hier weitergereicht ist es keine Frage des Baums mehr. Der Akzent
|
||
// steht daneben, weil er sonst still auf die Vorgabe zurückfiele —
|
||
// kein Absturz, aber die falsche Farbe.
|
||
.sheet(item: $trackPicker) { kind in
|
||
TrackPickerSheet(model: model, kind: kind)
|
||
.environment(\.haloAccent, accent)
|
||
}
|
||
.sheet(isPresented: $showsQualityChoice) {
|
||
QualitySheet(model: model)
|
||
.environment(settings)
|
||
.environment(\.haloAccent, accent)
|
||
}
|
||
.onChange(of: showsQualityChoice) { _, isShown in
|
||
// Nach dem Schließen die Bedienelemente stehen lassen und die
|
||
// Ausblendfrist neu starten. Ohne das verschwänden sie sofort,
|
||
// weil die alte Frist während des Dialogs abgelaufen ist.
|
||
guard !isShown else { return }
|
||
showsControls = true
|
||
scheduleControlsHide()
|
||
}
|
||
// **Mitten im Film.** Eine Einstellung, die erst beim nächsten
|
||
// Titel wirkt, hält jeder für kaputt — man stellt um und sieht
|
||
// nichts. `SubtitleStyle` ist vergleichbar, das feuert nur bei einer
|
||
// echten Änderung.
|
||
.onChange(of: subtitleAppearance.style) { _, neu in
|
||
model.engine?.applySubtitleStyle(neu)
|
||
}
|
||
.task { scheduleControlsHide() }
|
||
.onDisappear { hideTask?.cancel() }
|
||
}
|
||
|
||
// MARK: - Bedienelemente
|
||
|
||
private func controls(for model: PlayerModel) -> some View {
|
||
VStack {
|
||
// Platz für die Kopfzeile, die als eigene Ebene darüber liegt.
|
||
Spacer().frame(height: 44)
|
||
Spacer()
|
||
transport(for: model)
|
||
Spacer()
|
||
scrubber(for: model)
|
||
}
|
||
.padding(HaloSpacing.loose)
|
||
// **Zusätzlich zum Polster, nicht statt.** Der Sicherheitsrand sagt,
|
||
// wo der Bildschirm aufhört; das Polster sagt, wie luftig es aussehen
|
||
// soll. Ersetzte eines das andere, klebte der Schieberegler im
|
||
// Querformat am Home-Indikator.
|
||
.padding(.leading, safeArea.leading)
|
||
.padding(.trailing, safeArea.trailing)
|
||
.padding(.bottom, safeArea.bottom)
|
||
.background {
|
||
// Nur ein Hauch Abdunklung: genug, damit weiße Schrift auf hellen
|
||
// Szenen lesbar bleibt, wenig genug, dass das Bild nicht
|
||
// verfälscht wirkt.
|
||
//
|
||
// **Und unantastbar.** Er deckt das ganze Fenster ab und lag damit
|
||
// über dem Bild: solange die Bedienung zu sehen war, landete jeder
|
||
// Klick auf ihm statt auf dem Film. Zu merken war das an genau
|
||
// einer Stelle — nach dem Pausieren bleibt die Bedienung stehen,
|
||
// und der Klick, der wieder starten sollte, blendete nur aus.
|
||
// Anhalten ging, fortsetzen nicht.
|
||
Color.black.opacity(0.35).ignoresSafeArea().allowsHitTesting(false)
|
||
}
|
||
}
|
||
|
||
private func topBar(for model: PlayerModel) -> some View {
|
||
HStack(alignment: .top, spacing: HaloSpacing.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(HaloSpacing.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(HaloSpacing.snug)
|
||
}
|
||
.accessibilityLabel(Text("player.stop"))
|
||
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(model.title)
|
||
.font(.haloCardTitle)
|
||
.lineLimit(1)
|
||
if let subtitle = model.subtitle {
|
||
Text(subtitle)
|
||
.font(.haloCaption)
|
||
.foregroundStyle(.white.opacity(0.7))
|
||
.lineLimit(1)
|
||
}
|
||
}
|
||
|
||
Spacer()
|
||
|
||
// **Vollbild — nur auf dem Mac, und nur wenn es dort geht.**
|
||
//
|
||
// Ob die Aktion jemand entgegennimmt, steht nicht fest; siehe
|
||
// `MacFullScreen`. Gefragt wird vorher, damit kein Knopf dasteht,
|
||
// der nichts tut — das lässt den Nutzer an sich zweifeln statt an
|
||
// der Plattform.
|
||
if isMac, MacFullScreen.isAvailable {
|
||
Button {
|
||
if MacFullScreen.toggle() { isFullScreen.toggle() }
|
||
revealControls()
|
||
} label: {
|
||
Image(systemName: isFullScreen
|
||
? "arrow.down.right.and.arrow.up.left"
|
||
: "arrow.up.left.and.arrow.down.right")
|
||
.font(.callout.weight(.semibold))
|
||
.padding(HaloSpacing.snug)
|
||
}
|
||
.accessibilityLabel(Text(isFullScreen ? "player.exitFullScreen" : "player.fullScreen"))
|
||
.keyboardShortcut("f", modifiers: [.command, .control])
|
||
}
|
||
|
||
// **Einpassen oder füllen — überall ausser auf dem Mac.**
|
||
//
|
||
// Dort gibt es den Vollbildknopf darüber, und zwei Knöpfe, die
|
||
// beide „grösser" bedeuten, nebeneinander sind eine Zumutung.
|
||
if !isMac {
|
||
Button {
|
||
setFit(model.videoFit == .fill ? .fit : .fill, on: model)
|
||
} label: {
|
||
Image(systemName: model.videoFit == .fill
|
||
? "arrow.down.right.and.arrow.up.left"
|
||
: "arrow.up.left.and.arrow.down.right")
|
||
.font(.title3)
|
||
.padding(HaloSpacing.snug)
|
||
}
|
||
.accessibilityLabel(Text(model.videoFit == .fill ? "player.fitVideo" : "player.fillVideo"))
|
||
}
|
||
|
||
// **Drehen — nur am Telefon.**
|
||
//
|
||
// Ein iPad dreht sich von selbst, sobald man es dreht; ein Telefon
|
||
// im Player auch, seit die Sperre weg ist. Der Knopf ist für den
|
||
// Fall, dass die Ausrichtungssperre des Systems an ist — dann
|
||
// dreht das Gerät nicht mehr von allein, und das ist meistens
|
||
// genau dann, wenn man liegt.
|
||
if isPhone {
|
||
Button {
|
||
OrientationLock.shared.rotate(to: isLandscape ? .portrait : .landscape)
|
||
revealControls()
|
||
} label: {
|
||
// **Ein Symbol für beide Richtungen, nicht zwei.** Das
|
||
// Telefon-Symbol zeigte die Lage, in die es ginge — und
|
||
// las sich damit als Zustandsanzeige statt als Knopf.
|
||
// Zwei Pfeile im Kreis sagen, was passiert: es dreht.
|
||
Image(systemName: "arrow.triangle.2.circlepath")
|
||
.font(.title3)
|
||
.padding(HaloSpacing.snug)
|
||
}
|
||
.accessibilityLabel(Text("player.rotate"))
|
||
}
|
||
|
||
// Getrennte Knöpfe, weil es zwei verschiedene Fragen sind: die
|
||
// Tonspur stellt man einmal am Anfang um, die Untertitel mitten im
|
||
// Film. Ein gemeinsames Sheet bedeutete jedes Mal Scrollen an der
|
||
// anderen Liste vorbei.
|
||
if model.hasAudioChoice {
|
||
trackButton(
|
||
symbol: "waveform",
|
||
label: "player.audioTracks",
|
||
kind: .audio
|
||
)
|
||
}
|
||
|
||
if model.hasSubtitleChoice {
|
||
trackButton(
|
||
symbol: model.selectedSubtitleTrack == nil
|
||
? "captions.bubble"
|
||
: "captions.bubble.fill",
|
||
label: "player.subtitleTracks",
|
||
kind: .subtitles
|
||
)
|
||
}
|
||
|
||
// **Bild-in-Bild von Hand.** Automatisch geht es nur beim
|
||
// Verlassen der App — und auf dem Mac gar nicht. Der Knopf
|
||
// erscheint nur, wenn es wirklich ginge.
|
||
if model.canUsePictureInPicture {
|
||
Button {
|
||
model.togglePictureInPicture()
|
||
} label: {
|
||
Image(systemName: model.isPictureInPictureActive ? "pip.exit" : "pip.enter")
|
||
.font(.title3)
|
||
.padding(HaloSpacing.snug)
|
||
}
|
||
.accessibilityLabel(Text("player.pictureInPicture"))
|
||
}
|
||
|
||
if model.engine?.supportsAirPlay == true {
|
||
AirPlayButton(tint: accent)
|
||
.frame(width: 28, height: 28)
|
||
.padding(HaloSpacing.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(HaloSpacing.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(.haloCaption.weight(.semibold))
|
||
.monospacedDigit()
|
||
.padding(.horizontal, HaloSpacing.snug)
|
||
.padding(.vertical, HaloSpacing.tight)
|
||
.overlay {
|
||
RoundedRectangle(cornerRadius: HaloRadius.card)
|
||
.strokeBorder(.white.opacity(0.5), lineWidth: 1)
|
||
}
|
||
.padding(HaloSpacing.snug)
|
||
}
|
||
}
|
||
.disabled(model.isChangingQuality)
|
||
.accessibilityLabel(Text("player.quality"))
|
||
}
|
||
|
||
private func transport(for model: PlayerModel) -> some View {
|
||
HStack(spacing: HaloSpacing.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: HaloSpacing.tight) {
|
||
PlaybackScrubber(
|
||
position: model.currentTime,
|
||
duration: model.duration ?? .seconds(1),
|
||
buffered: model.bufferedUntil,
|
||
scrubbing: $scrubbing,
|
||
// Erst beim Loslassen springen: bei jedem Zwischenwert zu
|
||
// springen lässt das Bild flackern und hält bei großen
|
||
// Dateien den Puffer dauerhaft leer.
|
||
onCommit: { model.seek(to: $0) },
|
||
onActivity: { scheduleControlsHide() }
|
||
)
|
||
|
||
HStack {
|
||
Text(displayTime(for: model).timecode)
|
||
Spacer()
|
||
if let duration = model.duration {
|
||
Text("−" + (duration - displayTime(for: model)).timecode)
|
||
}
|
||
}
|
||
.font(.system(.caption, design: .monospaced))
|
||
.foregroundStyle(.white.opacity(0.8))
|
||
}
|
||
}
|
||
|
||
private func displayTime(for model: PlayerModel) -> Duration {
|
||
scrubbing.map(Duration.seconds) ?? model.currentTime
|
||
}
|
||
|
||
/// Überspringen-Knopf und „Nächste Folge".
|
||
@ViewBuilder
|
||
private func overlayActions(for model: PlayerModel) -> some View {
|
||
VStack {
|
||
Spacer()
|
||
HStack {
|
||
Spacer()
|
||
if model.hasReachedEnd, model.nextEpisodeRequest() != nil {
|
||
Button {
|
||
if let next = model.nextEpisodeRequest() {
|
||
playback.play(next)
|
||
}
|
||
} label: {
|
||
Label("player.nextEpisode", systemImage: "forward.end.fill")
|
||
.font(.haloCardTitle)
|
||
.padding(.horizontal, HaloSpacing.loose)
|
||
.padding(.vertical, HaloSpacing.regular)
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.tint(accent)
|
||
} else if let marker = model.activeMarker {
|
||
Button {
|
||
model.skipActiveMarker()
|
||
} label: {
|
||
Text(marker.isIntro ? "player.skipIntro" : "player.skipCredits")
|
||
.font(.haloCardTitle)
|
||
.padding(.horizontal, HaloSpacing.loose)
|
||
.padding(.vertical, HaloSpacing.regular)
|
||
}
|
||
.buttonStyle(.bordered)
|
||
.tint(.white)
|
||
.transition(.move(edge: .trailing).combined(with: .opacity))
|
||
}
|
||
}
|
||
.padding(.horizontal, HaloSpacing.section)
|
||
.padding(.leading, safeArea.leading)
|
||
.padding(.trailing, safeArea.trailing)
|
||
// Über dem Schieberegler, damit sich beides nicht überdeckt, wenn
|
||
// die Bedienelemente eingeblendet sind.
|
||
.padding(.bottom, (showsControls ? 110 : HaloSpacing.section) + safeArea.bottom)
|
||
}
|
||
.animation(.snappy, value: model.activeMarker?.id)
|
||
.animation(.snappy, value: model.hasReachedEnd)
|
||
}
|
||
|
||
/// Die Abdeckung während des Umstellens.
|
||
private func restoring(to target: Duration) -> some View {
|
||
ZStack {
|
||
Color.black.ignoresSafeArea()
|
||
|
||
VStack(spacing: HaloSpacing.regular) {
|
||
ProgressView().tint(.white)
|
||
Text("player.restoring")
|
||
.font(.haloCardTitle)
|
||
.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: HaloSpacing.loose) {
|
||
ProgressView().tint(.white)
|
||
Text(model.title)
|
||
.font(.haloCardTitle)
|
||
.foregroundStyle(.white)
|
||
.multilineTextAlignment(.center)
|
||
Text("player.preparing")
|
||
.font(.haloCaption)
|
||
.foregroundStyle(.white.opacity(0.6))
|
||
}
|
||
.padding(HaloSpacing.section)
|
||
}
|
||
|
||
private func failure(message: String) -> some View {
|
||
VStack(spacing: HaloSpacing.loose) {
|
||
Image(systemName: "exclamationmark.triangle")
|
||
.font(.largeTitle)
|
||
.foregroundStyle(.white.opacity(0.8))
|
||
Text(message)
|
||
.font(.haloBody)
|
||
.foregroundStyle(.white)
|
||
.multilineTextAlignment(.center)
|
||
Button("action.close") { playback.close() }
|
||
.buttonStyle(.borderedProminent)
|
||
.tint(accent)
|
||
}
|
||
.padding(HaloSpacing.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(HaloSpacing.section)
|
||
.background(.black.opacity(0.35), in: .circle)
|
||
.transition(.opacity.combined(with: .scale))
|
||
}
|
||
}
|
||
// Das doppelte zuerst: sonst gewinnt das einfache und man käme nie
|
||
// zum Springen.
|
||
.onTapGesture(count: 2) { seek(by: seconds) }
|
||
.onTapGesture { primaryTap() }
|
||
}
|
||
|
||
private func seek(by seconds: Double) {
|
||
guard let current = model.duration != nil || model.isLive ? model.currentTime : nil
|
||
else { return }
|
||
model.seek(to: current + .seconds(seconds))
|
||
|
||
withAnimation(.easeOut(duration: 0.15)) { seekHint = seconds }
|
||
hintTask?.cancel()
|
||
hintTask = Task {
|
||
try? await Task.sleep(for: .milliseconds(600))
|
||
guard !Task.isCancelled else { return }
|
||
withAnimation(.easeOut(duration: 0.2)) { seekHint = nil }
|
||
}
|
||
}
|
||
|
||
/// Die Bildfläche, auf das Seitenverhältnis zugeschnitten.
|
||
///
|
||
/// Solange nichts ankommt, ist keines bekannt — dann füllt sie alles. Das
|
||
/// ist richtig so: ohne Bild gibt es auch keinen schwarzen Rand daneben,
|
||
/// den man von ihm unterscheiden müsste.
|
||
@ViewBuilder
|
||
private func surface(for model: PlayerModel) -> some View {
|
||
let fläche = VideoSurface(
|
||
engine: model.engine,
|
||
surface: model.surface,
|
||
isActive: playback.presentation == .full,
|
||
// **Nur im Füllmodus, und nur wenn ein Untertitel läuft.**
|
||
//
|
||
// Füllend reicht das Bild über die untere Bildschirmkante hinaus,
|
||
// und genau dort zeichnet AVFoundation die Untertitel; die Ebene
|
||
// unten einzurücken rettet die Schrift. Läuft aber keiner, rettet
|
||
// es nichts — dann bleibt bloss ein schwarzer Streifen unter
|
||
// einem Bild, das gerade füllen soll. Das ist das Gegenteil
|
||
// dessen, wofür der Knopf da ist.
|
||
bottomInset: model.videoFit == .fill && model.selectedSubtitleTrack != nil
|
||
? safeArea.bottom
|
||
: 0
|
||
)
|
||
|
||
// **Im Füllmodus kein Seitenverhältnis.** Die Fläche soll den Schirm
|
||
// decken — das Einpassen ist genau das, was hier abgeschaltet wird.
|
||
// Dass der schwarze Rand dann keine Klicks mehr abfängt, ist kein
|
||
// Verlust: es gibt keinen.
|
||
if model.videoFit == .fill {
|
||
fläche
|
||
} else if let breite = model.statistics?.width,
|
||
let höhe = model.statistics?.height,
|
||
breite > 0, höhe > 0 {
|
||
// **Eingepasst heisst: der ganze Schirm, nur das Verhältnis
|
||
// bleibt.** Kein Sicherheitsrand — hier stand einer, und er war
|
||
// falsch.
|
||
//
|
||
// Nachgerechnet, Querformat auf einem Pro Max: 956 × 440 Punkt.
|
||
// Ein Bild in 16∶9 wird bei voller Höhe 782 Punkt breit, es
|
||
// bleiben also 87 Punkte Balken auf jeder Seite — und die Dynamic
|
||
// Island braucht 62. Das Bild kommt ihr von selbst nicht nahe.
|
||
// Das Polster schützte vor nichts und kostete vierzig Punkte
|
||
// Höhe, also jedes zehnte Bildpunktband.
|
||
//
|
||
// Erst was breiter ist als der Schirm (2,17∶1) läuft an die
|
||
// Kanten — bei 21∶9 über die ganze Breite, und dann liegt ein
|
||
// Streifen hinter der Island. Dafür gibt es den Zoomknopf; ein
|
||
// Film, der nie an den Rand darf, wäre der schlechtere Tausch.
|
||
fläche
|
||
.aspectRatio(CGFloat(breite) / CGFloat(höhe), contentMode: .fit)
|
||
} else {
|
||
fläche
|
||
}
|
||
}
|
||
|
||
/// Die Zweifingergeste, die zwischen einpassen und füllen umlegt.
|
||
private func zoomGesture(for model: PlayerModel) -> some Gesture {
|
||
MagnifyGesture(minimumScaleDelta: 0.1)
|
||
.onEnded { wert in
|
||
if wert.magnification > 1.1 {
|
||
setFit(.fill, on: model)
|
||
} else if wert.magnification < 0.9 {
|
||
setFit(.fit, on: model)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Legt die Skalierung um und zeigt dabei die Bedienelemente.
|
||
///
|
||
/// Das Einblenden gehört dazu: der Knopf wechselt sein Symbol, und wer
|
||
/// nach der Geste nicht sieht, welcher Zustand nun gilt, probiert sie
|
||
/// ein zweites Mal.
|
||
private func setFit(_ neu: PlayerModel.VideoFit, on model: PlayerModel) {
|
||
guard model.videoFit != neu else { return }
|
||
model.videoFit = neu
|
||
// Ausdrücklich: die Ansicht baut ihr Seitenverhältnis von selbst ab,
|
||
// aber das Bild in der Engine liegt weiter, wie es lag.
|
||
model.applyVideoFit()
|
||
revealControls()
|
||
}
|
||
|
||
private func isPlayingPhase(_ model: PlayerModel) -> Bool {
|
||
if case .playing = model.phase { return true }
|
||
return false
|
||
}
|
||
|
||
// MARK: - Ein- und Ausblenden
|
||
|
||
/// Was ein einfacher Klick oder Tipp ins Bild auslöst.
|
||
///
|
||
/// **Auf dem Mac: anhalten.** Dort liegt ein Zeiger auf dem Bild, und die
|
||
/// Bedienelemente kommen von selbst, sobald er sich bewegt — ein Klick
|
||
/// muss sie nicht erst holen. Erst aufdecken und dann den Pausenknopf
|
||
/// suchen sind zwei Handgriffe für eine Sache.
|
||
///
|
||
/// **Mit dem Finger nicht.** Dort gibt es keine Bewegung ohne Berührung:
|
||
/// ein Tipp, der sofort anhält, nähme den einzigen Weg zu Scrubber,
|
||
/// Tonspur und Qualität.
|
||
private func primaryTap() {
|
||
guard isMac else {
|
||
toggleControls()
|
||
return
|
||
}
|
||
|
||
// **Der Klick, der das Fenster nach vorn holt, zählt nicht.**
|
||
//
|
||
// Auf dem Mac pausiert ein Klick ins Bild. Wer aus einem anderen
|
||
// Fenster zurückkommt, klickt aber zuerst einmal, um dieses Fenster
|
||
// zu aktivieren — und genau dieser Klick kam ebenfalls hier an. Die
|
||
// Wiedergabe sprang dadurch von selbst wieder an, als hätte man auf
|
||
// Play gedrückt. Hat man ja auch, nur nicht absichtlich.
|
||
//
|
||
// Eine Viertelsekunde reicht: länger braucht niemand vom Aktivieren
|
||
// bis zum ersten gemeinten Klick, und kürzer erwischt den Fall nicht.
|
||
if let becameActive, ContinuousClock.now - becameActive < .milliseconds(250) {
|
||
self.becameActive = nil
|
||
return
|
||
}
|
||
|
||
model.togglePlayPause()
|
||
revealControls()
|
||
}
|
||
|
||
/// Führt eine Tastenaktion aus — aber nur dort, wo es eine Tastatur gibt.
|
||
private func onMac(_ action: () -> Void) -> KeyPress.Result {
|
||
guard isMac else { return .ignored }
|
||
action()
|
||
revealControls()
|
||
return .handled
|
||
}
|
||
|
||
private func toggleControls() {
|
||
if showsControls {
|
||
showsControls = false
|
||
} else {
|
||
revealControls()
|
||
}
|
||
}
|
||
|
||
/// Zeigt die Bedienelemente und setzt die Ausblendfrist neu.
|
||
private func revealControls() {
|
||
if !showsControls {
|
||
// Wie überblendet wird, steht am Player selbst — siehe dort.
|
||
showsControls = true
|
||
// Beim Aufdecken nachsehen, was inzwischen läuft — genau dann
|
||
// will man es wissen.
|
||
if model.isLive {
|
||
Task { await model.refreshLiveProgrammeIfNeeded() }
|
||
}
|
||
}
|
||
scheduleControlsHide()
|
||
}
|
||
|
||
/// Der Zeiger hat sich bewegt.
|
||
///
|
||
/// Gedrosselt, weil die Meldung bei jeder Bewegung kommt — bis zu
|
||
/// hundertmal je Sekunde. Die Ausblendfrist jedes Mal neu aufzusetzen
|
||
/// hieße, hundertmal je Sekunde eine Aufgabe abzubrechen und eine neue zu
|
||
/// starten. Fünfmal reicht vollkommen.
|
||
private func pointerMoved(to ort: CGPoint) {
|
||
// **Gleiche Stelle heisst: der Zeiger hat sich nicht bewegt.**
|
||
//
|
||
// Die Meldung kommt nicht nur bei Bewegung, sondern auch bei jedem
|
||
// Neuzeichnen — und seit die Bedienung dauerhaft montiert ist, zeichnet
|
||
// der Scrubber im Sekundentakt neu. Ohne diese Prüfung setzte die
|
||
// Ausblendfrist sich endlos selbst zurück: die Bedienung blieb für
|
||
// immer stehen, und die Überblendung, die nie stattfand, sah aus wie
|
||
// eine kaputte Animation. Sie war nie das Problem.
|
||
guard ort != lastPointerLocation else { return }
|
||
lastPointerLocation = ort
|
||
|
||
let now = Date.now
|
||
guard !showsControls || now.timeIntervalSince(lastPointerReveal) > 0.2 else { return }
|
||
lastPointerReveal = now
|
||
revealControls()
|
||
}
|
||
|
||
/// Blendet die Bedienelemente nach kurzer Ruhe aus — aber nur, solange
|
||
/// tatsächlich etwas läuft. Im pausierten Zustand wäre ein leerer
|
||
/// Bildschirm ohne jede Bedienmöglichkeit das Ergebnis.
|
||
private func scheduleControlsHide() {
|
||
hideTask?.cancel()
|
||
hideTask = Task {
|
||
try? await Task.sleep(for: .seconds(3.5))
|
||
guard !Task.isCancelled, model.isPlaying else { return }
|
||
showsControls = false
|
||
}
|
||
}
|
||
}
|
||
|
||
extension Duration {
|
||
/// Als „1:23:45" bzw. „23:45".
|
||
var timecode: String {
|
||
let total = max(0, Int(components.seconds))
|
||
let hours = total / 3600
|
||
let minutes = (total % 3600) / 60
|
||
let seconds = total % 60
|
||
if hours > 0 {
|
||
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
|
||
}
|
||
return String(format: "%d:%02d", minutes, seconds)
|
||
}
|
||
}
|