akborana4 commited on
Commit
edbf5f9
·
verified ·
1 Parent(s): 5bdead2

Update client/src/Room.jsx

Browse files
Files changed (1) hide show
  1. client/src/Room.jsx +91 -215
client/src/Room.jsx CHANGED
@@ -1,8 +1,8 @@
1
  // client/src/Room.jsx
2
  import React, { useEffect, useMemo, useState } from 'react';
3
  import { io } from 'socket.io-client';
4
- import Chat from './Chat.jsx';
5
  import Player from './Player.jsx';
 
6
  import MemberList from './MemberList.jsx';
7
  import { detectMediaTypeFromUrl, getThumb, prettyError, safeTitle } from './utils.js';
8
  import { useToasts } from './Toasts.jsx';
@@ -10,43 +10,13 @@ import { log } from './logger.js';
10
 
11
  const socket = io('', { transports: ['websocket'] });
12
 
13
- // Helper: when the remote URL is a googlevideo/redirector URL (or other blocked host),
14
- // return a proxied same-origin URL so the browser won't hit CORS.
15
- function proxiedUrl(remoteUrl) {
16
- if (!remoteUrl) return remoteUrl;
17
- try {
18
- // If it's already a relative proxy path, keep as-is
19
- if (remoteUrl.startsWith('/api/yt/proxy')) return remoteUrl;
20
- // If it's already an absolute URL that points to our origin proxy, keep as-is
21
- try {
22
- const maybeUrl = new URL(remoteUrl);
23
- // If remoteUrl already points to our origin + /api/yt/proxy, keep it
24
- if (maybeUrl.origin === window.location.origin && maybeUrl.pathname.startsWith('/api/yt/proxy')) {
25
- return remoteUrl;
26
- }
27
- } catch (e) {
28
- // not an absolute URL, continue
29
- }
30
-
31
- // If it's a googlevideo/redirector (common YouTube direct stream host), proxy it.
32
- // Also proxy known hosts which commonly block cross-origin accesses.
33
- const lower = String(remoteUrl).toLowerCase();
34
- const shouldProxy = lower.includes('googlevideo.com') || lower.includes('redirector.googlevideo.com') || lower.includes('r3---sn');
35
- if (shouldProxy) {
36
- // Return a same-origin proxied route
37
- return `/api/yt/proxy?url=${encodeURIComponent(remoteUrl)}`;
38
- }
39
- return remoteUrl;
40
- } catch (err) {
41
- // If anything goes wrong, return the remote URL unchanged
42
- return remoteUrl;
43
- }
44
- }
45
-
46
  function DirectLinkModal({ open, onClose, onPick }) {
47
  const [url, setUrl] = useState('');
48
  const [type, setType] = useState('auto');
49
- useEffect(() => { if (!open) { setUrl(''); setType('auto'); } }, [open]);
 
 
 
50
 
51
  if (!open) return null;
52
  return (
@@ -54,9 +24,14 @@ function DirectLinkModal({ open, onClose, onPick }) {
54
  <div className="modal">
55
  <div className="section-title">Play a direct link</div>
56
  <div style={{ color:'var(--muted)', fontSize:13, marginBottom:8 }}>
57
- Supports video (MP4, MKV, WEBM, MOV), audio (MP3, M4A, WAV, FLAC, OGG, OPUS), and YouTube URLs/IDs.
58
  </div>
59
- <input className="input" placeholder="https://example.com/media.mp4 or https://youtu.be/xxxx" value={url} onChange={e => setUrl(e.target.value)} />
 
 
 
 
 
60
  <div style={{ display:'flex', gap:10, marginTop:10 }}>
61
  <select className="select" value={type} onChange={e => setType(e.target.value)}>
62
  <option value="auto">Auto-detect type</option>
@@ -72,141 +47,45 @@ function DirectLinkModal({ open, onClose, onPick }) {
72
  );
73
  }
74
 
75
- function JioSaavnModal({ open, onClose, onPick }) {
76
- const [mode, setMode] = useState('result');
77
- const [q, setQ] = useState('');
78
- const [items, setItems] = useState([]);
79
- const [loading, setLoading] = useState(false);
80
-
81
- useEffect(() => { if (!open) { setQ(''); setItems([]); setLoading(false); setMode('result'); } }, [open]);
82
 
83
- const search = async () => {
84
- if (!q.trim()) return;
85
- try {
86
- setLoading(true);
87
- const path = mode === 'result' ? '/api/result' : `/api/${mode}`;
88
- const res = await fetch(`${path}?q=${encodeURIComponent(q.trim())}`);
89
- const data = await res.json();
90
- const arr = Array.isArray(data?.data) ? data.data
91
- : (data?.results || data?.songs || data?.list || data?.items || data || []);
92
- setItems(arr);
93
- setLoading(false);
94
- } catch (e) {
95
- setLoading(false);
96
- log.error(e);
97
- alert('Failed to fetch: ' + prettyError(e));
98
- }
99
- };
100
 
101
  if (!open) return null;
102
  return (
103
  <div className="modal-backdrop" onClick={(e) => e.target === e.currentTarget && onClose()}>
104
  <div className="modal">
105
- <div className="section-title">JioSaavn search</div>
106
- <div className="row" style={{ gap:8 }}>
107
- <div className="col"><input className="input" placeholder="Search query (e.g., sanam re)" value={q} onChange={e => setQ(e.target.value)} /></div>
108
- <div className="col">
109
- <select className="select" value={mode} onChange={e => setMode(e.target.value)}>
110
- <option value="result">Search (all)</option>
111
- <option value="song">Song</option>
112
- <option value="album">Album</option>
113
- <option value="playlist">Playlist</option>
114
- <option value="lyrics">Lyrics</option>
115
- </select>
116
- </div>
117
- <div className="col" style={{ flex:'0 0 auto' }}>
118
- <button className="btn primary" onClick={search}>{loading ? 'Searching...' : 'Search'}</button>
119
- </div>
120
  </div>
121
- <div style={{ marginTop:12, maxHeight:320, overflow:'auto', display:'grid', gap:8 }}>
122
- {items?.length ? items.map((it, idx) => {
123
- const title = it.title || it.name || it.song || it?.data?.title || `Item ${idx+1}`;
124
- const mediaUrl = it.media_url || it.downloadUrl || it.url || it.perma_url || it.streamUrl;
125
- const thumb = getThumb(it);
126
- return (
127
- <div key={idx} className="room-card" style={{ display:'flex', alignItems:'center', justifyContent:'space-between', gap:10 }}>
128
- <div style={{ display:'flex', alignItems:'center', gap:10, minWidth:0 }}>
129
- <div className="thumb" style={{ width:48, height:48, backgroundImage: thumb ? `url("${thumb}")` : undefined }} />
130
- <div style={{ minWidth:0 }}>
131
- <div style={{ fontWeight:700, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{title}</div>
132
- <div className="meta" style={{ marginTop:4, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{mediaUrl || 'No stream URL in item'}</div>
133
- </div>
134
- </div>
135
- <div style={{ display:'flex', gap:8 }}>
136
- {mediaUrl ? (
137
- <button className="btn good" onClick={() => onPick({ url: mediaUrl, title, meta: it, thumb })}>Play</button>
138
- ) : <button className="btn" disabled>No URL</button>}
139
- </div>
140
- </div>
141
- );
142
- }) : (
143
- <div style={{ color:'var(--muted)' }}>{loading ? 'Searching…' : 'No results yet.'}</div>
144
- )}
145
  </div>
146
- </div>
147
- </div>
148
- );
149
- }
150
 
151
- function YouTubeModal({ open, onClose, onPick }) {
152
- const [q, setQ] = useState('');
153
- const [items, setItems] = useState([]);
154
- const [loading, setLoading] = useState(false);
155
- const [unavailable, setUnavailable] = useState(false);
156
-
157
- useEffect(() => { if (!open) { setQ(''); setItems([]); setLoading(false); setUnavailable(false); } }, [open]);
158
-
159
- const search = async () => {
160
- if (!q.trim()) return;
161
- try {
162
- setLoading(true);
163
- const res = await fetch(`/api/ytsearch?q=${encodeURIComponent(q.trim())}`);
164
- if (res.status === 501) { setUnavailable(true); setLoading(false); return; }
165
- const data = await res.json();
166
- setItems(data.items || []);
167
- setLoading(false);
168
- } catch (e) {
169
- setLoading(false);
170
- alert('YouTube search failed: ' + e.message);
171
- }
172
- };
173
-
174
- if (!open) return null;
175
- return (
176
- <div className="modal-backdrop" onClick={(e) => e.target === e.currentTarget && onClose()}>
177
- <div className="modal">
178
- <div className="section-title">YouTube search</div>
179
- {unavailable ? (
180
- <div style={{ color:'var(--warn)' }}>
181
- YouTube search is disabled. Set YT_API_KEY to enable search. You can still paste YouTube URLs/IDs via Direct link.
182
- </div>
183
- ) : (
184
- <>
185
- <div className="row" style={{ gap:8 }}>
186
- <div className="col"><input className="input" placeholder="Search YouTube" value={q} onChange={e => setQ(e.target.value)} /></div>
187
- <div className="col" style={{ flex:'0 0 auto' }}>
188
- <button className="btn primary" onClick={search}>{loading ? 'Searching...' : 'Search'}</button>
189
  </div>
190
- </div>
191
- <div style={{ marginTop:12, maxHeight:320, overflow:'auto', display:'grid', gap:8 }}>
192
- {items.length ? items.map((it) => (
193
- <div key={it.videoId} className="room-card" style={{ display:'flex', alignItems:'center', justifyContent:'space-between', gap:10 }}>
194
- <div style={{ display:'flex', alignItems:'center', gap:10, minWidth:0 }}>
195
- <div className="thumb" style={{ width:64, height:36, backgroundImage: it.thumb ? `url("${it.thumb}")` : undefined }} />
196
- <div style={{ minWidth:0 }}>
197
- <div style={{ fontWeight:700, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{it.title}</div>
198
- <div className="meta" style={{ marginTop:4, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{it.channelTitle}</div>
199
- </div>
200
- </div>
201
- <div>
202
- <button className="btn good" onClick={() => onPick(it)}>Play</button>
203
- </div>
204
- </div>
205
- )) : (
206
- <div style={{ color:'var(--muted)' }}>{loading ? 'Searching…' : 'No results yet.'}</div>
207
- )}
208
- </div>
209
- </>
210
  )}
211
  </div>
212
  </div>
@@ -253,8 +132,8 @@ export default function Room({ roomId, name, asHost, roomName }) {
253
  const [partyName, setPartyName] = useState(roomName || roomId);
254
 
255
  const [showDirect, setShowDirect] = useState(false);
256
- const [showJS, setShowJS] = useState(false);
257
  const [showYT, setShowYT] = useState(false);
 
258
 
259
  const [requests, setRequests] = useState([]);
260
 
@@ -263,19 +142,18 @@ export default function Room({ roomId, name, asHost, roomName }) {
263
  setIsHost(resp.isHost);
264
  if (resp.state) setState(s => ({ ...s, ...resp.state }));
265
  if (resp.roomName) setPartyName(resp.roomName);
266
- // Avoid duplicate local join toasts; rely on system broadcast in chat
267
  });
268
 
269
- socket.on('set_track', ({ track }) => { setState(s => ({ ...s, track })); });
270
  socket.on('play', ({ anchor, anchorAt }) => setState(s => ({ ...s, isPlaying: true, anchor, anchorAt })));
271
  socket.on('pause', ({ anchor, anchorAt }) => setState(s => ({ ...s, isPlaying: false, anchor, anchorAt })));
272
  socket.on('seek', ({ anchor, anchorAt, isPlaying }) => setState(s => ({ ...s, anchor, anchorAt, isPlaying })));
273
  socket.on('host_changed', ({ hostId }) => setIsHost(socket.id === hostId));
274
  socket.on('members', ({ members, roomName }) => { setMembers(members || []); if (roomName) setPartyName(roomName); });
275
  socket.on('system', ({ text }) => { if (text) console.log(text); });
276
- socket.on('queue_update', ({ queue }) => { setState(s => ({ ...s, queue: queue || [] })); });
277
 
278
- // Host receives a request → attach a preview (first result)
279
  socket.on('song_request', async (req) => {
280
  if (!isHost) return;
281
  const enriched = { ...req, preview: null };
@@ -288,9 +166,7 @@ export default function Room({ roomId, name, asHost, roomName }) {
288
  const url = first.media_url || first.downloadUrl || first.url || first.perma_url || first.streamUrl;
289
  if (url) enriched.preview = { url, title: first.title || req.query, meta: first, kind: detectMediaTypeFromUrl(url) || 'audio', thumb: getThumb(first) };
290
  }
291
- } catch (e) {
292
- log.error('song_request preview failed', e);
293
- }
294
  setRequests(prev => [enriched, ...prev].slice(0, 20));
295
  });
296
 
@@ -302,67 +178,70 @@ export default function Room({ roomId, name, asHost, roomName }) {
302
  };
303
  }, [roomId, name, asHost, roomName, isHost]);
304
 
305
- // Use vidfly / yt source through our backend for YouTube links/IDs
 
 
 
 
 
 
 
306
  const pickDirectUrl = async ({ url, type }) => {
307
  if (!url) return;
308
- const isYT = (type === 'youtube') || detectMediaTypeFromUrl(url) === 'youtube' || /^(?:https?:\/\/)?(?:www\.)?youtu/.test(url) || /^[A-Za-z0-9_-]{11}$/.test(url);
309
  if (isYT) {
310
- try {
311
- const resp = await fetch(`/api/yt/source?url=${encodeURIComponent(url)}`);
312
- const data = await resp.json();
313
- if (!resp.ok) throw new Error(data.error || 'YT resolve failed');
314
- // proxied URL for playback
315
- const prox = proxiedUrl(data.url);
316
- const track = { url: prox, title: data.title || url, meta: { thumb: data.thumbnail, source: 'youtube', originalUrl: data.url }, kind: 'video', thumb: data.thumbnail };
317
- socket.emit('set_track', { roomId, track });
318
- } catch (e) {
319
- alert('YouTube resolve failed: ' + (e.message || e));
320
- } finally {
321
- setShowDirect(false);
322
- }
323
  return;
324
  }
325
-
326
- let mediaType = type === 'auto' ? detectMediaTypeFromUrl(url) : type;
327
- if (!mediaType || mediaType === 'unknown') {
328
- setShowDirect(false);
329
  alert('Unknown media type. Choose Audio or Video.');
330
- setTimeout(() => setShowDirect(true), 30);
331
  return;
332
  }
333
  const track = { url, title: url, meta: { source: 'direct' }, kind: mediaType };
334
- socket.emit('set_track', { roomId, track });
335
- setShowDirect(false);
336
  };
337
 
338
- const playJioSaavnItem = (item) => {
339
- const url = item?.url || item?.media_url || item?.downloadUrl || item?.perma_url || item?.streamUrl;
340
- if (!url) return alert('Selected item has no stream URL');
341
- const type = detectMediaTypeFromUrl(url) || 'audio';
342
- const track = { url, title: item.title || item.name || url, meta: item, kind: type, thumb: getThumb(item) };
343
- socket.emit('set_track', { roomId, track });
344
- setShowJS(false);
 
 
 
345
  };
346
 
347
- const playYouTubeItem = async (yt) => {
348
  try {
349
- const resp = await fetch(`/api/yt/source?url=${encodeURIComponent(yt.videoId)}`);
350
- const data = await resp.json();
351
- if (!resp.ok) throw new Error(data.error || 'YT resolve failed');
352
- const prox = proxiedUrl(data.url);
353
- const track = { url: prox, title: yt.title, meta: { thumb: data.thumbnail, source: 'youtube', originalUrl: data.url }, kind: 'video', thumb: data.thumbnail || yt.thumb };
354
- socket.emit('set_track', { roomId, track });
355
- setShowYT(false);
 
 
356
  } catch (e) {
357
- alert('YouTube resolve failed: ' + (e.message || e));
 
 
 
 
 
358
  }
359
  };
360
 
 
 
361
  const Controls = useMemo(() => (
362
  isHost ? (
363
  <div style={{ display:'flex', flexWrap:'wrap', gap:8 }}>
364
  <button className="btn" onClick={() => setShowDirect(true)}>Direct link</button>
365
- <button className="btn" onClick={() => setShowJS(true)}>JioSaavn</button>
366
  <button className="btn" onClick={() => setShowYT(true)}>YouTube</button>
367
  <button className="btn good" onClick={() => socket.emit('play', { roomId })}>Play</button>
368
  <button className="btn" onClick={() => socket.emit('pause', { roomId })}>Pause</button>
@@ -381,9 +260,7 @@ export default function Room({ roomId, name, asHost, roomName }) {
381
  return;
382
  }
383
  if (!req.preview) return;
384
- // Ensure preview.url is proxied if necessary (avoids CORS for youtube direct links)
385
- const track = { ...req.preview };
386
- if (track.url) track.url = proxiedUrl(track.url);
387
  socket.emit('song_request_action', { roomId, action: action === 'accept' ? 'accept' : 'queue', track });
388
  setRequests(prev => prev.filter(r => r.requestId !== req.requestId));
389
  };
@@ -436,8 +313,7 @@ export default function Room({ roomId, name, asHost, roomName }) {
436
  </div>
437
 
438
  <DirectLinkModal open={showDirect} onClose={() => setShowDirect(false)} onPick={pickDirectUrl} />
439
- <JioSaavnModal open={showJS} onClose={() => setShowJS(false)} onPick={playJioSaavnItem} />
440
- <YouTubeModal open={showYT} onClose={() => setShowYT(false)} onPick={playYouTubeItem} />
441
  </div>
442
  );
443
- }
 
1
  // client/src/Room.jsx
2
  import React, { useEffect, useMemo, useState } from 'react';
3
  import { io } from 'socket.io-client';
 
4
  import Player from './Player.jsx';
5
+ import Chat from './Chat.jsx';
6
  import MemberList from './MemberList.jsx';
7
  import { detectMediaTypeFromUrl, getThumb, prettyError, safeTitle } from './utils.js';
8
  import { useToasts } from './Toasts.jsx';
 
10
 
11
  const socket = io('', { transports: ['websocket'] });
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  function DirectLinkModal({ open, onClose, onPick }) {
14
  const [url, setUrl] = useState('');
15
  const [type, setType] = useState('auto');
16
+
17
+ useEffect(() => {
18
+ if (!open) { setUrl(''); setType('auto'); }
19
+ }, [open]);
20
 
21
  if (!open) return null;
22
  return (
 
24
  <div className="modal">
25
  <div className="section-title">Play a direct link</div>
26
  <div style={{ color:'var(--muted)', fontSize:13, marginBottom:8 }}>
27
+ Paste a direct media URL (MP4, WEBM, MP3, M4A, etc.) or a YouTube URL/ID.
28
  </div>
29
+ <input
30
+ className="input"
31
+ placeholder="https://example.com/video.mp4 or https://youtu.be/xxxx or 11-char ID"
32
+ value={url}
33
+ onChange={e => setUrl(e.target.value)}
34
+ />
35
  <div style={{ display:'flex', gap:10, marginTop:10 }}>
36
  <select className="select" value={type} onChange={e => setType(e.target.value)}>
37
  <option value="auto">Auto-detect type</option>
 
47
  );
48
  }
49
 
50
+ function YouTubeModal({ open, onClose, onPick, ytError, onTryFallback }) {
51
+ const [idOrUrl, setIdOrUrl] = useState('');
 
 
 
 
 
52
 
53
+ useEffect(() => {
54
+ if (!open) { setIdOrUrl(''); }
55
+ }, [open]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
 
57
  if (!open) return null;
58
  return (
59
  <div className="modal-backdrop" onClick={(e) => e.target === e.currentTarget && onClose()}>
60
  <div className="modal">
61
+ <div className="section-title">Play YouTube</div>
62
+ <div style={{ color:'var(--muted)', fontSize:13, marginBottom:8 }}>
63
+ Enter a YouTube URL or 11‑character ID. We’ll try Vidfly first and fall back to yt‑dlp if needed.
 
 
 
 
 
 
 
 
 
 
 
 
64
  </div>
65
+ <input
66
+ className="input"
67
+ placeholder="https://www.youtube.com/watch?v=XXXXXXXXXXX or XXXXXXXID"
68
+ value={idOrUrl}
69
+ onChange={e => setIdOrUrl(e.target.value)}
70
+ />
71
+ <div style={{ display:'flex', gap:8, marginTop:10 }}>
72
+ <button className="btn good" onClick={() => onPick(idOrUrl, 'vidfly')}>Play with Vidfly</button>
73
+ <button className="btn" onClick={() => onPick(idOrUrl, 'yt-dlp')}>Play with yt‑dlp</button>
74
+ <button className="btn" onClick={onClose}>Close</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  </div>
 
 
 
 
76
 
77
+ {ytError && (
78
+ <div className="room-card" style={{ marginTop:12 }}>
79
+ <div style={{ fontWeight:700, color:'var(--bad)' }}>YouTube failed</div>
80
+ <div className="meta" style={{ marginTop:6 }}>{ytError.message}</div>
81
+ {ytError.canFallback && (
82
+ <div style={{ marginTop:8 }}>
83
+ <button className="btn warn" onClick={() => onTryFallback(ytError.url, ytError.from === 'vidfly' ? 'yt-dlp' : 'vidfly')}>
84
+ Try {ytError.from === 'vidfly' ? 'yt‑dlp' : 'Vidfly'} instead
85
+ </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  </div>
87
+ )}
88
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  )}
90
  </div>
91
  </div>
 
132
  const [partyName, setPartyName] = useState(roomName || roomId);
133
 
134
  const [showDirect, setShowDirect] = useState(false);
 
135
  const [showYT, setShowYT] = useState(false);
136
+ const [ytError, setYtError] = useState(null);
137
 
138
  const [requests, setRequests] = useState([]);
139
 
 
142
  setIsHost(resp.isHost);
143
  if (resp.state) setState(s => ({ ...s, ...resp.state }));
144
  if (resp.roomName) setPartyName(resp.roomName);
 
145
  });
146
 
147
+ socket.on('set_track', ({ track }) => setState(s => ({ ...s, track })));
148
  socket.on('play', ({ anchor, anchorAt }) => setState(s => ({ ...s, isPlaying: true, anchor, anchorAt })));
149
  socket.on('pause', ({ anchor, anchorAt }) => setState(s => ({ ...s, isPlaying: false, anchor, anchorAt })));
150
  socket.on('seek', ({ anchor, anchorAt, isPlaying }) => setState(s => ({ ...s, anchor, anchorAt, isPlaying })));
151
  socket.on('host_changed', ({ hostId }) => setIsHost(socket.id === hostId));
152
  socket.on('members', ({ members, roomName }) => { setMembers(members || []); if (roomName) setPartyName(roomName); });
153
  socket.on('system', ({ text }) => { if (text) console.log(text); });
154
+ socket.on('queue_update', ({ queue }) => setState(s => ({ ...s, queue: queue || [] })));
155
 
156
+ // Host receives a request → attach a preview (best effort via /api/result)
157
  socket.on('song_request', async (req) => {
158
  if (!isHost) return;
159
  const enriched = { ...req, preview: null };
 
166
  const url = first.media_url || first.downloadUrl || first.url || first.perma_url || first.streamUrl;
167
  if (url) enriched.preview = { url, title: first.title || req.query, meta: first, kind: detectMediaTypeFromUrl(url) || 'audio', thumb: getThumb(first) };
168
  }
169
+ } catch {}
 
 
170
  setRequests(prev => [enriched, ...prev].slice(0, 20));
171
  });
172
 
 
178
  };
179
  }, [roomId, name, asHost, roomName, isHost]);
180
 
181
+ // DIRECT LINKS AND YOUTUBE RESOLUTION
182
+
183
+ const setTrackAndClose = (track) => {
184
+ socket.emit('set_track', { roomId, track });
185
+ setShowDirect(false);
186
+ setShowYT(false);
187
+ };
188
+
189
  const pickDirectUrl = async ({ url, type }) => {
190
  if (!url) return;
191
+ const isYT = (type === 'youtube') || detectMediaTypeFromUrl(url) === 'youtube' || /^[A-Za-z0-9_-]{11}$/.test(url);
192
  if (isYT) {
193
+ setShowDirect(false);
194
+ setShowYT(true);
 
 
 
 
 
 
 
 
 
 
 
195
  return;
196
  }
197
+ const mediaType = (type === 'auto' || !type) ? (detectMediaTypeFromUrl(url) || 'audio') : type;
198
+ if (mediaType === 'unknown') {
 
 
199
  alert('Unknown media type. Choose Audio or Video.');
 
200
  return;
201
  }
202
  const track = { url, title: url, meta: { source: 'direct' }, kind: mediaType };
203
+ setTrackAndClose(track);
 
204
  };
205
 
206
+ const resolveYouTube = async (idOrUrl, api) => {
207
+ setYtError(null);
208
+ const q = new URLSearchParams({ url: idOrUrl, api }).toString();
209
+ const resp = await fetch(`/api/yt/source?${q}`);
210
+ const data = await resp.json();
211
+ if (!resp.ok) {
212
+ const message = data?.error || 'Failed to resolve YouTube';
213
+ throw new Error(message);
214
+ }
215
+ return data;
216
  };
217
 
218
+ const onPickYouTube = async (idOrUrl, api = 'vidfly') => {
219
  try {
220
+ const data = await resolveYouTube(idOrUrl, api);
221
+ const track = {
222
+ url: data.url,
223
+ title: data.title || idOrUrl,
224
+ meta: { thumb: data.thumbnail, source: data.source || api, yt: true },
225
+ kind: data.kind || 'video',
226
+ thumb: data.thumbnail
227
+ };
228
+ setTrackAndClose(track);
229
  } catch (e) {
230
+ setYtError({
231
+ message: e.message,
232
+ canFallback: api === 'vidfly' || api === 'yt-dlp',
233
+ from: api,
234
+ url: idOrUrl
235
+ });
236
  }
237
  };
238
 
239
+ const onTryFallback = (idOrUrl, api) => onPickYouTube(idOrUrl, api);
240
+
241
  const Controls = useMemo(() => (
242
  isHost ? (
243
  <div style={{ display:'flex', flexWrap:'wrap', gap:8 }}>
244
  <button className="btn" onClick={() => setShowDirect(true)}>Direct link</button>
 
245
  <button className="btn" onClick={() => setShowYT(true)}>YouTube</button>
246
  <button className="btn good" onClick={() => socket.emit('play', { roomId })}>Play</button>
247
  <button className="btn" onClick={() => socket.emit('pause', { roomId })}>Pause</button>
 
260
  return;
261
  }
262
  if (!req.preview) return;
263
+ const track = req.preview;
 
 
264
  socket.emit('song_request_action', { roomId, action: action === 'accept' ? 'accept' : 'queue', track });
265
  setRequests(prev => prev.filter(r => r.requestId !== req.requestId));
266
  };
 
313
  </div>
314
 
315
  <DirectLinkModal open={showDirect} onClose={() => setShowDirect(false)} onPick={pickDirectUrl} />
316
+ <YouTubeModal open={showYT} onClose={() => setShowYT(false)} onPick={onPickYouTube} ytError={ytError} onTryFallback={onTryFallback} />
 
317
  </div>
318
  );
319
+ }