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(SubtitleAppearance.self) private var subtitleAppearance @Environment(NetworkConditions.self) private var network @Environment(DownloadCoordinator.self) private var downloads @Environment(PlaybackDiagnostics.self) private var diagnostics @Environment(\.flixyAccent) 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? @State private var scrubbing: Double? /// Welche Richtung gerade als Hinweis eingeblendet ist. @State private var seekHint: Double? @State private var hintTask: Task? /// 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 { FlixyPlatform.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(FlixySpacing.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, FlixyPlatform.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(\.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 } 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(FlixySpacing.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: 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() // **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(FlixySpacing.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(FlixySpacing.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(FlixySpacing.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.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 ) } // **Bild-in-Bild von Hand.** Automatisch geht es bisher nur beim // Verlassen der App — und auf dem Mac gar nicht. Der Knopf // erscheint nur, wenn es wirklich ginge: bei VLC gibt es keine // `AVPlayerLayer`, und ohne die kann das System kein kleines Fenster // aufziehen. if model.canUsePictureInPicture { Button { model.togglePictureInPicture() } label: { Image(systemName: model.isPictureInPictureActive ? "pip.exit" : "pip.enter") .font(.title3) .padding(FlixySpacing.snug) } .accessibilityLabel(Text("player.pictureInPicture")) } 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) { 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(.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) .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 : FlixySpacing.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: 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 } } } /// 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) } }