Add press-and-hold continuous seek on tvOS d-pad

The Siri Remote's left/right d-pad only delivered a single discrete
seek per click — holding the button did nothing. A window-level custom
UIGestureRecognizer now tracks the actual press duration and drives a
repeating seek tick (10s → 20s → 30s acceleration) until release,
routing through the existing accumulating-seek paths so the scrubber
preview, debounced commit, and on-screen feedback all keep working.
This commit is contained in:
Arkadiusz Fal
2026-05-07 06:18:40 +02:00
parent cc109043b3
commit 51108738aa
2 changed files with 325 additions and 6 deletions

View File

@@ -268,6 +268,30 @@ struct TVPlayerView: View {
.allowsHitTesting(false)
}
// Press-and-hold continuous seek (UIPress-based; routes to the
// same accumulating seek functions as the discrete .onMoveCommand
// path).
TVRemoteHoldSeekOverlay(
isActive: !isDetailsPanelVisible
&& !isDebugOverlayVisible
&& !showingQualitySheet
&& !showingQueueSheet
&& !isScrubbing
) { forward, step in
if controlsVisible, !isScrubbing {
// Controls visible mirror the discrete focused-bar
// path. We do NOT gate on focusedControl == .progressBar
// because tvOS focus may briefly drop while the window
// recognizer captures the press; the user's intent is
// clearly to scrub the visible bar.
triggerScrubberRemoteSeek(forward: forward, stepSeconds: step)
} else {
// Controls hidden accumulating overlay seek.
triggerRemoteSeek(forward: forward, stepSeconds: step)
}
}
.allowsHitTesting(false)
// Autoplay countdown overlay
if showAutoplayCountdown, let nextVideo = playerState?.nextQueuedVideo {
TVAutoplayCountdownView(
@@ -555,8 +579,7 @@ struct TVPlayerView: View {
/// 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 stepSeconds = 10
private func triggerRemoteSeek(forward: Bool, stepSeconds: Int = 10) {
let currentTime = playerState?.currentTime ?? 0
let duration = playerState?.duration ?? 0
@@ -586,8 +609,7 @@ struct TVPlayerView: View {
/// 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
private func triggerScrubberRemoteSeek(forward: Bool, stepSeconds: Int = 10) {
let currentTime = playerState?.currentTime ?? 0
let duration = playerState?.duration ?? 0
@@ -606,8 +628,18 @@ struct TVPlayerView: View {
let netMagnitude = abs(clampedNet)
let netIsForward = clampedNet >= 0
scrubberRemoteSeek = (isForward: netIsForward, seconds: netMagnitude)
scrubberRemoteSeekTime = currentTime + TimeInterval(clampedNet)
// When the seek is clamped at the edge of the seekable range,
// successive ticks would write the same values; skip the @State
// assignments to avoid spurious SwiftUI invalidations.
if scrubberRemoteSeek?.isForward != netIsForward
|| scrubberRemoteSeek?.seconds != netMagnitude
{
scrubberRemoteSeek = (isForward: netIsForward, seconds: netMagnitude)
}
let newSeekTime = currentTime + TimeInterval(clampedNet)
if scrubberRemoteSeekTime != newSeekTime {
scrubberRemoteSeekTime = newSeekTime
}
scrubberRemoteSeekTask?.cancel()
scrubberRemoteSeekTask = Task { @MainActor in

View File

@@ -0,0 +1,287 @@
//
// TVRemoteHoldSeekObserver.swift
// Yattee
//
// Press-and-hold continuous seeking. Implemented by attaching a pair of
// UILongPressGestureRecognizer instances (one per arrow press type) to
// the UIWindow, so they intercept Siri Remote dpad presses regardless of
// which SwiftUI view is currently focused. SwiftUI's `.onMoveCommand`
// continues to receive the discrete "press" event for the first step;
// our recognizer kicks in after 400 ms to drive continuous seeking until
// the user releases.
//
#if os(tvOS)
import Combine
import SwiftUI
import UIKit
import UIKit.UIGestureRecognizerSubclass
/// Routes the d-pad direction to the appropriate seek action while held.
typealias TVRemoteHoldSeekTick = @Sendable @MainActor (_ forward: Bool, _ stepSeconds: Int) -> Void
/// Invisible SwiftUI view that, while in the hierarchy, wires window-level
/// long-press recognizers for left/right arrow press types.
struct TVRemoteHoldSeekOverlay: UIViewRepresentable {
let isActive: Bool
let onTick: TVRemoteHoldSeekTick
func makeCoordinator() -> Coordinator {
Coordinator(onTick: onTick)
}
func makeUIView(context: Context) -> UIView {
let view = TVRemoteHoldSeekHostView()
view.backgroundColor = .clear
view.isUserInteractionEnabled = false
view.coordinator = context.coordinator
return view
}
func updateUIView(_: UIView, context: Context) {
context.coordinator.update(onTick: onTick, isActive: isActive)
}
static func dismantleUIView(_: UIView, coordinator: Coordinator) {
coordinator.detachFromWindow()
}
@MainActor
final class Coordinator: NSObject, UIGestureRecognizerDelegate {
var onTick: TVRemoteHoldSeekTick
private static let initialDelay: TimeInterval = 0.4
private static let tickInterval: TimeInterval = 0.25
private static let mediumThreshold: TimeInterval = 1.5
private static let fastThreshold: TimeInterval = 3.0
private static let baseStep = 10
private static let mediumStep = 20
private static let fastStep = 30
private weak var attachedWindow: UIWindow?
private var leftRecognizer: RemoteArrowPressRecognizer?
private var rightRecognizer: RemoteArrowPressRecognizer?
private var heldForward: Bool?
private var holdStart: Date?
private var initialDelayWorkItem: DispatchWorkItem?
private var tickTimer: Timer?
private var active = true
init(onTick: @escaping TVRemoteHoldSeekTick) {
self.onTick = onTick
}
func update(onTick: @escaping TVRemoteHoldSeekTick, isActive: Bool) {
if active != isActive {
active = isActive
if !isActive { cancelHold() }
leftRecognizer?.isEnabled = isActive
rightRecognizer?.isEnabled = isActive
}
self.onTick = onTick
}
func attach(to window: UIWindow) {
if attachedWindow === window { return }
detachFromWindow()
attachedWindow = window
let left = makeRecognizer(pressType: .leftArrow, action: #selector(handleLeft(_:)))
let right = makeRecognizer(pressType: .rightArrow, action: #selector(handleRight(_:)))
window.addGestureRecognizer(left)
window.addGestureRecognizer(right)
leftRecognizer = left
rightRecognizer = right
}
private func makeRecognizer(pressType: UIPress.PressType, action: Selector) -> RemoteArrowPressRecognizer {
let recognizer = RemoteArrowPressRecognizer(pressType: pressType, target: self, action: action)
recognizer.cancelsTouchesInView = false
recognizer.delegate = self
return recognizer
}
func detachFromWindow() {
if let leftRecognizer, let attachedWindow {
attachedWindow.removeGestureRecognizer(leftRecognizer)
}
if let rightRecognizer, let attachedWindow {
attachedWindow.removeGestureRecognizer(rightRecognizer)
}
leftRecognizer = nil
rightRecognizer = nil
attachedWindow = nil
cancelHold()
}
@objc func handleLeft(_ gr: UIGestureRecognizer) {
handle(gr, forward: false)
}
@objc func handleRight(_ gr: UIGestureRecognizer) {
handle(gr, forward: true)
}
private func handle(_ gr: UIGestureRecognizer, forward: Bool) {
switch gr.state {
case .began:
// A new press of any direction always restarts the hold
// if a previous .ended was dropped by UIKit, this catches
// it and resets the watchdog cleanly.
cancelHold()
beginHold(forward: forward)
case .ended, .cancelled, .failed:
if heldForward == forward {
cancelHold()
}
default:
break
}
}
private func beginHold(forward: Bool) {
heldForward = forward
holdStart = Date()
// DispatchQueue.main and Timer.scheduledTimer always fire on
// the main thread, but neither closure is statically isolated
// to MainActor `assumeIsolated` is the cheap, allocation-free
// bridge.
let work = DispatchWorkItem { [weak self] in
MainActor.assumeIsolated { self?.startTickTimer() }
}
initialDelayWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + Self.initialDelay, execute: work)
}
private func startTickTimer() {
guard heldForward != nil, tickTimer == nil else { return }
emitTick()
let timer = Timer(timeInterval: Self.tickInterval, repeats: true) { [weak self] _ in
MainActor.assumeIsolated { self?.emitTick() }
}
RunLoop.main.add(timer, forMode: .common)
tickTimer = timer
}
private func emitTick() {
guard let forward = heldForward, let holdStart else { return }
// Defensive check: if UIKit transitioned the tracked press out
// of an active phase but failed to call pressesEnded on the
// recognizer, the press's own phase still reflects reality.
// Catch that here so the timer doesn't run away.
let activeRecognizer = forward ? rightRecognizer : leftRecognizer
if let phase = activeRecognizer?.trackedPressPhase,
phase != .began, phase != .changed, phase != .stationary
{
cancelHold()
return
}
let elapsed = Date().timeIntervalSince(holdStart)
let step: Int
if elapsed >= Self.fastThreshold {
step = Self.fastStep
} else if elapsed >= Self.mediumThreshold {
step = Self.mediumStep
} else {
step = Self.baseStep
}
onTick(forward, step)
}
private func cancelHold() {
initialDelayWorkItem?.cancel()
initialDelayWorkItem = nil
tickTimer?.invalidate()
tickTimer = nil
heldForward = nil
holdStart = nil
}
// Allow these recognizers to coexist with everything else (focus
// engine taps, SwiftUI gestures, etc.) we only OBSERVE the press
// duration; we do not want to swallow events.
func gestureRecognizer(
_: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith _: UIGestureRecognizer
) -> Bool {
true
}
func gestureRecognizer(
_: UIGestureRecognizer,
shouldReceive _: UIPress
) -> Bool {
true
}
}
}
/// Custom gesture recognizer that observes a single arrow press type.
/// Overrides the press hooks directly because `UILongPressGestureRecognizer`
/// with `allowedPressTypes` does not always fire when a focused view
/// consumes the press.
private final class RemoteArrowPressRecognizer: UIGestureRecognizer {
let pressType: UIPress.PressType
private var trackedPress: UIPress?
/// Exposes the live phase of the press currently being tracked, so
/// callers can sanity-check whether UIKit still considers the press
/// active even when `pressesEnded` was never delivered.
var trackedPressPhase: UIPress.Phase? { trackedPress?.phase }
init(pressType: UIPress.PressType, target: Any?, action: Selector?) {
self.pressType = pressType
super.init(target: target, action: action)
allowedPressTypes = [NSNumber(value: pressType.rawValue)]
}
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent) {
super.pressesBegan(presses, with: event)
guard trackedPress == nil else { return }
if let match = presses.first(where: { $0.type == pressType }) {
trackedPress = match
state = .began
}
}
override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent) {
super.pressesEnded(presses, with: event)
if let trackedPress, presses.contains(trackedPress) {
self.trackedPress = nil
state = .ended
}
}
override func pressesCancelled(_ presses: Set<UIPress>, with event: UIPressesEvent) {
super.pressesCancelled(presses, with: event)
if let trackedPress, presses.contains(trackedPress) {
self.trackedPress = nil
state = .cancelled
}
}
override func reset() {
super.reset()
trackedPress = nil
}
}
/// Bridge view that, on `didMoveToWindow`, hands the window to its
/// coordinator so the gesture recognizers can be installed at the
/// window level.
private final class TVRemoteHoldSeekHostView: UIView {
weak var coordinator: TVRemoteHoldSeekOverlay.Coordinator?
override var canBecomeFocused: Bool { false }
override func didMoveToWindow() {
super.didMoveToWindow()
if let window {
coordinator?.attach(to: window)
} else {
coordinator?.detachFromWindow()
}
}
}
#endif