Files
Flaro/Flixy/Views/Player/PlaybackScrubber.swift
T
ScarriffleandClaude Opus 5 f48e018fa3 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>
2026-09-28 19:28:44 +02:00

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
}
}