Scale tvOS scrub step by swipe rate

tvOS rasterizes Siri Remote touchpad swipes into discrete onMoveCommand
events at ~300-400ms, so a fast swipe and a single tap delivered the
same fixed step. Track gap between events: rapid same-direction events
(under 500ms) build a streak that multiplies the step via a power curve,
while deliberate taps still land on the base step.
This commit is contained in:
Arkadiusz Fal
2026-04-15 03:06:01 +02:00
parent 4c29ca9455
commit bfc646a73f

View File

@@ -49,6 +49,13 @@ struct TVPlayerProgressBar: View {
/// Accumulated pan translation for scrubbing. /// Accumulated pan translation for scrubbing.
@State private var panAccumulator: CGFloat = 0 @State private var panAccumulator: CGFloat = 0
/// Consecutive-event streak for rapid D-pad/touchpad scrubbing.
/// tvOS routes touchpad swipes as rapid `onMoveCommand` events, so we
/// amplify step size when events arrive in quick succession.
@State private var dpadStreakCount: Int = 0
@State private var lastDPadTime: Date?
@State private var lastDPadDirection: MoveCommandDirection?
/// The time to display. SELECT-based scrub takes priority, then the /// The time to display. SELECT-based scrub takes priority, then the
/// parent's pending remote-seek target, then the actual playback time. /// parent's pending remote-seek target, then the actual playback time.
private var displayTime: TimeInterval { private var displayTime: TimeInterval {
@@ -256,16 +263,16 @@ struct TVPlayerProgressBar: View {
// Lower values = slower/finer scrubbing // Lower values = slower/finer scrubbing
let baseSensitivity: CGFloat let baseSensitivity: CGFloat
if duration > 3600 { if duration > 3600 {
baseSensitivity = duration / 2000 // ~1.8 sec per unit for 1hr video baseSensitivity = duration / 1500
} else if duration > 600 { } else if duration > 600 {
baseSensitivity = duration / 3000 // ~0.2 sec per unit for 10min video baseSensitivity = duration / 2000
} else { } else {
baseSensitivity = duration / 4000 // very fine control for short videos baseSensitivity = duration / 3000
} }
// Apply velocity multiplier (faster swipe = faster scrub) // Non-linear velocity response: slow swipes stay precise, fast flicks accelerate.
// Reduced range: 0.3x to 2.0x let normalizedVelocity = abs(velocity) / 500
let velocityMultiplier = min(max(abs(velocity) / 800, 0.3), 2.0) let velocityMultiplier = min(max(pow(normalizedVelocity, 1.4), 0.3), 6.0)
let adjustedSensitivity = baseSensitivity * velocityMultiplier let adjustedSensitivity = baseSensitivity * velocityMultiplier
// Update scrub time based on translation delta // Update scrub time based on translation delta
@@ -306,15 +313,35 @@ struct TVPlayerProgressBar: View {
switch direction { switch direction {
case .left, .right: case .left, .right:
// Determine scrub increment based on video length // Track event rate. When events arrive quickly in the same
let scrubAmount: TimeInterval // direction (i.e. a touchpad swipe being rasterized into move
if duration > 3600 { // commands), grow the streak and scale the step size up.
scrubAmount = 30 let now = Date()
} else if duration > 600 { let gap = lastDPadTime.map { now.timeIntervalSince($0) } ?? .infinity
scrubAmount = 15
// tvOS throttles onMoveCommand at ~300-400ms even during a fast
// swipe, so we need a generous window to still recognize a burst.
if gap < 0.5, lastDPadDirection == direction {
dpadStreakCount = min(dpadStreakCount + 1, 30)
} else { } else {
scrubAmount = 10 dpadStreakCount = 1
} }
lastDPadTime = now
lastDPadDirection = direction
// Base step based on video length.
let baseStep: TimeInterval
if duration > 3600 {
baseStep = 15
} else if duration > 600 {
baseStep = 8
} else {
baseStep = 5
}
// Steeper curve so a swipe (few events) actually covers ground.
let streakMultiplier = pow(Double(dpadStreakCount), 1.6)
let scrubAmount = baseStep * streakMultiplier
let currentScrubTime = scrubTime ?? currentTime let currentScrubTime = scrubTime ?? currentTime
if direction == .left { if direction == .left {
@@ -322,10 +349,14 @@ struct TVPlayerProgressBar: View {
} else { } else {
scrubTime = min(duration, currentScrubTime + scrubAmount) scrubTime = min(duration, currentScrubTime + scrubAmount)
} }
scheduleSeek()
case .up, .down: case .up, .down:
// Exit scrub mode and let navigation happen // Exit scrub mode and let navigation happen
commitScrub() commitScrub()
dpadStreakCount = 0
lastDPadTime = nil
lastDPadDirection = nil
@unknown default: @unknown default:
break break
@@ -349,6 +380,9 @@ struct TVPlayerProgressBar: View {
isScrubbing = false isScrubbing = false
} }
panAccumulator = 0 panAccumulator = 0
dpadStreakCount = 0
lastDPadTime = nil
lastDPadDirection = nil
if wasScrubbing { if wasScrubbing {
onScrubbingChanged?(false) onScrubbingChanged?(false)