akborana4 commited on
Commit
cf5ac1c
·
verified ·
1 Parent(s): fe0a27f

Update client/src/Room.jsx

Browse files
Files changed (1) hide show
  1. client/src/Room.jsx +192 -44
client/src/Room.jsx CHANGED
@@ -1,3 +1,4 @@
 
1
  import React, { useEffect, useMemo, useState } from 'react';
2
  import { io } from 'socket.io-client';
3
  import Player from './Player.jsx';
@@ -6,23 +7,145 @@ import MemberList from './MemberList.jsx';
6
  import { detectMediaTypeFromUrl, getThumb } from './utils.js';
7
  import { useToasts } from './Toasts.jsx';
8
 
 
9
  const socket = io('', { transports: ['websocket'] });
10
 
11
- // ... DirectLinkModal, YouTubeModal, RequestsPanel components here (same as before) ...
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  export default function Room({ roomId, name, asHost, roomName }) {
14
  const { push } = useToasts();
 
15
  const [isHost, setIsHost] = useState(false);
16
  const [state, setState] = useState({ track: null, isPlaying: false, anchor: 0, anchorAt: 0, queue: [] });
17
  const [members, setMembers] = useState([]);
18
  const [partyName, setPartyName] = useState(roomName || roomId);
 
19
  const [showDirect, setShowDirect] = useState(false);
20
  const [showYT, setShowYT] = useState(false);
21
  const [ytError, setYtError] = useState(null);
22
  const [requests, setRequests] = useState([]);
23
 
 
24
  useEffect(() => {
25
- socket.emit('join_room', { roomId, name, asHost, roomName }, (resp) => {
26
  setIsHost(resp.isHost);
27
  if (resp.state) setState(s => ({ ...s, ...resp.state }));
28
  if (resp.roomName) setPartyName(resp.roomName);
@@ -32,50 +155,63 @@ export default function Room({ roomId, name, asHost, roomName }) {
32
  socket.on('play', ({ anchor, anchorAt }) => setState(s => ({ ...s, isPlaying: true, anchor, anchorAt })));
33
  socket.on('pause', ({ anchor, anchorAt }) => setState(s => ({ ...s, isPlaying: false, anchor, anchorAt })));
34
  socket.on('seek', ({ anchor, anchorAt, isPlaying }) => setState(s => ({ ...s, anchor, anchorAt, isPlaying })));
35
- socket.on('host_changed', ({ hostId }) => setIsHost(socket.id === hostId));
36
- socket.on('members', ({ members, roomName }) => { setMembers(members || []); if (roomName) setPartyName(roomName); });
 
 
37
  socket.on('queue_update', ({ queue }) => setState(s => ({ ...s, queue: queue || [] })));
38
 
39
  return () => {
40
- socket.off('set_track'); socket.off('play'); socket.off('pause'); socket.off('seek');
41
- socket.off('host_changed'); socket.off('members'); socket.off('queue_update');
 
 
 
 
42
  };
43
  }, [roomId, name, asHost, roomName]);
44
 
45
- const setTrackAndClose = (track) => {
 
46
  socket.emit('set_track', { roomId, track });
47
  setShowDirect(false);
48
  setShowYT(false);
49
  };
50
 
 
51
  const pickDirectUrl = ({ url, type }) => {
52
  if (!url) return;
53
- const isYT = (type === 'youtube') || detectMediaTypeFromUrl(url) === 'youtube' || /^[A-Za-z0-9_-]{11}$/.test(url);
 
 
54
  if (isYT) {
55
  setShowDirect(false);
56
  setShowYT(true);
57
  return;
58
  }
59
- const mediaType = (type === 'auto' || !type) ? (detectMediaTypeFromUrl(url) || 'audio') : type;
60
- const track = { url, title: url, meta: { source: 'direct' }, kind: mediaType };
 
 
61
  setTrackAndClose(track);
62
  };
63
 
64
- const resolveYouTube = async (idOrUrl) => {
 
65
  setYtError(null);
66
  const resp = await fetch(`/api/yt/source?url=${encodeURIComponent(idOrUrl)}`);
67
  const data = await resp.json();
68
- if (!resp.ok) throw new Error(data?.error || 'Failed to resolve YouTube');
69
  return data;
70
  };
71
 
72
- const onPickYouTube = async (idOrUrl) => {
73
  try {
74
  const data = await resolveYouTube(idOrUrl);
75
  const track = {
76
  url: data.url,
77
  title: data.title || idOrUrl,
78
- meta: { thumb: data.thumbnail, source: data.source || 'vidfly', yt: true },
79
  kind: data.kind || 'video',
80
  thumb: data.thumbnail
81
  };
@@ -85,44 +221,47 @@ export default function Room({ roomId, name, asHost, roomName }) {
85
  }
86
  };
87
 
88
- const Controls = useMemo(() => (
89
- isHost ? (
90
- <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
91
- <button className="btn" onClick={() => setShowDirect(true)}>Direct link</button>
92
- <button className="btn" onClick={() => setShowYT(true)}>YouTube</button>
93
- <button className="btn good" onClick={() => socket.emit('play', { roomId })}>Play</button>
94
- <button className="btn" onClick={() => socket.emit('pause', { roomId })}>Pause</button>
95
- <button className="btn" onClick={() => {
96
- const toStr = prompt('Seek to seconds', '60');
97
- const to = Number(toStr);
98
- if (Number.isFinite(to)) socket.emit('seek', { roomId, to });
99
- }}>Seek</button>
100
- </div>
101
- ) : <div className="badge">Waiting for host controls…</div>
 
 
102
  ), [isHost, roomId]);
103
 
 
104
  const handleRequestAction = (action, req) => {
105
  if (action === 'reject') {
106
- setRequests(prev => prev.filter(r => r.requestId !== req.requestId));
107
  return;
108
  }
109
  if (!req.preview) return;
110
- const track = req.preview;
111
  socket.emit('song_request_action', {
112
  roomId,
113
  action: action === 'accept' ? 'accept' : 'queue',
114
- track
115
  });
116
- setRequests(prev => prev.filter(r => r.requestId !== req.requestId));
117
  };
118
 
119
  return (
120
  <div className="container">
121
  <div className="row">
 
122
  <div className="col" style={{ minWidth: 320 }}>
123
  <div className="player">
124
  <div className="player-header">
125
- <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
126
  <div className="badge">{partyName}</div>
127
  <div className="tag">{isHost ? 'Host' : 'Guest'}</div>
128
  </div>
@@ -133,7 +272,7 @@ export default function Room({ roomId, name, asHost, roomName }) {
133
  </div>
134
  </div>
135
 
136
- <div className="panel" style={{ marginTop: 12 }}>
137
  <div className="section-title">Chat</div>
138
  <Chat socket={socket} roomId={roomId} name={name} isHost={isHost} members={members} />
139
  </div>
@@ -141,34 +280,43 @@ export default function Room({ roomId, name, asHost, roomName }) {
141
  <RequestsPanel isHost={isHost} requests={requests} onAct={handleRequestAction} />
142
  </div>
143
 
144
- <div className="col" style={{ flex: '0 0 340px' }}>
 
145
  <div className="panel">
146
  <div className="section-title">Members</div>
147
  <MemberList members={members} />
148
  </div>
149
 
150
- <div className="panel" style={{ marginTop: 12 }}>
151
  <div className="section-title">Queue</div>
152
- {state.queue?.length ? (
153
- <div style={{ display: 'grid', gap: 8 }}>
154
  {state.queue.map((t, i) => (
155
- <div key={i} className="room-card" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
156
- <div className="thumb" style={{ width: 40, height: 40, backgroundImage: t.thumb ? `url("${t.thumb}")` : undefined }} />
157
- <div style={{ fontWeight: 700, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
 
 
 
 
 
 
 
158
  {t.title || t.url}
159
  </div>
160
  </div>
161
  ))}
162
  </div>
163
- ) : <div style={{ color: 'var(--muted)' }}>No songs in queue</div>}
 
 
164
  </div>
165
  </div>
166
  </div>
167
 
 
168
  <DirectLinkModal open={showDirect} onClose={() => setShowDirect(false)} onPick={pickDirectUrl} />
169
  <YouTubeModal open={showYT} onClose={() => setShowYT(false)} onPick={onPickYouTube} ytError={ytError} />
170
  </div>
171
  );
172
  }
173
-
174
-
 
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';
 
7
  import { detectMediaTypeFromUrl, getThumb } from './utils.js';
8
  import { useToasts } from './Toasts.jsx';
9
 
10
+ // initialize socket once
11
  const socket = io('', { transports: ['websocket'] });
12
 
13
+ // Modal for arbitrary direct URLs (MP4/MP3/etc or YouTube ID/URL)
14
+ function DirectLinkModal({ open, onClose, onPick }) {
15
+ const [url, setUrl] = useState('');
16
+ const [type, setType] = useState('auto');
17
+ useEffect(() => {
18
+ if (!open) {
19
+ setUrl('');
20
+ setType('auto');
21
+ }
22
+ }, [open]);
23
+
24
+ if (!open) return null;
25
+ return (
26
+ <div className="modal-backdrop" onClick={e => e.target === e.currentTarget && onClose()}>
27
+ <div className="modal">
28
+ <div className="section-title">Play a direct link</div>
29
+ <div style={{ color:'var(--muted)', fontSize:13, marginBottom:8 }}>
30
+ Paste a direct media URL (MP4, WEBM, MP3, M4A) or a YouTube URL/ID.
31
+ </div>
32
+ <input
33
+ className="input"
34
+ placeholder="https://…/video.mp4 or https://youtu.be/xxxx or 11-char ID"
35
+ value={url}
36
+ onChange={e => setUrl(e.target.value)}
37
+ />
38
+ <div style={{ display:'flex', gap:10, marginTop:10 }}>
39
+ <select className="select" value={type} onChange={e => setType(e.target.value)}>
40
+ <option value="auto">Auto-detect type</option>
41
+ <option value="video">Force Video</option>
42
+ <option value="audio">Force Audio</option>
43
+ <option value="youtube">YouTube</option>
44
+ </select>
45
+ <button className="btn primary" onClick={() => onPick({ url, type })}>Set</button>
46
+ <button className="btn" onClick={onClose}>Cancel</button>
47
+ </div>
48
+ </div>
49
+ </div>
50
+ );
51
+ }
52
 
53
+ // Modal for YouTube (via Vidfly) only
54
+ function YouTubeModal({ open, onClose, onPick, ytError }) {
55
+ const [idOrUrl, setIdOrUrl] = useState('');
56
+ useEffect(() => {
57
+ if (!open) setIdOrUrl('');
58
+ }, [open]);
59
+
60
+ if (!open) return null;
61
+ return (
62
+ <div className="modal-backdrop" onClick={e => e.target === e.currentTarget && onClose()}>
63
+ <div className="modal">
64
+ <div className="section-title">Play YouTube (Vidfly)</div>
65
+ <div style={{ color:'var(--muted)', fontSize:13, marginBottom:8 }}>
66
+ Enter a YouTube URL or 11-character ID. We resolve via Vidfly and proxy if needed.
67
+ </div>
68
+ <input
69
+ className="input"
70
+ placeholder="https://www.youtube.com/watch?v=XXXXXXXXXXX or XXXXXXXID"
71
+ value={idOrUrl}
72
+ onChange={e => setIdOrUrl(e.target.value)}
73
+ />
74
+ <div style={{ display:'flex', gap:8, marginTop:10 }}>
75
+ <button className="btn good" onClick={() => onPick(idOrUrl)}>Play</button>
76
+ <button className="btn" onClick={onClose}>Close</button>
77
+ </div>
78
+ {ytError && (
79
+ <div className="room-card" style={{ marginTop:12 }}>
80
+ <div style={{ fontWeight:700, color:'var(--bad)' }}>YouTube failed</div>
81
+ <div className="meta" style={{ marginTop:6 }}>{ytError.message}</div>
82
+ </div>
83
+ )}
84
+ </div>
85
+ </div>
86
+ );
87
+ }
88
+
89
+ // Panel for host to accept/queue/reject song requests
90
+ function RequestsPanel({ isHost, requests, onAct }) {
91
+ if (!isHost || !requests.length) return null;
92
+ return (
93
+ <div className="panel" style={{ marginTop:12 }}>
94
+ <div className="section-title">Song requests</div>
95
+ <div style={{ display:'grid', gap:8 }}>
96
+ {requests.map(r => (
97
+ <div
98
+ key={r.requestId}
99
+ className="room-card"
100
+ style={{ display:'flex', alignItems:'center', justifyContent:'space-between', gap:10 }}
101
+ >
102
+ <div style={{ display:'flex', alignItems:'center', gap:10, minWidth:0 }}>
103
+ <div
104
+ className="thumb"
105
+ style={{
106
+ width:48,
107
+ height:48,
108
+ backgroundImage: r.preview?.thumb ? `url("${r.preview.thumb}")` : undefined
109
+ }}
110
+ />
111
+ <div style={{ minWidth:0 }}>
112
+ <div style={{ fontWeight:700, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
113
+ {r.requester} → {r.query}
114
+ </div>
115
+ <div className="meta" style={{ marginTop:4, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
116
+ {r.preview?.title || (r.preview ? 'Result found' : 'Searching…')}
117
+ </div>
118
+ </div>
119
+ </div>
120
+ <div style={{ display:'flex', gap:8 }}>
121
+ <button className="btn good" onClick={() => onAct('accept', r)} disabled={!r.preview}>Accept</button>
122
+ <button className="btn" onClick={() => onAct('queue', r)} disabled={!r.preview}>Queue</button>
123
+ <button className="btn warn" onClick={() => onAct('reject', r)}>Reject</button>
124
+ </div>
125
+ </div>
126
+ ))}
127
+ </div>
128
+ </div>
129
+ );
130
+ }
131
+
132
+ // Main Room component
133
  export default function Room({ roomId, name, asHost, roomName }) {
134
  const { push } = useToasts();
135
+
136
  const [isHost, setIsHost] = useState(false);
137
  const [state, setState] = useState({ track: null, isPlaying: false, anchor: 0, anchorAt: 0, queue: [] });
138
  const [members, setMembers] = useState([]);
139
  const [partyName, setPartyName] = useState(roomName || roomId);
140
+
141
  const [showDirect, setShowDirect] = useState(false);
142
  const [showYT, setShowYT] = useState(false);
143
  const [ytError, setYtError] = useState(null);
144
  const [requests, setRequests] = useState([]);
145
 
146
+ // join room & socket listeners
147
  useEffect(() => {
148
+ socket.emit('join_room', { roomId, name, asHost, roomName }, resp => {
149
  setIsHost(resp.isHost);
150
  if (resp.state) setState(s => ({ ...s, ...resp.state }));
151
  if (resp.roomName) setPartyName(resp.roomName);
 
155
  socket.on('play', ({ anchor, anchorAt }) => setState(s => ({ ...s, isPlaying: true, anchor, anchorAt })));
156
  socket.on('pause', ({ anchor, anchorAt }) => setState(s => ({ ...s, isPlaying: false, anchor, anchorAt })));
157
  socket.on('seek', ({ anchor, anchorAt, isPlaying }) => setState(s => ({ ...s, anchor, anchorAt, isPlaying })));
158
+ socket.on('members', ({ members, roomName }) => {
159
+ setMembers(members || []);
160
+ if (roomName) setPartyName(roomName);
161
+ });
162
  socket.on('queue_update', ({ queue }) => setState(s => ({ ...s, queue: queue || [] })));
163
 
164
  return () => {
165
+ socket.off('set_track');
166
+ socket.off('play');
167
+ socket.off('pause');
168
+ socket.off('seek');
169
+ socket.off('members');
170
+ socket.off('queue_update');
171
  };
172
  }, [roomId, name, asHost, roomName]);
173
 
174
+ // helper to set track and close modals
175
+ const setTrackAndClose = track => {
176
  socket.emit('set_track', { roomId, track });
177
  setShowDirect(false);
178
  setShowYT(false);
179
  };
180
 
181
+ // pick a direct URL or YouTube
182
  const pickDirectUrl = ({ url, type }) => {
183
  if (!url) return;
184
+ const isYT = type === 'youtube'
185
+ || detectMediaTypeFromUrl(url) === 'youtube'
186
+ || /^[A-Za-z0-9_-]{11}$/.test(url);
187
  if (isYT) {
188
  setShowDirect(false);
189
  setShowYT(true);
190
  return;
191
  }
192
+ const kind = type === 'auto'
193
+ ? (detectMediaTypeFromUrl(url) || 'audio')
194
+ : type;
195
+ const track = { url, title: url, meta: {}, kind };
196
  setTrackAndClose(track);
197
  };
198
 
199
+ // resolve YouTube via our /api/yt/source
200
+ const resolveYouTube = async idOrUrl => {
201
  setYtError(null);
202
  const resp = await fetch(`/api/yt/source?url=${encodeURIComponent(idOrUrl)}`);
203
  const data = await resp.json();
204
+ if (!resp.ok) throw new Error(data.error || 'Failed to resolve YouTube');
205
  return data;
206
  };
207
 
208
+ const onPickYouTube = async idOrUrl => {
209
  try {
210
  const data = await resolveYouTube(idOrUrl);
211
  const track = {
212
  url: data.url,
213
  title: data.title || idOrUrl,
214
+ meta: { thumb: data.thumbnail },
215
  kind: data.kind || 'video',
216
  thumb: data.thumbnail
217
  };
 
221
  }
222
  };
223
 
224
+ // control bar (host only)
225
+ const Controls = useMemo(() => (
226
+ isHost
227
+ ? (
228
+ <div style={{ display:'flex', flexWrap:'wrap', gap:8 }}>
229
+ <button className="btn" onClick={() => setShowDirect(true)}>Direct link</button>
230
+ <button className="btn" onClick={() => setShowYT(true)}>YouTube</button>
231
+ <button className="btn good" onClick={() => socket.emit('play', { roomId })}>Play</button>
232
+ <button className="btn" onClick={() => socket.emit('pause', { roomId })}>Pause</button>
233
+ <button className="btn" onClick={() => {
234
+ const t = Number(prompt('Seek to seconds', '60'));
235
+ if (Number.isFinite(t)) socket.emit('seek', { roomId, to: t });
236
+ }}>Seek</button>
237
+ </div>
238
+ )
239
+ : <div className="badge">Waiting for host controls…</div>
240
  ), [isHost, roomId]);
241
 
242
+ // handle song request accept/queue/reject
243
  const handleRequestAction = (action, req) => {
244
  if (action === 'reject') {
245
+ setRequests(r => r.filter(x => x.requestId !== req.requestId));
246
  return;
247
  }
248
  if (!req.preview) return;
 
249
  socket.emit('song_request_action', {
250
  roomId,
251
  action: action === 'accept' ? 'accept' : 'queue',
252
+ track: req.preview
253
  });
254
+ setRequests(r => r.filter(x => x.requestId !== req.requestId));
255
  };
256
 
257
  return (
258
  <div className="container">
259
  <div className="row">
260
+ {/* Left column: player + chat + requests */}
261
  <div className="col" style={{ minWidth: 320 }}>
262
  <div className="player">
263
  <div className="player-header">
264
+ <div style={{ display:'flex', alignItems:'center', gap:10 }}>
265
  <div className="badge">{partyName}</div>
266
  <div className="tag">{isHost ? 'Host' : 'Guest'}</div>
267
  </div>
 
272
  </div>
273
  </div>
274
 
275
+ <div className="panel" style={{ marginTop:12 }}>
276
  <div className="section-title">Chat</div>
277
  <Chat socket={socket} roomId={roomId} name={name} isHost={isHost} members={members} />
278
  </div>
 
280
  <RequestsPanel isHost={isHost} requests={requests} onAct={handleRequestAction} />
281
  </div>
282
 
283
+ {/* Right column: members + queue */}
284
+ <div className="col" style={{ flex:'0 0 340px' }}>
285
  <div className="panel">
286
  <div className="section-title">Members</div>
287
  <MemberList members={members} />
288
  </div>
289
 
290
+ <div className="panel" style={{ marginTop:12 }}>
291
  <div className="section-title">Queue</div>
292
+ {state.queue.length > 0 ? (
293
+ <div style={{ display:'grid', gap:8 }}>
294
  {state.queue.map((t, i) => (
295
+ <div key={i} className="room-card" style={{ display:'flex', alignItems:'center', gap:10 }}>
296
+ <div
297
+ className="thumb"
298
+ style={{
299
+ width:40,
300
+ height:40,
301
+ backgroundImage: t.thumb ? `url("${t.thumb}")` : undefined
302
+ }}
303
+ />
304
+ <div style={{ fontWeight:700, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
305
  {t.title || t.url}
306
  </div>
307
  </div>
308
  ))}
309
  </div>
310
+ ) : (
311
+ <div style={{ color:'var(--muted)' }}>No songs in queue</div>
312
+ )}
313
  </div>
314
  </div>
315
  </div>
316
 
317
+ {/* Overlays */}
318
  <DirectLinkModal open={showDirect} onClose={() => setShowDirect(false)} onPick={pickDirectUrl} />
319
  <YouTubeModal open={showYT} onClose={() => setShowYT(false)} onPick={onPickYouTube} ytError={ytError} />
320
  </div>
321
  );
322
  }