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)
|
.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
|
// Autoplay countdown overlay
|
||||||
if showAutoplayCountdown, let nextVideo = playerState?.nextQueuedVideo {
|
if showAutoplayCountdown, let nextVideo = playerState?.nextQueuedVideo {
|
||||||
TVAutoplayCountdownView(
|
TVAutoplayCountdownView(
|
||||||
@@ -555,8 +579,7 @@ struct TVPlayerView: View {
|
|||||||
/// subtracts from the pending offset instead of restarting from the
|
/// subtracts from the pending offset instead of restarting from the
|
||||||
/// current playback time (e.g. right, right, left from 30s → +10s → 40s,
|
/// current playback time (e.g. right, right, left from 30s → +10s → 40s,
|
||||||
/// not −10s → 20s).
|
/// not −10s → 20s).
|
||||||
private func triggerRemoteSeek(forward: Bool) {
|
private func triggerRemoteSeek(forward: Bool, stepSeconds: Int = 10) {
|
||||||
let stepSeconds = 10
|
|
||||||
let currentTime = playerState?.currentTime ?? 0
|
let currentTime = playerState?.currentTime ?? 0
|
||||||
let duration = playerState?.duration ?? 0
|
let duration = playerState?.duration ?? 0
|
||||||
|
|
||||||
@@ -586,8 +609,7 @@ struct TVPlayerView: View {
|
|||||||
/// controls are visible. Suppresses the circular feedback overlay — the
|
/// controls are visible. Suppresses the circular feedback overlay — the
|
||||||
/// visible scrubber shows the pending target instead — and uses the same
|
/// visible scrubber shows the pending target instead — and uses the same
|
||||||
/// signed net-offset accumulation as the hidden-controls flow.
|
/// signed net-offset accumulation as the hidden-controls flow.
|
||||||
private func triggerScrubberRemoteSeek(forward: Bool) {
|
private func triggerScrubberRemoteSeek(forward: Bool, stepSeconds: Int = 10) {
|
||||||
let stepSeconds = 10
|
|
||||||
let currentTime = playerState?.currentTime ?? 0
|
let currentTime = playerState?.currentTime ?? 0
|
||||||
let duration = playerState?.duration ?? 0
|
let duration = playerState?.duration ?? 0
|
||||||
|
|
||||||
@@ -606,8 +628,18 @@ struct TVPlayerView: View {
|
|||||||
let netMagnitude = abs(clampedNet)
|
let netMagnitude = abs(clampedNet)
|
||||||
let netIsForward = clampedNet >= 0
|
let netIsForward = clampedNet >= 0
|
||||||
|
|
||||||
scrubberRemoteSeek = (isForward: netIsForward, seconds: netMagnitude)
|
// When the seek is clamped at the edge of the seekable range,
|
||||||
scrubberRemoteSeekTime = currentTime + TimeInterval(clampedNet)
|
// 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?.cancel()
|
||||||
scrubberRemoteSeekTask = Task { @MainActor in
|
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