mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 10:25:02 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 netMagnitude = abs(clampedNet)
|
||||
let netIsForward = clampedNet >= 0
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
let result = await gestureActionHandler.handleTapAction(action, position: position)
|
||||
let accumulated = result.accumulatedSeconds ?? seekSeconds
|
||||
/// 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
|
||||
|
||||
await MainActor.run {
|
||||
currentTapFeedback = (action, position, result.accumulatedSeconds)
|
||||
pendingSeek = (isForward: forward, seconds: accumulated)
|
||||
// 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() {
|
||||
|
||||
Reference in New Issue
Block a user