Seek with remote arrows when tvOS scrubber is focused

Pressing left/right on the focused progress bar now triggers the same
accumulating 10s seek as the hidden-controls flow, but updates the
visible scrubber in place with no overlay. Both tvOS arrow-seek paths
accumulate a signed net offset so a reverse press subtracts from the
pending amount instead of restarting from the current playback time.
This commit is contained in:
Arkadiusz Fal
2026-04-15 02:11:13 +02:00
parent 9260d48f4c
commit 4c29ca9455
3 changed files with 119 additions and 35 deletions

View File

@@ -22,6 +22,11 @@ struct TVPlayerControlsView: View {
let onClose: () -> Void
/// Called when scrubbing state changes - parent should stop auto-hide timer when true
var onScrubbingChanged: ((Bool) -> Void)?
/// Pending target time for the bar's accumulating remote-seek flow (arrow
/// presses while focused but not in SELECT scrub mode).
var remoteSeekTime: TimeInterval? = nil
/// Called when user presses left/right on the focused bar outside SELECT scrub.
var onRemoteSeek: ((Bool) -> Void)? = nil
/// Whether the Debug button should be visible (user-toggled in Developer settings).
private var showDebugButton: Bool {
@@ -57,7 +62,9 @@ struct TVPlayerControlsView: View {
},
onScrubbingChanged: onScrubbingChanged,
isLive: playerState?.isLive ?? false,
sponsorSegments: playerState?.sponsorSegments ?? []
sponsorSegments: playerState?.sponsorSegments ?? [],
remoteSeekTime: remoteSeekTime,
onRemoteSeek: onRemoteSeek
)
.focusSection()
.padding(.horizontal, 88)

View File

@@ -29,6 +29,13 @@ struct TVPlayerProgressBar: View {
var sponsorBlockSettings: SponsorBlockSegmentSettings = .default
/// Color for the played portion of the progress bar.
var playedColor: Color = .red
/// Pending target time from the parent's accumulating remote-seek flow
/// (arrow presses while the bar is focused but not in SELECT scrub mode).
/// When set, the handle and played portion reflect this value.
var remoteSeekTime: TimeInterval? = nil
/// Called when the bar is focused (not scrubbing) and user presses left/right.
/// Parameter: `forward` true for right, false for left.
var onRemoteSeek: ((Bool) -> Void)? = nil
/// Track focus state internally.
@FocusState private var isFocused: Bool
@@ -42,9 +49,10 @@ struct TVPlayerProgressBar: View {
/// Accumulated pan translation for scrubbing.
@State private var panAccumulator: CGFloat = 0
/// The time to display (scrub time if scrubbing, else current time).
/// The time to display. SELECT-based scrub takes priority, then the
/// parent's pending remote-seek target, then the actual playback time.
private var displayTime: TimeInterval {
scrubTime ?? currentTime
scrubTime ?? remoteSeekTime ?? currentTime
}
/// Progress as a fraction (0-1).
@@ -88,9 +96,13 @@ struct TVPlayerProgressBar: View {
}
.focused($isFocused)
.onMoveCommand { direction in
// D-pad fallback for scrubbing
if isScrubbing {
// D-pad fallback while in SELECT-based scrub mode.
handleDPad(direction: direction)
} else if !isLive, direction == .left || direction == .right {
// Focused but not scrubbing: delegate accumulating remote seek
// to parent. Up/down falls through to normal focus navigation.
onRemoteSeek?(direction == .right)
}
}
.onChange(of: isFocused) { _, focused in

View File

@@ -66,15 +66,25 @@ struct TVPlayerView: View {
/// Timer for the countdown.
@State private var autoplayTimer: Timer?
/// Handler for seek accumulation when using remote arrows with controls hidden.
@State private var gestureActionHandler = PlayerGestureActionHandler()
/// Current tap-seek feedback to display.
@State private var currentTapFeedback: (action: TapGestureAction, position: TapZonePosition, accumulated: Int?)?
/// Pending seek to execute when feedback completes.
@State private var pendingSeek: (isForward: Bool, seconds: Int)?
/// Pending target time for arrow-key seeks while the progress bar is
/// focused (controls visible). Mirrored onto the scrubber so the handle
/// moves without any extra overlay.
@State private var scrubberRemoteSeekTime: TimeInterval?
/// Most recent accumulated seek amount for the focused-bar flow; applied
/// on debounced commit.
@State private var scrubberRemoteSeek: (isForward: Bool, seconds: Int)?
/// Debounce task that commits the focused-bar arrow-key seek 1s after the
/// last press.
@State private var scrubberRemoteSeekTask: Task<Void, Never>?
// MARK: - Computed Properties
private var playerService: PlayerService? {
@@ -192,6 +202,10 @@ struct TVPlayerView: View {
} else {
startControlsTimer()
}
},
remoteSeekTime: scrubberRemoteSeekTime,
onRemoteSeek: { forward in
triggerScrubberRemoteSeek(forward: forward)
}
)
.transition(.opacity.animation(.easeInOut(duration: 0.25)))
@@ -251,6 +265,10 @@ struct TVPlayerView: View {
stopControlsTimer()
stopDebugUpdates()
stopAutoplayCountdown()
scrubberRemoteSeekTask?.cancel()
scrubberRemoteSeekTask = nil
scrubberRemoteSeek = nil
scrubberRemoteSeekTime = nil
}
// Remote event handling - these work globally
.onPlayPauseCommand {
@@ -495,39 +513,90 @@ struct TVPlayerView: View {
}
}
/// Triggers a seek action from the remote, accumulating across rapid presses.
/// Triggers a seek action from the remote, accumulating a signed net
/// offset across rapid presses. A reverse press within the active window
/// subtracts from the pending offset instead of restarting from the
/// current playback time (e.g. right, right, left from 30s +10s 40s,
/// not 10s 20s).
private func triggerRemoteSeek(forward: Bool) {
let seekSeconds = 10
let action: TapGestureAction = forward
? .seekForward(seconds: seekSeconds)
: .seekBackward(seconds: seekSeconds)
let position: TapZonePosition = forward ? .right : .left
let stepSeconds = 10
let currentTime = playerState?.currentTime ?? 0
let duration = playerState?.duration ?? 0
Task {
await gestureActionHandler.updatePlayerState(
currentTime: currentTime,
duration: duration
)
// Current signed offset from any in-flight accumulation.
let currentNet: Int = pendingSeek.map { $0.isForward ? $0.seconds : -$0.seconds } ?? 0
let step = forward ? stepSeconds : -stepSeconds
let rawNet = currentNet + step
// If switching seek direction, cancel any pending seek first.
if let pending = pendingSeek, pending.isForward != forward {
await MainActor.run {
pendingSeek = nil
currentTapFeedback = nil
}
await gestureActionHandler.cancelAccumulation()
}
// Clamp to the available seekable range in either direction.
let maxForward = Int(max(0, duration - currentTime))
let maxBackward = Int(max(0, currentTime))
let clampedNet = min(max(rawNet, -maxBackward), maxForward)
let result = await gestureActionHandler.handleTapAction(action, position: position)
let accumulated = result.accumulatedSeconds ?? seekSeconds
let netMagnitude = abs(clampedNet)
let netIsForward = clampedNet >= 0
await MainActor.run {
currentTapFeedback = (action, position, result.accumulatedSeconds)
pendingSeek = (isForward: forward, seconds: accumulated)
let action: TapGestureAction = netIsForward
? .seekForward(seconds: stepSeconds)
: .seekBackward(seconds: stepSeconds)
let position: TapZonePosition = netIsForward ? .right : .left
currentTapFeedback = (action, position, netMagnitude)
pendingSeek = (isForward: netIsForward, seconds: netMagnitude)
}
/// Accumulating arrow-key seek for when the progress bar is focused and
/// controls are visible. Suppresses the circular feedback overlay the
/// visible scrubber shows the pending target instead and uses the same
/// signed net-offset accumulation as the hidden-controls flow.
private func triggerScrubberRemoteSeek(forward: Bool) {
let stepSeconds = 10
let currentTime = playerState?.currentTime ?? 0
let duration = playerState?.duration ?? 0
// Keep controls on-screen while the user is arrow-seeking.
stopControlsTimer()
// Current signed offset from any in-flight accumulation.
let currentNet: Int = scrubberRemoteSeek.map { $0.isForward ? $0.seconds : -$0.seconds } ?? 0
let step = forward ? stepSeconds : -stepSeconds
let rawNet = currentNet + step
let maxForward = Int(max(0, duration - currentTime))
let maxBackward = Int(max(0, currentTime))
let clampedNet = min(max(rawNet, -maxBackward), maxForward)
let netMagnitude = abs(clampedNet)
let netIsForward = clampedNet >= 0
scrubberRemoteSeek = (isForward: netIsForward, seconds: netMagnitude)
scrubberRemoteSeekTime = currentTime + TimeInterval(clampedNet)
scrubberRemoteSeekTask?.cancel()
scrubberRemoteSeekTask = Task { @MainActor in
try? await Task.sleep(for: .milliseconds(1000))
guard !Task.isCancelled else { return }
commitScrubberRemoteSeek()
}
}
/// Commits the debounced accumulating arrow-key seek for the focused bar.
private func commitScrubberRemoteSeek() {
guard let seek = scrubberRemoteSeek else { return }
scrubberRemoteSeek = nil
scrubberRemoteSeekTime = nil
scrubberRemoteSeekTask = nil
if seek.seconds > 0 {
if seek.isForward {
playerService?.seekForward(by: TimeInterval(seek.seconds))
} else {
playerService?.seekBackward(by: TimeInterval(seek.seconds))
}
}
// Resume the auto-hide timer now that the user is done seeking.
startControlsTimer()
}
/// Commits the pending seek when the feedback overlay finishes its dismiss animation.
@@ -541,10 +610,6 @@ struct TVPlayerView: View {
} else {
playerService?.seekBackward(by: TimeInterval(seek.seconds))
}
Task {
await gestureActionHandler.cancelAccumulation()
}
}
private func handleMenuButton() {