'use client'; import { useEffect, useState, useRef } from 'react'; import { useRouter } from 'next/navigation'; declare global { interface Window { Hls: any; } } interface VideoPlayerProps { videoId: string; title?: string; nextVideoId?: string; } interface QualityOption { label: string; height: number; url: string; audio_url?: string; is_hls: boolean; has_audio?: boolean; } interface StreamInfo { stream_url: string; audio_url?: string; qualities?: QualityOption[]; best_quality?: number; error?: string; } function PlayerSkeleton() { return (
); } export default function VideoPlayer({ videoId, title, nextVideoId }: VideoPlayerProps) { const router = useRouter(); const videoRef = useRef(null); const audioRef = useRef(null); const hlsRef = useRef(null); const audioHlsRef = useRef(null); const [error, setError] = useState(null); const [useFallback, setUseFallback] = useState(false); const [showControls, setShowControls] = useState(false); const [qualities, setQualities] = useState([]); const [currentQuality, setCurrentQuality] = useState(0); const [showQualityMenu, setShowQualityMenu] = useState(false); const [hasSeparateAudio, setHasSeparateAudio] = useState(false); const [isLoading, setIsLoading] = useState(true); const [isBuffering, setIsBuffering] = useState(false); const audioUrlRef = useRef(''); useEffect(() => { const script = document.createElement('script'); script.src = 'https://cdn.jsdelivr.net/npm/hls.js@latest'; script.async = true; if (!document.querySelector('script[src*="hls.js"]')) { document.head.appendChild(script); } }, []); const syncAudio = () => { const video = videoRef.current; const audio = audioRef.current; if (!video || !audio || !hasSeparateAudio) return; if (Math.abs(video.currentTime - audio.currentTime) > 0.2) { audio.currentTime = video.currentTime; } if (video.paused && !audio.paused) { audio.pause(); } else if (!video.paused && audio.paused) { audio.play().catch(() => {}); } }; useEffect(() => { if (useFallback) return; const loadStream = async () => { try { const res = await fetch(`/api/get_stream_info?v=${videoId}`); const data: StreamInfo = await res.json(); if (data.error || !data.stream_url) { throw new Error(data.error || 'No stream URL'); } if (data.qualities && data.qualities.length > 0) { setQualities(data.qualities); setCurrentQuality(data.best_quality || data.qualities[0].height); } if (data.audio_url) { audioUrlRef.current = data.audio_url; } playStream(data.stream_url, data.audio_url); } catch (err) { console.error('Stream load error:', err); setError('Failed to load stream'); setUseFallback(true); } }; const tryLoad = () => { if (window.Hls) { loadStream(); } else { setTimeout(tryLoad, 100); } }; tryLoad(); return () => { if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; } if (audioHlsRef.current) { audioHlsRef.current.destroy(); audioHlsRef.current = null; } }; }, [videoId]); useEffect(() => { if (!hasSeparateAudio) return; const video = videoRef.current; if (!video) return; const handlers = { play: syncAudio, pause: syncAudio, seeked: syncAudio, timeupdate: syncAudio, }; Object.entries(handlers).forEach(([event, handler]) => { video.addEventListener(event, handler); }); return () => { Object.entries(handlers).forEach(([event, handler]) => { video.removeEventListener(event, handler); }); }; }, [hasSeparateAudio]); const playStream = (streamUrl: string, audioStreamUrl?: string) => { const video = videoRef.current; if (!video) return; setIsLoading(true); const isHLS = streamUrl.includes('.m3u8') || streamUrl.includes('manifest'); const needsSeparateAudio = audioStreamUrl && audioStreamUrl !== ''; setHasSeparateAudio(!!needsSeparateAudio); const handleCanPlay = () => setIsLoading(false); const handlePlaying = () => { setIsLoading(false); setIsBuffering(false); }; const handleWaiting = () => setIsBuffering(true); const handleLoadStart = () => setIsLoading(true); video.addEventListener('canplay', handleCanPlay); video.addEventListener('playing', handlePlaying); video.addEventListener('waiting', handleWaiting); video.addEventListener('loadstart', handleLoadStart); if (isHLS && window.Hls && window.Hls.isSupported()) { if (hlsRef.current) hlsRef.current.destroy(); const hls = new window.Hls({ xhrSetup: (xhr: XMLHttpRequest) => { xhr.setRequestHeader('Referer', 'https://www.youtube.com/'); }, }); hlsRef.current = hls; hls.loadSource(streamUrl); hls.attachMedia(video); hls.on(window.Hls.Events.MANIFEST_PARSED, () => { video.play().catch(() => {}); }); hls.on(window.Hls.Events.ERROR, (_: any, data: any) => { if (data.fatal) { setIsLoading(false); setError('Playback error'); setUseFallback(true); } }); } else if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = streamUrl; video.onloadedmetadata = () => video.play().catch(() => {}); } else { video.src = streamUrl; video.onloadeddata = () => video.play().catch(() => {}); } if (needsSeparateAudio) { const audio = audioRef.current; if (audio) { const audioIsHLS = audioStreamUrl!.includes('.m3u8') || audioStreamUrl!.includes('manifest'); if (audioIsHLS && window.Hls && window.Hls.isSupported()) { if (audioHlsRef.current) audioHlsRef.current.destroy(); const audioHls = new window.Hls({ xhrSetup: (xhr: XMLHttpRequest) => { xhr.setRequestHeader('Referer', 'https://www.youtube.com/'); }, }); audioHlsRef.current = audioHls; audioHls.loadSource(audioStreamUrl!); audioHls.attachMedia(audio); } else { audio.src = audioStreamUrl!; } } } video.onended = () => { setIsLoading(false); if (nextVideoId) router.push(`/watch?v=${nextVideoId}`); }; }; const changeQuality = (quality: QualityOption) => { const video = videoRef.current; if (!video) return; const currentTime = video.currentTime; const wasPlaying = !video.paused; setShowQualityMenu(false); const audioUrl = quality.audio_url || audioUrlRef.current; playStream(quality.url, audioUrl); setCurrentQuality(quality.height); video.currentTime = currentTime; if (wasPlaying) video.play().catch(() => {}); }; useEffect(() => { if (!useFallback) return; const handleMessage = (event: MessageEvent) => { if (event.origin !== 'https://www.youtube.com') return; try { const data = JSON.parse(event.data); if (data.event === 'onStateChange' && data.info === 0 && nextVideoId) { router.push(`/watch?v=${nextVideoId}`); } } catch {} }; window.addEventListener('message', handleMessage); return () => window.removeEventListener('message', handleMessage); }, [useFallback, nextVideoId, router]); if (!videoId) { return
No video ID
; } if (useFallback) { return (
setShowControls(true)} onMouseLeave={() => setShowControls(false)}>