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>
120 lines
4.6 KiB
Swift
120 lines
4.6 KiB
Swift
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
|
|
}
|
|
}
|