From 51108738aac6da9f4a290d3b026345040b2dfbf9 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Thu, 7 May 2026 06:18:40 +0200 Subject: [PATCH] Add press-and-hold continuous seek on tvOS d-pad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Yattee/Views/Player/tvOS/TVPlayerView.swift | 44 ++- .../tvOS/TVRemoteHoldSeekObserver.swift | 287 ++++++++++++++++++ 2 files changed, 325 insertions(+), 6 deletions(-) create mode 100644 Yattee/Views/Player/tvOS/TVRemoteHoldSeekObserver.swift diff --git a/Yattee/Views/Player/tvOS/TVPlayerView.swift b/Yattee/Views/Player/tvOS/TVPlayerView.swift index 2880d498..d955fae6 100644 --- a/Yattee/Views/Player/tvOS/TVPlayerView.swift +++ b/Yattee/Views/Player/tvOS/TVPlayerView.swift @@ -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 diff --git a/Yattee/Views/Player/tvOS/TVRemoteHoldSeekObserver.swift b/Yattee/Views/Player/tvOS/TVRemoteHoldSeekObserver.swift new file mode 100644 index 00000000..f0bd161b --- /dev/null +++ b/Yattee/Views/Player/tvOS/TVRemoteHoldSeekObserver.swift @@ -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, 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, with event: UIPressesEvent) { + super.pressesEnded(presses, with: event) + if let trackedPress, presses.contains(trackedPress) { + self.trackedPress = nil + state = .ended + } + } + + override func pressesCancelled(_ presses: Set, 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