import SwiftUI import HaloDesign /// 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(\.haloAccent) 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 } }