Anmelde-Adresse wurde doppelt kodiert URLComponents.fragment kodiert beim Setzen erneut, aus %5B wurde %255B. Plex antwortete mit „Diese Anfrage konnte nicht abgeschlossen werden". Die Adresse wird jetzt von Hand zusammengesetzt. Verbindungssuche hing bis zu anderthalb Minuten probeTimeout war deklariert, aber nirgends angewendet — es galt der URLSession-Standard von 60 Sekunden. Zusammen mit der Prüfung Rang für Rang kostete eine schweigende lokale Adresse die volle Zeit, bevor der direkte Weg überhaupt begann. Jetzt laufen alle Adressen gleichzeitig, mit Zeitgrenze, und die Rangordnung entscheidet unter den erreichbaren. Kein Zurück während des Ladens Die Kopfzeile mit dem Schließen-Knopf wurde nur im Zustand .playing gezeichnet. Bei langsamer Verbindung saß man ohne Ausweg vor einem Ladekreis. Sie liegt jetzt als eigene Ebene darüber und erscheint auch beim Vorbereiten — samt Titel, damit erkennbar bleibt, worauf gewartet wird. Sechs neue Tests, darunter drei, die die Zeitgrenze der Verbindungssuche an einer schweigenden Adresse nachstellen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
289 lines
9.7 KiB
Swift
289 lines
9.7 KiB
Swift
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<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)
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|