Spaces:
Running
Running
| 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> | |
| ); | |
| } | |