diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index c68da3c..082a0e9 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -3672,6 +3672,23 @@ } } }, + "player.position": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Position" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Position" + } + } + } + }, "player.preparing": { "comment": "Hinweis, während der Player die Datei öffnet", "localizations": { diff --git a/Flixy/Views/Player/PlaybackScrubber.swift b/Flixy/Views/Player/PlaybackScrubber.swift new file mode 100644 index 0000000..b5675d0 --- /dev/null +++ b/Flixy/Views/Player/PlaybackScrubber.swift @@ -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 + } +} diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index 168be9d..f802581 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -98,6 +98,17 @@ final class PlayerModel { /// Was gerade über die Leitung kommt. Beim Öffnen der Wahl abgefragt. 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 /// Sinn ergeben. Eine 720p-Datei auf 1080p „hochzurechnen" bringt kein /// Bild dazu. @@ -182,6 +193,11 @@ final class PlayerModel { private var currentPart: MediaFile? /// In welcher Mediathek der Titel liegt — einmal nachgeschlagen. 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? private var reportTask: Task? /// Was der Server für diese Wiedergabe angelegt hat. @@ -709,17 +725,36 @@ final class PlayerModel { /// einzugreifen, alles andere nicht. private func startHealthWatch() { healthTask?.cancel() - guard quality == .automatic, !isOffline else { return } + guard !isOffline else { return } healthTask = Task { [weak self] in while !Task.isCancelled { try? await Task.sleep(for: Self.healthInterval) 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() } } } + 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. private func sampleStream() { guard let engine, let startedAt = playbackStartedAt else { return } @@ -1105,6 +1140,26 @@ final class PlayerModel { func seek(to time: Duration) { guard let engine else { return } 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) } } @@ -1371,6 +1426,14 @@ final class PlayerModel { ].joined(separator: "\n\n")) } 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 // Gedrosselt in `NowPlaying`: zwischen zwei Meldungen // zählt das System selbst weiter, es kennt das Tempo. diff --git a/Flixy/Views/Player/PlayerView.swift b/Flixy/Views/Player/PlayerView.swift index 3855b2b..3fd4105 100644 --- a/Flixy/Views/Player/PlayerView.swift +++ b/Flixy/Views/Player/PlayerView.swift @@ -488,24 +488,17 @@ struct PlayerView: View { private func scrubber(for model: PlayerModel) -> some View { VStack(spacing: FlixySpacing.tight) { - Slider( - value: Binding( - get: { scrubbing ?? model.currentTime.seconds }, - set: { scrubbing = $0 } - ), - in: 0...max(model.duration?.seconds ?? 1, 1), - onEditingChanged: { editing in - // Erst beim Loslassen springen: bei jedem Zwischenwert zu - // springen lässt das Bild flackern und hält bei großen - // Dateien den Puffer dauerhaft leer. - if !editing, let target = scrubbing { - model.seek(to: .seconds(target)) - scrubbing = nil - } - scheduleControlsHide() - } + PlaybackScrubber( + position: model.currentTime, + duration: model.duration ?? .seconds(1), + buffered: model.bufferedUntil, + scrubbing: $scrubbing, + // Erst beim Loslassen springen: bei jedem Zwischenwert zu + // springen lässt das Bild flackern und hält bei großen + // Dateien den Puffer dauerhaft leer. + onCommit: { model.seek(to: $0) }, + onActivity: { scheduleControlsHide() } ) - .tint(accent) HStack { Text(displayTime(for: model).timecode)