'use client' import * as React from 'react' import { useState, useEffect, useRef, useCallback } from 'react' import { Play, Pause, Volume2, VolumeX, Maximize, Minimize, SkipBack, SkipForward, Loader2, HelpCircle, PictureInPicture2, Check } from 'lucide-react' import { cn, formatTime } from '@/lib/utils' import { Button } from '@/components/ui/button' import { KeyboardShortcutsOverlay } from '@/components/KeyboardShortcutsOverlay' interface VideoSubtitleTrack { src: string srcLang: string label: string default?: boolean } interface VideoPlayerProps { src: string poster?: string subtitles?: VideoSubtitleTrack[] onTimeUpdate?: (time: number) => void onEnded?: () => void onProgressSave?: (time: number) => void initialTime?: number autoPlay?: boolean seekRequest?: { time: number; nonce: number } | null } export function VideoPlayer({ src, poster, subtitles, onTimeUpdate, onEnded, onProgressSave, initialTime = 0, autoPlay = true, seekRequest = null, }: VideoPlayerProps) { const videoRef = useRef(null) const [isPlaying, setIsPlaying] = useState(false) const [currentTime, setCurrentTime] = useState(initialTime) const [duration, setDuration] = useState(0) const [volume, setVolume] = useState(1) const [isMuted, setIsMuted] = useState(false) const [isFullscreen, setIsFullscreen] = useState(false) const [showControls, setShowControls] = useState(true) const [buffered, setBuffered] = useState(0) const [playbackRate, setPlaybackRate] = useState(1) const playbackRates = [0.75, 1, 1.25, 1.5, 2] const [showShortcuts, setShowShortcuts] = useState(false) const [isPiPSupported, setIsPiPSupported] = useState(false) const [isPiPActive, setIsPiPActive] = useState(false) const [hoverProgress, setHoverProgress] = useState<{ time: number; x: number } | null>(null) const [showSettingsMenu, setShowSettingsMenu] = useState(false) const [selectedSubtitle, setSelectedSubtitle] = useState(() => { const defaultIndex = subtitles?.findIndex(track => track.default) ?? -1 return defaultIndex >= 0 ? defaultIndex : 'off' }) const settingsMenuRef = useRef(null) const controlsTimeoutRef = useRef>() const progressSaveTimeoutRef = useRef>() const hasAutoPlayedRef = useRef(false) const formatTimeDisplay = (seconds: number) => formatTime(seconds) useEffect(() => { if (autoPlay && videoRef.current && !hasAutoPlayedRef.current) { hasAutoPlayedRef.current = true const playPromise = videoRef.current.play() if (playPromise !== undefined) { playPromise .then(() => { setIsPlaying(true) }) .catch(() => { // Auto-play failed (browser policy), user must tap to play setIsPlaying(false) hasAutoPlayedRef.current = false // Allow retry on user interaction }) } } }, [autoPlay]) useEffect(() => { const video = videoRef.current if (!video) return const handleEnterPiP = () => setIsPiPActive(true) const handleLeavePiP = () => setIsPiPActive(false) video.addEventListener('enterpictureinpicture', handleEnterPiP) video.addEventListener('leavepictureinpicture', handleLeavePiP) return () => { video.removeEventListener('enterpictureinpicture', handleEnterPiP) video.removeEventListener('leavepictureinpicture', handleLeavePiP) } }, []) useEffect(() => { if (typeof document !== 'undefined') { setIsPiPSupported(Boolean((document as Document & { pictureInPictureEnabled?: boolean }).pictureInPictureEnabled)) } }, []) useEffect(() => { const handleFullscreenChange = () => { setIsFullscreen(Boolean(document.fullscreenElement)) } document.addEventListener('fullscreenchange', handleFullscreenChange) return () => document.removeEventListener('fullscreenchange', handleFullscreenChange) }, []) const handleMouseMove = useCallback(() => { setShowControls(true) if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current) controlsTimeoutRef.current = setTimeout(() => setShowControls(false), 3000) }, []) const togglePlay = useCallback(() => { if (!videoRef.current) return if (isPlaying) { videoRef.current.pause() } else { videoRef.current.play() } }, [isPlaying]) const togglePictureInPicture = useCallback(async () => { if (!videoRef.current) return const video = videoRef.current const doc = document as Document & { pictureInPictureEnabled?: boolean; pictureInPictureElement?: Element | null } if (!doc.pictureInPictureEnabled) { window.alert('Picture-in-Picture is not supported in this browser.') return } try { if (doc.pictureInPictureElement === video) { await doc.exitPictureInPicture?.() } else { await video.requestPictureInPicture?.() } } catch (error) { console.error('Picture-in-Picture toggle failed:', error) } }, []) const saveProgress = useCallback((time: number) => { if (progressSaveTimeoutRef.current) clearTimeout(progressSaveTimeoutRef.current) progressSaveTimeoutRef.current = setTimeout(() => { onProgressSave?.(time) }, 500) }, [onProgressSave]) const seekToPercent = (percent: number) => { if (videoRef.current && duration > 0) { videoRef.current.currentTime = (percent / 100) * duration setCurrentTime(videoRef.current.currentTime) saveProgress(videoRef.current.currentTime) } } const handleProgressHover = useCallback((e: React.MouseEvent) => { if (duration > 0) { const rect = e.currentTarget.getBoundingClientRect() const percent = (e.clientX - rect.left) / rect.width const hoverTime = percent * duration setHoverProgress({ time: hoverTime, x: e.clientX - rect.left }) } }, [duration]) const handleProgressLeave = useCallback(() => { setHoverProgress(null) }, []) const handleProgressClick = useCallback((e: React.MouseEvent) => { if (videoRef.current && duration > 0) { const rect = e.currentTarget.getBoundingClientRect() const percent = (e.clientX - rect.left) / rect.width videoRef.current.currentTime = percent * duration setCurrentTime(videoRef.current.currentTime) saveProgress(videoRef.current.currentTime) } }, [duration, saveProgress]) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (!videoRef.current) return const video = videoRef.current if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return switch (e.key) { case '?': if (e.shiftKey) { e.preventDefault() setShowShortcuts(prev => !prev) } break case ' ': case 'k': e.preventDefault() togglePlay() break case 'j': case 'ArrowLeft': e.preventDefault() video.currentTime = Math.max(0, video.currentTime - 10) saveProgress(video.currentTime) break case 'l': case 'ArrowRight': e.preventDefault() video.currentTime = Math.min(video.duration, video.currentTime + 30) saveProgress(video.currentTime) break case 'ArrowUp': e.preventDefault() video.volume = Math.min(1, video.volume + 0.1) setVolume(video.volume) setIsMuted(false) break case 'ArrowDown': e.preventDefault() video.volume = Math.max(0, video.volume - 0.1) setVolume(video.volume) if (video.volume === 0) setIsMuted(true) break case 'm': if (videoRef.current) { videoRef.current.muted = !isMuted setIsMuted(!isMuted) } break case 'f': if (videoRef.current) { if (!isFullscreen) { videoRef.current.requestFullscreen() } else { document.exitFullscreen() } } break case 'p': case 'i': e.preventDefault() togglePictureInPicture() break case 'Escape': e.preventDefault() if (showShortcuts) { setShowShortcuts(false) break } if (document.fullscreenElement) { document.exitFullscreen() break } if (isPiPActive) { togglePictureInPicture() } break case ',': case '[': video.playbackRate = Math.max(0.25, video.playbackRate - 0.25) setPlaybackRate(video.playbackRate) break case '.': case ']': video.playbackRate = Math.min(2, video.playbackRate + 0.25) setPlaybackRate(video.playbackRate) break case '0': case '1': case '2': case '3': case '4': case '5': case '6': case '7': case '8': case '9': e.preventDefault() seekToPercent(parseInt(e.key) * 10) break } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [togglePlay, isMuted, isFullscreen, saveProgress, duration, seekToPercent, togglePictureInPicture, showShortcuts, isPiPActive]) const handleLoadedMetadata = () => { if (videoRef.current) { setDuration(videoRef.current.duration) if (initialTime > 0) { videoRef.current.currentTime = initialTime } } } const handleTimeUpdate = () => { if (videoRef.current) { const time = videoRef.current.currentTime setCurrentTime(time) onTimeUpdate?.(time) saveProgress(time) } } const handleDurationChange = () => { if (videoRef.current) { setDuration(videoRef.current.duration) } } const handleProgress = () => { if (videoRef.current && videoRef.current.buffered.length > 0) { setBuffered(videoRef.current.buffered.end(videoRef.current.buffered.length - 1)) } } const handleEnded = () => { setIsPlaying(false) onEnded?.() } const handlePlay = () => setIsPlaying(true) const handlePause = () => { setIsPlaying(false) if (videoRef.current) { saveProgress(videoRef.current.currentTime) } } const handleVolumeChangeNative = () => { if (videoRef.current) { setVolume(videoRef.current.volume) setIsMuted(videoRef.current.muted) } } const handleRateChange = () => { if (videoRef.current) { setPlaybackRate(videoRef.current.playbackRate) } } useEffect(() => { if (videoRef.current) { videoRef.current.playbackRate = playbackRate } }, [playbackRate]) useEffect(() => { if (videoRef.current && initialTime > 0 && videoRef.current.readyState >= 1) { videoRef.current.currentTime = initialTime // Allow auto-play attempt again when seeking to saved position hasAutoPlayedRef.current = false } }, [initialTime]) useEffect(() => { if (!videoRef.current || !seekRequest) return const nextTime = Math.max(0, seekRequest.time) videoRef.current.currentTime = nextTime setCurrentTime(nextTime) saveProgress(nextTime) videoRef.current.play().catch(() => {}) }, [seekRequest?.nonce, saveProgress]) useEffect(() => { return () => { if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current) if (progressSaveTimeoutRef.current) clearTimeout(progressSaveTimeoutRef.current) } }, []) useEffect(() => { function handleClickOutside(e: MouseEvent) { if (settingsMenuRef.current && !settingsMenuRef.current.contains(e.target as Node)) { setShowSettingsMenu(false) } } if (showSettingsMenu) { document.addEventListener('mousedown', handleClickOutside) } return () => document.removeEventListener('mousedown', handleClickOutside) }, [showSettingsMenu]) useEffect(() => { const defaultIndex = subtitles?.findIndex(track => track.default) ?? -1 setSelectedSubtitle(defaultIndex >= 0 ? defaultIndex : 'off') }, [subtitles]) useEffect(() => { const textTracks = videoRef.current?.textTracks if (!textTracks) return for (let index = 0; index < textTracks.length; index += 1) { textTracks[index].mode = selectedSubtitle === index ? 'showing' : 'disabled' } }, [selectedSubtitle, subtitles]) return (
setShowControls(false)} >
{videoRef.current && videoRef.current.readyState < 2 && (
)}
{formatTimeDisplay(currentTime)}
{buffered > 0 && duration > 0 && (
)}
0 ? (currentTime / duration) * 100 : 0}%` }} /> {hoverProgress && (
0 ? 1 : 1)) * 100}%` }} >
{formatTimeDisplay(hoverProgress.time)}
)}
{formatTimeDisplay(duration)}
{ const vol = parseFloat(e.target.value) if (videoRef.current) { videoRef.current.volume = vol setVolume(vol) setIsMuted(vol === 0) } }} className="w-20 h-1 bg-white/20 rounded-lg appearance-none accent-primary cursor-pointer" aria-label="Volume" />
{/* Fullscreen - FIRST */} {/* Picture-in-Picture */} {/* Settings Menu (3-dot) - Speed + Shortcuts */}
{showSettingsMenu && (
Speed
{playbackRates.map((rate) => ( ))} {subtitles && subtitles.length > 0 && ( <>
Subtitles
{subtitles.map((track, index) => ( ))} )}

)}
setShowShortcuts(false)} />
) }