import SwiftUI import FlixyDesign import FlixyDownloads import FlixyPlayback import PlexKit /// Der Vollbildplayer. struct PlayerView: View { /// Das Modell gehört der Wurzel, nicht dieser Ansicht. /// /// Vorher wurde es hier gebaut und beim Verschwinden abgeräumt — und /// genau daran starb die Wiedergabe, sobald man zurückging. Jetzt /// überdauert es die Ansicht, und das Beenden ist eine Entscheidung des /// Nutzers statt eine Nebenwirkung der Navigation. let model: PlayerModel @Environment(FlixySession.self) private var session @Environment(PlaybackCoordinator.self) private var playback @Environment(PlaybackSettings.self) private var settings @Environment(NetworkConditions.self) private var network @Environment(DownloadCoordinator.self) private var downloads @Environment(PlaybackDiagnostics.self) private var diagnostics @Environment(\.flixyAccent) private var accent /// Wie hoch die Fensterleiste auf dem Mac ist. /// /// Fest, weil es keine Auskunft darüber gibt, die hier ankäme: die /// Sicherheitsränder einer Szene kennen die Fensterleiste nicht, sie /// gehört dem System. Achtundzwanzig Punkte sind das Maß eines /// gewöhnlichen Fensters. private static let titleBarHeight: CGFloat = 28 @State private var showsControls = true @State private var trackPicker: TrackPickerSheet.Kind? @State private var showsQualityChoice = false @State private var hideTask: Task? @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 /// Ob Zeiger und Tastatur gelten. Einmal beim Start ermittelt. private var isMac: Bool { FlixyPlatform.isMac } /// Wann der Zeiger zuletzt die Bedienelemente geholt hat. @State private var lastPointerReveal: Date = .distantPast var body: some View { ZStack { Color.black.ignoresSafeArea() Group { // Bei „nur Ton" bleibt der Schirm dunkel — das ist der halbe // Zweck der Stufe. if model.showsVideo { VideoSurface(engine: model.engine, surface: model.surface) .ignoresSafeArea() } // **Unter** den Bedienelementen, nicht darüber. Als Überzug // über dem ganzen Player lag hier eine durchsichtige Fläche // vor jedem Knopf und hat sämtliche Tipper verschluckt — // Pause, Scrubber, Qualität, alles tot. Ein Überzug liegt in // SwiftUI zwangsläufig obenauf; hier im Stapel entscheidet // die Reihenfolge, und alles Spätere liegt darüber. seekZones // Deckt den Umstieg ab. Ohne sie sieht man den Filmanfang, // während der neue Stream anläuft — und denkt, die Stelle sei // weg. Die Abdeckung nennt sie ausdrücklich. 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. // // Nur, solange die Bedienelemente aus sind. Sind sie an, // steht der Kreisel **im** Abspielknopf — dort, wo sonst // das Symbol ist. Vorher lag er dahinter und das Dreieck // davor: es sah pausiert aus, war es aber nicht. if model.isBuffering && !showsControls { ProgressView() .tint(.white) .scaleEffect(1.4) .transition(.opacity) } if showsControls { controls(for: model).transition(.opacity) } // Überspringen und „Nächste Folge" liegen außerhalb der // Bedienelemente: sie sollen auch dann erscheinen, wenn // die ausgeblendet sind — sonst müsste man erst antippen, // um das Intro überspringen zu können. overlayActions(for: model) } // Die Kopfzeile liegt über allem und erscheint auch während // des Ladens. Ohne sie sitzt man bei langsamer Verbindung // fest: kein Zurück, kein Abbrechen, nur ein Ladekreis. if showsControls || !isPlayingPhase(model) { VStack { topBar(for: model) Spacer() } .padding(FlixySpacing.loose) // **Unter der Fensterleiste beginnen.** // // Der Player deckt das ganze Fenster ab, und auf dem Mac // zeichnet das System seine Ampelknöpfe darüber. Die // Kopfzeile lag dadurch nicht nur optisch unter ihnen: der // ganze Streifen gehört der Fensterleiste, und die // schluckt jeden Klick. Verkleinern, Schließen, Tonspur, // Untertitel und die Qualitätsstufe waren dort tot — und // ein zweiter Klick auf dieselbe Stelle legte das Fenster // ins Dock, weil das der Doppelklick auf eine // Fensterleiste tut. .padding(.top, FlixyPlatform.isMac ? Self.titleBarHeight : 0) } } } // Immer aus, nicht nur wenn die Bedienelemente ausgeblendet sind. // Die Uhrzeit über einem laufenden Film hat dort nichts zu suchen — // und im Querformat erst recht nicht. .animation(.easeInOut(duration: 0.2), value: model.isBuffering) #if os(iOS) // Die Statusleiste gehört nicht über einen Film. Auf dem Mac gibt es // keine. .statusBarHidden(true) #endif .persistentSystemOverlays(showsControls ? .automatic : .hidden) .landscapeWhileVisible(settings.locksLandscape) .contentShape(.rect) // Für alles außerhalb der Sprungflächen. .onTapGesture { primaryTap() } // **Tastatur und Zeiger — nur auf dem Mac.** // // Dort ist beides selbstverständlich: Leertaste hält an, die Pfeile // springen, und die Bedienelemente kommen von allein, sobald sich der // Zeiger regt. Auf dem iPhone gibt es keines von beidem. // // Zur Laufzeit entschieden, nicht beim Übersetzen: „für iPad // angepasst" ist der iOS-Bau und läuft trotzdem auf dem Mac. Ein // `#if` hätte das Ganze genau dort abgeschaltet. `focusable(_:)` // nimmt den Schalter entgegen, die Tastenhandler geben sonst // `.ignored` zurück — so bleibt es **eine** Ansicht, und der Player // wird nicht neu aufgebaut, nur weil die Frage vom Gerät abhängt. .focusable(isMac) .focusEffectDisabled() .focused($keyboardFocus) // Von Hand gesetzt, weil sonst niemand den Fokus hat: der Player // besteht aus Knöpfen, und solange keiner angeklickt wurde, ginge // jeder Tastendruck ins Leere. .onAppear { if isMac { keyboardFocus = true } } .onKeyPress(.space) { onMac { model.togglePlayPause() } } .onKeyPress(.leftArrow) { onMac { seek(by: -Double(settings.rewindSeconds)) } } .onKeyPress(.rightArrow) { onMac { seek(by: Double(settings.forwardSeconds)) } } .onContinuousHover { phase in guard isMac, case .active = phase else { return } pointerMoved() } .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: { ZStack { // Solange auf Daten gewartet wird, steht hier der Kreisel // — kein Dreieck. Ein Dreieck heißt „angehalten, tipp // mich", und beides stimmte nicht: es lief bereits, und // der Tipp bewirkte nichts. if model.isBuffering { ProgressView() .tint(.white) .scaleEffect(1.4) } else { Image(systemName: model.isPlaying ? "pause.fill" : "play.fill") .font(.system(size: 52)) } } // Feste Größe, damit die beiden Knöpfe daneben stehen // bleiben. Sie wanderten bisher bei jedem Wechsel ein Stück, // weil Dreieck und Doppelbalken verschieden breit sind. .frame(width: 64, height: 64) } .accessibilityLabel(Text(playPauseLabel(for: model))) .animation(.easeInOut(duration: 0.2), value: model.isBuffering) Button { model.skip(by: .seconds(settings.forwardSeconds)) scheduleControlsHide() } label: { Image(systemName: "goforward.\(settings.forwardSeconds)").font(.largeTitle) } .accessibilityLabel(Text("player.forward10")) } .foregroundStyle(.white) } /// Was die Sprachausgabe zu dem Knopf sagt. /// /// Beim Warten weder „Abspielen" noch „Pause": beides wäre eine Auskunft /// über einen Zustand, in dem der Player gerade nicht ist. private func playPauseLabel(for model: PlayerModel) -> LocalizedStringKey { if model.isBuffering { return "player.buffering" } return model.isPlaying ? "player.pause" : "player.play" } private func scrubber(for model: PlayerModel) -> some View { VStack(spacing: FlixySpacing.tight) { Slider( value: Binding( get: { scrubbing ?? model.currentTime.seconds }, set: { scrubbing = $0 } ), in: 0...max(model.duration?.seconds ?? 1, 1), onEditingChanged: { editing in // Erst beim Loslassen springen: bei jedem Zwischenwert zu // springen lässt das Bild flackern und hält bei großen // Dateien den Puffer dauerhaft leer. if !editing, let target = scrubbing { model.seek(to: .seconds(target)) scrubbing = nil } scheduleControlsHide() } ) .tint(accent) HStack { Text(displayTime(for: model).timecode) Spacer() if let duration = model.duration { Text("−" + (duration - displayTime(for: model)).timecode) } } .font(.system(.caption, design: .monospaced)) .foregroundStyle(.white.opacity(0.8)) } } private func displayTime(for model: PlayerModel) -> Duration { scrubbing.map(Duration.seconds) ?? model.currentTime } /// Überspringen-Knopf und „Nächste Folge". @ViewBuilder private func overlayActions(for model: PlayerModel) -> some View { VStack { Spacer() HStack { Spacer() if model.hasReachedEnd, model.nextEpisodeRequest() != nil { Button { if let next = model.nextEpisodeRequest() { playback.play(next) } } label: { Label("player.nextEpisode", systemImage: "forward.end.fill") .font(.flixyCardTitle) .padding(.horizontal, FlixySpacing.loose) .padding(.vertical, FlixySpacing.regular) } .buttonStyle(.borderedProminent) .tint(accent) } else if let marker = model.activeMarker { Button { model.skipActiveMarker() } label: { Text(marker.isIntro ? "player.skipIntro" : "player.skipCredits") .font(.flixyCardTitle) .padding(.horizontal, FlixySpacing.loose) .padding(.vertical, FlixySpacing.regular) } .buttonStyle(.bordered) .tint(.white) .transition(.move(edge: .trailing).combined(with: .opacity)) } } .padding(.horizontal, FlixySpacing.section) // Über dem Schieberegler, damit sich beides nicht überdeckt, wenn // die Bedienelemente eingeblendet sind. .padding(.bottom, showsControls ? 110 : FlixySpacing.section) } .animation(.snappy, value: model.activeMarker?.id) .animation(.snappy, value: model.hasReachedEnd) } /// Die Abdeckung während des Umstellens. private func restoring(to target: Duration) -> some View { ZStack { Color.black.ignoresSafeArea() VStack(spacing: FlixySpacing.regular) { ProgressView().tint(.white) Text("player.restoring") .font(.flixyCardTitle) .foregroundStyle(.white) // Die Stelle ausdrücklich nennen: der ganze Zweck ist, dass // niemand denkt, er müsse sie wiederfinden. Text(target.timecode) .font(.system(.title3, design: .monospaced)) .foregroundStyle(.white.opacity(0.7)) } } .transition(.opacity) } private func preparing(for model: PlayerModel) -> some View { VStack(spacing: FlixySpacing.loose) { ProgressView().tint(.white) Text(model.title) .font(.flixyCardTitle) .foregroundStyle(.white) .multilineTextAlignment(.center) Text("player.preparing") .font(.flixyCaption) .foregroundStyle(.white.opacity(0.6)) // Über die Vermittlung von Plex ist die Leitung auf wenige Mbit // gedrosselt. Ein großer Film läuft darüber nicht an, und ohne // diesen Hinweis sucht man den Fehler in der App statt in der // Verbindung. if session.server?.connection.relay == true { Label("player.relayWarning", systemImage: "exclamationmark.triangle") .font(.flixyCaption) .foregroundStyle(.white.opacity(0.8)) .multilineTextAlignment(.center) .padding(.top, FlixySpacing.snug) } } .padding(FlixySpacing.section) } private func failure(message: String) -> some View { VStack(spacing: FlixySpacing.loose) { Image(systemName: "exclamationmark.triangle") .font(.largeTitle) .foregroundStyle(.white.opacity(0.8)) Text(message) .font(.flixyBody) .foregroundStyle(.white) .multilineTextAlignment(.center) Button("action.close") { playback.close() } .buttonStyle(.borderedProminent) .tint(accent) } .padding(FlixySpacing.section) } /// Die beiden Flächen zum Springen. /// /// Bewusst nicht die ganzen Bildschirmhälften: in der Mitte sitzt der /// Pause-Knopf, oben die Kopfzeile, unten der Scrubber. Deckten die /// Flächen alles ab, träfe man beim Zielen auf einen Knopf zuerst die /// Fläche — und der Knopf bliebe wirkungslos. private var seekZones: some View { GeometryReader { geometry in let width = geometry.size.width * 0.3 let height = geometry.size.height * 0.45 HStack(spacing: 0) { seekZone( seconds: Double(-settings.rewindSeconds), symbol: "gobackward.\(settings.rewindSeconds)" ).frame(width: width) Spacer(minLength: 0) seekZone( seconds: Double(settings.forwardSeconds), symbol: "goforward.\(settings.forwardSeconds)" ).frame(width: width) } .frame(height: height) // Etwas unterhalb der Mitte: oben bleibt die Kopfzeile frei, // unten der Scrubber. .position(x: geometry.size.width / 2, y: geometry.size.height * 0.5) } } /// Eine Fläche zum Springen. /// /// Ohne sichtbare Begrenzung: die Fläche soll sich nicht aufdrängen. Der /// kurze Hinweis beim Doppeltippen sagt genug. private func seekZone(seconds: Double, symbol: String) -> some View { Color.clear .contentShape(.rect) .overlay { if seekHint == seconds { Label( seconds < 0 ? "player.back10" : "player.forward10", systemImage: symbol ) .labelStyle(.iconOnly) .font(.system(size: 44)) .foregroundStyle(.white) .padding(FlixySpacing.section) .background(.black.opacity(0.35), in: .circle) .transition(.opacity.combined(with: .scale)) } } // Das doppelte zuerst: sonst gewinnt das einfache und man käme nie // zum Springen. .onTapGesture(count: 2) { seek(by: seconds) } .onTapGesture { primaryTap() } } private func seek(by seconds: Double) { guard let current = model.duration != nil || model.isLive ? model.currentTime : nil else { return } model.seek(to: current + .seconds(seconds)) withAnimation(.easeOut(duration: 0.15)) { seekHint = seconds } hintTask?.cancel() hintTask = Task { try? await Task.sleep(for: .milliseconds(600)) guard !Task.isCancelled else { return } withAnimation(.easeOut(duration: 0.2)) { seekHint = nil } } } private func isPlayingPhase(_ model: PlayerModel) -> Bool { if case .playing = model.phase { return true } return false } // MARK: - Ein- und Ausblenden /// Was ein einfacher Klick oder Tipp ins Bild auslöst. /// /// **Auf dem Mac: anhalten.** Dort liegt ein Zeiger auf dem Bild, und die /// Bedienelemente kommen von selbst, sobald er sich bewegt — ein Klick /// muss sie nicht erst holen. Erst aufdecken und dann den Pausenknopf /// suchen sind zwei Handgriffe für eine Sache. /// /// **Mit dem Finger nicht.** Dort gibt es keine Bewegung ohne Berührung: /// ein Tipp, der sofort anhält, nähme den einzigen Weg zu Scrubber, /// Tonspur und Qualität. private func primaryTap() { guard isMac else { toggleControls() return } model.togglePlayPause() revealControls() } /// Führt eine Tastenaktion aus — aber nur dort, wo es eine Tastatur gibt. private func onMac(_ action: () -> Void) -> KeyPress.Result { guard isMac else { return .ignored } action() revealControls() return .handled } private func toggleControls() { if showsControls { withAnimation(.easeInOut(duration: 0.2)) { showsControls = false } } else { revealControls() } } /// Zeigt die Bedienelemente und setzt die Ausblendfrist neu. private func revealControls() { if !showsControls { withAnimation(.easeInOut(duration: 0.2)) { showsControls = true } // Beim Aufdecken nachsehen, was inzwischen läuft — genau dann // will man es wissen. if model.isLive { Task { await model.refreshLiveProgrammeIfNeeded() } } } scheduleControlsHide() } /// Der Zeiger hat sich bewegt. /// /// Gedrosselt, weil die Meldung bei jeder Bewegung kommt — bis zu /// hundertmal je Sekunde. Die Ausblendfrist jedes Mal neu aufzusetzen /// hieße, hundertmal je Sekunde eine Aufgabe abzubrechen und eine neue zu /// starten. Fünfmal reicht vollkommen. private func pointerMoved() { let now = Date.now guard !showsControls || now.timeIntervalSince(lastPointerReveal) > 0.2 else { return } lastPointerReveal = now revealControls() } /// Blendet die Bedienelemente nach kurzer Ruhe aus — aber nur, solange /// tatsächlich etwas läuft. Im pausierten Zustand wäre ein leerer /// Bildschirm ohne jede Bedienmöglichkeit das Ergebnis. private func scheduleControlsHide() { hideTask?.cancel() hideTask = Task { try? await Task.sleep(for: .seconds(3.5)) guard !Task.isCancelled, model.isPlaying else { return } withAnimation(.easeInOut(duration: 0.3)) { showsControls = false } } } } extension Duration { /// Als „1:23:45" bzw. „23:45". var timecode: String { let total = max(0, Int(components.seconds)) let hours = total / 3600 let minutes = (total % 3600) / 60 let seconds = total % 60 if hours > 0 { return String(format: "%d:%02d:%02d", hours, minutes, seconds) } return String(format: "%d:%02d", minutes, seconds) } }