Spaces:
Sleeping
Sleeping
| // 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 ( | |
| <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 }}> | |
| Paste a direct media URL (MP4, WEBM, MP3, M4A) or a YouTube URL/ID. | |
| </div> | |
| <input | |
| className="input" | |
| placeholder="https://…/video.mp4 or https://youtu.be/xxxx or 11-char ID" | |
| 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> | |
| ); | |
| } | |
| // 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 ( | |
| <div className="modal-backdrop" onClick={e => e.target === e.currentTarget && onClose()}> | |
| <div className="modal"> | |
| <div className="section-title">Play YouTube (Vidfly)</div> | |
| <div style={{ color:'var(--muted)', fontSize:13, marginBottom:8 }}> | |
| Enter a YouTube URL or 11-character ID. We resolve via Vidfly and proxy if needed. | |
| </div> | |
| <input | |
| className="input" | |
| placeholder="https://www.youtube.com/watch?v=XXXXXXXXXXX or XXXXXXXID" | |
| value={idOrUrl} | |
| onChange={e => setIdOrUrl(e.target.value)} | |
| /> | |
| <div style={{ display:'flex', gap:8, marginTop:10 }}> | |
| <button className="btn good" onClick={() => onPick(idOrUrl)}>Play</button> | |
| <button className="btn" onClick={onClose}>Close</button> | |
| </div> | |
| {ytError && ( | |
| <div className="room-card" style={{ marginTop:12 }}> | |
| <div style={{ fontWeight:700, color:'var(--bad)' }}>YouTube failed</div> | |
| <div className="meta" style={{ marginTop:6 }}>{ytError.message}</div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // Panel for host to accept/queue/reject song requests | |
| 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}>Queue</button> | |
| <button className="btn warn" onClick={() => onAct('reject', r)}>Reject</button> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // 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 | |
| ? ( | |
| <div style={{ display:'flex', flexWrap:'wrap', gap:8 }}> | |
| <button className="btn" onClick={() => setShowDirect(true)}>Direct link</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 t = Number(prompt('Seek to seconds', '60')); | |
| if (Number.isFinite(t)) socket.emit('seek', { roomId, to: t }); | |
| }}>Seek</button> | |
| </div> | |
| ) | |
| : <div className="badge">Waiting for host controls…</div> | |
| ), [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 ( | |
| <div className="container"> | |
| <div className="row"> | |
| {/* Left column: player + chat + requests */} | |
| <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> | |
| {/* Right column: members + queue */} | |
| <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 > 0 ? ( | |
| <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> | |
| {/* Overlays */} | |
| <DirectLinkModal open={showDirect} onClose={() => setShowDirect(false)} onPick={pickDirectUrl} /> | |
| <YouTubeModal open={showYT} onClose={() => setShowYT(false)} onPick={onPickYouTube} ytError={ytError} /> | |
| </div> | |
| ); | |
| } | |