mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 02:17:46 +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.
|
// Handles execution of gesture actions on the player.
|
||||||
//
|
//
|
||||||
|
|
||||||
#if os(iOS)
|
import Foundation
|
||||||
import UIKit
|
|
||||||
|
|
||||||
/// Result of executing a tap gesture action.
|
/// Result of executing a tap gesture action.
|
||||||
struct TapActionResult: Sendable {
|
struct TapActionResult: Sendable {
|
||||||
@@ -206,4 +205,3 @@ actor PlayerGestureActionHandler {
|
|||||||
self.duration = duration
|
self.duration = duration
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|||||||
@@ -5,10 +5,21 @@
|
|||||||
// Visual feedback overlay for tap gesture actions.
|
// Visual feedback overlay for tap gesture actions.
|
||||||
//
|
//
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
import SwiftUI
|
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.
|
/// Position for tap feedback display.
|
||||||
enum TapFeedbackPosition {
|
enum TapFeedbackPosition {
|
||||||
@@ -105,22 +116,22 @@ struct TapGestureFeedbackView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var feedbackContent: some View {
|
private var feedbackContent: some View {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: tapFeedbackVStackSpacing) {
|
||||||
Image(systemName: iconName)
|
Image(systemName: iconName)
|
||||||
.font(.system(size: 44, weight: .medium))
|
.font(.system(size: tapFeedbackIconSize, weight: .medium))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
if let text = feedbackText {
|
if let text = feedbackText {
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.system(size: 16, weight: .semibold))
|
.font(.system(size: tapFeedbackTextSize, weight: .semibold))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(20)
|
.padding(tapFeedbackPadding)
|
||||||
.background(
|
.background(
|
||||||
Circle()
|
Circle()
|
||||||
.fill(Color.black.opacity(0.5))
|
.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.
|
/// Timer for the countdown.
|
||||||
@State private var autoplayTimer: Timer?
|
@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
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
private var playerService: PlayerService? {
|
private var playerService: PlayerService? {
|
||||||
@@ -209,6 +218,20 @@ struct TVPlayerView: View {
|
|||||||
.transition(.opacity.combined(with: .scale(scale: 0.95)))
|
.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
|
// Autoplay countdown overlay
|
||||||
if showAutoplayCountdown, let nextVideo = playerState?.nextQueuedVideo {
|
if showAutoplayCountdown, let nextVideo = playerState?.nextQueuedVideo {
|
||||||
TVAutoplayCountdownView(
|
TVAutoplayCountdownView(
|
||||||
@@ -271,9 +294,8 @@ struct TVPlayerView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(TVBackgroundButtonStyle())
|
.buttonStyle(TVBackgroundButtonStyle())
|
||||||
.focused($focusedControl, equals: .background)
|
.focused($focusedControl, equals: .background)
|
||||||
.onMoveCommand { _ in
|
.onMoveCommand { direction in
|
||||||
// Any direction press shows controls
|
handleBackgroundMoveCommand(direction)
|
||||||
showControls()
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// When controls visible, just a plain background
|
// 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() {
|
private func handleMenuButton() {
|
||||||
if showAutoplayCountdown {
|
if showAutoplayCountdown {
|
||||||
// First priority: cancel countdown
|
// First priority: cancel countdown
|
||||||
|
|||||||
Reference in New Issue
Block a user