Die vier erfundenen Stufen weichen der Leiter, die der Server selbst meldet: 64, 96, 208, 320, 720, 1500, 2000, 3000, 4000, 8000, 10000, 12000, 20000 kbps mit den Auflösungen, die Plex dafür vorsieht. Eine eigene Leiter hieße, Stufen anzubieten, die der Transcoder nicht kennt — er rundet dann still auf die nächste, und die Anzeige log. Angeboten wird nur, was zur Quelle passt. Eine 720p-Datei auf 1080p hochzurechnen bringt kein Bild dazu, und die Stufen sähen alle gleich aus. Oben im Blatt steht, was tatsächlich über die Leitung kommt — gemessene Bitrate, Auflösung, verworfene Bilder. Der Unterschied zur gewählten Stufe ist keine Haarspalterei: die eine sagt, was erlaubt ist, die andere, was ankommt. Ruckelt es trotz hoher Stufe, steht die Antwort dort. „Nur Ton" gibt es, aber ehrlich beschriftet: Plex kennt für Videos keinen reinen Tonstrom, also wird die unterste Sprosse geholt und das Bild ausgeblendet. Dazu Doppeltipp links und rechts für zehn Sekunden zurück und vor. Die Reihenfolge der Gesten ist entscheidend — das einfache Tippen verschluckt sonst das doppelte. Die Kachel hängt nicht mehr in vier Ecken fest, sondern klebt links oder rechts und ist in der Höhe frei. Vier Ecken sind schnell gebaut, aber im Weg: steht sie über einem Knopf, bleiben drei Orte, die ebenso stören. Größer wird sie durch Zusammen- und Auseinanderziehen; der Griff ist weg, er saß zwangsläufig auf dem Bild und stritt sich mit dem Verschieben. Und die Begriffe: „Motor" für engine sagt kein deutscher Entwickler. Fachwörter, die im Deutschen englisch bleiben, bleiben jetzt englisch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
457 lines
18 KiB
Swift
457 lines
18 KiB
Swift
import SwiftUI
|
||
import FlixyDesign
|
||
import FlixyDownloads
|
||
import FlixyPlayback
|
||
import PlexKit
|
||
|
||
/// Der Vollbildplayer.
|
||
struct PlayerView: View {
|
||
/// Das Modell gehört der Wurzel, nicht dieser Ansicht.
|
||
///
|
||
/// Vorher wurde es hier gebaut und beim Verschwinden abgeräumt — und
|
||
/// genau daran starb die Wiedergabe, sobald man zurückging. Jetzt
|
||
/// überdauert es die Ansicht, und das Beenden ist eine Entscheidung des
|
||
/// Nutzers statt eine Nebenwirkung der Navigation.
|
||
let model: PlayerModel
|
||
|
||
@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 showsControls = true
|
||
@State private var showsTrackPicker = false
|
||
@State private var showsQualityChoice = false
|
||
@State private var hideTask: Task<Void, Never>?
|
||
@State private var scrubbing: Double?
|
||
/// Welche Richtung gerade als Hinweis eingeblendet ist.
|
||
@State private var seekHint: Double?
|
||
@State private var hintTask: Task<Void, Never>?
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
Color.black.ignoresSafeArea()
|
||
|
||
Group {
|
||
// Bei „nur Ton" bleibt der Schirm dunkel — das ist der halbe
|
||
// Zweck der Stufe.
|
||
if model.showsVideo {
|
||
VideoSurface(engine: model.engine, surface: model.surface)
|
||
.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)
|
||
// Zwei Hälften: links zurück, rechts vor. Ein einzelnes Tippen zeigt
|
||
// weiterhin die Bedienelemente — die Reihenfolge entscheidet, sonst
|
||
// verschluckt das einfache Tippen das doppelte.
|
||
.overlay {
|
||
HStack(spacing: 0) {
|
||
seekZone(seconds: -10, symbol: "gobackward.10")
|
||
seekZone(seconds: 10, symbol: "goforward.10")
|
||
}
|
||
}
|
||
.sheet(isPresented: $showsTrackPicker) {
|
||
TrackPickerSheet(model: model)
|
||
}
|
||
.sheet(isPresented: $showsQualityChoice) {
|
||
QualitySheet(model: model)
|
||
}
|
||
.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 { scheduleControlsHide() }
|
||
.onDisappear { hideTask?.cancel() }
|
||
}
|
||
|
||
// 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) {
|
||
// Der Pfeil verkleinert, er beendet nicht. Das ist der ganze
|
||
// Zweck der Übung: weiterschauen, während man stöbert.
|
||
Button {
|
||
playback.minimize()
|
||
} label: {
|
||
Image(systemName: "chevron.down")
|
||
.font(.title3)
|
||
.padding(FlixySpacing.snug)
|
||
}
|
||
.accessibilityLabel(Text("player.minimize"))
|
||
|
||
// Und daneben der Weg, wirklich Schluss zu machen — sonst gäbe es
|
||
// keinen mehr, seit der Pfeil nur noch verkleinert.
|
||
Button {
|
||
playback.close()
|
||
} label: {
|
||
Image(systemName: "xmark")
|
||
.font(.callout.weight(.semibold))
|
||
.padding(FlixySpacing.snug)
|
||
}
|
||
.accessibilityLabel(Text("player.stop"))
|
||
|
||
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.play(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.close() }
|
||
.buttonStyle(.borderedProminent)
|
||
.tint(accent)
|
||
}
|
||
.padding(FlixySpacing.section)
|
||
}
|
||
|
||
/// Eine Bildschirmhälfte zum Springen.
|
||
///
|
||
/// Bewusst ohne sichtbare Trennung: die Fläche soll sich nicht aufdrängen.
|
||
/// Der kurze Hinweis beim Doppeltippen sagt genug.
|
||
private func seekZone(seconds: Double, symbol: String) -> some View {
|
||
Color.clear
|
||
.contentShape(.rect)
|
||
.overlay {
|
||
if seekHint == seconds {
|
||
Label(
|
||
seconds < 0 ? "player.back10" : "player.forward10",
|
||
systemImage: symbol
|
||
)
|
||
.labelStyle(.iconOnly)
|
||
.font(.system(size: 44))
|
||
.foregroundStyle(.white)
|
||
.padding(FlixySpacing.section)
|
||
.background(.black.opacity(0.35), in: .circle)
|
||
.transition(.opacity.combined(with: .scale))
|
||
}
|
||
}
|
||
// Das doppelte zuerst: sonst gewinnt das einfache und man käme nie
|
||
// zum Springen.
|
||
.onTapGesture(count: 2) { seek(by: seconds) }
|
||
.onTapGesture { toggleControls() }
|
||
}
|
||
|
||
private func seek(by seconds: Double) {
|
||
guard let current = model.duration != nil || model.isLive ? model.currentTime : nil
|
||
else { return }
|
||
model.seek(to: current + .seconds(seconds))
|
||
|
||
withAnimation(.easeOut(duration: 0.15)) { seekHint = seconds }
|
||
hintTask?.cancel()
|
||
hintTask = Task {
|
||
try? await Task.sleep(for: .milliseconds(600))
|
||
guard !Task.isCancelled else { return }
|
||
withAnimation(.easeOut(duration: 0.2)) { seekHint = nil }
|
||
}
|
||
}
|
||
|
||
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() }
|
||
// Beim Aufdecken nachsehen, was inzwischen läuft — genau dann will
|
||
// man es wissen.
|
||
if showsControls, model.isLive {
|
||
Task { await model.refreshLiveProgrammeIfNeeded() }
|
||
}
|
||
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 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)
|
||
}
|
||
}
|