test / client /src /Room.jsx
akborana4's picture
Update client/src/Room.jsx
cf5ac1c verified
// 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>
);
}