// // 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 } /// Main tvOS fullscreen player view. struct TVPlayerView: View { @Environment(\.appEnvironment) private var appEnvironment @Environment(\.dismiss) private var dismiss // 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 the quality sheet is shown. @State private var showingQualitySheet = false /// Whether the queue sheet is shown. @State private var showingQueueSheet = 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? /// 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() } } } // 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() }, onScrubbingChanged: { scrubbing in isScrubbing = scrubbing if scrubbing { stopControlsTimer() } else { startControlsTimer() } }, 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) // Swipe-up details panel if isDetailsPanelVisible { TVDetailsPanel( video: playerState?.currentVideo, initialTab: detailsPanelInitialTab, onDismiss: { hideDetailsPanel() } ) .transition(.move(edge: .bottom).combined(with: .opacity)) } // Debug overlay if isDebugOverlayVisible { MPVDebugOverlay( stats: debugStats, isVisible: $isDebugOverlayVisible, isLandscape: true, onClose: { hideDebugOverlay() } ) .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( countdown: autoplayCountdown, nextVideo: nextVideo, onPlayNext: { playNextInQueue() }, onCancel: { cancelAutoplay() } ) .transition(.opacity.combined(with: .scale(scale: 0.95))) } } .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() } } // Dismiss countdown if video changes during countdown (e.g., from remote control) .onChange(of: playerState?.currentVideo?.id) { _, _ in if showAutoplayCountdown { stopAutoplayCountdown() showControls() } } } // MARK: - Background Layer @ViewBuilder private var backgroundLayer: some View { if !controlsVisible && !isDetailsPanelVisible && !isDebugOverlayVisible { // 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 } // 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) { let stepSeconds = 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) { let stepSeconds = 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 scrubberRemoteSeek = (isForward: netIsForward, seconds: netMagnitude) scrubberRemoteSeekTime = currentTime + TimeInterval(clampedNet) 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 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 { // 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 } } #endif