Files
Flaro/Flixy/Views/Player/PlayerView.swift
T
GuidoandClaude Opus 5 f10f0c4086 Diagnose-Bildschirm und Build-Datum
Zweimal in Folge stand „es läuft nicht" gegen „bei mir ist es behoben",
ohne dass sich klären ließ, welcher Stand überhaupt auf dem Gerät liegt.
Das soll nicht noch einmal passieren.

Einstellungen → Diagnose zeigt jetzt:
- welcher Server und über welche Verbindung, mit Warnung bei der
  gedrosselten Plex-Vermittlung
- den letzten Abspielversuch: Titel, gewählter Weg, Quelladresse
- jeden Schritt mit Zeitabstand zum Start. Der Abstand ist die
  eigentliche Auskunft: liegt zwischen „Quelle gewählt" und „spielt"
  eine Minute, hängt es an der Leitung; kommt „spielt" gar nicht, am
  Motor.
- den Zeitpunkt, zu dem der laufende Build entstanden ist

Das Build-Datum kommt aus dem Änderungsdatum der eigenen Binärdatei —
kein Build-Skript, keine erzeugte Datei, und immer richtig.

Der Bericht lässt sich kopieren und enthält keinen Token: die
Quelladresse wird ohne Abfrageteil abgelegt, und dort steckt er.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 20:56:46 +02:00

379 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 hideTask: Task<Void, Never>?
@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,
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 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)
}
}