From 4c29ca9455170d83dfab7dfb7ec4009bf76e39c6 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Wed, 15 Apr 2026 02:11:13 +0200 Subject: [PATCH] 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. --- .../Player/tvOS/TVPlayerControlsView.swift | 9 +- .../Player/tvOS/TVPlayerProgressBar.swift | 18 ++- Yattee/Views/Player/tvOS/TVPlayerView.swift | 127 +++++++++++++----- 3 files changed, 119 insertions(+), 35 deletions(-) diff --git a/Yattee/Views/Player/tvOS/TVPlayerControlsView.swift b/Yattee/Views/Player/tvOS/TVPlayerControlsView.swift index 91c272dd..6c037284 100644 --- a/Yattee/Views/Player/tvOS/TVPlayerControlsView.swift +++ b/Yattee/Views/Player/tvOS/TVPlayerControlsView.swift @@ -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) diff --git a/Yattee/Views/Player/tvOS/TVPlayerProgressBar.swift b/Yattee/Views/Player/tvOS/TVPlayerProgressBar.swift index c31f4131..e9d9a582 100644 --- a/Yattee/Views/Player/tvOS/TVPlayerProgressBar.swift +++ b/Yattee/Views/Player/tvOS/TVPlayerProgressBar.swift @@ -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 diff --git a/Yattee/Views/Player/tvOS/TVPlayerView.swift b/Yattee/Views/Player/tvOS/TVPlayerView.swift index 2e973487..c9683f92 100644 --- a/Yattee/Views/Player/tvOS/TVPlayerView.swift +++ b/Yattee/Views/Player/tvOS/TVPlayerView.swift @@ -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? + // 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() {