Spaces:
Sleeping
Sleeping
File size: 3,908 Bytes
f90b43a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 | 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>
);
}
|