// // 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 skipBackward case playPause case skipForward case progressBar case qualityButton case captionsButton case debugButton case infoButton case volumeDown case volumeUp case playNext } /// 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 /// 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 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? // 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 selector sheet .sheet(isPresented: $showingQualitySheet) { if let playerService { let dashEnabled = appEnvironment?.settingsManager.dashEnabled ?? false let supportedFormats = playerService.currentBackendType.supportedFormats 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) } ) } } } // 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 if controlsVisible && !isDetailsPanelVisible && !isDebugOverlayVisible { TVPlayerControlsView( playerState: playerState, playerService: playerService, focusedControl: $focusedControl, onShowDetails: { showDetailsPanel() }, onShowQuality: { showQualitySheet() }, onShowDebug: { showDebugOverlay() }, onDismiss: { dismissPlayer() }, onScrubbingChanged: { scrubbing in isScrubbing = scrubbing if scrubbing { stopControlsTimer() } else { startControlsTimer() } } ) .transition(.opacity.animation(.easeInOut(duration: 0.25))) } // Swipe-up details panel if isDetailsPanelVisible { TVDetailsPanel( video: playerState?.currentVideo, 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))) } // 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 = .playPause } .onDisappear { stopControlsTimer() stopDebugUpdates() stopAutoplayCountdown() } // 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 { _ in // Any direction press shows controls showControls() } } 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: - 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.3)) { controlsVisible = false 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 = .playPause } startControlsTimer() } // MARK: - Details Panel private func showDetailsPanel() { stopControlsTimer() withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { isDetailsPanelVisible = true controlsVisible = false } } // MARK: - Quality Sheet private func showQualitySheet() { stopControlsTimer() showingQualitySheet = 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() } } } 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: exit scrub mode (handled by progress bar losing focus) // Just hide controls 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 focusedControl = .background } } private func dismissPlayer() { // Save progress and stop player before dismissing (matches iOS/macOS pattern) // This ensures watch history is updated when user exits player with Menu button playerService?.stop() 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