import React, { useEffect, useMemo, useState } from 'react'; import { io } from 'socket.io-client'; import Chat from './Chat.jsx'; import Player from './Player.jsx'; import MemberList from './MemberList.jsx'; import { detectMediaTypeFromUrl, getThumb, prettyError, safeTitle } from './utils.js'; import { useToasts } from './Toasts.jsx'; import { log } from './logger.js'; const socket = io('', { transports: ['websocket'] }); // Helper: when the remote URL is a googlevideo/redirector URL (or other blocked host), // return a proxied same-origin URL so the browser won't hit CORS. function proxiedUrl(remoteUrl) { if (!remoteUrl) return remoteUrl; try { const u = new URL(remoteUrl); const host = u.hostname.toLowerCase(); // If it points to googlevideo (common YouTube direct stream host), proxy it. if (host.includes('googlevideo.com') || host.includes('redirector.googlevideo.com')) { return `/api/yt/proxy?url=${encodeURIComponent(remoteUrl)}`; } // If the remote link is already proxied (starts with /api/yt/proxy), keep as-is if (remoteUrl.startsWith('/api/yt/proxy')) return remoteUrl; return remoteUrl; } catch { // not a valid URL — return as-is return remoteUrl; } } 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
Supports video (MP4, MKV, WEBM, MOV), audio (MP3, M4A, WAV, FLAC, OGG, OPUS), and YouTube URLs/IDs.
setUrl(e.target.value)} />
); } function JioSaavnModal({ open, onClose, onPick }) { const [mode, setMode] = useState('result'); const [q, setQ] = useState(''); const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); useEffect(() => { if (!open) { setQ(''); setItems([]); setLoading(false); setMode('result'); } }, [open]); const search = async () => { if (!q.trim()) return; try { setLoading(true); const path = mode === 'result' ? '/api/result' : `/api/${mode}`; const res = await fetch(`${path}?q=${encodeURIComponent(q.trim())}`); const data = await res.json(); const arr = Array.isArray(data?.data) ? data.data : (data?.results || data?.songs || data?.list || data?.items || data || []); setItems(arr); setLoading(false); } catch (e) { setLoading(false); log.error(e); alert('Failed to fetch: ' + prettyError(e)); } }; if (!open) return null; return (
e.target === e.currentTarget && onClose()}>
JioSaavn search
setQ(e.target.value)} />
{items?.length ? items.map((it, idx) => { const title = it.title || it.name || it.song || it?.data?.title || `Item ${idx+1}`; const mediaUrl = it.media_url || it.downloadUrl || it.url || it.perma_url || it.streamUrl; const thumb = getThumb(it); return (
{title}
{mediaUrl || 'No stream URL in item'}
{mediaUrl ? ( ) : }
); }) : (
{loading ? 'Searching…' : 'No results yet.'}
)}
); } function YouTubeModal({ open, onClose, onPick }) { const [q, setQ] = useState(''); const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [unavailable, setUnavailable] = useState(false); useEffect(() => { if (!open) { setQ(''); setItems([]); setLoading(false); setUnavailable(false); } }, [open]); const search = async () => { if (!q.trim()) return; try { setLoading(true); const res = await fetch(`/api/ytsearch?q=${encodeURIComponent(q.trim())}`); if (res.status === 501) { setUnavailable(true); setLoading(false); return; } const data = await res.json(); setItems(data.items || []); setLoading(false); } catch (e) { setLoading(false); alert('YouTube search failed: ' + e.message); } }; if (!open) return null; return (
e.target === e.currentTarget && onClose()}>
YouTube search
{unavailable ? (
YouTube search is disabled. Set YT_API_KEY to enable search. You can still paste YouTube URLs/IDs via Direct link.
) : ( <>
setQ(e.target.value)} />
{items.length ? items.map((it) => (
{it.title}
{it.channelTitle}
)) : (
{loading ? 'Searching…' : 'No results yet.'}
)}
)}
); } 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 [showJS, setShowJS] = useState(false); const [showYT, setShowYT] = useState(false); 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); // Avoid duplicate local join toasts; rely on system broadcast in chat }); 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 (first result) 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]); // Use vidfly / yt source through our backend for YouTube links/IDs const pickDirectUrl = async ({ url, type }) => { if (!url) return; const isYT = (type === 'youtube') || detectMediaTypeFromUrl(url) === 'youtube' || /^(?:https?:\/\/)?(?:www\.)?youtu/.test(url) || /^[A-Za-z0-9_-]{11}$/.test(url); if (isYT) { try { const resp = await fetch(`/api/yt/source?url=${encodeURIComponent(url)}`); const data = await resp.json(); if (!resp.ok) throw new Error(data.error || 'YT resolve failed'); const prox = proxiedUrl(data.url); const track = { url: prox, title: data.title || url, meta: { thumb: data.thumbnail, source: 'youtube', originalUrl: data.url }, kind: 'video', thumb: data.thumbnail }; socket.emit('set_track', { roomId, track }); } catch (e) { alert('YouTube resolve failed: ' + (e.message || e)); } finally { setShowDirect(false); } return; } let mediaType = type === 'auto' ? detectMediaTypeFromUrl(url) : type; if (!mediaType || mediaType === 'unknown') { setShowDirect(false); alert('Unknown media type. Choose Audio or Video.'); setTimeout(() => setShowDirect(true), 30); return; } const track = { url, title: url, meta: { source: 'direct' }, kind: mediaType }; socket.emit('set_track', { roomId, track }); setShowDirect(false); }; const playJioSaavnItem = (item) => { const url = item?.url || item?.media_url || item?.downloadUrl || item?.perma_url || item?.streamUrl; if (!url) return alert('Selected item has no stream URL'); const type = detectMediaTypeFromUrl(url) || 'audio'; const track = { url, title: item.title || item.name || url, meta: item, kind: type, thumb: getThumb(item) }; socket.emit('set_track', { roomId, track }); setShowJS(false); }; const playYouTubeItem = async (yt) => { try { const resp = await fetch(`/api/yt/source?url=${encodeURIComponent(yt.videoId)}`); const data = await resp.json(); if (!resp.ok) throw new Error(data.error || 'YT resolve failed'); const prox = proxiedUrl(data.url); const track = { url: prox, title: yt.title, meta: { thumb: data.thumbnail, source: 'youtube', originalUrl: data.url }, kind: 'video', thumb: data.thumbnail || yt.thumb }; socket.emit('set_track', { roomId, track }); setShowYT(false); } catch (e) { alert('YouTube resolve failed: ' + (e.message || e)); } }; 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; // Ensure preview.url is proxied if necessary (avoids CORS for youtube direct links) const track = { ...req.preview }; if (track.url) track.url = proxiedUrl(track.url); 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} /> setShowJS(false)} onPick={playJioSaavnItem} /> setShowYT(false)} onPick={playYouTubeItem} />
); }