// client/src/Room.jsx import React, { useEffect, useMemo, useState } from 'react'; import { io } from 'socket.io-client'; import Player from './Player.jsx'; import Chat from './Chat.jsx'; import MemberList from './MemberList.jsx'; import { detectMediaTypeFromUrl, getThumb } from './utils.js'; import { useToasts } from './Toasts.jsx'; // initialize socket once const socket = io('', { transports: ['websocket'] }); // Modal for arbitrary direct URLs (MP4/MP3/etc or YouTube ID/URL) function DirectLinkModal({ open, onClose, onPick }) { const [url, setUrl] = useState(''); const [type, setType] = useState('auto'); useEffect(() => { if (!open) { setUrl(''); setType('auto'); } }, [open]); if (!open) return null; return (
e.target === e.currentTarget && onClose()}>
Play a direct link
Paste a direct media URL (MP4, WEBM, MP3, M4A) or a YouTube URL/ID.
setUrl(e.target.value)} />
); } // Modal for YouTube (via Vidfly) only function YouTubeModal({ open, onClose, onPick, ytError }) { const [idOrUrl, setIdOrUrl] = useState(''); useEffect(() => { if (!open) setIdOrUrl(''); }, [open]); if (!open) return null; return (
e.target === e.currentTarget && onClose()}>
Play YouTube (Vidfly)
Enter a YouTube URL or 11-character ID. We resolve via Vidfly and proxy if needed.
setIdOrUrl(e.target.value)} />
{ytError && (
YouTube failed
{ytError.message}
)}
); } // Panel for host to accept/queue/reject song requests function RequestsPanel({ isHost, requests, onAct }) { if (!isHost || !requests.length) return null; return (
Song requests
{requests.map(r => (
{r.requester} → {r.query}
{r.preview?.title || (r.preview ? 'Result found' : 'Searching…')}
))}
); } // Main Room component export default function Room({ roomId, name, asHost, roomName }) { const { push } = useToasts(); const [isHost, setIsHost] = useState(false); const [state, setState] = useState({ track: null, isPlaying: false, anchor: 0, anchorAt: 0, queue: [] }); const [members, setMembers] = useState([]); const [partyName, setPartyName] = useState(roomName || roomId); const [showDirect, setShowDirect] = useState(false); const [showYT, setShowYT] = useState(false); const [ytError, setYtError] = useState(null); const [requests, setRequests] = useState([]); // join room & socket listeners useEffect(() => { socket.emit('join_room', { roomId, name, asHost, roomName }, resp => { setIsHost(resp.isHost); if (resp.state) setState(s => ({ ...s, ...resp.state })); if (resp.roomName) setPartyName(resp.roomName); }); socket.on('set_track', ({ track }) => setState(s => ({ ...s, track }))); socket.on('play', ({ anchor, anchorAt }) => setState(s => ({ ...s, isPlaying: true, anchor, anchorAt }))); socket.on('pause', ({ anchor, anchorAt }) => setState(s => ({ ...s, isPlaying: false, anchor, anchorAt }))); socket.on('seek', ({ anchor, anchorAt, isPlaying }) => setState(s => ({ ...s, anchor, anchorAt, isPlaying }))); socket.on('members', ({ members, roomName }) => { setMembers(members || []); if (roomName) setPartyName(roomName); }); socket.on('queue_update', ({ queue }) => setState(s => ({ ...s, queue: queue || [] }))); return () => { socket.off('set_track'); socket.off('play'); socket.off('pause'); socket.off('seek'); socket.off('members'); socket.off('queue_update'); }; }, [roomId, name, asHost, roomName]); // helper to set track and close modals const setTrackAndClose = track => { socket.emit('set_track', { roomId, track }); setShowDirect(false); setShowYT(false); }; // pick a direct URL or YouTube const pickDirectUrl = ({ url, type }) => { if (!url) return; const isYT = type === 'youtube' || detectMediaTypeFromUrl(url) === 'youtube' || /^[A-Za-z0-9_-]{11}$/.test(url); if (isYT) { setShowDirect(false); setShowYT(true); return; } const kind = type === 'auto' ? (detectMediaTypeFromUrl(url) || 'audio') : type; const track = { url, title: url, meta: {}, kind }; setTrackAndClose(track); }; // resolve YouTube via our /api/yt/source const resolveYouTube = async idOrUrl => { setYtError(null); const resp = await fetch(`/api/yt/source?url=${encodeURIComponent(idOrUrl)}`); const data = await resp.json(); if (!resp.ok) throw new Error(data.error || 'Failed to resolve YouTube'); return data; }; const onPickYouTube = async idOrUrl => { try { const data = await resolveYouTube(idOrUrl); const track = { url: data.url, title: data.title || idOrUrl, meta: { thumb: data.thumbnail }, kind: data.kind || 'video', thumb: data.thumbnail }; setTrackAndClose(track); } catch (e) { setYtError({ message: e.message }); } }; // control bar (host only) const Controls = useMemo(() => ( isHost ? (
) :
Waiting for host controls…
), [isHost, roomId]); // handle song request accept/queue/reject const handleRequestAction = (action, req) => { if (action === 'reject') { setRequests(r => r.filter(x => x.requestId !== req.requestId)); return; } if (!req.preview) return; socket.emit('song_request_action', { roomId, action: action === 'accept' ? 'accept' : 'queue', track: req.preview }); setRequests(r => r.filter(x => x.requestId !== req.requestId)); }; return (
{/* Left column: player + chat + requests */}
{partyName}
{isHost ? 'Host' : 'Guest'}
{Controls}
Chat
{/* Right column: members + queue */}
Members
Queue
{state.queue.length > 0 ? (
{state.queue.map((t, i) => (
{t.title || t.url}
))}
) : (
No songs in queue
)}
{/* Overlays */} setShowDirect(false)} onPick={pickDirectUrl} /> setShowYT(false)} onPick={onPickYouTube} ytError={ytError} />
); }