test / client /src /YouTubePlayer.jsx
akborana4's picture
Create YouTubePlayer.jsx
f90b43a verified
import React, { useEffect, useRef } from 'react';
import { useToasts } from './Toasts.jsx';
import { log } from './logger.js';
function extractVideoId(u) {
try {
if (!u) return null;
if (/^[a-zA-Z0-9_-]{11}$/.test(u)) return u;
const url = new URL(u);
if (url.hostname.includes('youtu.be')) return url.pathname.replace('/', '');
if (url.searchParams.get('v')) return url.searchParams.get('v');
const m = url.pathname.match(/\/embed\/([a-zA-Z0-9_-]{11})/);
if (m) return m[1];
} catch {}
return null;
}
let apiLoading = false;
let apiReady = false;
const readyCallbacks = [];
function ensureYTApi(cb) {
if (apiReady) return cb();
readyCallbacks.push(cb);
if (apiLoading) return;
apiLoading = true;
const tag = document.createElement('script');
tag.src = 'https://www.youtube.com/iframe_api';
window.onYouTubeIframeAPIReady = () => {
apiReady = true;
readyCallbacks.splice(0).forEach(fn => fn());
};
document.head.appendChild(tag);
}
export default function YouTubePlayer({ socket, roomId, state, isHost }) {
const { push } = useToasts();
const containerRef = useRef(null);
const playerRef = useRef(null);
const lastSyncRef = useRef(0);
useEffect(() => {
ensureYTApi(() => {
if (!containerRef.current) return;
if (playerRef.current && playerRef.current.destroy) {
try { playerRef.current.destroy(); } catch {}
}
const vid = extractVideoId(state?.track?.url);
if (!vid) {
push('Invalid YouTube URL or ID', 'bad');
return;
}
// eslint-disable-next-line no-undef
playerRef.current = new YT.Player(containerRef.current, {
width: '100%',
height: '390',
videoId: vid,
playerVars: { modestbranding: 1, rel: 0, playsinline: 1, controls: isHost ? 1 : 0 },
events: {
onReady: (e) => {
try {
const target = state.isPlaying
? Math.max(0, (state.anchor || 0) + (Date.now() - (state.anchorAt || 0)) / 1000)
: (state.anchor || 0);
e.target.seekTo(target, true);
if (state.isPlaying) e.target.playVideo(); else e.target.pauseVideo();
} catch (err) {
log.error('YT onReady error', err);
}
},
onStateChange: (ev) => {
const P = window.YT.PlayerState;
if (ev.data === P.ENDED && isHost) socket.emit('ended', { roomId });
},
onError: (e) => {
log.error('YouTube error', e);
push('YouTube playback error', 'bad');
}
}
});
});
return () => {
if (playerRef.current && playerRef.current.destroy) {
try { playerRef.current.destroy(); } catch {}
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state?.track?.url, isHost]);
useEffect(() => {
const p = playerRef.current;
if (!p || !p.getPlayerState) return;
const now = Date.now();
if (now - lastSyncRef.current < 250) return;
lastSyncRef.current = now;
try {
const P = window.YT.PlayerState;
const target = state.isPlaying
? Math.max(0, (state.anchor || 0) + (Date.now() - (state.anchorAt || 0)) / 1000)
: (state.anchor || 0);
const cur = p.getCurrentTime ? p.getCurrentTime() : 0;
if (Math.abs(cur - target) > 0.4) p.seekTo(target, true);
const isPlayingNow = p.getPlayerState && p.getPlayerState() === P.PLAYING;
if (state.isPlaying && !isPlayingNow) p.playVideo();
if (!state.isPlaying && isPlayingNow) p.pauseVideo();
} catch (e) {
log.warn('YT sync warn', e);
}
}, [state.isPlaying, state.anchor, state.anchorAt]);
return (
<div style={{ position:'relative' }}>
<div ref={containerRef} style={{ width:'100%', aspectRatio:'16/9', background:'#000' }} />
</div>
);
}