// 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'; const socket = io('', { transports: ['websocket'] }); 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)} placeholder="https://example.com/video.mp4 or https://youtu.be/xxxx or 11-char ID" />
); } 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. Playback auto-switches to proxy if CORS blocks it.
setIdOrUrl(e.target.value)} placeholder="https://www.youtube.com/watch?v=XXXXXXXXXXX or XXXXXXXID" />
{ytError && (
YouTube failed
{ytError.message}
)}
); } 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…')}
))}
); } 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([]); 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('host_changed', ({ hostId }) => setIsHost(socket.id === hostId)); socket.on('members', ({ members, roomName }) => { setMembers(members || []); if (roomName) setPartyName(roomName); }); socket.on('system', ({ text }) => { if (text) console.log(text); }); socket.on('queue_update', ({ queue }) => setState(s => ({ ...s, queue: queue || [] }))); // Host receives a request → attach a preview via JioSaavn search (best effort) socket.on('song_request', async (req) => { if (!isHost) return; const enriched = { ...req, preview: null }; try { const res = await fetch(`/api/result?q=${encodeURIComponent(req.query)}`); const data = await res.json(); const list = Array.isArray(data?.data) ? data.data : (data?.results || data?.songs || data?.list || data?.items || data || []); const first = list?.[0]; if (first) { const url = first.media_url || first.downloadUrl || first.url || first.perma_url || first.streamUrl; if (url) enriched.preview = { url, title: first.title || req.query, meta: first, kind: detectMediaTypeFromUrl(url) || 'audio', thumb: getThumb(first) }; } } catch {} setRequests(prev => [enriched, ...prev].slice(0, 20)); }); socket.emit('rename', { roomId, newName: name }); return () => { socket.off('set_track'); socket.off('play'); socket.off('pause'); socket.off('seek'); socket.off('host_changed'); socket.off('members'); socket.off('system'); socket.off('queue_update'); socket.off('song_request'); }; }, [roomId, name, asHost, roomName, isHost]); // Direct links const setTrackAndClose = (track) => { socket.emit('set_track', { roomId, track }); setShowDirect(false); setShowYT(false); }; const pickDirectUrl = async ({ 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 mediaType = (type === 'auto' || !type) ? (detectMediaTypeFromUrl(url) || 'audio') : type; if (mediaType === 'unknown') { alert('Unknown media type. Choose Audio or Video.'); return; } const track = { url, title: url, meta: { source: 'direct' }, kind: mediaType }; setTrackAndClose(track); }; // YouTube via Vidfly only const resolveYouTube = async (idOrUrl) => { setYtError(null); const resp = await fetch(`/api/yt/source?url=${encodeURIComponent(idOrUrl)}`); const data = await resp.json(); if (!resp.ok) { const message = data?.error || 'Failed to resolve YouTube'; throw new Error(message); } 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, source: data.source || 'vidfly', yt: true }, kind: data.kind || 'video', thumb: data.thumbnail }; setTrackAndClose(track); } catch (e) { setYtError({ message: e.message }); } }; const Controls = useMemo(() => ( isHost ? (
) :
Waiting for host controls…
), [isHost, roomId]); const handleRequestAction = (action, req) => { if (action === 'reject') { setRequests(prev => prev.filter(r => r.requestId !== req.requestId)); return; } if (!req.preview) return; const track = req.preview; socket.emit('song_request_action', { roomId, action: action === 'accept' ? 'accept' : 'queue', track }); setRequests(prev => prev.filter(r => r.requestId !== req.requestId)); }; return (
{partyName}
{isHost ? 'Host' : 'Guest'}
{Controls}
Chat
Members
Queue
{state.queue?.length ? (
{state.queue.map((t, i) => (
{t.title || t.url}
))}
) :
No songs in queue
}
setShowDirect(false)} onPick={pickDirectUrl} /> setShowYT(false)} onPick={onPickYouTube} ytError={ytError} />
); }