mirror of
https://github.com/yattee/yattee.git
synced 2026-05-11 18:05:03 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user