Spaces:
Sleeping
Sleeping
Update client/src/Room.jsx
Browse files- client/src/Room.jsx +208 -27
client/src/Room.jsx
CHANGED
|
@@ -23,14 +23,15 @@ function DirectLinkModal({ open, onClose, onPick }) {
|
|
| 23 |
<div className="modal">
|
| 24 |
<div className="section-title">Play a direct link</div>
|
| 25 |
<div style={{ color:'var(--muted)', fontSize:13, marginBottom:8 }}>
|
| 26 |
-
Supports video (MP4, MKV, WEBM, MOV)
|
| 27 |
</div>
|
| 28 |
-
<input className="input" placeholder="https://example.com/media.mp4" value={url} onChange={e => setUrl(e.target.value)} />
|
| 29 |
<div style={{ display:'flex', gap:10, marginTop:10 }}>
|
| 30 |
<select className="select" value={type} onChange={e => setType(e.target.value)}>
|
| 31 |
<option value="auto">Auto-detect type</option>
|
| 32 |
<option value="video">Force Video</option>
|
| 33 |
<option value="audio">Force Audio</option>
|
|
|
|
| 34 |
</select>
|
| 35 |
<button className="btn primary" onClick={() => onPick({ url, type })}>Set</button>
|
| 36 |
<button className="btn" onClick={onClose}>Cancel</button>
|
|
@@ -55,11 +56,13 @@ function JioSaavnModal({ open, onClose, onPick }) {
|
|
| 55 |
const path = mode === 'result' ? '/api/result' : `/api/${mode}`;
|
| 56 |
const res = await fetch(`${path}?q=${encodeURIComponent(q.trim())}`);
|
| 57 |
const data = await res.json();
|
| 58 |
-
|
|
|
|
|
|
|
| 59 |
setLoading(false);
|
| 60 |
} catch (e) {
|
| 61 |
setLoading(false);
|
| 62 |
-
|
| 63 |
alert('Failed to fetch: ' + prettyError(e));
|
| 64 |
}
|
| 65 |
};
|
|
@@ -90,15 +93,19 @@ function JioSaavnModal({ open, onClose, onPick }) {
|
|
| 90 |
{items?.length ? items.map((it, idx) => {
|
| 91 |
const title = it.title || it.name || it.song || it?.data?.title || `Item ${idx+1}`;
|
| 92 |
const mediaUrl = it.media_url || it.downloadUrl || it.url || it.perma_url || it.streamUrl;
|
|
|
|
| 93 |
return (
|
| 94 |
<div key={idx} className="room-card" style={{ display:'flex', alignItems:'center', justifyContent:'space-between', gap:10 }}>
|
| 95 |
-
<div style={{ minWidth:0 }}>
|
| 96 |
-
<div style={{
|
| 97 |
-
<div
|
|
|
|
|
|
|
|
|
|
| 98 |
</div>
|
| 99 |
<div style={{ display:'flex', gap:8 }}>
|
| 100 |
{mediaUrl ? (
|
| 101 |
-
<button className="btn good" onClick={() => onPick({ url: mediaUrl, title, meta: it })}>Play</button>
|
| 102 |
) : (
|
| 103 |
<button className="btn" disabled>No URL</button>
|
| 104 |
)}
|
|
@@ -114,22 +121,130 @@ function JioSaavnModal({ open, onClose, onPick }) {
|
|
| 114 |
);
|
| 115 |
}
|
| 116 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
export default function Room({ roomId, name, asHost, roomName }) {
|
| 118 |
const { push } = useToasts();
|
| 119 |
|
| 120 |
const [isHost, setIsHost] = useState(false);
|
| 121 |
-
const [state, setState] = useState({ track: null, isPlaying: false, anchor: 0, anchorAt: 0 });
|
| 122 |
const [members, setMembers] = useState([]);
|
| 123 |
const [partyName, setPartyName] = useState(roomName || roomId);
|
| 124 |
|
| 125 |
const [showDirect, setShowDirect] = useState(false);
|
| 126 |
const [showJS, setShowJS] = useState(false);
|
|
|
|
| 127 |
|
| 128 |
-
|
|
|
|
|
|
|
| 129 |
useEffect(() => {
|
| 130 |
socket.emit('join_room', { roomId, name, asHost, roomName }, (resp) => {
|
| 131 |
setIsHost(resp.isHost);
|
| 132 |
-
if (resp.state) setState(resp.state);
|
| 133 |
if (resp.roomName) setPartyName(resp.roomName);
|
| 134 |
push(`Joined ${resp.roomName || roomId} as ${resp.isHost ? 'Host' : 'Guest'}`, 'good');
|
| 135 |
});
|
|
@@ -147,25 +262,58 @@ export default function Room({ roomId, name, asHost, roomName }) {
|
|
| 147 |
setMembers(members || []);
|
| 148 |
if (roomName) setPartyName(roomName);
|
| 149 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
-
// Initial rename sync
|
| 152 |
socket.emit('rename', { roomId, newName: name });
|
| 153 |
|
| 154 |
return () => {
|
| 155 |
socket.off('set_track'); socket.off('play'); socket.off('pause'); socket.off('seek');
|
| 156 |
-
socket.off('host_changed'); socket.off('members');
|
| 157 |
};
|
| 158 |
}, [roomId, name, asHost, roomName, push]);
|
| 159 |
|
| 160 |
-
// Host actions
|
| 161 |
const pickDirectUrl = ({ url, type }) => {
|
| 162 |
if (!url) return;
|
| 163 |
let mediaType = type === 'auto' ? detectMediaTypeFromUrl(url) : type;
|
| 164 |
-
if (
|
|
|
|
| 165 |
setShowDirect(false);
|
| 166 |
-
push('Unknown media type. Please choose Audio or
|
| 167 |
-
|
| 168 |
-
setTimeout(() => setShowDirect(true), 50);
|
| 169 |
return;
|
| 170 |
}
|
| 171 |
const track = { url, title: url, meta: { source: 'direct' }, kind: mediaType };
|
|
@@ -173,21 +321,32 @@ export default function Room({ roomId, name, asHost, roomName }) {
|
|
| 173 |
setShowDirect(false);
|
| 174 |
};
|
| 175 |
|
|
|
|
| 176 |
const playJioSaavnItem = (item) => {
|
| 177 |
if (!item?.url) {
|
| 178 |
push('Selected item has no stream URL', 'warn');
|
| 179 |
return;
|
| 180 |
}
|
| 181 |
-
const type = detectMediaTypeFromUrl(item.url)
|
| 182 |
-
|
|
|
|
| 183 |
setShowJS(false);
|
| 184 |
};
|
| 185 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
const Controls = useMemo(() => (
|
| 187 |
isHost ? (
|
| 188 |
<div style={{ display:'flex', flexWrap:'wrap', gap:8 }}>
|
| 189 |
<button className="btn" onClick={() => setShowDirect(true)}>Direct link</button>
|
| 190 |
<button className="btn" onClick={() => setShowJS(true)}>JioSaavn</button>
|
|
|
|
| 191 |
<button className="btn good" onClick={() => socket.emit('play', { roomId })}>Play</button>
|
| 192 |
<button className="btn" onClick={() => socket.emit('pause', { roomId })}>Pause</button>
|
| 193 |
<button className="btn" onClick={() => {
|
|
@@ -199,10 +358,22 @@ export default function Room({ roomId, name, asHost, roomName }) {
|
|
| 199 |
) : <div className="badge">Waiting for host controls…</div>
|
| 200 |
), [isHost, roomId]);
|
| 201 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
return (
|
| 203 |
<div className="container">
|
| 204 |
<div className="row">
|
| 205 |
-
<div className="col" style={{ minWidth:
|
| 206 |
<div className="player">
|
| 207 |
<div className="player-header">
|
| 208 |
<div style={{ display:'flex', alignItems:'center', gap:10 }}>
|
|
@@ -218,8 +389,10 @@ export default function Room({ roomId, name, asHost, roomName }) {
|
|
| 218 |
|
| 219 |
<div className="panel" style={{ marginTop: 12 }}>
|
| 220 |
<div className="section-title">Chat</div>
|
| 221 |
-
<Chat socket={socket} roomId={roomId} name={name} />
|
| 222 |
</div>
|
|
|
|
|
|
|
| 223 |
</div>
|
| 224 |
|
| 225 |
<div className="col" style={{ flex:'0 0 340px' }}>
|
|
@@ -227,18 +400,26 @@ export default function Room({ roomId, name, asHost, roomName }) {
|
|
| 227 |
<div className="section-title">Members</div>
|
| 228 |
<MemberList members={members} />
|
| 229 |
</div>
|
|
|
|
| 230 |
<div className="panel" style={{ marginTop: 12 }}>
|
| 231 |
-
<div className="section-title">
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
</div>
|
| 237 |
</div>
|
| 238 |
</div>
|
| 239 |
|
| 240 |
<DirectLinkModal open={showDirect} onClose={() => setShowDirect(false)} onPick={pickDirectUrl} />
|
| 241 |
<JioSaavnModal open={showJS} onClose={() => setShowJS(false)} onPick={playJioSaavnItem} />
|
|
|
|
| 242 |
</div>
|
| 243 |
);
|
| 244 |
}
|
|
|
|
| 23 |
<div className="modal">
|
| 24 |
<div className="section-title">Play a direct link</div>
|
| 25 |
<div style={{ color:'var(--muted)', fontSize:13, marginBottom:8 }}>
|
| 26 |
+
Supports video (MP4, MKV, WEBM, MOV), audio (MP3, M4A, WAV, FLAC, OGG, OPUS), and YouTube URLs.
|
| 27 |
</div>
|
| 28 |
+
<input className="input" placeholder="https://example.com/media.mp4 or https://youtu.be/xxxx" value={url} onChange={e => setUrl(e.target.value)} />
|
| 29 |
<div style={{ display:'flex', gap:10, marginTop:10 }}>
|
| 30 |
<select className="select" value={type} onChange={e => setType(e.target.value)}>
|
| 31 |
<option value="auto">Auto-detect type</option>
|
| 32 |
<option value="video">Force Video</option>
|
| 33 |
<option value="audio">Force Audio</option>
|
| 34 |
+
<option value="youtube">YouTube</option>
|
| 35 |
</select>
|
| 36 |
<button className="btn primary" onClick={() => onPick({ url, type })}>Set</button>
|
| 37 |
<button className="btn" onClick={onClose}>Cancel</button>
|
|
|
|
| 56 |
const path = mode === 'result' ? '/api/result' : `/api/${mode}`;
|
| 57 |
const res = await fetch(`${path}?q=${encodeURIComponent(q.trim())}`);
|
| 58 |
const data = await res.json();
|
| 59 |
+
const arr = Array.isArray(data?.data) ? data.data
|
| 60 |
+
: (data?.results || data?.songs || data?.list || data?.items || data || []);
|
| 61 |
+
setItems(arr);
|
| 62 |
setLoading(false);
|
| 63 |
} catch (e) {
|
| 64 |
setLoading(false);
|
| 65 |
+
log.error(e);
|
| 66 |
alert('Failed to fetch: ' + prettyError(e));
|
| 67 |
}
|
| 68 |
};
|
|
|
|
| 93 |
{items?.length ? items.map((it, idx) => {
|
| 94 |
const title = it.title || it.name || it.song || it?.data?.title || `Item ${idx+1}`;
|
| 95 |
const mediaUrl = it.media_url || it.downloadUrl || it.url || it.perma_url || it.streamUrl;
|
| 96 |
+
const thumb = it.image || it.thumbnail || it.song_image || it.album_image || it.images?.[0] || it.images?.cover || it.image_url;
|
| 97 |
return (
|
| 98 |
<div key={idx} className="room-card" style={{ display:'flex', alignItems:'center', justifyContent:'space-between', gap:10 }}>
|
| 99 |
+
<div style={{ display:'flex', alignItems:'center', gap:10, minWidth:0 }}>
|
| 100 |
+
<div className="thumb" style={{ width:48, height:48, backgroundImage: thumb ? `url("${thumb}")` : undefined }} />
|
| 101 |
+
<div style={{ minWidth:0 }}>
|
| 102 |
+
<div style={{ fontWeight:700, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{title}</div>
|
| 103 |
+
<div className="meta" style={{ marginTop:4, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{mediaUrl || 'No stream URL in item'}</div>
|
| 104 |
+
</div>
|
| 105 |
</div>
|
| 106 |
<div style={{ display:'flex', gap:8 }}>
|
| 107 |
{mediaUrl ? (
|
| 108 |
+
<button className="btn good" onClick={() => onPick({ url: mediaUrl, title, meta: it, thumb })}>Play</button>
|
| 109 |
) : (
|
| 110 |
<button className="btn" disabled>No URL</button>
|
| 111 |
)}
|
|
|
|
| 121 |
);
|
| 122 |
}
|
| 123 |
|
| 124 |
+
function YouTubeModal({ open, onClose, onPick }) {
|
| 125 |
+
const [q, setQ] = useState('');
|
| 126 |
+
const [items, setItems] = useState([]);
|
| 127 |
+
const [loading, setLoading] = useState(false);
|
| 128 |
+
const [unavailable, setUnavailable] = useState(false);
|
| 129 |
+
|
| 130 |
+
useEffect(() => {
|
| 131 |
+
if (!open) { setQ(''); setItems([]); setLoading(false); setUnavailable(false); }
|
| 132 |
+
}, [open]);
|
| 133 |
+
|
| 134 |
+
const search = async () => {
|
| 135 |
+
if (!q.trim()) return;
|
| 136 |
+
try {
|
| 137 |
+
setLoading(true);
|
| 138 |
+
const res = await fetch(`/api/ytsearch?q=${encodeURIComponent(q.trim())}`);
|
| 139 |
+
if (res.status === 501) {
|
| 140 |
+
setUnavailable(true);
|
| 141 |
+
setLoading(false);
|
| 142 |
+
return;
|
| 143 |
+
}
|
| 144 |
+
const data = await res.json();
|
| 145 |
+
setItems(data.items || []);
|
| 146 |
+
setLoading(false);
|
| 147 |
+
} catch (e) {
|
| 148 |
+
setLoading(false);
|
| 149 |
+
alert('YouTube search failed: ' + e.message);
|
| 150 |
+
}
|
| 151 |
+
};
|
| 152 |
+
|
| 153 |
+
if (!open) return null;
|
| 154 |
+
return (
|
| 155 |
+
<div className="modal-backdrop" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
| 156 |
+
<div className="modal">
|
| 157 |
+
<div className="section-title">YouTube search</div>
|
| 158 |
+
{unavailable ? (
|
| 159 |
+
<div style={{ color:'var(--warn)' }}>
|
| 160 |
+
YouTube search is disabled. Set YT_API_KEY in server environment to enable search.
|
| 161 |
+
</div>
|
| 162 |
+
) : (
|
| 163 |
+
<>
|
| 164 |
+
<div className="row" style={{ gap:8 }}>
|
| 165 |
+
<div className="col">
|
| 166 |
+
<input className="input" placeholder="Search YouTube" value={q} onChange={e => setQ(e.target.value)} />
|
| 167 |
+
</div>
|
| 168 |
+
<div className="col" style={{ flex:'0 0 auto' }}>
|
| 169 |
+
<button className="btn primary" onClick={search}>{loading ? 'Searching...' : 'Search'}</button>
|
| 170 |
+
</div>
|
| 171 |
+
</div>
|
| 172 |
+
<div style={{ marginTop:12, maxHeight:320, overflow:'auto', display:'grid', gap:8 }}>
|
| 173 |
+
{items.length ? items.map((it) => (
|
| 174 |
+
<div key={it.videoId} className="room-card" style={{ display:'flex', alignItems:'center', justifyContent:'space-between', gap:10 }}>
|
| 175 |
+
<div style={{ display:'flex', alignItems:'center', gap:10, minWidth:0 }}>
|
| 176 |
+
<div className="thumb" style={{ width:64, height:36, backgroundImage: it.thumb ? `url("${it.thumb}")` : undefined }} />
|
| 177 |
+
<div style={{ minWidth:0 }}>
|
| 178 |
+
<div style={{ fontWeight:700, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{it.title}</div>
|
| 179 |
+
<div className="meta" style={{ marginTop:4, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{it.channelTitle}</div>
|
| 180 |
+
</div>
|
| 181 |
+
</div>
|
| 182 |
+
<div>
|
| 183 |
+
<button className="btn good" onClick={() => onPick({ url: it.videoId, title: it.title, meta: it, kind: 'youtube', thumb: it.thumb })}>Play</button>
|
| 184 |
+
</div>
|
| 185 |
+
</div>
|
| 186 |
+
)) : (
|
| 187 |
+
<div style={{ color:'var(--muted)' }}>{loading ? 'Searching…' : 'No results yet.'}</div>
|
| 188 |
+
)}
|
| 189 |
+
</div>
|
| 190 |
+
</>
|
| 191 |
+
)}
|
| 192 |
+
</div>
|
| 193 |
+
</div>
|
| 194 |
+
);
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
function RequestsPanel({ isHost, requests, onAct }) {
|
| 198 |
+
if (!isHost) return null;
|
| 199 |
+
if (!requests.length) return null;
|
| 200 |
+
return (
|
| 201 |
+
<div className="panel" style={{ marginTop:12 }}>
|
| 202 |
+
<div className="section-title">Song requests</div>
|
| 203 |
+
<div style={{ display:'grid', gap:8 }}>
|
| 204 |
+
{requests.map((r) => (
|
| 205 |
+
<div key={r.requestId} className="room-card" style={{ display:'flex', alignItems:'center', justifyContent:'space-between', gap:10 }}>
|
| 206 |
+
<div style={{ display:'flex', alignItems:'center', gap:10, minWidth:0 }}>
|
| 207 |
+
<div className="thumb" style={{ width:48, height:48, backgroundImage: r.preview?.thumb ? `url("${r.preview.thumb}")` : undefined }} />
|
| 208 |
+
<div style={{ minWidth:0 }}>
|
| 209 |
+
<div style={{ fontWeight:700, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
|
| 210 |
+
{r.requester} → {r.query}
|
| 211 |
+
</div>
|
| 212 |
+
<div className="meta" style={{ marginTop:4, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
|
| 213 |
+
{r.preview?.title || (r.preview ? 'Result found' : 'Searching…')}
|
| 214 |
+
</div>
|
| 215 |
+
</div>
|
| 216 |
+
</div>
|
| 217 |
+
<div style={{ display:'flex', gap:8 }}>
|
| 218 |
+
<button className="btn good" onClick={() => onAct('accept', r)} disabled={!r.preview}>Accept</button>
|
| 219 |
+
<button className="btn" onClick={() => onAct('queue', r)} disabled={!r.preview}>Add to queue</button>
|
| 220 |
+
<button className="btn warn" onClick={() => onAct('reject', r)}>Reject</button>
|
| 221 |
+
</div>
|
| 222 |
+
</div>
|
| 223 |
+
))}
|
| 224 |
+
</div>
|
| 225 |
+
</div>
|
| 226 |
+
);
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
export default function Room({ roomId, name, asHost, roomName }) {
|
| 230 |
const { push } = useToasts();
|
| 231 |
|
| 232 |
const [isHost, setIsHost] = useState(false);
|
| 233 |
+
const [state, setState] = useState({ track: null, isPlaying: false, anchor: 0, anchorAt: 0, queue: [] });
|
| 234 |
const [members, setMembers] = useState([]);
|
| 235 |
const [partyName, setPartyName] = useState(roomName || roomId);
|
| 236 |
|
| 237 |
const [showDirect, setShowDirect] = useState(false);
|
| 238 |
const [showJS, setShowJS] = useState(false);
|
| 239 |
+
const [showYT, setShowYT] = useState(false);
|
| 240 |
|
| 241 |
+
const [requests, setRequests] = useState([]);
|
| 242 |
+
|
| 243 |
+
// Join / events
|
| 244 |
useEffect(() => {
|
| 245 |
socket.emit('join_room', { roomId, name, asHost, roomName }, (resp) => {
|
| 246 |
setIsHost(resp.isHost);
|
| 247 |
+
if (resp.state) setState(s => ({ ...s, ...resp.state }));
|
| 248 |
if (resp.roomName) setPartyName(resp.roomName);
|
| 249 |
push(`Joined ${resp.roomName || roomId} as ${resp.isHost ? 'Host' : 'Guest'}`, 'good');
|
| 250 |
});
|
|
|
|
| 262 |
setMembers(members || []);
|
| 263 |
if (roomName) setPartyName(roomName);
|
| 264 |
});
|
| 265 |
+
socket.on('system', ({ text }) => {
|
| 266 |
+
if (text) push(text, 'good');
|
| 267 |
+
});
|
| 268 |
+
socket.on('queue_update', ({ queue }) => {
|
| 269 |
+
setState(s => ({ ...s, queue: queue || [] }));
|
| 270 |
+
});
|
| 271 |
+
|
| 272 |
+
// Request arrives -> host side auto-search to attach preview with thumb
|
| 273 |
+
socket.on('song_request', async (req) => {
|
| 274 |
+
const enriched = { ...req, preview: null };
|
| 275 |
+
try {
|
| 276 |
+
const res = await fetch(`/api/result?q=${encodeURIComponent(req.query)}`);
|
| 277 |
+
const data = await res.json();
|
| 278 |
+
const list = Array.isArray(data?.data) ? data.data : (data?.results || data?.songs || data?.list || data?.items || data || []);
|
| 279 |
+
const first = list?.[0];
|
| 280 |
+
if (first) {
|
| 281 |
+
const url = first.media_url || first.downloadUrl || first.url || first.perma_url || first.streamUrl;
|
| 282 |
+
const thumb = first.image || first.thumbnail || first.song_image || first.album_image || first.images?.[0] || first.images?.cover || first.image_url;
|
| 283 |
+
if (url) {
|
| 284 |
+
enriched.preview = {
|
| 285 |
+
url,
|
| 286 |
+
title: first.title || req.query,
|
| 287 |
+
meta: first,
|
| 288 |
+
kind: detectMediaTypeFromUrl(url) || 'audio',
|
| 289 |
+
thumb
|
| 290 |
+
};
|
| 291 |
+
}
|
| 292 |
+
}
|
| 293 |
+
} catch (e) {
|
| 294 |
+
// ignore, just show request without preview
|
| 295 |
+
}
|
| 296 |
+
setRequests(prev => [enriched, ...prev].slice(0, 20));
|
| 297 |
+
push(`Song request: ${req.requester} → ${req.query}`, 'warn');
|
| 298 |
+
});
|
| 299 |
|
|
|
|
| 300 |
socket.emit('rename', { roomId, newName: name });
|
| 301 |
|
| 302 |
return () => {
|
| 303 |
socket.off('set_track'); socket.off('play'); socket.off('pause'); socket.off('seek');
|
| 304 |
+
socket.off('host_changed'); socket.off('members'); socket.off('system'); socket.off('queue_update'); socket.off('song_request');
|
| 305 |
};
|
| 306 |
}, [roomId, name, asHost, roomName, push]);
|
| 307 |
|
| 308 |
+
// Host actions: set track from direct link
|
| 309 |
const pickDirectUrl = ({ url, type }) => {
|
| 310 |
if (!url) return;
|
| 311 |
let mediaType = type === 'auto' ? detectMediaTypeFromUrl(url) : type;
|
| 312 |
+
if (type === 'youtube') mediaType = 'youtube';
|
| 313 |
+
if (!mediaType || mediaType === 'unknown') {
|
| 314 |
setShowDirect(false);
|
| 315 |
+
push('Unknown media type. Please choose Audio, Video or YouTube.', 'warn');
|
| 316 |
+
setTimeout(() => setShowDirect(true), 30);
|
|
|
|
| 317 |
return;
|
| 318 |
}
|
| 319 |
const track = { url, title: url, meta: { source: 'direct' }, kind: mediaType };
|
|
|
|
| 321 |
setShowDirect(false);
|
| 322 |
};
|
| 323 |
|
| 324 |
+
// Host actions: set track from JioSaavn
|
| 325 |
const playJioSaavnItem = (item) => {
|
| 326 |
if (!item?.url) {
|
| 327 |
push('Selected item has no stream URL', 'warn');
|
| 328 |
return;
|
| 329 |
}
|
| 330 |
+
const type = detectMediaTypeFromUrl(item.url) || 'audio';
|
| 331 |
+
const track = { url: item.url, title: item.title, meta: item, kind: type, thumb: item.thumb || item.image };
|
| 332 |
+
socket.emit('set_track', { roomId, track });
|
| 333 |
setShowJS(false);
|
| 334 |
};
|
| 335 |
|
| 336 |
+
// Host actions: set track from YouTube search
|
| 337 |
+
const playYouTubeItem = (item) => {
|
| 338 |
+
if (!item?.videoId) return;
|
| 339 |
+
const track = { url: item.videoId, title: item.title, meta: item, kind: 'youtube', thumb: item.thumb };
|
| 340 |
+
socket.emit('set_track', { roomId, track });
|
| 341 |
+
setShowYT(false);
|
| 342 |
+
};
|
| 343 |
+
|
| 344 |
const Controls = useMemo(() => (
|
| 345 |
isHost ? (
|
| 346 |
<div style={{ display:'flex', flexWrap:'wrap', gap:8 }}>
|
| 347 |
<button className="btn" onClick={() => setShowDirect(true)}>Direct link</button>
|
| 348 |
<button className="btn" onClick={() => setShowJS(true)}>JioSaavn</button>
|
| 349 |
+
<button className="btn" onClick={() => setShowYT(true)}>YouTube</button>
|
| 350 |
<button className="btn good" onClick={() => socket.emit('play', { roomId })}>Play</button>
|
| 351 |
<button className="btn" onClick={() => socket.emit('pause', { roomId })}>Pause</button>
|
| 352 |
<button className="btn" onClick={() => {
|
|
|
|
| 358 |
) : <div className="badge">Waiting for host controls…</div>
|
| 359 |
), [isHost, roomId]);
|
| 360 |
|
| 361 |
+
// Handle request action (host)
|
| 362 |
+
const handleRequestAction = (action, req) => {
|
| 363 |
+
if (action === 'reject') {
|
| 364 |
+
setRequests(prev => prev.filter(r => r.requestId !== req.requestId));
|
| 365 |
+
return;
|
| 366 |
+
}
|
| 367 |
+
if (!req.preview) return;
|
| 368 |
+
const track = req.preview;
|
| 369 |
+
socket.emit('song_request_action', { roomId, action: action === 'accept' ? 'accept' : 'queue', track });
|
| 370 |
+
setRequests(prev => prev.filter(r => r.requestId !== req.requestId));
|
| 371 |
+
};
|
| 372 |
+
|
| 373 |
return (
|
| 374 |
<div className="container">
|
| 375 |
<div className="row">
|
| 376 |
+
<div className="col" style={{ minWidth: 320 }}>
|
| 377 |
<div className="player">
|
| 378 |
<div className="player-header">
|
| 379 |
<div style={{ display:'flex', alignItems:'center', gap:10 }}>
|
|
|
|
| 389 |
|
| 390 |
<div className="panel" style={{ marginTop: 12 }}>
|
| 391 |
<div className="section-title">Chat</div>
|
| 392 |
+
<Chat socket={socket} roomId={roomId} name={name} isHost={isHost} members={members} />
|
| 393 |
</div>
|
| 394 |
+
|
| 395 |
+
<RequestsPanel isHost={isHost} requests={requests} onAct={handleRequestAction} />
|
| 396 |
</div>
|
| 397 |
|
| 398 |
<div className="col" style={{ flex:'0 0 340px' }}>
|
|
|
|
| 400 |
<div className="section-title">Members</div>
|
| 401 |
<MemberList members={members} />
|
| 402 |
</div>
|
| 403 |
+
|
| 404 |
<div className="panel" style={{ marginTop: 12 }}>
|
| 405 |
+
<div className="section-title">Queue</div>
|
| 406 |
+
{state.queue?.length ? (
|
| 407 |
+
<div style={{ display:'grid', gap:8 }}>
|
| 408 |
+
{state.queue.map((t, i) => (
|
| 409 |
+
<div key={i} className="room-card" style={{ display:'flex', alignItems:'center', gap:10 }}>
|
| 410 |
+
<div className="thumb" style={{ width:40, height:40, backgroundImage: t.thumb ? `url("${t.thumb}")` : undefined }} />
|
| 411 |
+
<div style={{ fontWeight:700, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{t.title || t.url}</div>
|
| 412 |
+
</div>
|
| 413 |
+
))}
|
| 414 |
+
</div>
|
| 415 |
+
) : <div style={{ color:'var(--muted)' }}>No songs in queue</div>}
|
| 416 |
</div>
|
| 417 |
</div>
|
| 418 |
</div>
|
| 419 |
|
| 420 |
<DirectLinkModal open={showDirect} onClose={() => setShowDirect(false)} onPick={pickDirectUrl} />
|
| 421 |
<JioSaavnModal open={showJS} onClose={() => setShowJS(false)} onPick={playJioSaavnItem} />
|
| 422 |
+
<YouTubeModal open={showYT} onClose={() => setShowYT(false)} onPick={playYouTubeItem} />
|
| 423 |
</div>
|
| 424 |
);
|
| 425 |
}
|