Wer im Fernsehen etwas sieht und nebenher nachsehen will, was sonst läuft, musste bisher abbrechen. Plex' eigene Handy-Apps können das auch nicht — kein Grund, es hier genauso zu machen. Der eigentliche Umbau ist nicht die Kachel, sondern der Besitz. Die Wiedergabe hing an der Sichtbarkeit der Ansicht, und zwar dreifach: das Modell war `@State` im Player, `onDisappear` hat den Motor abgeräumt, und die Bildebene entstand in `makeUIView` neu. Zurückgehen hieß deshalb: Motor tot, Transcoder aus, beim Live-TV der Tuner freigegeben. Jetzt gehört das Modell der Wurzelansicht, die immer da ist. Verkleinern und Vergrößern wechseln nur noch die Darstellung; beendet wird die Wiedergabe an genau einer Stelle, und zwar weil jemand es will. Die Bildebene hält das Modell selbst — dasselbe Muster, das VLC seit jeher verwendet. Dieselbe `UIView` wandert zwischen Kachel und Vollbild, statt neu zu entstehen; sonst setzte das Bild bei jedem Wechsel neu an. Bild-in-Bild wandert mit und bekommt endlich einen Delegaten: bisher war es eine Einbahnstraße, aus der man nicht in die App zurückkam. Die Kachel schwebt und lässt sich in jede Ecke ziehen, statt als Leiste unten zu kleben — eine Leiste nähme jeder Liste Platz weg und müsste in jedem Bildschirm bedacht werden. Die Eckenberechnung steht in MiniPlayerPlacement und ist geprüft: in einer View wäre eine Kachel, die halb aus dem Bild ragt, erst auf dem Gerät aufgefallen. Der Pfeil oben verkleinert, ein neuer Knopf daneben beendet wirklich. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
426 lines
16 KiB
Swift
426 lines
16 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?
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
Color.black.ignoresSafeArea()
|
||
|
||
Group {
|
||
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)
|
||
.onTapGesture { toggleControls() }
|
||
.sheet(isPresented: $showsTrackPicker) {
|
||
TrackPickerSheet(model: model)
|
||
}
|
||
.confirmationDialog(
|
||
Text("player.quality"),
|
||
isPresented: $showsQualityChoice,
|
||
titleVisibility: .visible
|
||
) {
|
||
ForEach(PlayerQuality.allCases) { option in
|
||
Button(qualityChoiceLabel(option, current: model.quality)) {
|
||
Task { await model.select(quality: option) }
|
||
}
|
||
}
|
||
Button("action.cancel", role: .cancel) {}
|
||
}
|
||
.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)
|
||
}
|
||
|
||
/// Die gewählte Stufe bekommt einen Haken.
|
||
///
|
||
/// Ein `Picker` im Dialog wäre eleganter, aber `confirmationDialog` zeigt
|
||
/// nur Knöpfe — und die Auswahl muss trotzdem erkennbar sein.
|
||
private func qualityChoiceLabel(_ option: PlayerQuality, current: PlayerQuality) -> String {
|
||
let name = String(localized: option.localizedName)
|
||
return option == current ? "\(name) ✓" : name
|
||
}
|
||
|
||
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 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)
|
||
}
|
||
}
|
||
|
||
extension PlayerQuality {
|
||
var localizedName: String.LocalizationValue {
|
||
switch self {
|
||
case .automatic: "quality.automatic"
|
||
case .original: "quality.original"
|
||
case .fullHD: "quality.fullHD"
|
||
case .hd: "quality.hd"
|
||
case .low: "quality.lowRes"
|
||
}
|
||
}
|
||
}
|