Spaces:
Sleeping
Sleeping
| 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 ( | |
| <div className="modal-backdrop" onClick={(e) => e.target === e.currentTarget && onClose()}> | |
| <div className="modal"> | |
| <div className="section-title">Play a direct link</div> | |
| <div style={{ color:'var(--muted)', fontSize:13, marginBottom:8 }}> | |
| Supports video (MP4, MKV, WEBM, MOV), audio (MP3, M4A, WAV, FLAC, OGG, OPUS), and YouTube URLs/IDs. | |
| </div> | |
| <input className="input" placeholder="https://example.com/media.mp4 or https://youtu.be/xxxx" value={url} onChange={e => setUrl(e.target.value)} /> | |
| <div style={{ display:'flex', gap:10, marginTop:10 }}> | |
| <select className="select" value={type} onChange={e => setType(e.target.value)}> | |
| <option value="auto">Auto-detect type</option> | |
| <option value="video">Force Video</option> | |
| <option value="audio">Force Audio</option> | |
| <option value="youtube">YouTube</option> | |
| </select> | |
| <button className="btn primary" onClick={() => onPick({ url, type })}>Set</button> | |
| <button className="btn" onClick={onClose}>Cancel</button> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| 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 ( | |
| <div className="modal-backdrop" onClick={(e) => e.target === e.currentTarget && onClose()}> | |
| <div className="modal"> | |
| <div className="section-title">JioSaavn search</div> | |
| <div className="row" style={{ gap:8 }}> | |
| <div className="col"><input className="input" placeholder="Search query (e.g., sanam re)" value={q} onChange={e => setQ(e.target.value)} /></div> | |
| <div className="col"> | |
| <select className="select" value={mode} onChange={e => setMode(e.target.value)}> | |
| <option value="result">Search (all)</option> | |
| <option value="song">Song</option> | |
| <option value="album">Album</option> | |
| <option value="playlist">Playlist</option> | |
| <option value="lyrics">Lyrics</option> | |
| </select> | |
| </div> | |
| <div className="col" style={{ flex:'0 0 auto' }}> | |
| <button className="btn primary" onClick={search}>{loading ? 'Searching...' : 'Search'}</button> | |
| </div> | |
| </div> | |
| <div style={{ marginTop:12, maxHeight:320, overflow:'auto', display:'grid', gap:8 }}> | |
| {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 ( | |
| <div key={idx} className="room-card" style={{ display:'flex', alignItems:'center', justifyContent:'space-between', gap:10 }}> | |
| <div style={{ display:'flex', alignItems:'center', gap:10, minWidth:0 }}> | |
| <div className="thumb" style={{ width:48, height:48, backgroundImage: thumb ? `url("${thumb}")` : undefined }} /> | |
| <div style={{ minWidth:0 }}> | |
| <div style={{ fontWeight:700, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{title}</div> | |
| <div className="meta" style={{ marginTop:4, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{mediaUrl || 'No stream URL in item'}</div> | |
| </div> | |
| </div> | |
| <div style={{ display:'flex', gap:8 }}> | |
| {mediaUrl ? ( | |
| <button className="btn good" onClick={() => onPick({ url: mediaUrl, title, meta: it, thumb })}>Play</button> | |
| ) : <button className="btn" disabled>No URL</button>} | |
| </div> | |
| </div> | |
| ); | |
| }) : ( | |
| <div style={{ color:'var(--muted)' }}>{loading ? 'Searching…' : 'No results yet.'}</div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| 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 ( | |
| <div className="modal-backdrop" onClick={(e) => e.target === e.currentTarget && onClose()}> | |
| <div className="modal"> | |
| <div className="section-title">YouTube search</div> | |
| {unavailable ? ( | |
| <div style={{ color:'var(--warn)' }}> | |
| YouTube search is disabled. Set YT_API_KEY to enable search. You can still paste YouTube URLs/IDs via Direct link. | |
| </div> | |
| ) : ( | |
| <> | |
| <div className="row" style={{ gap:8 }}> | |
| <div className="col"><input className="input" placeholder="Search YouTube" value={q} onChange={e => setQ(e.target.value)} /></div> | |
| <div className="col" style={{ flex:'0 0 auto' }}> | |
| <button className="btn primary" onClick={search}>{loading ? 'Searching...' : 'Search'}</button> | |
| </div> | |
| </div> | |
| <div style={{ marginTop:12, maxHeight:320, overflow:'auto', display:'grid', gap:8 }}> | |
| {items.length ? items.map((it) => ( | |
| <div key={it.videoId} className="room-card" style={{ display:'flex', alignItems:'center', justifyContent:'space-between', gap:10 }}> | |
| <div style={{ display:'flex', alignItems:'center', gap:10, minWidth:0 }}> | |
| <div className="thumb" style={{ width:64, height:36, backgroundImage: it.thumb ? `url("${it.thumb}")` : undefined }} /> | |
| <div style={{ minWidth:0 }}> | |
| <div style={{ fontWeight:700, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{it.title}</div> | |
| <div className="meta" style={{ marginTop:4, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{it.channelTitle}</div> | |
| </div> | |
| </div> | |
| <div> | |
| <button className="btn good" onClick={() => onPick(it)}>Play</button> | |
| </div> | |
| </div> | |
| )) : ( | |
| <div style={{ color:'var(--muted)' }}>{loading ? 'Searching…' : 'No results yet.'}</div> | |
| )} | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function RequestsPanel({ isHost, requests, onAct }) { | |
| if (!isHost || !requests.length) return null; | |
| return ( | |
| <div className="panel" style={{ marginTop:12 }}> | |
| <div className="section-title">Song requests</div> | |
| <div style={{ display:'grid', gap:8 }}> | |
| {requests.map((r) => ( | |
| <div key={r.requestId} className="room-card" style={{ display:'flex', alignItems:'center', justifyContent:'space-between', gap:10 }}> | |
| <div style={{ display:'flex', alignItems:'center', gap:10, minWidth:0 }}> | |
| <div className="thumb" style={{ width:48, height:48, backgroundImage: r.preview?.thumb ? `url("${r.preview.thumb}")` : undefined }} /> | |
| <div style={{ minWidth:0 }}> | |
| <div style={{ fontWeight:700, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}> | |
| {r.requester} → {r.query} | |
| </div> | |
| <div className="meta" style={{ marginTop:4, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}> | |
| {r.preview?.title || (r.preview ? 'Result found' : 'Searching…')} | |
| </div> | |
| </div> | |
| </div> | |
| <div style={{ display:'flex', gap:8 }}> | |
| <button className="btn good" onClick={() => onAct('accept', r)} disabled={!r.preview}>Accept</button> | |
| <button className="btn" onClick={() => onAct('queue', r)} disabled={!r.preview}>Add to queue</button> | |
| <button className="btn warn" onClick={() => onAct('reject', r)}>Reject</button> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| 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 ? ( | |
| <div style={{ display:'flex', flexWrap:'wrap', gap:8 }}> | |
| <button className="btn" onClick={() => setShowDirect(true)}>Direct link</button> | |
| <button className="btn" onClick={() => setShowJS(true)}>JioSaavn</button> | |
| <button className="btn" onClick={() => setShowYT(true)}>YouTube</button> | |
| <button className="btn good" onClick={() => socket.emit('play', { roomId })}>Play</button> | |
| <button className="btn" onClick={() => socket.emit('pause', { roomId })}>Pause</button> | |
| <button className="btn" onClick={() => { | |
| const toStr = prompt('Seek to seconds', '60'); | |
| const to = Number(toStr); | |
| if (Number.isFinite(to)) socket.emit('seek', { roomId, to }); | |
| }}>Seek</button> | |
| </div> | |
| ) : <div className="badge">Waiting for host controls…</div> | |
| ), [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 ( | |
| <div className="container"> | |
| <div className="row"> | |
| <div className="col" style={{ minWidth: 320 }}> | |
| <div className="player"> | |
| <div className="player-header"> | |
| <div style={{ display:'flex', alignItems:'center', gap:10 }}> | |
| <div className="badge">{partyName}</div> | |
| <div className="tag">{isHost ? 'Host' : 'Guest'}</div> | |
| </div> | |
| {Controls} | |
| </div> | |
| <div className="player-body"> | |
| <Player socket={socket} roomId={roomId} state={state} isHost={isHost} /> | |
| </div> | |
| </div> | |
| <div className="panel" style={{ marginTop: 12 }}> | |
| <div className="section-title">Chat</div> | |
| <Chat socket={socket} roomId={roomId} name={name} isHost={isHost} members={members} /> | |
| </div> | |
| <RequestsPanel isHost={isHost} requests={requests} onAct={handleRequestAction} /> | |
| </div> | |
| <div className="col" style={{ flex:'0 0 340px' }}> | |
| <div className="panel"> | |
| <div className="section-title">Members</div> | |
| <MemberList members={members} /> | |
| </div> | |
| <div className="panel" style={{ marginTop: 12 }}> | |
| <div className="section-title">Queue</div> | |
| {state.queue?.length ? ( | |
| <div style={{ display:'grid', gap:8 }}> | |
| {state.queue.map((t, i) => ( | |
| <div key={i} className="room-card" style={{ display:'flex', alignItems:'center', gap:10 }}> | |
| <div className="thumb" style={{ width:40, height:40, backgroundImage: t.thumb ? `url("${t.thumb}")` : undefined }} /> | |
| <div style={{ fontWeight:700, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{t.title || t.url}</div> | |
| </div> | |
| ))} | |
| </div> | |
| ) : <div style={{ color:'var(--muted)' }}>No songs in queue</div>} | |
| </div> | |
| </div> | |
| </div> | |
| <DirectLinkModal open={showDirect} onClose={() => setShowDirect(false)} onPick={pickDirectUrl} /> | |
| <JioSaavnModal open={showJS} onClose={() => setShowJS(false)} onPick={playJioSaavnItem} /> | |
| <YouTubeModal open={showYT} onClose={() => setShowYT(false)} onPick={playYouTubeItem} /> | |
| </div> | |
| ); | |
| } |