diff --git a/Yattee/Services/PlayerControls/PlayerGestureActionHandler.swift b/Yattee/Services/PlayerControls/PlayerGestureActionHandler.swift index 0c958384..e9998a57 100644 --- a/Yattee/Services/PlayerControls/PlayerGestureActionHandler.swift +++ b/Yattee/Services/PlayerControls/PlayerGestureActionHandler.swift @@ -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 diff --git a/Yattee/Views/Player/Gestures/TapGestureFeedbackView.swift b/Yattee/Views/Player/Gestures/TapGestureFeedbackView.swift index 03656f4c..9ae81eb5 100644 --- a/Yattee/Views/Player/Gestures/TapGestureFeedbackView.swift +++ b/Yattee/Views/Player/Gestures/TapGestureFeedbackView.swift @@ -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 diff --git a/Yattee/Views/Player/tvOS/TVPlayerView.swift b/Yattee/Views/Player/tvOS/TVPlayerView.swift index 92dd0945..2e973487 100644 --- a/Yattee/Views/Player/tvOS/TVPlayerView.swift +++ b/Yattee/Views/Player/tvOS/TVPlayerView.swift @@ -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