Phase 2: Transcoding, adaptive Bitrate, Spuren, Marker, Sync
Der Decider brauchte keine Änderung — nur eine andere Richtlinie. Genau dafür war er gebaut. Transcoding - TranscodeRequest baut die Parameter für /video/:/transcode/universal. directStream=1 lässt Plex nur umpacken, wenn der Bildcodec passt, statt pauschal neu zu kodieren. - Vor dem Start wird der Server gefragt (decision); sagt er nein, steht der Grund im Klartext da statt eines schwarzen Bildes. - Beim Verlassen wird der Transcoder angehalten. Ohne dieses Signal rechnet der Server minutenlang für niemanden weiter. - Die adaptive Bitrate kommt mit HLS: AVPlayer wechselt sie selbst. Qualitätseinstellungen - Getrennt für WLAN und Mobilfunk, fünf spürbare Stufen. Voreinstellung: zuhause Original, unterwegs 4 Mbit. - NetworkConditions erkennt Mobilfunk über isExpensive — das deckt auch den Hotspot ab, der technisch WLAN ist und trotzdem Volumen kostet. Spuren und Marken - Ton- und Untertitelwahl im Player, bevorzugte Sprache in den Einstellungen, nachsichtiger Sprachvergleich (de/deu/ger/de-DE). - Intro- und Abspann-Marken mit Überspringen-Knopf, optional automatisch. Bereits übersprungene Marken werden gemerkt, sonst käme man nach einem Rücksprung nie ins Intro zurück. - Nächste Folge inkl. Staffelwechsel; Specials in Staffel 0 werden nicht vorgezogen. Fortschritt - Alle zehn Sekunden an den Server, plus Schlussmeldung beim Verlassen. Erst die nimmt den Titel aus „wird gerade abgespielt" heraus. - markWatched/markUnwatched/rate/refreshLibrary stehen bereit. 86 Tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,10 +9,13 @@ struct PlayerView: View {
|
||||
|
||||
@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(\.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?
|
||||
|
||||
@@ -33,15 +36,18 @@ struct PlayerView: View {
|
||||
|
||||
case .playing:
|
||||
if showsControls {
|
||||
controls(for: model)
|
||||
.transition(.opacity)
|
||||
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 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.
|
||||
// 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)
|
||||
@@ -51,21 +57,33 @@ struct PlayerView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.statusBarHidden(showsControls == false)
|
||||
// Die Wiedergabe soll den Bildschirm wachhalten — bei einem Film ohne
|
||||
// Berührung schaltet das Gerät sonst nach zwei Minuten ab.
|
||||
.statusBarHidden(!showsControls)
|
||||
.persistentSystemOverlays(showsControls ? .automatic : .hidden)
|
||||
.contentShape(.rect)
|
||||
.onTapGesture { toggleControls() }
|
||||
.sheet(isPresented: $showsTrackPicker) {
|
||||
if let model { TrackPickerSheet(model: model) }
|
||||
}
|
||||
.task {
|
||||
let model = PlayerModel(request: request, server: session.server)
|
||||
let model = PlayerModel(
|
||||
request: request,
|
||||
server: session.server,
|
||||
policy: settings.policy(for: network),
|
||||
settings: settings,
|
||||
isLocalConnection: session.server?.connection.local ?? false
|
||||
)
|
||||
self.model = model
|
||||
await model.start()
|
||||
scheduleControlsHide()
|
||||
}
|
||||
.onDisappear {
|
||||
hideTask?.cancel()
|
||||
model?.stop()
|
||||
// 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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,14 +101,14 @@ struct PlayerView: View {
|
||||
.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.
|
||||
// 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) {
|
||||
HStack(alignment: .top, spacing: FlixySpacing.regular) {
|
||||
Button {
|
||||
playback.dismiss()
|
||||
} label: {
|
||||
@@ -114,6 +132,18 @@ struct PlayerView: View {
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -173,10 +203,10 @@ struct PlayerView: View {
|
||||
.tint(accent)
|
||||
|
||||
HStack {
|
||||
Text((scrubbing.map(Duration.seconds) ?? model.currentTime).timecode)
|
||||
Text(displayTime(for: model).timecode)
|
||||
Spacer()
|
||||
if let duration = model.duration {
|
||||
Text("−" + (duration - (scrubbing.map(Duration.seconds) ?? model.currentTime)).timecode)
|
||||
Text("−" + (duration - displayTime(for: model)).timecode)
|
||||
}
|
||||
}
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
@@ -184,11 +214,53 @@ struct PlayerView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 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)
|
||||
@@ -245,9 +317,6 @@ struct PlayerView: View {
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user