mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 18:35:05 +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
|
let onClose: () -> Void
|
||||||
/// Called when scrubbing state changes - parent should stop auto-hide timer when true
|
/// Called when scrubbing state changes - parent should stop auto-hide timer when true
|
||||||
var onScrubbingChanged: ((Bool) -> Void)?
|
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).
|
/// Whether the Debug button should be visible (user-toggled in Developer settings).
|
||||||
private var showDebugButton: Bool {
|
private var showDebugButton: Bool {
|
||||||
@@ -57,7 +62,9 @@ struct TVPlayerControlsView: View {
|
|||||||
},
|
},
|
||||||
onScrubbingChanged: onScrubbingChanged,
|
onScrubbingChanged: onScrubbingChanged,
|
||||||
isLive: playerState?.isLive ?? false,
|
isLive: playerState?.isLive ?? false,
|
||||||
sponsorSegments: playerState?.sponsorSegments ?? []
|
sponsorSegments: playerState?.sponsorSegments ?? [],
|
||||||
|
remoteSeekTime: remoteSeekTime,
|
||||||
|
onRemoteSeek: onRemoteSeek
|
||||||
)
|
)
|
||||||
.focusSection()
|
.focusSection()
|
||||||
.padding(.horizontal, 88)
|
.padding(.horizontal, 88)
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ struct TVPlayerProgressBar: View {
|
|||||||
var sponsorBlockSettings: SponsorBlockSegmentSettings = .default
|
var sponsorBlockSettings: SponsorBlockSegmentSettings = .default
|
||||||
/// Color for the played portion of the progress bar.
|
/// Color for the played portion of the progress bar.
|
||||||
var playedColor: Color = .red
|
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.
|
/// Track focus state internally.
|
||||||
@FocusState private var isFocused: Bool
|
@FocusState private var isFocused: Bool
|
||||||
@@ -42,9 +49,10 @@ 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
|
||||||
|
|
||||||
/// 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 {
|
private var displayTime: TimeInterval {
|
||||||
scrubTime ?? currentTime
|
scrubTime ?? remoteSeekTime ?? currentTime
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Progress as a fraction (0-1).
|
/// Progress as a fraction (0-1).
|
||||||
@@ -88,9 +96,13 @@ struct TVPlayerProgressBar: View {
|
|||||||
}
|
}
|
||||||
.focused($isFocused)
|
.focused($isFocused)
|
||||||
.onMoveCommand { direction in
|
.onMoveCommand { direction in
|
||||||
// D-pad fallback for scrubbing
|
|
||||||
if isScrubbing {
|
if isScrubbing {
|
||||||
|
// D-pad fallback while in SELECT-based scrub mode.
|
||||||
handleDPad(direction: direction)
|
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
|
.onChange(of: isFocused) { _, focused in
|
||||||
|
|||||||
@@ -66,15 +66,25 @@ struct TVPlayerView: View {
|
|||||||
/// Timer for the countdown.
|
/// Timer for the countdown.
|
||||||
@State private var autoplayTimer: Timer?
|
@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.
|
/// Current tap-seek feedback to display.
|
||||||
@State private var currentTapFeedback: (action: TapGestureAction, position: TapZonePosition, accumulated: Int?)?
|
@State private var currentTapFeedback: (action: TapGestureAction, position: TapZonePosition, accumulated: Int?)?
|
||||||
|
|
||||||
/// Pending seek to execute when feedback completes.
|
/// Pending seek to execute when feedback completes.
|
||||||
@State private var pendingSeek: (isForward: Bool, seconds: Int)?
|
@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
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
private var playerService: PlayerService? {
|
private var playerService: PlayerService? {
|
||||||
@@ -192,6 +202,10 @@ struct TVPlayerView: View {
|
|||||||
} else {
|
} else {
|
||||||
startControlsTimer()
|
startControlsTimer()
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
remoteSeekTime: scrubberRemoteSeekTime,
|
||||||
|
onRemoteSeek: { forward in
|
||||||
|
triggerScrubberRemoteSeek(forward: forward)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.transition(.opacity.animation(.easeInOut(duration: 0.25)))
|
.transition(.opacity.animation(.easeInOut(duration: 0.25)))
|
||||||
@@ -251,6 +265,10 @@ struct TVPlayerView: View {
|
|||||||
stopControlsTimer()
|
stopControlsTimer()
|
||||||
stopDebugUpdates()
|
stopDebugUpdates()
|
||||||
stopAutoplayCountdown()
|
stopAutoplayCountdown()
|
||||||
|
scrubberRemoteSeekTask?.cancel()
|
||||||
|
scrubberRemoteSeekTask = nil
|
||||||
|
scrubberRemoteSeek = nil
|
||||||
|
scrubberRemoteSeekTime = nil
|
||||||
}
|
}
|
||||||
// Remote event handling - these work globally
|
// Remote event handling - these work globally
|
||||||
.onPlayPauseCommand {
|
.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) {
|
private func triggerRemoteSeek(forward: Bool) {
|
||||||
let seekSeconds = 10
|
let stepSeconds = 10
|
||||||
let action: TapGestureAction = forward
|
|
||||||
? .seekForward(seconds: seekSeconds)
|
|
||||||
: .seekBackward(seconds: seekSeconds)
|
|
||||||
let position: TapZonePosition = forward ? .right : .left
|
|
||||||
let currentTime = playerState?.currentTime ?? 0
|
let currentTime = playerState?.currentTime ?? 0
|
||||||
let duration = playerState?.duration ?? 0
|
let duration = playerState?.duration ?? 0
|
||||||
|
|
||||||
Task {
|
// Current signed offset from any in-flight accumulation.
|
||||||
await gestureActionHandler.updatePlayerState(
|
let currentNet: Int = pendingSeek.map { $0.isForward ? $0.seconds : -$0.seconds } ?? 0
|
||||||
currentTime: currentTime,
|
let step = forward ? stepSeconds : -stepSeconds
|
||||||
duration: duration
|
let rawNet = currentNet + step
|
||||||
)
|
|
||||||
|
|
||||||
// If switching seek direction, cancel any pending seek first.
|
// Clamp to the available seekable range in either direction.
|
||||||
if let pending = pendingSeek, pending.isForward != forward {
|
let maxForward = Int(max(0, duration - currentTime))
|
||||||
await MainActor.run {
|
let maxBackward = Int(max(0, currentTime))
|
||||||
pendingSeek = nil
|
let clampedNet = min(max(rawNet, -maxBackward), maxForward)
|
||||||
currentTapFeedback = nil
|
|
||||||
}
|
|
||||||
await gestureActionHandler.cancelAccumulation()
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = await gestureActionHandler.handleTapAction(action, position: position)
|
let netMagnitude = abs(clampedNet)
|
||||||
let accumulated = result.accumulatedSeconds ?? seekSeconds
|
let netIsForward = clampedNet >= 0
|
||||||
|
|
||||||
await MainActor.run {
|
let action: TapGestureAction = netIsForward
|
||||||
currentTapFeedback = (action, position, result.accumulatedSeconds)
|
? .seekForward(seconds: stepSeconds)
|
||||||
pendingSeek = (isForward: forward, seconds: accumulated)
|
: .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.
|
/// Commits the pending seek when the feedback overlay finishes its dismiss animation.
|
||||||
@@ -541,10 +610,6 @@ struct TVPlayerView: View {
|
|||||||
} else {
|
} else {
|
||||||
playerService?.seekBackward(by: TimeInterval(seek.seconds))
|
playerService?.seekBackward(by: TimeInterval(seek.seconds))
|
||||||
}
|
}
|
||||||
|
|
||||||
Task {
|
|
||||||
await gestureActionHandler.cancelAccumulation()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleMenuButton() {
|
private func handleMenuButton() {
|
||||||
|
|||||||
Reference in New Issue
Block a user