Der Regler springt sofort, und man sieht, bis wohin geladen ist
Zwei Dinge, die beide dasselbe Missverständnis erzeugt haben. Der Regler blieb beim Spulen liegen, wo er war, und rückte erst nach, wenn das erste Bild von der neuen Stelle da war — über das Netz Sekunden später. Das liest sich als „der Sprung ist nicht angekommen", und man spult noch einmal. Der Sprung ist aber angekommen, in dem Moment, in dem man ihn auslöst; alles Weitere ist Warten auf Daten, und dafür gibt es den Kreisel. Die Stelle steht jetzt sofort. Nachzügler von der alten Stelle werden verworfen, sonst zöge die letzte Meldung den Regler wieder zurück. Und bis wohin geladen ist, stand nirgends. Dafür ist der `Slider` gewichen: er hat genau eine Spur, und die gehört dem Fortschritt. Etwas dahinter zu zeichnen und auf gleiche Innenabstände zu hoffen ginge so lange gut, bis Apple sie ändert. Der neue Regler zeichnet drei Schichten aus derselben Rechnung — Grund, Geladen, Gespielt — und hat einen Griff, den ein Finger trifft. Der Vorrat wird dabei abgelegt statt ausgerechnet. Eine berechnete Eigenschaft wird nur gelesen, wenn ohnehin neu gezeichnet wird, und das geschieht, weil die Abspielstelle weiterläuft. Im pausierten Zustand läuft sie nicht — und ausgerechnet da will man zusehen, wie der Vorrat wächst. Die Schleife, die ihn nachführt, lief bisher ausserdem nur bei „automatisch": wer eine Stufe von Hand gewählt hatte, hätte nie gesehen, wie weit geladen ist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
67c679006f
commit
f48e018fa3
@@ -3672,6 +3672,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"player.position": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Position"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Position"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"player.preparing": {
|
"player.preparing": {
|
||||||
"comment": "Hinweis, während der Player die Datei öffnet",
|
"comment": "Hinweis, während der Player die Datei öffnet",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import FlixyDesign
|
||||||
|
|
||||||
|
/// Der Regler unter dem Film — mit dem, was schon geladen ist.
|
||||||
|
///
|
||||||
|
/// **Warum nicht `Slider`.** Ein `Slider` hat genau eine Spur, und die gehört
|
||||||
|
/// dem Fortschritt. Was bereits geladen ist, lässt sich dort nicht
|
||||||
|
/// unterbringen — und das ist die Auskunft, um die es hier geht: wie weit
|
||||||
|
/// kann ich springen, ohne zu warten. Dahinter etwas zu zeichnen und auf
|
||||||
|
/// gleiche Ausrichtung zu hoffen ginge so lange gut, bis Apple die
|
||||||
|
/// Innenabstände ändert.
|
||||||
|
///
|
||||||
|
/// Drei Schichten übereinander, alle aus derselben Rechnung:
|
||||||
|
///
|
||||||
|
/// Grund ── die ganze Länge, gedämpft
|
||||||
|
/// Geladen ── bis wohin Daten da sind, heller
|
||||||
|
/// Gespielt ── bis zur Abspielstelle, in der Akzentfarbe
|
||||||
|
///
|
||||||
|
/// **Der Griff ist grösser als der Strich.** Vier Punkte hoch trifft niemand
|
||||||
|
/// mit dem Finger; der unsichtbare Bereich darüber ist deshalb so hoch wie
|
||||||
|
/// eine Zeile.
|
||||||
|
struct PlaybackScrubber: View {
|
||||||
|
/// Wo abgespielt wird.
|
||||||
|
let position: Duration
|
||||||
|
/// Wie lang das Ganze ist.
|
||||||
|
let duration: Duration
|
||||||
|
/// Bis wohin geladen ist — `nil`, wenn es niemand weiss.
|
||||||
|
let buffered: Duration?
|
||||||
|
/// Ob gerade gezogen wird; dann bleibt der Griff gross.
|
||||||
|
@Binding var scrubbing: Double?
|
||||||
|
|
||||||
|
/// Beim Loslassen, mit dem Ziel.
|
||||||
|
let onCommit: (Duration) -> Void
|
||||||
|
/// Bei jeder Berührung — damit die Bedienung stehen bleibt.
|
||||||
|
let onActivity: () -> Void
|
||||||
|
|
||||||
|
@Environment(\.flixyAccent) private var accent
|
||||||
|
|
||||||
|
private static let trackHeight: CGFloat = 5
|
||||||
|
private static let thumbSize: CGFloat = 13
|
||||||
|
/// So hoch, dass ein Finger ihn trifft.
|
||||||
|
private static let hitHeight: CGFloat = 34
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let breite = geometry.size.width
|
||||||
|
let gespielt = anteil(of: angezeigt) * breite
|
||||||
|
let geladen = buffered.map { anteil(of: $0) * breite } ?? 0
|
||||||
|
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
Capsule()
|
||||||
|
.fill(.white.opacity(0.22))
|
||||||
|
.frame(height: Self.trackHeight)
|
||||||
|
|
||||||
|
// Nur wenn er weiter reicht als das Gespielte. Sonst sähe man
|
||||||
|
// einen Balken, der hinter der Abspielstelle zurückbleibt —
|
||||||
|
// und das behauptete etwas Falsches.
|
||||||
|
if geladen > gespielt {
|
||||||
|
Capsule()
|
||||||
|
.fill(.white.opacity(0.45))
|
||||||
|
.frame(width: geladen, height: Self.trackHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
Capsule()
|
||||||
|
.fill(accent)
|
||||||
|
.frame(width: gespielt, height: Self.trackHeight)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.fill(accent)
|
||||||
|
.frame(width: scrubbing == nil ? Self.thumbSize : Self.thumbSize * 1.5)
|
||||||
|
.offset(x: gespielt - Self.thumbSize / 2)
|
||||||
|
.animation(.easeOut(duration: 0.15), value: scrubbing == nil)
|
||||||
|
}
|
||||||
|
.frame(height: Self.hitHeight)
|
||||||
|
.contentShape(.rect)
|
||||||
|
.gesture(
|
||||||
|
DragGesture(minimumDistance: 0)
|
||||||
|
.onChanged { wert in
|
||||||
|
scrubbing = sekunden(at: wert.location.x, width: breite)
|
||||||
|
onActivity()
|
||||||
|
}
|
||||||
|
.onEnded { wert in
|
||||||
|
let ziel = sekunden(at: wert.location.x, width: breite)
|
||||||
|
scrubbing = nil
|
||||||
|
onCommit(.seconds(ziel))
|
||||||
|
onActivity()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.frame(height: Self.hitHeight)
|
||||||
|
.accessibilityElement()
|
||||||
|
.accessibilityLabel(Text("player.position"))
|
||||||
|
.accessibilityValue(Text(angezeigt.timecode))
|
||||||
|
.accessibilityAdjustableAction { richtung in
|
||||||
|
let schritt = Duration.seconds(15)
|
||||||
|
switch richtung {
|
||||||
|
case .increment: onCommit(min(angezeigt + schritt, duration))
|
||||||
|
case .decrement: onCommit(max(.zero, angezeigt - schritt))
|
||||||
|
@unknown default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was der Regler zeigt: beim Ziehen der Finger, sonst die Wiedergabe.
|
||||||
|
private var angezeigt: Duration {
|
||||||
|
scrubbing.map(Duration.seconds) ?? position
|
||||||
|
}
|
||||||
|
|
||||||
|
private func anteil(of zeit: Duration) -> CGFloat {
|
||||||
|
let gesamt = duration.seconds
|
||||||
|
guard gesamt > 0 else { return 0 }
|
||||||
|
return min(max(CGFloat(zeit.seconds / gesamt), 0), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sekunden(at x: CGFloat, width: CGFloat) -> Double {
|
||||||
|
guard width > 0 else { return 0 }
|
||||||
|
return min(max(Double(x / width), 0), 1) * duration.seconds
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,6 +98,17 @@ final class PlayerModel {
|
|||||||
/// 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 }
|
||||||
|
|
||||||
|
/// Bis wohin schon geladen ist.
|
||||||
|
///
|
||||||
|
/// Für den helleren Balken hinter dem Regler. `nil`, solange nichts
|
||||||
|
/// bekannt ist — dann wird auch nichts behauptet.
|
||||||
|
///
|
||||||
|
/// **Abgelegt, nicht ausgerechnet.** Eine berechnete Eigenschaft wird nur
|
||||||
|
/// gelesen, wenn ohnehin neu gezeichnet wird, und das geschieht, weil die
|
||||||
|
/// Abspielstelle weiterläuft. Im **pausierten** Zustand läuft sie nicht —
|
||||||
|
/// und ausgerechnet da will man zusehen, wie der Vorrat wächst.
|
||||||
|
private(set) var bufferedUntil: Duration?
|
||||||
|
|
||||||
/// Die Höhe der Quelldatei — danach richtet sich, welche Stufen überhaupt
|
/// Die Höhe der Quelldatei — danach richtet sich, welche Stufen überhaupt
|
||||||
/// Sinn ergeben. Eine 720p-Datei auf 1080p „hochzurechnen" bringt kein
|
/// Sinn ergeben. Eine 720p-Datei auf 1080p „hochzurechnen" bringt kein
|
||||||
/// Bild dazu.
|
/// Bild dazu.
|
||||||
@@ -182,6 +193,11 @@ final class PlayerModel {
|
|||||||
private var currentPart: MediaFile?
|
private var currentPart: MediaFile?
|
||||||
/// In welcher Mediathek der Titel liegt — einmal nachgeschlagen.
|
/// In welcher Mediathek der Titel liegt — einmal nachgeschlagen.
|
||||||
private var libraryID: String?
|
private var libraryID: String?
|
||||||
|
/// Wohin zuletzt gesprungen wurde — bis der Player dort ankommt.
|
||||||
|
///
|
||||||
|
/// Ohne das gewönne die letzte Meldung von der alten Stelle gegen den
|
||||||
|
/// gerade ausgelösten Sprung.
|
||||||
|
private var seekTarget: Duration?
|
||||||
private var eventTask: Task<Void, Never>?
|
private var eventTask: Task<Void, Never>?
|
||||||
private var reportTask: Task<Void, Never>?
|
private var reportTask: Task<Void, Never>?
|
||||||
/// Was der Server für diese Wiedergabe angelegt hat.
|
/// Was der Server für diese Wiedergabe angelegt hat.
|
||||||
@@ -709,17 +725,36 @@ final class PlayerModel {
|
|||||||
/// einzugreifen, alles andere nicht.
|
/// einzugreifen, alles andere nicht.
|
||||||
private func startHealthWatch() {
|
private func startHealthWatch() {
|
||||||
healthTask?.cancel()
|
healthTask?.cancel()
|
||||||
guard quality == .automatic, !isOffline else { return }
|
guard !isOffline else { return }
|
||||||
|
|
||||||
healthTask = Task { [weak self] in
|
healthTask = Task { [weak self] in
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
try? await Task.sleep(for: Self.healthInterval)
|
try? await Task.sleep(for: Self.healthInterval)
|
||||||
guard !Task.isCancelled, let self else { return }
|
guard !Task.isCancelled, let self else { return }
|
||||||
|
|
||||||
|
// **Läuft immer**, auch bei einer von Hand gewählten Stufe und
|
||||||
|
// im pausierten Zustand: der Vorrat wächst weiter, und der
|
||||||
|
// Balken soll das zeigen. Vorher hing die Schleife ganz an
|
||||||
|
// „automatisch" — wer eine Stufe gewählt hatte, sah nie, wie
|
||||||
|
// weit geladen ist.
|
||||||
|
self.refreshBuffered()
|
||||||
|
|
||||||
|
// Geregelt wird dagegen nur, wo es etwas zu regeln gibt.
|
||||||
|
guard self.quality == .automatic else { continue }
|
||||||
self.sampleStream()
|
self.sampleStream()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func refreshBuffered() {
|
||||||
|
guard let vorrat = engine?.statistics?.bufferedAhead else {
|
||||||
|
bufferedUntil = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let ende = currentTime + vorrat
|
||||||
|
bufferedUntil = duration.map { min(ende, $0) } ?? ende
|
||||||
|
}
|
||||||
|
|
||||||
/// Eine Probe nehmen und daraus handeln.
|
/// Eine Probe nehmen und daraus handeln.
|
||||||
private func sampleStream() {
|
private func sampleStream() {
|
||||||
guard let engine, let startedAt = playbackStartedAt else { return }
|
guard let engine, let startedAt = playbackStartedAt else { return }
|
||||||
@@ -1105,6 +1140,26 @@ final class PlayerModel {
|
|||||||
func seek(to time: Duration) {
|
func seek(to time: Duration) {
|
||||||
guard let engine else { return }
|
guard let engine else { return }
|
||||||
if let startedAt = playbackStartedAt { lastSeekAt = ContinuousClock.now - startedAt }
|
if let startedAt = playbackStartedAt { lastSeekAt = ContinuousClock.now - startedAt }
|
||||||
|
|
||||||
|
// **Sofort, nicht wenn es geladen ist.**
|
||||||
|
//
|
||||||
|
// Die Stelle stand bisher erst dann neu da, wenn der Player das erste
|
||||||
|
// Bild von dort hatte — bei einem Stream über das Netz Sekunden
|
||||||
|
// später. Sichtbar war: man spult, und der Regler bleibt liegen, wo er
|
||||||
|
// war. Das liest sich als „der Sprung ist nicht angekommen", und man
|
||||||
|
// spult noch einmal.
|
||||||
|
//
|
||||||
|
// Der Sprung **ist** angekommen, in dem Moment, in dem man ihn
|
||||||
|
// auslöst. Alles Weitere ist Warten auf Daten, und dafür gibt es den
|
||||||
|
// Kreisel.
|
||||||
|
currentTime = time
|
||||||
|
seekTarget = time
|
||||||
|
// Der Vorrat von vorhin gilt für die alte Stelle. Ihn stehen zu lassen
|
||||||
|
// behauptete, man könne sofort weiterspringen.
|
||||||
|
bufferedUntil = nil
|
||||||
|
NowPlaying.shared.update(from: self)
|
||||||
|
updateActiveMarker()
|
||||||
|
|
||||||
Task { await engine.seek(to: time) }
|
Task { await engine.seek(to: time) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1371,6 +1426,14 @@ final class PlayerModel {
|
|||||||
].joined(separator: "\n\n"))
|
].joined(separator: "\n\n"))
|
||||||
}
|
}
|
||||||
case .timeChanged(let time):
|
case .timeChanged(let time):
|
||||||
|
// **Nachzügler verwerfen.** Nach einem Sprung meldet der
|
||||||
|
// Player noch ein paar Mal die alte Stelle, bis er dort
|
||||||
|
// ankommt. Ungeprüft übernommen zöge das den Regler wieder
|
||||||
|
// zurück — einmal hin, einmal zurück, einmal hin.
|
||||||
|
if let ziel = seekTarget {
|
||||||
|
guard abs((time - ziel).seconds) < 1.5 else { break }
|
||||||
|
seekTarget = nil
|
||||||
|
}
|
||||||
currentTime = time
|
currentTime = time
|
||||||
// Gedrosselt in `NowPlaying`: zwischen zwei Meldungen
|
// Gedrosselt in `NowPlaying`: zwischen zwei Meldungen
|
||||||
// zählt das System selbst weiter, es kennt das Tempo.
|
// zählt das System selbst weiter, es kennt das Tempo.
|
||||||
|
|||||||
@@ -488,24 +488,17 @@ struct PlayerView: View {
|
|||||||
|
|
||||||
private func scrubber(for model: PlayerModel) -> some View {
|
private func scrubber(for model: PlayerModel) -> some View {
|
||||||
VStack(spacing: FlixySpacing.tight) {
|
VStack(spacing: FlixySpacing.tight) {
|
||||||
Slider(
|
PlaybackScrubber(
|
||||||
value: Binding(
|
position: model.currentTime,
|
||||||
get: { scrubbing ?? model.currentTime.seconds },
|
duration: model.duration ?? .seconds(1),
|
||||||
set: { scrubbing = $0 }
|
buffered: model.bufferedUntil,
|
||||||
),
|
scrubbing: $scrubbing,
|
||||||
in: 0...max(model.duration?.seconds ?? 1, 1),
|
// Erst beim Loslassen springen: bei jedem Zwischenwert zu
|
||||||
onEditingChanged: { editing in
|
// springen lässt das Bild flackern und hält bei großen
|
||||||
// Erst beim Loslassen springen: bei jedem Zwischenwert zu
|
// Dateien den Puffer dauerhaft leer.
|
||||||
// springen lässt das Bild flackern und hält bei großen
|
onCommit: { model.seek(to: $0) },
|
||||||
// Dateien den Puffer dauerhaft leer.
|
onActivity: { scheduleControlsHide() }
|
||||||
if !editing, let target = scrubbing {
|
|
||||||
model.seek(to: .seconds(target))
|
|
||||||
scrubbing = nil
|
|
||||||
}
|
|
||||||
scheduleControlsHide()
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
.tint(accent)
|
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Text(displayTime(for: model).timecode)
|
Text(displayTime(for: model).timecode)
|
||||||
|
|||||||
Reference in New Issue
Block a user