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:
co-authored by
Claude Opus 5
parent
171ba99908
commit
33dfac352a
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"sourceLanguage": "en",
|
||||
"strings": {
|
||||
"%@ GB": {
|
||||
"comment": "A picker option in the settings view that lets the user select the maximum amount of storage space that can be used for downloaded content. The value is given in gigabytes.",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"%@ → %@": {
|
||||
"comment": "A line in a diagnostic trace that shows a network request path and its result. The first argument is the network request path. The second argument is the network response result.",
|
||||
"isCommentAutoGenerated": true,
|
||||
@@ -13,8 +17,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@ GB" : {
|
||||
"comment" : "A picker option in the settings view that lets the user select the maximum amount of storage space that can be used for downloaded content. The value is given in gigabytes.",
|
||||
"1080p": {
|
||||
"comment": "A label for a 1080p video.",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"4K": {
|
||||
@@ -25,8 +29,8 @@
|
||||
"comment": "A label for a 720p video.",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"1080p" : {
|
||||
"comment" : "A label for a 1080p video.",
|
||||
"SD": {
|
||||
"comment": "A label for a 720p movie.",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"action.apply": {
|
||||
@@ -2528,6 +2532,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.restoring": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Stelle wird wiederhergestellt"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Returning to your position"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.skipCredits": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
@@ -2595,6 +2615,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.subtitleTracks": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Untertitel"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Subtitles"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.subtitlesOff": {
|
||||
"comment": "Eintrag, der die Untertitel abschaltet",
|
||||
"extractionState": "stale",
|
||||
@@ -2613,23 +2650,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.subtitleTracks" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Untertitel"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Subtitles"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.tracks": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
@@ -2969,10 +2989,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SD" : {
|
||||
"comment" : "A label for a 720p movie.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"search.hint": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
|
||||
@@ -57,6 +57,13 @@ final class PlayerModel {
|
||||
private(set) var quality: PlayerQuality = .automatic
|
||||
/// Ob gerade auf eine andere Stufe umgestellt wird.
|
||||
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.
|
||||
var statistics: PlaybackStatistics? { engine?.statistics }
|
||||
@@ -210,6 +217,7 @@ final class PlayerModel {
|
||||
defer { isChangingQuality = false }
|
||||
|
||||
let resumeAt = currentTime
|
||||
resumeTarget = resumeAt
|
||||
quality = newQuality
|
||||
|
||||
// Den alten Transcoder anhalten, bevor der neue anläuft. Sonst rechnet
|
||||
@@ -246,6 +254,13 @@ final class PlayerModel {
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -483,6 +498,7 @@ final class PlayerModel {
|
||||
/// weggenommen. Was man hörte: der alte Ton lief weiter — auch nachdem
|
||||
/// die App im Hintergrund war —, und das Neue blieb stumm.
|
||||
func silence() {
|
||||
resumeTarget = nil
|
||||
eventTask?.cancel()
|
||||
reportTask?.cancel()
|
||||
|
||||
@@ -666,6 +682,11 @@ final class PlayerModel {
|
||||
}
|
||||
case .timeChanged(let 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()
|
||||
case .durationResolved(let value):
|
||||
duration = value
|
||||
|
||||
@@ -51,6 +51,13 @@ struct PlayerView: View {
|
||||
// die Reihenfolge, und alles Spätere liegt darüber.
|
||||
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 {
|
||||
case .preparing:
|
||||
preparing(for: model)
|
||||
@@ -330,6 +337,26 @@ struct PlayerView: View {
|
||||
.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 {
|
||||
VStack(spacing: FlixySpacing.loose) {
|
||||
ProgressView().tint(.white)
|
||||
|
||||
Reference in New Issue
Block a user