import SwiftUI import FlixyDesign import FlixyDownloads import FlixyPlayback import PlexKit /// Der Vollbildplayer. struct PlayerView: View { let request: PlaybackRequest @Environment(FlixySession.self) private var session @Environment(PlaybackCoordinator.self) private var playback @Environment(PlaybackSettings.self) private var settings @Environment(NetworkConditions.self) private var network @Environment(DownloadCoordinator.self) private var downloads @Environment(PlaybackDiagnostics.self) private var diagnostics @Environment(\.flixyAccent) private var accent @State private var model: PlayerModel? @State private var showsControls = true @State private var showsTrackPicker = false @State private var showsQualityChoice = false @State private var hideTask: Task? @State private var scrubbing: Double? var body: some View { ZStack { Color.black.ignoresSafeArea() if let model { VideoSurface(engine: model.engine) .ignoresSafeArea() switch model.phase { case .preparing: preparing(for: model) case .failed(let message): failure(message: message) case .playing: // Wartet auf Daten: ohne Anzeige sieht das aus wie ein // Standbild, und man weiß nicht, ob noch etwas kommt. if model.isBuffering { ProgressView() .tint(.white) .scaleEffect(1.4) .transition(.opacity) } if showsControls { controls(for: model).transition(.opacity) } // Überspringen und „Nächste Folge" liegen außerhalb der // Bedienelemente: sie sollen auch dann erscheinen, wenn // die ausgeblendet sind — sonst müsste man erst antippen, // um das Intro überspringen zu können. overlayActions(for: model) } // Die Kopfzeile liegt über allem und erscheint auch während // des Ladens. Ohne sie sitzt man bei langsamer Verbindung // fest: kein Zurück, kein Abbrechen, nur ein Ladekreis. if showsControls || !isPlayingPhase(model) { VStack { topBar(for: model) Spacer() } .padding(FlixySpacing.loose) } } } // Immer aus, nicht nur wenn die Bedienelemente ausgeblendet sind. // Die Uhrzeit über einem laufenden Film hat dort nichts zu suchen — // und im Querformat erst recht nicht. .animation(.easeInOut(duration: 0.2), value: model?.isBuffering) .statusBarHidden(true) .persistentSystemOverlays(showsControls ? .automatic : .hidden) .landscapeWhileVisible() .contentShape(.rect) .onTapGesture { toggleControls() } .sheet(isPresented: $showsTrackPicker) { if let model { TrackPickerSheet(model: model) } } .confirmationDialog( Text("player.quality"), isPresented: $showsQualityChoice, titleVisibility: .visible ) { if let model { ForEach(PlayerQuality.allCases) { option in Button(qualityChoiceLabel(option, current: model.quality)) { Task { await model.select(quality: option) } } } } Button("action.cancel", role: .cancel) {} } .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 { let model = PlayerModel( request: request, server: session.server, downloads: downloads, diagnostics: diagnostics, policy: settings.policy(for: network), settings: settings, isLocalConnection: session.server?.connection.local ?? false ) self.model = model await model.start() scheduleControlsHide() } .onDisappear { hideTask?.cancel() // Losgelöst von der Ansicht: das Anhalten des Transcoders und die // Schlussmeldung sollen auch dann durchlaufen, wenn die Ansicht // schon verschwunden ist. if let model { Task { await model.stop() } } } } // 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) { Button { playback.dismiss() } label: { Image(systemName: "chevron.down") .font(.title3) .padding(FlixySpacing.snug) } .accessibilityLabel(Text("player.close")) 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() if !model.audioTracks.isEmpty || !model.subtitleTracks.isEmpty { Button { showsTrackPicker = true hideTask?.cancel() } label: { Image(systemName: "captions.bubble") .font(.title3) .padding(FlixySpacing.snug) } .accessibilityLabel(Text("player.tracks")) } if !model.isOffline { qualityButton(for: model) } } .foregroundStyle(.white) } /// 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 { Image(systemName: "slider.horizontal.3") .font(.title3) .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(-10)) scheduleControlsHide() } label: { Image(systemName: "gobackward.10").font(.largeTitle) } .accessibilityLabel(Text("player.back10")) Button { model.togglePlayPause() scheduleControlsHide() } label: { Image(systemName: model.isPlaying ? "pause.fill" : "play.fill") .font(.system(size: 52)) } .accessibilityLabel(Text(model.isPlaying ? "player.pause" : "player.play")) Button { model.skip(by: .seconds(10)) scheduleControlsHide() } label: { Image(systemName: "goforward.10").font(.largeTitle) } .accessibilityLabel(Text("player.forward10")) } .foregroundStyle(.white) } private func scrubber(for model: PlayerModel) -> some View { VStack(spacing: FlixySpacing.tight) { Slider( value: Binding( get: { scrubbing ?? model.currentTime.seconds }, set: { scrubbing = $0 } ), in: 0...max(model.duration?.seconds ?? 1, 1), onEditingChanged: { editing in // Erst beim Loslassen springen: bei jedem Zwischenwert zu // springen lässt das Bild flackern und hält bei großen // Dateien den Puffer dauerhaft leer. if !editing, let target = scrubbing { model.seek(to: .seconds(target)) scrubbing = nil } scheduleControlsHide() } ) .tint(accent) HStack { Text(displayTime(for: model).timecode) Spacer() if let duration = model.duration { Text("−" + (duration - displayTime(for: model)).timecode) } } .font(.system(.caption, design: .monospaced)) .foregroundStyle(.white.opacity(0.8)) } } private func displayTime(for model: PlayerModel) -> Duration { scrubbing.map(Duration.seconds) ?? model.currentTime } /// Überspringen-Knopf und „Nächste Folge". @ViewBuilder private func overlayActions(for model: PlayerModel) -> some View { VStack { Spacer() HStack { Spacer() if model.hasReachedEnd, model.nextEpisodeRequest() != nil { Button { if let next = model.nextEpisodeRequest() { playback.request = 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) } 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.dismiss() } .buttonStyle(.borderedProminent) .tint(accent) } .padding(FlixySpacing.section) } /// Die gewählte Stufe bekommt einen Haken. /// /// Ein `Picker` im Dialog wäre eleganter, aber `confirmationDialog` zeigt /// nur Knöpfe — und die Auswahl muss trotzdem erkennbar sein. private func qualityChoiceLabel(_ option: PlayerQuality, current: PlayerQuality) -> String { let name = String(localized: option.localizedName) return option == current ? "\(name) ✓" : name } private func isPlayingPhase(_ model: PlayerModel) -> Bool { if case .playing = model.phase { return true } return false } // MARK: - Ein- und Ausblenden private func toggleControls() { withAnimation(.easeInOut(duration: 0.2)) { showsControls.toggle() } if showsControls { scheduleControlsHide() } } /// Blendet die Bedienelemente nach kurzer Ruhe aus — aber nur, solange /// tatsächlich etwas läuft. Im pausierten Zustand wäre ein leerer /// Bildschirm ohne jede Bedienmöglichkeit das Ergebnis. private func scheduleControlsHide() { hideTask?.cancel() hideTask = Task { try? await Task.sleep(for: .seconds(3.5)) guard !Task.isCancelled, model?.isPlaying == true 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) } } extension PlayerQuality { var localizedName: String.LocalizationValue { switch self { case .automatic: "quality.automatic" case .original: "quality.original" case .fullHD: "quality.fullHD" case .hd: "quality.hd" case .low: "quality.lowRes" } } }