mirror of
https://github.com/yattee/yattee.git
synced 2026-10-24 13:42:54 +00:00
The tvOS player covers the root view with a fullScreenCover and had no sheet host, so global sheet requests made while it was up (link action confirmations) were never presented and the action waited forever.
1152 lines
42 KiB
Swift
1152 lines
42 KiB
Swift
//
|
||
// TVPlayerView.swift
|
||
// Yattee
|
||
//
|
||
// Main tvOS player container with custom controls and Apple TV remote support.
|
||
//
|
||
|
||
#if os(tvOS)
|
||
import NukeUI
|
||
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()
|
||
// Global sheet requests (e.g. link action confirmations) while the
|
||
// player covers the screen; the root host is underneath it.
|
||
.appSheetHost(isTopLevel: appEnvironment?.navigationCoordinator.isPlayerExpanded == true)
|
||
.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)
|
||
}
|
||
// Covers don't inherit the environment applied below.
|
||
.environment(\.colorScheme, .dark)
|
||
}
|
||
// The player always sits over dark video, and its overlays
|
||
// (details panel, quality/queue panels) hardcode white text.
|
||
// Under the light color scheme, system controls (segmented
|
||
// picker, bordered buttons) and materials would otherwise
|
||
// render light-mode styles that are unreadable there.
|
||
.environment(\.colorScheme, .dark)
|
||
}
|
||
|
||
// MARK: - Quality Panel Content
|
||
|
||
/// Builds the quality/settings panel for the right-half overlay. The view
|
||
/// itself supplies its own glass backdrop (matches `TVDetailsPanel`).
|
||
@ViewBuilder
|
||
private var qualityPanelContent: some View {
|
||
if let playerService {
|
||
let supportedFormats = playerService.currentBackendType.supportedFormats
|
||
|
||
QualitySelectorView(
|
||
streams: playerService.availableStreams.filter { stream in
|
||
let format = StreamFormat.detect(from: stream)
|
||
// DASH is never offered for manual selection
|
||
if format == .dash {
|
||
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,
|
||
isAudioMode: appEnvironment?.settingsManager.audioOnlyModeEnabled ?? false,
|
||
embeddedAudioTracks: playerService.embeddedAudioTracks,
|
||
embeddedSubtitleTracks: playerService.embeddedSubtitleTracks,
|
||
currentEmbeddedAudioTrackID: playerService.selectedEmbeddedAudioTrackID,
|
||
currentEmbeddedSubtitleTrackID: playerService.selectedEmbeddedSubtitleTrackID,
|
||
embeddedVideoTrack: playerService.primaryEmbeddedVideoTrack,
|
||
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)
|
||
}
|
||
},
|
||
onEmbeddedAudioTrackSelected: { trackID in
|
||
playerService.selectEmbeddedAudioTrack(trackID)
|
||
},
|
||
onEmbeddedSubtitleTrackSelected: { trackID in
|
||
playerService.selectEmbeddedSubtitleTrack(trackID)
|
||
},
|
||
onRateChanged: { rate in
|
||
playerState?.rate = rate
|
||
playerService.currentBackend?.rate = Float(rate.rawValue)
|
||
},
|
||
onAudioModeToggled: { enabled in
|
||
Task {
|
||
await playerService.setAudioMode(enabled)
|
||
}
|
||
},
|
||
onDismiss: { hideQualitySheet() }
|
||
)
|
||
}
|
||
}
|
||
|
||
// 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))
|
||
}
|
||
|
||
// Right-side quality / settings panel (covers ~50% of screen)
|
||
if showingQualitySheet {
|
||
GeometryReader { geo in
|
||
HStack(spacing: 0) {
|
||
Spacer(minLength: 0)
|
||
qualityPanelContent
|
||
.frame(width: geo.size.width / 2)
|
||
// Ignore tvOS title-safe area on the trailing edge
|
||
// so panel content lines up symmetrically with the
|
||
// mid-screen leading edge instead of being inset
|
||
// from the physical right edge.
|
||
.ignoresSafeArea(.container, edges: .horizontal)
|
||
.focusSection()
|
||
}
|
||
}
|
||
.transition(.move(edge: .trailing).combined(with: .opacity))
|
||
}
|
||
|
||
// Right-side queue panel (covers ~50% of screen)
|
||
if showingQueueSheet {
|
||
GeometryReader { geo in
|
||
HStack(spacing: 0) {
|
||
Spacer(minLength: 0)
|
||
QueueManagementSheet(onDismiss: { hideQueueSheet() })
|
||
.frame(width: geo.size.width / 2)
|
||
.ignoresSafeArea(.container, edges: .horizontal)
|
||
.focusSection()
|
||
}
|
||
}
|
||
.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()
|
||
if showingQualitySheet {
|
||
hideQualitySheet()
|
||
}
|
||
if showingQueueSheet {
|
||
hideQueueSheet()
|
||
}
|
||
if isDetailsPanelVisible {
|
||
hideDetailsPanel()
|
||
}
|
||
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
|
||
&& !showingQualitySheet
|
||
&& !showingQueueSheet {
|
||
// 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 {
|
||
let isAudioOnly = playerState?.currentStream?.isAudioOnly == true
|
||
let hasBackend = playerService?.currentBackend is MPVBackend
|
||
|
||
ZStack {
|
||
if let playerService,
|
||
let backend = playerService.currentBackend as? MPVBackend,
|
||
let playerState {
|
||
MPVRenderViewRepresentable(
|
||
backend: backend,
|
||
playerState: playerState
|
||
)
|
||
.ignoresSafeArea()
|
||
.allowsHitTesting(false)
|
||
// Keep the render view alive but hidden for audio-only
|
||
// playback so toggling audio mode doesn't tear down GL state
|
||
.opacity(isAudioOnly ? 0 : 1)
|
||
}
|
||
|
||
// Thumbnail for audio-only playback and the pre-backend loading state
|
||
if isAudioOnly || !hasBackend,
|
||
let video = playerState?.currentVideo,
|
||
!video.thumbnailURLsByQuality.isEmpty {
|
||
FallbackLazyImage(urls: video.thumbnailURLsByQuality) { state in
|
||
if let image = state.image {
|
||
image
|
||
.resizable()
|
||
.aspectRatio(contentMode: .fit)
|
||
} else {
|
||
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
|
||
&& !showingQualitySheet
|
||
&& !showingQueueSheet
|
||
}
|
||
|
||
// 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()
|
||
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
|
||
showingQualitySheet = true
|
||
controlsVisible = false
|
||
}
|
||
}
|
||
|
||
private func hideQualitySheet() {
|
||
withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {
|
||
showingQualitySheet = false
|
||
}
|
||
showControls()
|
||
}
|
||
|
||
private func showQueueSheet() {
|
||
stopControlsTimer()
|
||
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
|
||
showingQueueSheet = true
|
||
controlsVisible = false
|
||
}
|
||
}
|
||
|
||
private func hideQueueSheet() {
|
||
withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {
|
||
showingQueueSheet = false
|
||
}
|
||
showControls()
|
||
}
|
||
|
||
private func switchToStream(_ stream: Stream, audioStream: Stream? = nil) {
|
||
Task {
|
||
await playerService?.selectStreamManually(stream, audioStream: audioStream)
|
||
}
|
||
}
|
||
|
||
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 any overlay is visible), then toggle playback
|
||
if !controlsVisible
|
||
&& !isDetailsPanelVisible
|
||
&& !isDebugOverlayVisible
|
||
&& !showingQualitySheet
|
||
&& !showingQueueSheet {
|
||
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 showingQualitySheet {
|
||
// Third: hide quality / settings panel
|
||
hideQualitySheet()
|
||
} else if showingQueueSheet {
|
||
// Fourth: hide queue panel
|
||
hideQueueSheet()
|
||
} else if isDetailsPanelVisible {
|
||
// Fifth: 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
|
||
}
|
||
|
||
// Repeat one: PlayerService restarts the video itself - no countdown, no replay controls
|
||
if playerState?.queueMode == .repeatOne {
|
||
return
|
||
}
|
||
|
||
// 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
|
||
.foregroundStyle(isFocused ? Color.black : .white)
|
||
.modifier(TVFocusBackground(isFocused: isFocused, shape: .capsule))
|
||
.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
|