akborana4 commited on
Commit
b485d97
·
verified ·
1 Parent(s): 469eb7c

Update client/src/Room.jsx

Browse files
Files changed (1) hide show
  1. 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) and audio (MP3, M4A, WAV, FLAC, OGG, OPUS). The server must allow CORS and range requests.
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
- setItems(Array.isArray(data?.data) ? data.data : (data?.results || data?.songs || data?.list || data || []));
 
 
59
  setLoading(false);
60
  } catch (e) {
61
  setLoading(false);
62
- console.error(e);
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={{ fontWeight:700, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{title}</div>
97
- <div className="meta" style={{ marginTop:4, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{mediaUrl || 'No stream URL in item'}</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
- // Join and wire events
 
 
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 (mediaType === 'unknown') {
 
165
  setShowDirect(false);
166
- push('Unknown media type. Please choose Audio or Video.', 'warn');
167
- // Re-open with hint
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) === 'unknown' ? 'audio' : detectMediaTypeFromUrl(item.url);
182
- socket.emit('set_track', { roomId, track: { url: item.url, title: item.title, meta: item, kind: type } });
 
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: 420 }}>
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">Now playing</div>
232
- <div style={{ color:'var(--muted)' }}>{state?.track ? safeTitle(state.track) : 'No track selected'}</div>
233
- <div style={{ marginTop: 8 }}>
234
- <div className="bar"><span style={{ width: state.isPlaying ? '100%' : '8%', transition:'width 1.8s ease' }}></span></div>
235
- </div>
 
 
 
 
 
 
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
  }