'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; } 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 }: 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 [nextVideoId, setNextVideoId] = useState(); const [nextListId, setNextListId] = useState(); const [showBackgroundHint, setShowBackgroundHint] = useState(false); const [wakeLock, setWakeLock] = useState(null); const [isPiPActive, setIsPiPActive] = useState(false); const [autoPiPEnabled, setAutoPiPEnabled] = useState(true); const [showPiPNotification, setShowPiPNotification] = useState(false); const audioUrlRef = useRef(''); useEffect(() => { const handleSetNextVideo = (e: CustomEvent) => { if (e.detail && e.detail.videoId) { setNextVideoId(e.detail.videoId); if (e.detail.listId !== undefined) { setNextListId(e.detail.listId); } else { setNextListId(undefined); } } }; window.addEventListener('setNextVideoId', handleSetNextVideo as EventListener); return () => window.removeEventListener('setNextVideoId', handleSetNextVideo as EventListener); }, []); 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; const isHidden = document.visibilityState === 'hidden'; if (Math.abs(video.currentTime - audio.currentTime) > 0.4) { if (isHidden) { // When hidden, video might be suspended by the browser. // Don't pull audio back to the frozen video. Let audio play. // However, if audio is somehow pausing/lagging, we don't force it here. } else { // When visible, normally video is the master timeline. // BUT, if we just came back from background, audio might be way ahead. // Instead of always rewinding audio, if video is lagging behind audio (like recovering from sleep), // we should jump the video forward to catch up to the audio! if (audio.currentTime > video.currentTime + 1) { // Video is lagging, jump it forward video.currentTime = audio.currentTime; } else { // Audio is lagging or drifting slightly, snap audio to video audio.currentTime = video.currentTime; } } } if (video.paused && !audio.paused) { if (!isHidden) { audio.pause(); } } else if (!video.paused && audio.paused) { // Only force audio to play if it got stuck audio.play().catch(() => { }); } }; const handleVisibilityChange = async () => { const video = videoRef.current; const audio = audioRef.current; if (!video) return; if (document.visibilityState === 'hidden') { // Page is hidden - automatically enter Picture-in-Picture if enabled if (!video.paused && autoPiPEnabled) { try { if (document.pictureInPictureEnabled && !document.pictureInPictureElement) { await video.requestPictureInPicture(); setIsPiPActive(true); setShowPiPNotification(true); setTimeout(() => setShowPiPNotification(false), 3000); } } catch (error) { console.log('Auto PiP failed, using audio fallback:', error); } } // Release wake lock when page is hidden (PiP handles its own wake lock) releaseWakeLock(); } else { // Page is visible again if (autoPiPEnabled) { try { if (document.pictureInPictureElement) { await document.exitPictureInPicture(); setIsPiPActive(false); } } catch (error) { console.log('Exit PiP failed:', error); } } // Re-acquire wake lock when page is visible if (!video.paused) requestWakeLock(); // Recover video position from audio if audio continued playing in background if (hasSeparateAudio && audio && !audio.paused) { if (audio.currentTime > video.currentTime + 1) { video.currentTime = audio.currentTime; } if (video.paused) { video.play().catch(() => { }); } } } }; // Wake Lock API to prevent screen from sleeping during playback const requestWakeLock = async () => { try { if ('wakeLock' in navigator) { const wakeLockSentinel = await (navigator as any).wakeLock.request('screen'); setWakeLock(wakeLockSentinel); wakeLockSentinel.addEventListener('release', () => { setWakeLock(null); }); } } catch (err) { console.log('Wake Lock not supported or failed:', err); } }; const releaseWakeLock = async () => { if (wakeLock) { try { await wakeLock.release(); setWakeLock(null); } catch (err) { console.log('Failed to release wake lock:', err); } } }; // Picture-in-Picture support const togglePiP = async () => { const video = videoRef.current; if (!video) return; try { if (document.pictureInPictureElement) { await document.exitPictureInPicture(); setIsPiPActive(false); } else if (document.pictureInPictureEnabled) { await video.requestPictureInPicture(); setIsPiPActive(true); } } catch (error) { console.log('Picture-in-Picture not supported or failed:', error); } }; // Listen for PiP events 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 (useFallback) return; // Reset states when videoId changes setIsLoading(true); setIsBuffering(false); setError(null); setQualities([]); setShowQualityMenu(false); 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); // Only show error after multiple retries, not immediately setError('Failed to load stream'); setUseFallback(true); } }; const tryLoad = (retries = 0) => { if (window.Hls) { loadStream(); } else if (retries < 50) { // Wait up to 5 seconds for HLS to load setTimeout(() => tryLoad(retries + 1), 100); } else { // Fallback to native video player if HLS fails to load loadStream(); } }; tryLoad(); // Record history once per videoId fetch('/api/history', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ video_id: videoId, title: title, thumbnail: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`, }), }).catch(err => console.error('Failed to record history', err)); return () => { if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; } if (audioHlsRef.current) { audioHlsRef.current.destroy(); audioHlsRef.current = null; } }; }, [videoId]); useEffect(() => { 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); }); document.addEventListener('visibilitychange', handleVisibilityChange); return () => { Object.entries(handlers).forEach(([event, handler]) => { video.removeEventListener(event, handler); }); document.removeEventListener('visibilitychange', handleVisibilityChange); }; }, [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); if ('mediaSession' in navigator) { navigator.mediaSession.metadata = new MediaMetadata({ title: title || 'KV-Tube Video', artist: 'KV-Tube', artwork: [ { src: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`, sizes: '480x360', type: 'image/jpeg' } ] }); navigator.mediaSession.setActionHandler('play', () => { video.play().catch(() => { }); if (needsSeparateAudio && audioRef.current) audioRef.current.play().catch(() => { }); }); navigator.mediaSession.setActionHandler('pause', () => { video.pause(); if (needsSeparateAudio && audioRef.current) audioRef.current.pause(); }); // Add seek handlers for better background control navigator.mediaSession.setActionHandler('seekto', (details) => { if (details.seekTime !== undefined) { video.currentTime = details.seekTime; if (needsSeparateAudio && audioRef.current) { audioRef.current.currentTime = details.seekTime; } } }); } video.addEventListener('canplay', handleCanPlay); video.addEventListener('playing', handlePlaying); video.addEventListener('waiting', handleWaiting); video.addEventListener('loadstart', handleLoadStart); // Wake lock event listeners const handlePlay = () => requestWakeLock(); const handlePause = () => releaseWakeLock(); const handleEnded = () => releaseWakeLock(); video.addEventListener('play', handlePlay); video.addEventListener('pause', handlePause); video.addEventListener('ended', handleEnded); if (isHLS && window.Hls && window.Hls.isSupported()) { if (hlsRef.current) hlsRef.current.destroy(); // Enhance buffer to mitigate Safari slow loading and choppiness const hls = new window.Hls({ maxBufferLength: 60, maxMaxBufferLength: 120, enableWorker: true, 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) { // Try to recover from error first if (data.type === window.Hls.ErrorTypes.MEDIA_ERROR) { hls.recoverMediaError(); } else if (data.type === window.Hls.ErrorTypes.NETWORK_ERROR) { // Try to reload the source hls.loadSource(streamUrl); } else { // Only fall back for other fatal errors setIsLoading(false); setUseFallback(true); } } }); } else if (isHLS && 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({ maxBufferLength: 60, maxMaxBufferLength: 120, enableWorker: true, xhrSetup: (xhr: XMLHttpRequest) => { xhr.setRequestHeader('Referer', 'https://www.youtube.com/'); }, }); audioHlsRef.current = audioHls; audioHls.loadSource(audioStreamUrl!); audioHls.attachMedia(audio); } else if (audioIsHLS && audio.canPlayType('application/vnd.apple.mpegurl')) { audio.src = audioStreamUrl!; } else { audio.src = audioStreamUrl!; } } } video.onended = () => { setIsLoading(false); if (nextVideoId) { const url = nextListId ? `/watch?v=${nextVideoId}&list=${nextListId}` : `/watch?v=${nextVideoId}`; router.push(url); } }; // Handle video being paused by browser (e.g., when tab is hidden) const handlePauseForBackground = () => { const audio = audioRef.current; if (!audio || !hasSeparateAudio) return; // If the tab is hidden and video was paused, it was likely paused by Chrome saving resources. // Keep the audio playing! if (document.visibilityState === 'hidden') { audio.play().catch(() => { }); } }; video.addEventListener('pause', handlePauseForBackground); return () => { video.removeEventListener('pause', handlePauseForBackground); video.removeEventListener('play', handlePlay); video.removeEventListener('pause', handlePause); video.removeEventListener('ended', handleEnded); releaseWakeLock(); }; }; 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)}>