393 lines
17 KiB
TypeScript
Executable file
393 lines
17 KiB
TypeScript
Executable file
'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 (
|
|
<div style={skeletonContainerStyle}>
|
|
<div style={skeletonVideoStyle} className="skeleton" />
|
|
<div style={skeletonControlsStyle}>
|
|
<div style={skeletonProgressStyle} className="skeleton" />
|
|
<div style={skeletonButtonsRowStyle}>
|
|
<div style={{ ...skeletonButtonStyle, width: '60px' }} className="skeleton" />
|
|
<div style={{ ...skeletonButtonStyle, width: '40px' }} className="skeleton" />
|
|
<div style={{ ...skeletonButtonStyle, width: '40px' }} className="skeleton" />
|
|
<div style={{ ...skeletonButtonStyle, width: '80px' }} className="skeleton" />
|
|
<div style={{ flex: 1 }} />
|
|
<div style={{ ...skeletonButtonStyle, width: '100px' }} className="skeleton" />
|
|
<div style={{ ...skeletonButtonStyle, width: '40px' }} className="skeleton" />
|
|
<div style={{ ...skeletonButtonStyle, width: '40px' }} className="skeleton" />
|
|
</div>
|
|
</div>
|
|
<div style={skeletonCenterStyle}>
|
|
<div style={skeletonSpinnerStyle} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function VideoPlayer({ videoId, title, nextVideoId }: VideoPlayerProps) {
|
|
const router = useRouter();
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const audioRef = useRef<HTMLAudioElement>(null);
|
|
const hlsRef = useRef<any>(null);
|
|
const audioHlsRef = useRef<any>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [useFallback, setUseFallback] = useState(false);
|
|
const [showControls, setShowControls] = useState(false);
|
|
const [qualities, setQualities] = useState<QualityOption[]>([]);
|
|
const [currentQuality, setCurrentQuality] = useState<number>(0);
|
|
const [showQualityMenu, setShowQualityMenu] = useState(false);
|
|
const [hasSeparateAudio, setHasSeparateAudio] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isBuffering, setIsBuffering] = useState(false);
|
|
const audioUrlRef = useRef<string>('');
|
|
|
|
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 <div style={noVideoStyle}>No video ID</div>;
|
|
}
|
|
|
|
if (useFallback) {
|
|
return (
|
|
<div style={containerStyle} onMouseEnter={() => setShowControls(true)} onMouseLeave={() => setShowControls(false)}>
|
|
<iframe
|
|
src={`https://www.youtube.com/embed/${videoId}?autoplay=1&rel=0&modestbranding=1`}
|
|
style={iframeStyle}
|
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
allowFullScreen
|
|
title={title || 'Video'}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div style={containerStyle} onMouseEnter={() => setShowControls(true)} onMouseLeave={() => { setShowControls(false); setShowQualityMenu(false); }}>
|
|
{isLoading && <PlayerSkeleton />}
|
|
|
|
<video
|
|
ref={videoRef}
|
|
style={{ ...videoStyle, visibility: isLoading ? 'hidden' : 'visible' }}
|
|
controls
|
|
playsInline
|
|
poster={`https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`}
|
|
/>
|
|
|
|
{hasSeparateAudio && <audio ref={audioRef} style={{ display: 'none' }} />}
|
|
|
|
{error && (
|
|
<div style={errorStyle}>
|
|
<span>{error}</span>
|
|
<button onClick={() => setUseFallback(true)} style={retryBtnStyle}>Try YouTube Player</button>
|
|
</div>
|
|
)}
|
|
|
|
{showControls && !error && !isLoading && (
|
|
<>
|
|
<a href={`https://www.youtube.com/watch?v=${videoId}`} target="_blank" rel="noopener noreferrer" style={openBtnStyle}>
|
|
Open on YouTube ↗
|
|
</a>
|
|
|
|
{qualities.length > 0 && (
|
|
<div style={qualityContainerStyle}>
|
|
<button onClick={() => setShowQualityMenu(!showQualityMenu)} style={qualityBtnStyle}>
|
|
{qualities.find(q => q.height === currentQuality)?.label || 'Auto'}
|
|
</button>
|
|
|
|
{showQualityMenu && (
|
|
<div style={qualityMenuStyle}>
|
|
{qualities.map((q) => (
|
|
<button
|
|
key={q.height}
|
|
onClick={() => changeQuality(q)}
|
|
style={{
|
|
...qualityItemStyle,
|
|
background: q.height === currentQuality ? 'rgba(255,0,0,0.3)' : 'transparent',
|
|
}}
|
|
>
|
|
{q.label}
|
|
{q.height === currentQuality && ' ✓'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{isBuffering && !isLoading && (
|
|
<div style={bufferingOverlayStyle}>
|
|
<div style={spinnerStyle} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const noVideoStyle: React.CSSProperties = { width: '100%', background: '#000', borderRadius: '12px', aspectRatio: '16/9', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#666' };
|
|
const containerStyle: React.CSSProperties = { width: '100%', background: '#000', borderRadius: '12px', overflow: 'hidden', aspectRatio: '16/9', position: 'relative' };
|
|
const videoStyle: React.CSSProperties = { width: '100%', height: '100%', background: '#000' };
|
|
const iframeStyle: React.CSSProperties = { width: '100%', height: '100%', border: 'none' };
|
|
const errorStyle: React.CSSProperties = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '12px', background: 'rgba(0,0,0,0.9)', color: '#ff6b6b' };
|
|
const retryBtnStyle: React.CSSProperties = { padding: '8px 16px', background: '#ff0000', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '14px' };
|
|
const openBtnStyle: React.CSSProperties = { position: 'absolute', top: '10px', right: '10px', padding: '6px 12px', background: 'rgba(0,0,0,0.8)', color: '#fff', borderRadius: '4px', textDecoration: 'none', fontSize: '12px', zIndex: 10 };
|
|
const qualityContainerStyle: React.CSSProperties = { position: 'absolute', bottom: '50px', right: '10px', zIndex: 10 };
|
|
const qualityBtnStyle: React.CSSProperties = { padding: '6px 12px', background: 'rgba(0,0,0,0.8)', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '12px', fontWeight: 500 };
|
|
const qualityMenuStyle: React.CSSProperties = { position: 'absolute', bottom: '100%', right: 0, marginBottom: '4px', background: 'rgba(0,0,0,0.95)', borderRadius: '8px', overflow: 'hidden', minWidth: '100px' };
|
|
const qualityItemStyle: React.CSSProperties = { display: 'block', width: '100%', padding: '8px 16px', color: '#fff', border: 'none', background: 'transparent', textAlign: 'left', cursor: 'pointer', fontSize: '13px', whiteSpace: 'nowrap' };
|
|
|
|
const skeletonContainerStyle: React.CSSProperties = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', flexDirection: 'column', background: '#000', zIndex: 5 };
|
|
const skeletonVideoStyle: React.CSSProperties = { flex: 1, margin: '8px', borderRadius: '8px' };
|
|
const skeletonControlsStyle: React.CSSProperties = { padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: '8px' };
|
|
const skeletonProgressStyle: React.CSSProperties = { height: '4px', borderRadius: '2px' };
|
|
const skeletonButtonsRowStyle: React.CSSProperties = { display: 'flex', gap: '8px', alignItems: 'center' };
|
|
const skeletonButtonStyle: React.CSSProperties = { height: '24px', borderRadius: '4px' };
|
|
const skeletonCenterStyle: React.CSSProperties = { position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)' };
|
|
const skeletonSpinnerStyle: React.CSSProperties = { width: '48px', height: '48px', border: '4px solid rgba(255,255,255,0.1)', borderTopColor: 'rgba(255,255,255,0.8)', borderRadius: '50%', animation: 'spin 1s linear infinite' };
|
|
const bufferingOverlayStyle: React.CSSProperties = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.3)', pointerEvents: 'none', zIndex: 5 };
|
|
const spinnerStyle: React.CSSProperties = { width: '40px', height: '40px', border: '3px solid rgba(255,255,255,0.2)', borderTopColor: '#fff', borderRadius: '50%', animation: 'spin 0.8s linear infinite' };
|