Player: beim Umstellen sah man den Filmanfang statt seiner Stelle

Ein frisch angeforderter Stream beginnt vorn, puffert und springt erst
danach an die Stelle, an der man war. Zehn Sekunden lang lief sichtbar
der Filmanfang — und der Eindruck, die Stelle sei weg und man müsse sie
wiederfinden.

Der Umstieg wird jetzt abgedeckt, solange der Player noch nicht dort ist,
und die Abdeckung nennt die Stelle ausdrücklich. Genau darum geht es:
nicht, dass es schneller wird, sondern dass niemand denkt, er habe etwas
verloren.

Aufgehoben wird sie, sobald der Player innerhalb von zwei Sekunden um die
Stelle liegt. Dazu eine Notbremse nach dreißig Sekunden — springt er nie
dorthin, bliebe sonst für immer eine schwarze Fläche über dem Bild.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-12 22:21:51 +02:00
co-authored by Claude Opus 5
parent 171ba99908
commit 33dfac352a
3 changed files with 2838 additions and 2774 deletions
File diff suppressed because it is too large Load Diff
+21
View File
@@ -57,6 +57,13 @@ final class PlayerModel {
private(set) var quality: PlayerQuality = .automatic private(set) var quality: PlayerQuality = .automatic
/// Ob gerade auf eine andere Stufe umgestellt wird. /// Ob gerade auf eine andere Stufe umgestellt wird.
private(set) var isChangingQuality = false private(set) var isChangingQuality = false
/// Wohin nach dem Umstellen zurückgesprungen wird.
///
/// Solange das gesetzt ist, ist das Bild nicht die Stelle, an der man war:
/// ein frisch angeforderter Stream beginnt vorn, puffert und springt erst
/// danach. Sichtbar war in dieser Zeit der **Filmanfang** — und der
/// Eindruck, die Stelle verloren zu haben.
private(set) var resumeTarget: Duration?
/// Was gerade über die Leitung kommt. Beim Öffnen der Wahl abgefragt. /// Was gerade über die Leitung kommt. Beim Öffnen der Wahl abgefragt.
var statistics: PlaybackStatistics? { engine?.statistics } var statistics: PlaybackStatistics? { engine?.statistics }
@@ -210,6 +217,7 @@ final class PlayerModel {
defer { isChangingQuality = false } defer { isChangingQuality = false }
let resumeAt = currentTime let resumeAt = currentTime
resumeTarget = resumeAt
quality = newQuality quality = newQuality
// Den alten Transcoder anhalten, bevor der neue anläuft. Sonst rechnet // Den alten Transcoder anhalten, bevor der neue anläuft. Sonst rechnet
@@ -246,6 +254,13 @@ final class PlayerModel {
} }
await load(source, decision: newDecision) await load(source, decision: newDecision)
// Notbremse: springt der Player nie an die Stelle, bliebe sonst für
// immer eine Abdeckung über dem Bild.
Task { [weak self] in
try? await Task.sleep(for: .seconds(30))
self?.resumeTarget = nil
}
} }
/// Wiedergabe aus dem Download-Ordner. /// Wiedergabe aus dem Download-Ordner.
@@ -483,6 +498,7 @@ final class PlayerModel {
/// weggenommen. Was man hörte: der alte Ton lief weiter — auch nachdem /// weggenommen. Was man hörte: der alte Ton lief weiter — auch nachdem
/// die App im Hintergrund war —, und das Neue blieb stumm. /// die App im Hintergrund war —, und das Neue blieb stumm.
func silence() { func silence() {
resumeTarget = nil
eventTask?.cancel() eventTask?.cancel()
reportTask?.cancel() reportTask?.cancel()
@@ -666,6 +682,11 @@ final class PlayerModel {
} }
case .timeChanged(let time): case .timeChanged(let time):
currentTime = time currentTime = time
// Angekommen: ab hier zeigt das Bild wieder die Stelle,
// an der man war.
if let target = resumeTarget, time + .seconds(2) >= target {
resumeTarget = nil
}
updateActiveMarker() updateActiveMarker()
case .durationResolved(let value): case .durationResolved(let value):
duration = value duration = value
+27
View File
@@ -51,6 +51,13 @@ struct PlayerView: View {
// die Reihenfolge, und alles Spätere liegt darüber. // die Reihenfolge, und alles Spätere liegt darüber.
seekZones seekZones
// Deckt den Umstieg ab. Ohne sie sieht man den Filmanfang,
// während der neue Stream anläuft — und denkt, die Stelle sei
// weg. Die Abdeckung nennt sie ausdrücklich.
if let target = model.resumeTarget {
restoring(to: target)
}
switch model.phase { switch model.phase {
case .preparing: case .preparing:
preparing(for: model) preparing(for: model)
@@ -330,6 +337,26 @@ struct PlayerView: View {
.animation(.snappy, value: model.hasReachedEnd) .animation(.snappy, value: model.hasReachedEnd)
} }
/// Die Abdeckung während des Umstellens.
private func restoring(to target: Duration) -> some View {
ZStack {
Color.black.ignoresSafeArea()
VStack(spacing: FlixySpacing.regular) {
ProgressView().tint(.white)
Text("player.restoring")
.font(.flixyCardTitle)
.foregroundStyle(.white)
// Die Stelle ausdrücklich nennen: der ganze Zweck ist, dass
// niemand denkt, er müsse sie wiederfinden.
Text(target.timecode)
.font(.system(.title3, design: .monospaced))
.foregroundStyle(.white.opacity(0.7))
}
}
.transition(.opacity)
}
private func preparing(for model: PlayerModel) -> some View { private func preparing(for model: PlayerModel) -> some View {
VStack(spacing: FlixySpacing.loose) { VStack(spacing: FlixySpacing.loose) {
ProgressView().tint(.white) ProgressView().tint(.white)