import SwiftUI import FlixyDesign 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(\.flixyAccent) private var accent @State private var model: PlayerModel? @State private var showsControls = true @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) } } // Die Kopfzeile mit dem Schließen-Knopf liegt über allem und // erscheint auch, solange noch geladen wird. Ohne sie sitzt // man bei einer langsamen Verbindung fest: kein Zurück, kein // Abbrechen, nur ein Ladekreis. if showsControls || !isPlayingPhase(model) { VStack { topBar(for: model) Spacer() } .padding(FlixySpacing.loose) } } } .statusBarHidden(showsControls == false) // Die Wiedergabe soll den Bildschirm wachhalten — bei einem Film ohne // Berührung schaltet das Gerät sonst nach zwei Minuten ab. .persistentSystemOverlays(showsControls ? .automatic : .hidden) .contentShape(.rect) .onTapGesture { toggleControls() } .task { let model = PlayerModel(request: request, server: session.server) self.model = model await model.start() scheduleControlsHide() } .onDisappear { hideTask?.cancel() 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) { 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 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((scrubbing.map(Duration.seconds) ?? model.currentTime).timecode) Spacer() if let duration = model.duration { Text("−" + (duration - (scrubbing.map(Duration.seconds) ?? model.currentTime)).timecode) } } .font(.system(.caption, design: .monospaced)) .foregroundStyle(.white.opacity(0.8)) } } /// Während geladen wird. /// /// Zeigt den Titel mit an, damit erkennbar bleibt, worauf gewartet wird — /// und weil ein nackter Ladekreis nach zehn Sekunden wie ein Absturz /// aussieht. 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)) } .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. /// /// In Phase 1 vor allem eine Hilfe beim Prüfen: an ihr lässt sich ablesen, ob /// eine Datei wirklich direkt läuft oder ob die Entscheidung danebenlag. 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) } }