Spaces:
Sleeping
Sleeping
| import React, { useEffect, useMemo, useRef, useState } from 'react'; | |
| import { detectMediaTypeFromUrl, getThumb, safeTitle } from './utils.js'; | |
| import { useToasts } from './Toasts.jsx'; | |
| import { log } from './logger.js'; | |
| export default function Player({ socket, roomId, state, isHost }) { | |
| const { push } = useToasts(); | |
| const audioRef = useRef(null); | |
| const videoRef = useRef(null); | |
| const [err, setErr] = useState(null); | |
| const [useProxy, setUseProxy] = useState(false); | |
| const busyRef = useRef(false); | |
| const lastAppliedRef = useRef({ url: null }); | |
| const mediaType = useMemo(() => { | |
| if (!state?.track?.url) return 'none'; | |
| if (state?.track?.kind) return state.track.kind; | |
| return detectMediaTypeFromUrl(state.track.url) || 'audio'; | |
| }, [state?.track?.url, state?.track?.kind]); | |
| const logicalTime = () => { | |
| if (!state) return 0; | |
| const { anchor = 0, anchorAt = 0, isPlaying = false } = state; | |
| if (!isPlaying) return anchor; | |
| const elapsed = (Date.now() - anchorAt) / 1000; | |
| return Math.max(0, anchor + elapsed); | |
| }; | |
| const currentSrc = useMemo(() => { | |
| if (!state?.track?.url) return ''; | |
| return useProxy ? `/api/proxy?url=${encodeURIComponent(state.track.url)}` : state.track.url; | |
| }, [state?.track?.url, useProxy]); | |
| const applyMediaState = async (el) => { | |
| if (!el || !state?.track?.url) return; | |
| if (busyRef.current) return; | |
| busyRef.current = true; | |
| try { | |
| const marker = `${state.track.url}|${useProxy ? 'px' : 'dir'}`; | |
| if (lastAppliedRef.current.url !== marker) { | |
| el.src = currentSrc; | |
| el.load(); | |
| lastAppliedRef.current.url = marker; | |
| } | |
| const target = logicalTime(); | |
| if (Number.isFinite(target)) { | |
| try { el.currentTime = Math.max(0, target); } catch {} | |
| } | |
| if (state.isPlaying) { | |
| await el.play().catch(() => {}); | |
| } else { | |
| el.pause(); | |
| } | |
| setErr(null); | |
| } catch (e) { | |
| log.error('Playback error', e); | |
| setErr(e?.message || 'Playback failed'); | |
| push(`Playback failed: ${e?.message || 'Unknown error'}`, 'bad', 4500); | |
| } finally { | |
| busyRef.current = false; | |
| } | |
| }; | |
| useEffect(() => { | |
| setUseProxy(false); | |
| lastAppliedRef.current.url = null; | |
| const el = mediaType === 'video' ? videoRef.current : audioRef.current; | |
| if (el) applyMediaState(el); | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [state?.track?.url, mediaType]); | |
| useEffect(() => { | |
| const el = mediaType === 'video' ? videoRef.current : audioRef.current; | |
| if (!el) return; | |
| const target = logicalTime(); | |
| const delta = target - el.currentTime; | |
| if (Math.abs(delta) > 0.35) { | |
| try { el.currentTime = Math.max(0, target); } catch {} | |
| } | |
| if (state.isPlaying && el.paused) { | |
| el.play().catch(() => {}); | |
| } else if (!state.isPlaying && !el.paused) { | |
| el.pause(); | |
| } | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [state.isPlaying, state.anchor, state.anchorAt]); | |
| useEffect(() => { | |
| const onEnded = () => { if (isHost) socket.emit('ended', { roomId }); }; | |
| const a = audioRef.current, v = videoRef.current; | |
| a?.addEventListener('ended', onEnded); | |
| v?.addEventListener('ended', onEnded); | |
| return () => { | |
| a?.removeEventListener('ended', onEnded); | |
| v?.removeEventListener('ended', onEnded); | |
| }; | |
| }, [socket, roomId, isHost]); | |
| const onMediaError = () => { | |
| if (!useProxy) { | |
| setUseProxy(true); | |
| lastAppliedRef.current.url = null; | |
| const el = mediaType === 'video' ? videoRef.current : audioRef.current; | |
| if (el) applyMediaState(el); | |
| } else { | |
| setErr('Playback failed (even via proxy)'); | |
| } | |
| }; | |
| const title = state?.track ? safeTitle(state.track) : 'No track selected'; | |
| const thumb = state?.track?.thumb || getThumb(state?.track?.meta); | |
| return ( | |
| <div style={{ position:'relative' }}> | |
| <div | |
| className="hero-thumb" | |
| style={{ backgroundImage: thumb ? `url("${thumb}")` : undefined }} | |
| aria-hidden | |
| /> | |
| <div className="ambient"></div> | |
| <div className="now-playing" style={{ marginBottom: 10, position:'relative' }}> | |
| <div className="thumb" style={{ width:72, height:72, backgroundImage: thumb ? `url("${thumb}")` : undefined }} /> | |
| <div style={{ minWidth:0, flex:1 }}> | |
| <div style={{ fontWeight:700, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{title}</div> | |
| <div style={{ fontSize:12, color:'var(--muted)', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}> | |
| {state?.track?.meta?.artists?.join?.(', ') || state?.track?.meta?.artist || state?.track?.meta?.album || state?.track?.kind?.toUpperCase()} | |
| </div> | |
| </div> | |
| {!isHost && <div className="tag">Listener</div>} | |
| </div> | |
| {mediaType === 'video' ? ( | |
| <video | |
| ref={videoRef} | |
| src={currentSrc} | |
| onError={onMediaError} | |
| controls={isHost} | |
| playsInline | |
| style={{ width:'100%', maxHeight: '56vh', background:'#000' }} | |
| crossOrigin="anonymous" | |
| /> | |
| ) : mediaType === 'audio' ? ( | |
| <audio | |
| ref={audioRef} | |
| src={currentSrc} | |
| onError={onMediaError} | |
| controls={isHost} | |
| style={{ width:'100%' }} | |
| crossOrigin="anonymous" | |
| /> | |
| ) : ( | |
| <div style={{ color:'var(--muted)', padding:'12px 0' }}>No track selected</div> | |
| )} | |
| {useProxy && ( | |
| <div style={{ marginTop:8, fontSize:12, color:'var(--muted)' }}> | |
| Using proxy due to CORS on the original media URL. | |
| </div> | |
| )} | |
| {err && <div style={{ marginTop:8, color:'var(--bad)' }}>{err}</div>} | |
| </div> | |
| ); | |
| } | |