Files
yattee/Yattee/Views/Player/tvOS/TVPlayerView.swift
Arkadiusz Fal 4935fbdb83 Remove tvOS close button from MPV debug stats overlay
Menu remote already dismisses the overlay via TVPlayerView's exit
command handler, so the in-overlay close button is redundant.
2026-05-10 15:28:11 +02:00

1052 lines
38 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// TVPlayerView.swift
// Yattee
//
// Main tvOS player container with custom controls and Apple TV remote support.
//
#if os(tvOS)
import SwiftUI
/// Focus targets for tvOS player controls navigation.
enum TVPlayerFocusTarget: Hashable {
case background // For capturing events when controls hidden
case progressBar
case settingsButton
case infoButton
case commentsButton
case debugButton
case playPrevious
case playPauseButton
case playNext
case closeButton
case queueButton
case errorDetails
case errorRetry
case errorPlayNext
case errorClose
}
/// Main tvOS fullscreen player view.
struct TVPlayerView: View {
@Environment(\.appEnvironment) private var appEnvironment
@Environment(\.dismiss) private var dismiss
@Environment(\.scenePhase) private var scenePhase
// MARK: - State
/// Whether controls overlay is visible.
@State private var controlsVisible = true
/// Timer for auto-hiding controls.
@State private var controlsHideTimer: Timer?
/// Whether the details panel is shown.
@State private var isDetailsPanelVisible = false
/// Initial tab for the details panel when opened.
@State private var detailsPanelInitialTab: TVDetailsTab = .info
/// Whether user is scrubbing the progress bar.
@State private var isScrubbing = false
/// Whether playback was active when the current scrub session began;
/// used to decide whether to resume on scrub end.
@State private var wasPlayingBeforeScrub = false
/// Whether the quality sheet is shown.
@State private var showingQualitySheet = false
/// Whether the queue sheet is shown.
@State private var showingQueueSheet = false
/// Whether the error details sheet is shown.
@State private var showingErrorSheet = false
/// Whether the debug overlay is shown.
@State private var isDebugOverlayVisible = false
/// Debug statistics from MPV.
@State private var debugStats: MPVDebugStats = .init()
/// Timer for updating debug stats.
@State private var debugUpdateTimer: Timer?
/// Current focus target for D-pad navigation.
@FocusState private var focusedControl: TVPlayerFocusTarget?
/// Whether the autoplay countdown is visible.
@State private var showAutoplayCountdown = false
/// Current countdown value (5, 4, 3, 2, 1).
@State private var autoplayCountdown = 5
/// Timer for the countdown.
@State private var autoplayTimer: Timer?
/// 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)?
/// Pending target time for arrow-key seeks while the progress bar is
/// focused (controls visible). Mirrored onto the scrubber so the handle
/// moves without any extra overlay.
@State private var scrubberRemoteSeekTime: TimeInterval?
/// Most recent accumulated seek amount for the focused-bar flow; applied
/// on debounced commit.
@State private var scrubberRemoteSeek: (isForward: Bool, seconds: Int)?
/// Debounce task that commits the focused-bar arrow-key seek 1s after the
/// last press.
@State private var scrubberRemoteSeekTask: Task<Void, Never>?
/// Bumped to signal `TVPlayerProgressBar` to cancel an in-progress scrub
/// without performing the seek (used when Menu is pressed during scrub).
@State private var cancelScrubTrigger: UUID?
// MARK: - Computed Properties
private var playerService: PlayerService? {
appEnvironment?.playerService
}
private var playerState: PlayerState? {
playerService?.state
}
// MARK: - Body
var body: some View {
mpvPlayerContent
.ignoresSafeArea()
.playerToastOverlay()
// Quality / Settings selector (fullscreen cover gives tvOS enough room)
.fullScreenCover(isPresented: $showingQualitySheet) {
qualitySheetContent
}
.fullScreenCover(isPresented: $showingQueueSheet) {
ZStack {
Rectangle()
.fill(.ultraThinMaterial)
.ignoresSafeArea()
QueueManagementSheet()
}
}
.fullScreenCover(isPresented: $showingErrorSheet) {
ZStack {
Rectangle()
.fill(.ultraThinMaterial)
.ignoresSafeArea()
ErrorDetailsSheet(errorMessage: playerState?.errorMessage ?? "Unknown error")
.frame(maxWidth: 1200, maxHeight: 700)
.padding(.horizontal, 200)
.padding(.vertical, 80)
}
}
}
// MARK: - Quality Sheet Content
@ViewBuilder
private var qualitySheetContent: some View {
if let playerService {
let dashEnabled = appEnvironment?.settingsManager.dashEnabled ?? false
let supportedFormats = playerService.currentBackendType.supportedFormats
ZStack {
// Glass backdrop matches info/comments panel for visual uniformity
Rectangle()
.fill(.ultraThinMaterial)
.ignoresSafeArea()
QualitySelectorView(
streams: playerService.availableStreams.filter { stream in
let format = StreamFormat.detect(from: stream)
if format == .dash && !dashEnabled {
return false
}
return supportedFormats.contains(format)
},
captions: playerService.availableCaptions,
currentStream: playerState?.currentStream,
currentAudioStream: playerState?.currentAudioStream,
currentCaption: playerService.currentCaption,
isLoading: playerState?.playbackState == .loading,
currentDownload: playerService.currentDownload,
isLoadingOnlineStreams: playerService.isLoadingOnlineStreams,
localCaptionURL: playerService.currentDownload.flatMap { download in
guard let path = download.localCaptionPath else { return nil }
return appEnvironment?.downloadManager.downloadsDirectory().appendingPathComponent(path)
},
currentRate: playerState?.rate ?? .x1,
onStreamSelected: { stream, audioStream in
switchToStream(stream, audioStream: audioStream)
},
onCaptionSelected: { caption in
playerService.loadCaption(caption)
},
onLoadOnlineStreams: {
Task {
await playerService.loadOnlineStreams()
}
},
onSwitchToOnlineStream: { stream, audioStream in
Task {
await playerService.switchToOnlineStream(stream, audioStream: audioStream)
}
},
onRateChanged: { rate in
playerState?.rate = rate
playerService.currentBackend?.rate = Float(rate.rawValue)
}
)
.frame(maxWidth: 900, maxHeight: 700)
.padding(.horizontal, 200)
.padding(.vertical, 80)
}
}
}
// MARK: - MPV Content
/// Custom MPV player view with custom controls.
@ViewBuilder
private var mpvPlayerContent: some View {
ZStack {
// Background - always focusable to capture remote events
backgroundLayer
// Video layer
videoLayer
// Controls overlay always in tree, toggled via opacity so the fade-out
// isn't skipped by tvOS's focus engine forcibly tearing down a focused
// subview when the conditional flips to false. Focus is redirected to
// the background Button after each hide so the remote touch area still
// triggers showControls() instead of hitting a disabled hidden control.
TVPlayerControlsView(
playerState: playerState,
playerService: playerService,
focusedControl: $focusedControl,
onShowSettings: { showQualitySheet() },
onShowQueue: { showQueueSheet() },
onShowDetails: { showDetailsPanel(tab: .info) },
onShowComments: { showDetailsPanel(tab: .comments) },
onShowDebug: { showDebugOverlay() },
onClose: { closeVideo() },
onTogglePlayPause: { handlePlayPause() },
onScrubbingChanged: { scrubbing in
isScrubbing = scrubbing
if scrubbing {
stopControlsTimer()
wasPlayingBeforeScrub = playerService?.state.playbackState == .playing
if wasPlayingBeforeScrub {
playerService?.pause()
}
} else {
startControlsTimer()
if wasPlayingBeforeScrub {
playerService?.resume()
}
wasPlayingBeforeScrub = false
}
},
remoteSeekTime: scrubberRemoteSeekTime,
onRemoteSeek: { forward in
triggerScrubberRemoteSeek(forward: forward)
},
cancelScrubTrigger: cancelScrubTrigger
)
.opacity(shouldShowControls ? 1 : 0)
.allowsHitTesting(shouldShowControls)
.disabled(!shouldShowControls)
.animation(.easeInOut(duration: 0.25), value: shouldShowControls)
// Right-side details panel (covers ~50% of screen)
if isDetailsPanelVisible {
GeometryReader { geo in
HStack(spacing: 0) {
Spacer(minLength: 0)
TVDetailsPanel(
video: playerState?.currentVideo,
initialTab: detailsPanelInitialTab,
onDismiss: { hideDetailsPanel() }
)
.frame(width: geo.size.width / 2)
}
}
.transition(.move(edge: .trailing).combined(with: .opacity))
}
// Debug overlay
if isDebugOverlayVisible {
MPVDebugOverlay(
stats: debugStats,
isVisible: $isDebugOverlayVisible,
isLandscape: true
)
.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)
}
// 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(
countdown: autoplayCountdown,
nextVideo: nextVideo,
onPlayNext: { playNextInQueue() },
onCancel: { cancelAutoplay() }
)
.transition(.opacity.combined(with: .scale(scale: 0.95)))
}
// Playback failure overlay
if playerState?.isFailed == true {
failedOverlay
.transition(.opacity)
} else if playerState?.retryState.exhausted == true {
retryExhaustedOverlay
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.25), value: playerState?.isFailed)
.animation(.easeInOut(duration: 0.25), value: playerState?.retryState.exhausted)
.onAppear {
startControlsTimer()
focusedControl = .progressBar
}
.onDisappear {
stopControlsTimer()
stopDebugUpdates()
stopAutoplayCountdown()
scrubberRemoteSeekTask?.cancel()
scrubberRemoteSeekTask = nil
scrubberRemoteSeek = nil
scrubberRemoteSeekTime = nil
}
// Remote event handling - these work globally
.onPlayPauseCommand {
handlePlayPause()
}
.onExitCommand {
handleMenuButton()
}
// Track focus changes to show controls when navigating
.onChange(of: focusedControl) { oldValue, newValue in
handleFocusChange(from: oldValue, to: newValue)
}
// Start auto-hide timer when playback starts, handle video ended
.onChange(of: playerState?.playbackState) { _, newState in
if newState == .playing && controlsVisible && !isScrubbing {
startControlsTimer()
} else if newState == .ended {
handleVideoEnded()
} else if case .failed = newState {
handleVideoFailed()
}
}
.onChange(of: playerState?.retryState.exhausted) { _, exhausted in
if exhausted == true {
handleVideoFailed()
}
}
// Dismiss countdown if video changes during countdown (e.g., from remote control)
.onChange(of: playerState?.currentVideo?.id) { _, _ in
if showAutoplayCountdown {
stopAutoplayCountdown()
showControls()
}
}
// When app returns to foreground (e.g. after auto-pause from background),
// surface the controls so the user can immediately resume or navigate.
.onChange(of: scenePhase) { oldPhase, newPhase in
if newPhase == .active && oldPhase != .active {
showControls()
}
}
}
// MARK: - Failure Overlays
/// Whether either failure overlay is currently visible.
private var isFailureOverlayVisible: Bool {
playerState?.isFailed == true || playerState?.retryState.exhausted == true
}
@ViewBuilder
private var failedOverlay: some View {
ZStack {
Color.black.opacity(0.55)
.ignoresSafeArea()
VStack(spacing: 36) {
VStack(spacing: 20) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 64, weight: .semibold))
.foregroundStyle(.yellow)
if let message = playerState?.errorMessage, !message.isEmpty {
Text(message)
.font(.system(size: 24))
.foregroundStyle(.white.opacity(0.85))
.multilineTextAlignment(.center)
.lineLimit(3)
.frame(maxWidth: 1000)
}
}
.padding(.horizontal, 60)
.padding(.vertical, 36)
.glassBackground(.regular, in: .rect(cornerRadius: 28), fallback: .ultraThinMaterial)
HStack(spacing: 32) {
failureButton(
title: String(localized: "player.error.button"),
systemImage: "info.circle",
focus: .errorDetails,
action: { showingErrorSheet = true }
)
failureButton(
title: String(localized: "player.error.retry"),
systemImage: "arrow.clockwise",
focus: .errorRetry,
action: { retryPlayback() }
)
if playerState?.nextQueuedVideo != nil {
failureButton(
title: String(localized: "player.autoplay.playNext"),
systemImage: "forward.fill",
focus: .errorPlayNext,
action: { playNextInQueue() }
)
} else {
failureButton(
title: String(localized: "player.close"),
systemImage: "xmark",
focus: .errorClose,
action: { closeVideo() }
)
}
}
.focusSection()
}
}
}
@ViewBuilder
private var retryExhaustedOverlay: some View {
ZStack {
Color.black.opacity(0.55)
.ignoresSafeArea()
VStack(spacing: 36) {
VStack(spacing: 12) {
Image(systemName: "arrow.clockwise.circle")
.font(.system(size: 56, weight: .semibold))
.foregroundStyle(.white.opacity(0.85))
Text(String(localized: "player.retry.button"))
.font(.system(size: 32, weight: .semibold))
.foregroundStyle(.white)
}
HStack(spacing: 32) {
failureButton(
title: String(localized: "player.error.retry"),
systemImage: "arrow.clockwise",
focus: .errorRetry,
action: { retryPlayback() }
)
failureButton(
title: String(localized: "player.close"),
systemImage: "xmark",
focus: .errorClose,
action: { closeVideo() }
)
}
.focusSection()
}
}
}
@ViewBuilder
private func failureButton(
title: String,
systemImage: String,
focus: TVPlayerFocusTarget,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Label(title, systemImage: systemImage)
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 320, height: 80)
}
.buttonStyle(TVFailureButtonStyle())
.focused($focusedControl, equals: focus)
}
// MARK: - Failure Actions
/// Restart playback of the current video from scratch.
private func retryPlayback() {
guard let playerService, let video = playerState?.currentVideo else { return }
Task {
await playerService.play(video: video)
}
}
/// Called when playback enters the failed state or retries are exhausted.
private func handleVideoFailed() {
stopControlsTimer()
stopAutoplayCountdown()
withAnimation(.easeOut(duration: 0.25)) {
controlsVisible = false
}
// Defer focus assignment so the overlay is in the tree before the focus
// engine evaluates it.
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(50))
focusedControl = .errorRetry
}
}
// MARK: - Background Layer
@ViewBuilder
private var backgroundLayer: some View {
if !controlsVisible && !isDetailsPanelVisible && !isDebugOverlayVisible && !isFailureOverlayVisible {
// When controls hidden, use a Button to capture both click and swipe
Button {
showControls()
} label: {
Color.black
.ignoresSafeArea()
}
.buttonStyle(TVBackgroundButtonStyle())
.focused($focusedControl, equals: .background)
.onMoveCommand { direction in
handleBackgroundMoveCommand(direction)
}
} else {
// When controls visible, just a plain background
Color.black
.ignoresSafeArea()
}
}
// MARK: - Video Layer
@ViewBuilder
private var videoLayer: some View {
if let playerService,
let backend = playerService.currentBackend as? MPVBackend,
let playerState {
MPVRenderViewRepresentable(
backend: backend,
playerState: playerState
)
.ignoresSafeArea()
.allowsHitTesting(false)
} else {
// Fallback/loading state - show thumbnail
if let video = playerState?.currentVideo,
let thumbnailURL = video.bestThumbnail?.url {
AsyncImage(url: thumbnailURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fit)
} placeholder: {
Color.black
}
.allowsHitTesting(false)
}
}
}
// MARK: - Focus Handling
private func handleFocusChange(from oldValue: TVPlayerFocusTarget?, to newValue: TVPlayerFocusTarget?) {
// If focus moved to a control, ensure controls are visible
if let newValue, newValue != .background {
if !controlsVisible {
showControls()
}
startControlsTimer()
}
}
// MARK: - Derived State
/// Whether the primary controls overlay should be visible right now.
private var shouldShowControls: Bool {
controlsVisible && !isDetailsPanelVisible && !isDebugOverlayVisible && !isFailureOverlayVisible
}
// MARK: - Controls Timer
private func startControlsTimer() {
stopControlsTimer()
// Don't auto-hide if paused
guard playerState?.playbackState == .playing else { return }
controlsHideTimer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: false) { _ in
Task { @MainActor in
withAnimation(.easeOut(duration: 0.25)) {
controlsVisible = false
}
// After controlsVisible flips, the backgroundLayer Button is in the
// tree and focusable move focus to it so the remote touch area
// calls showControls() instead of a hidden/disabled control.
focusedControl = .background
}
}
}
private func stopControlsTimer() {
controlsHideTimer?.invalidate()
controlsHideTimer = nil
}
private func showControls() {
withAnimation(.easeIn(duration: 0.2)) {
controlsVisible = true
}
if focusedControl == .background || focusedControl == nil {
focusedControl = .progressBar
}
startControlsTimer()
}
// MARK: - Details Panel
private func showDetailsPanel(tab: TVDetailsTab = .info) {
stopControlsTimer()
detailsPanelInitialTab = tab
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
isDetailsPanelVisible = true
controlsVisible = false
}
}
// MARK: - Quality Sheet
private func showQualitySheet() {
stopControlsTimer()
showingQualitySheet = true
}
private func showQueueSheet() {
stopControlsTimer()
showingQueueSheet = true
}
private func switchToStream(_ stream: Stream, audioStream: Stream? = nil) {
guard let video = playerState?.currentVideo else { return }
let currentTime = playerState?.currentTime
Task {
await playerService?.play(video: video, stream: stream, audioStream: audioStream, startTime: currentTime)
}
}
private func hideDetailsPanel() {
withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {
isDetailsPanelVisible = false
}
showControls()
}
// MARK: - Debug Overlay
private func showDebugOverlay() {
stopControlsTimer()
startDebugUpdates()
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
isDebugOverlayVisible = true
controlsVisible = false
}
}
private func hideDebugOverlay() {
stopDebugUpdates()
withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {
isDebugOverlayVisible = false
}
showControls()
}
private func startDebugUpdates() {
stopDebugUpdates()
guard let backend = playerService?.currentBackend as? MPVBackend else { return }
// Update immediately
debugStats = backend.getDebugStats()
// Then update every second
debugUpdateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
Task { @MainActor in
guard let backend = self.playerService?.currentBackend as? MPVBackend else { return }
self.debugStats = backend.getDebugStats()
}
}
}
private func stopDebugUpdates() {
debugUpdateTimer?.invalidate()
debugUpdateTimer = nil
}
// MARK: - Remote Event Handlers
private func handlePlayPause() {
// Cancel countdown if visible
if showAutoplayCountdown {
stopAutoplayCountdown()
showControls()
return
}
// Show controls if hidden (but not if debug overlay is visible), then toggle playback
if !controlsVisible && !isDetailsPanelVisible && !isDebugOverlayVisible {
showControls()
}
playerService?.togglePlayPause()
// Reset timer when interacting (only if controls are visible)
if !isDebugOverlayVisible {
if playerState?.playbackState == .playing {
startControlsTimer()
} else {
stopControlsTimer()
}
}
}
/// 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 a signed net
/// offset across rapid presses. A reverse press within the active window
/// 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, stepSeconds: Int = 10) {
let currentTime = playerState?.currentTime ?? 0
let duration = playerState?.duration ?? 0
// Current signed offset from any in-flight accumulation.
let currentNet: Int = pendingSeek.map { $0.isForward ? $0.seconds : -$0.seconds } ?? 0
let step = forward ? stepSeconds : -stepSeconds
let rawNet = currentNet + step
// Clamp to the available seekable range in either direction.
let maxForward = Int(max(0, duration - currentTime))
let maxBackward = Int(max(0, currentTime))
let clampedNet = min(max(rawNet, -maxBackward), maxForward)
let netMagnitude = abs(clampedNet)
let netIsForward = clampedNet >= 0
let action: TapGestureAction = netIsForward
? .seekForward(seconds: stepSeconds)
: .seekBackward(seconds: stepSeconds)
let position: TapZonePosition = netIsForward ? .right : .left
currentTapFeedback = (action, position, netMagnitude)
pendingSeek = (isForward: netIsForward, seconds: netMagnitude)
}
/// Accumulating arrow-key seek for when the progress bar is focused and
/// 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, stepSeconds: Int = 10) {
let currentTime = playerState?.currentTime ?? 0
let duration = playerState?.duration ?? 0
// Keep controls on-screen while the user is arrow-seeking.
stopControlsTimer()
// Current signed offset from any in-flight accumulation.
let currentNet: Int = scrubberRemoteSeek.map { $0.isForward ? $0.seconds : -$0.seconds } ?? 0
let step = forward ? stepSeconds : -stepSeconds
let rawNet = currentNet + step
let maxForward = Int(max(0, duration - currentTime))
let maxBackward = Int(max(0, currentTime))
let clampedNet = min(max(rawNet, -maxBackward), maxForward)
let netMagnitude = abs(clampedNet)
let netIsForward = clampedNet >= 0
// 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
try? await Task.sleep(for: .milliseconds(1000))
guard !Task.isCancelled else { return }
commitScrubberRemoteSeek()
}
}
/// Commits the debounced accumulating arrow-key seek for the focused bar.
private func commitScrubberRemoteSeek() {
guard let seek = scrubberRemoteSeek else { return }
scrubberRemoteSeek = nil
scrubberRemoteSeekTime = nil
scrubberRemoteSeekTask = nil
if seek.seconds > 0 {
if seek.isForward {
playerService?.seekForward(by: TimeInterval(seek.seconds))
} else {
playerService?.seekBackward(by: TimeInterval(seek.seconds))
}
}
// Resume the auto-hide timer now that the user is done seeking.
startControlsTimer()
}
/// 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))
}
}
private func handleMenuButton() {
if showingErrorSheet {
// Top priority: close the error details sheet
showingErrorSheet = false
} else if isFailureOverlayVisible {
// While the failure overlay is up, Menu closes the video so the
// user isn't stranded with no working remote affordance.
closeVideo()
} else if showAutoplayCountdown {
// First priority: cancel countdown
cancelAutoplay()
} else if isDebugOverlayVisible {
// Second: hide debug overlay
hideDebugOverlay()
} else if isDetailsPanelVisible {
// Third: hide details panel
hideDetailsPanel()
} else if isScrubbing {
// Fourth: cancel scrub without seeking, then hide controls. The
// subsequent focus-loss path sees cleared scrub state and no-ops.
cancelScrubTrigger = UUID()
hideControls()
} else if controlsVisible {
// Fifth: hide controls
hideControls()
} else if appEnvironment?.settingsManager.tvOSMenuButtonClosesVideo == true {
// Sixth (Menu-closes mode): fully close the video like the xmark button
closeVideo()
} else {
// Sixth: dismiss player (controls already hidden)
dismissPlayer()
}
}
private func hideControls() {
stopControlsTimer()
withAnimation(.easeOut(duration: 0.25)) {
controlsVisible = false
}
// Focus is redirected outside withAnimation to keep the fade-out animation
// from being skipped, and to keep the remote touch area pointing at the
// background Button's showControls() action rather than a hidden control.
focusedControl = .background
}
private func closeVideo() {
playerState?.isClosingVideo = true
appEnvironment?.queueManager.clearQueue()
playerService?.stop()
appEnvironment?.navigationCoordinator.isPlayerExpanded = false
dismiss()
}
private func dismissPlayer() {
// Collapse the player but keep it alive so audio continues in the background
// and the "Now Playing" sidebar entry can restore the session. Matches the
// iOS/macOS ExpandedPlayerWindow dismissal path, which also does not stop()
// on dismiss.
appEnvironment?.navigationCoordinator.isPlayerExpanded = false
dismiss()
}
// MARK: - Autoplay Countdown
private func handleVideoEnded() {
// Hide controls immediately
stopControlsTimer()
withAnimation(.easeOut(duration: 0.25)) {
controlsVisible = false
}
// Check if autoplay is enabled and there's a next video
let autoPlayEnabled = appEnvironment?.settingsManager.queueAutoPlayNext ?? true
let hasNextVideo = playerState?.hasNext ?? false
if autoPlayEnabled && hasNextVideo {
startAutoplayCountdown()
} else {
// No next video or autoplay disabled - show controls with replay option
showControls()
}
}
private func startAutoplayCountdown() {
stopAutoplayCountdown()
// Get countdown duration from settings (default: 5 seconds, range: 1-15)
let countdownDuration = appEnvironment?.settingsManager.queueAutoPlayCountdown ?? 5
autoplayCountdown = countdownDuration
withAnimation(.easeIn(duration: 0.3)) {
showAutoplayCountdown = true
}
// Start countdown timer
autoplayTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
Task { @MainActor in
if self.autoplayCountdown > 1 {
self.autoplayCountdown -= 1
} else {
// Countdown finished - play next video
self.stopAutoplayCountdown()
self.playNextInQueue()
}
}
}
}
private func stopAutoplayCountdown() {
autoplayTimer?.invalidate()
autoplayTimer = nil
withAnimation(.easeOut(duration: 0.2)) {
showAutoplayCountdown = false
}
}
private func playNextInQueue() {
stopAutoplayCountdown()
Task {
await playerService?.playNext()
}
}
private func cancelAutoplay() {
stopAutoplayCountdown()
// Show controls so user can replay or manually navigate
showControls()
}
}
// MARK: - Background Button Style
/// Invisible button style for the background - no visual feedback, just captures input.
struct TVBackgroundButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
}
}
/// Glass-backed button style used by the playback failure overlay.
/// Scales on focus and brightens the glass material to indicate selection.
struct TVFailureButtonStyle: ButtonStyle {
@Environment(\.isFocused) private var isFocused
func makeBody(configuration: Configuration) -> some View {
configuration.label
.glassBackground(
isFocused ? .tinted(.white.opacity(0.25)) : .regular,
in: .capsule,
fallback: isFocused ? .ultraThickMaterial : .ultraThinMaterial
)
.scaleEffect(configuration.isPressed ? 0.95 : (isFocused ? 1.08 : 1.0))
.animation(.easeInOut(duration: 0.15), value: isFocused)
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
}
}
#endif