Seek with remote arrows when tvOS player controls are hidden

Left/right on the Siri Remote now seek instead of revealing controls,
reusing the iOS tap-seek accumulation handler and feedback overlay so
rapid presses compound into a single "+30s" / "-20s" jump. Up/down
still show controls.
This commit is contained in:
Arkadiusz Fal
2026-04-14 19:12:56 +02:00
parent f5ddcd0fa5
commit c7d1f1c20b
3 changed files with 111 additions and 14 deletions

View File

@@ -5,8 +5,7 @@
// Handles execution of gesture actions on the player.
//
#if os(iOS)
import UIKit
import Foundation
/// Result of executing a tap gesture action.
struct TapActionResult: Sendable {
@@ -206,4 +205,3 @@ actor PlayerGestureActionHandler {
self.duration = duration
}
}
#endif

View File

@@ -5,10 +5,21 @@
// Visual feedback overlay for tap gesture actions.
//
#if os(iOS)
import SwiftUI
#if os(tvOS)
private let tapFeedbackIconSize: CGFloat = 88
private let tapFeedbackTextSize: CGFloat = 32
private let tapFeedbackCircleSize: CGFloat = 240
private let tapFeedbackVStackSpacing: CGFloat = 16
private let tapFeedbackPadding: CGFloat = 40
#else
private let tapFeedbackIconSize: CGFloat = 44
private let tapFeedbackTextSize: CGFloat = 16
private let tapFeedbackCircleSize: CGFloat = 120
private let tapFeedbackVStackSpacing: CGFloat = 8
private let tapFeedbackPadding: CGFloat = 20
#endif
/// Position for tap feedback display.
enum TapFeedbackPosition {
@@ -105,22 +116,22 @@ struct TapGestureFeedbackView: View {
@ViewBuilder
private var feedbackContent: some View {
VStack(spacing: 8) {
VStack(spacing: tapFeedbackVStackSpacing) {
Image(systemName: iconName)
.font(.system(size: 44, weight: .medium))
.font(.system(size: tapFeedbackIconSize, weight: .medium))
.foregroundStyle(.white)
if let text = feedbackText {
Text(text)
.font(.system(size: 16, weight: .semibold))
.font(.system(size: tapFeedbackTextSize, weight: .semibold))
.foregroundStyle(.white)
}
}
.padding(20)
.padding(tapFeedbackPadding)
.background(
Circle()
.fill(Color.black.opacity(0.5))
.frame(width: 120, height: 120)
.frame(width: tapFeedbackCircleSize, height: tapFeedbackCircleSize)
)
}
@@ -278,4 +289,3 @@ private struct SeekRipple: View {
)
}
}
#endif

View File

@@ -66,6 +66,15 @@ struct TVPlayerView: View {
/// Timer for the countdown.
@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.
@State private var currentTapFeedback: (action: TapGestureAction, position: TapZonePosition, accumulated: Int?)?
/// Pending seek to execute when feedback completes.
@State private var pendingSeek: (isForward: Bool, seconds: Int)?
// MARK: - Computed Properties
private var playerService: PlayerService? {
@@ -209,6 +218,20 @@ struct TVPlayerView: View {
.transition(.opacity.combined(with: .scale(scale: 0.95)))
}
// Arrow-key seek feedback (when controls are hidden)
if let feedback = currentTapFeedback {
TapGestureFeedbackView(
action: feedback.action,
accumulatedSeconds: feedback.accumulated,
onComplete: {
currentTapFeedback = nil
executePendingSeek()
}
)
.id("\(feedback.action.actionType.rawValue)-\(feedback.position.rawValue)")
.allowsHitTesting(false)
}
// Autoplay countdown overlay
if showAutoplayCountdown, let nextVideo = playerState?.nextQueuedVideo {
TVAutoplayCountdownView(
@@ -271,9 +294,8 @@ struct TVPlayerView: View {
}
.buttonStyle(TVBackgroundButtonStyle())
.focused($focusedControl, equals: .background)
.onMoveCommand { _ in
// Any direction press shows controls
showControls()
.onMoveCommand { direction in
handleBackgroundMoveCommand(direction)
}
} else {
// When controls visible, just a plain background
@@ -458,6 +480,73 @@ struct TVPlayerView: View {
}
}
/// Handles D-pad / arrow presses while controls are hidden.
/// Left/right trigger an accumulating seek with on-screen feedback; up/down reveal controls.
private func handleBackgroundMoveCommand(_ direction: MoveCommandDirection) {
switch direction {
case .left:
triggerRemoteSeek(forward: false)
case .right:
triggerRemoteSeek(forward: true)
case .up, .down:
showControls()
@unknown default:
showControls()
}
}
/// Triggers a seek action from the remote, accumulating across rapid presses.
private func triggerRemoteSeek(forward: Bool) {
let seekSeconds = 10
let action: TapGestureAction = forward
? .seekForward(seconds: seekSeconds)
: .seekBackward(seconds: seekSeconds)
let position: TapZonePosition = forward ? .right : .left
let currentTime = playerState?.currentTime ?? 0
let duration = playerState?.duration ?? 0
Task {
await gestureActionHandler.updatePlayerState(
currentTime: currentTime,
duration: duration
)
// If switching seek direction, cancel any pending seek first.
if let pending = pendingSeek, pending.isForward != forward {
await MainActor.run {
pendingSeek = nil
currentTapFeedback = nil
}
await gestureActionHandler.cancelAccumulation()
}
let result = await gestureActionHandler.handleTapAction(action, position: position)
let accumulated = result.accumulatedSeconds ?? seekSeconds
await MainActor.run {
currentTapFeedback = (action, position, result.accumulatedSeconds)
pendingSeek = (isForward: forward, seconds: accumulated)
}
}
}
/// Commits the pending seek when the feedback overlay finishes its dismiss animation.
private func executePendingSeek() {
guard let seek = pendingSeek else { return }
pendingSeek = nil
guard seek.seconds > 0 else { return }
if seek.isForward {
playerService?.seekForward(by: TimeInterval(seek.seconds))
} else {
playerService?.seekBackward(by: TimeInterval(seek.seconds))
}
Task {
await gestureActionHandler.cancelAccumulation()
}
}
private func handleMenuButton() {
if showAutoplayCountdown {
// First priority: cancel countdown