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(\.flixyAccent) private var accent @State private var model: PlayerModel? @State private var showsControls = true @State private var showsTrackPicker = 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: 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. .statusBarHidden(true) .persistentSystemOverlays(showsControls ? .automatic : .hidden) .landscapeWhileVisible() .contentShape(.rect) .onTapGesture { toggleControls() } .sheet(isPresented: $showsTrackPicker) { if let model { TrackPickerSheet(model: model) } } .task { let model = PlayerModel( request: request, server: session.server, downloads: downloads, 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 let decision = model.decision { PlaybackMethodBadge(decision: decision) } } .foregroundStyle(.white) } 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) } 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 } } } } /// Zeigt an, auf welchem Weg gerade abgespielt wird. struct PlaybackMethodBadge: View { let decision: PlaybackDecision var body: some View { Text(label) .font(.system(.caption2, design: .monospaced)) .foregroundStyle(.white.opacity(0.75)) .padding(.horizontal, FlixySpacing.snug) .padding(.vertical, FlixySpacing.tight) .background(.white.opacity(0.15), in: .capsule) } private var label: String { switch decision.method { case .directPlay(let engine): switch engine { case .avPlayer: "AVPlayer" case .vlc: "VLC" } case .transcode: "Transcode" } } } 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) } }