mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 18:35:05 +00:00
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:
@@ -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
|
||||
|
||||
287
Yattee/Views/Player/tvOS/TVRemoteHoldSeekObserver.swift
Normal file
287
Yattee/Views/Player/tvOS/TVRemoteHoldSeekObserver.swift
Normal 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
|
||||
Reference in New Issue
Block a user