Skip to content

Commit fb7e3c1

Browse files
authored
feat: add playlist for videos (#16)
2 parents cffa9c2 + 177a1f0 commit fb7e3c1

6 files changed

Lines changed: 218 additions & 41 deletions

File tree

apps/backend/src/server.ts

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ interface Room {
55
messages: Message[];
66
videoUrl?: string;
77
createdAt: string;
8+
playlist: string[];
9+
currentVideoIndex: number;
810
}
911

1012
interface Message {
@@ -18,7 +20,10 @@ type SocketTypes =
1820
| 'join-room'
1921
| 'send-message'
2022
| 'set-video'
21-
| 'video-sync';
23+
| 'video-sync'
24+
| 'add-to-playlist'
25+
| 'next-video'
26+
| 'video-ended';
2227

2328
interface WebSocketData {
2429
type: SocketTypes;
@@ -53,6 +58,8 @@ wss.on('connection', (ws: WebSocket) => {
5358
clients: new Set([ws]),
5459
messages: [],
5560
createdAt: new Date().toISOString(),
61+
playlist: [],
62+
currentVideoIndex: -1,
5663
});
5764
ws.send(JSON.stringify({ type: 'room-created', roomId }));
5865
console.log('Room created:', roomId);
@@ -68,6 +75,8 @@ wss.on('connection', (ws: WebSocket) => {
6875
messages: room.messages,
6976
videoUrl: room.videoUrl,
7077
createdAt: room.createdAt,
78+
playlist: room.playlist,
79+
currentVideoIndex: room.currentVideoIndex,
7180
})
7281
);
7382
broadcast(room.clients, {
@@ -123,6 +132,77 @@ wss.on('connection', (ws: WebSocket) => {
123132
}
124133
break;
125134

135+
case 'add-to-playlist':
136+
const playlistRoom = rooms.get(data.roomId!);
137+
if (playlistRoom) {
138+
// If there's no video playing currently, set it as the current video
139+
if (!playlistRoom.videoUrl) {
140+
playlistRoom.videoUrl = data.videoUrl;
141+
// Don't add to playlist, just set as current video
142+
broadcast(playlistRoom.clients, {
143+
type: 'video-update',
144+
videoUrl: data.videoUrl,
145+
currentVideoIndex: -1, // -1 indicates no playlist video is playing
146+
});
147+
} else {
148+
// If there's already a video playing, add to playlist
149+
playlistRoom.playlist.push(data.videoUrl!);
150+
broadcast(playlistRoom.clients, {
151+
type: 'playlist-update',
152+
playlist: playlistRoom.playlist,
153+
currentVideoIndex: playlistRoom.currentVideoIndex,
154+
});
155+
}
156+
}
157+
break;
158+
159+
case 'next-video':
160+
const nextVideoRoom = rooms.get(data.roomId!);
161+
if (
162+
nextVideoRoom &&
163+
nextVideoRoom.playlist.length > nextVideoRoom.currentVideoIndex + 1
164+
) {
165+
nextVideoRoom.currentVideoIndex++;
166+
nextVideoRoom.videoUrl =
167+
nextVideoRoom.playlist[nextVideoRoom.currentVideoIndex];
168+
broadcast(nextVideoRoom.clients, {
169+
type: 'video-update',
170+
videoUrl: nextVideoRoom.videoUrl,
171+
currentVideoIndex: nextVideoRoom.currentVideoIndex,
172+
});
173+
}
174+
break;
175+
176+
case 'video-ended':
177+
const endedVideoRoom = rooms.get(data.roomId!);
178+
if (endedVideoRoom) {
179+
if (endedVideoRoom.playlist.length === 0) {
180+
endedVideoRoom.videoUrl = undefined;
181+
broadcast(endedVideoRoom.clients, {
182+
type: 'video-update',
183+
videoUrl: undefined,
184+
currentVideoIndex: -1,
185+
});
186+
} else {
187+
endedVideoRoom.videoUrl = endedVideoRoom.playlist[0];
188+
endedVideoRoom.playlist.shift();
189+
endedVideoRoom.currentVideoIndex = -1;
190+
191+
broadcast(endedVideoRoom.clients, {
192+
type: 'video-update',
193+
videoUrl: endedVideoRoom.videoUrl,
194+
currentVideoIndex: -1,
195+
});
196+
197+
broadcast(endedVideoRoom.clients, {
198+
type: 'playlist-update',
199+
playlist: endedVideoRoom.playlist,
200+
currentVideoIndex: -1,
201+
});
202+
}
203+
}
204+
break;
205+
126206
default:
127207
console.log('Unknown message type:', data.type);
128208
}

apps/web/src/app/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export default function RootLayout({
3232
>
3333
<ThemeProvider attribute="class">
3434
{children}
35-
<Toaster richColors position="top-center" />
35+
<Toaster richColors position="bottom-center" />
3636
</ThemeProvider>
3737
</body>
3838
</html>

apps/web/src/app/room/[roomId]/page.tsx

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ export default function RoomPage() {
4040
subscribeToVideoUpdates,
4141
subscribeToVideoSync,
4242
syncVideoState,
43+
addToPlaylist,
44+
subscribeToPlaylistUpdates,
45+
nextVideo,
46+
videoEnded,
4347
} = useSocket();
4448

4549
const [messages, setMessages] = useState<Message[]>([]);
@@ -52,6 +56,9 @@ export default function RoomPage() {
5256
});
5357
const [createdAt, setCreatedAt] = useState('');
5458
const [elapsedTime, setElapsedTime] = useState('');
59+
const [playlist, setPlaylist] = useState<string[]>([]);
60+
const [currentVideoIndex, setCurrentVideoIndex] = useState(0);
61+
const [isPlayingFromPlaylist, setIsPlayingFromPlaylist] = useState(false);
5562

5663
const playerRef = useRef<YouTubePlayer | null>(null);
5764
const playerInitializedRef = useRef(false);
@@ -77,9 +84,12 @@ export default function RoomPage() {
7784
type: 'video-pause',
7885
timestamp: currentTime,
7986
});
87+
} else if (playerState === 0) {
88+
videoEnded(roomId);
89+
// nextVideo(roomId);
8090
}
8191
},
82-
[roomId, syncVideoState]
92+
[roomId, syncVideoState, videoEnded]
8393
);
8494

8595
useEffect(() => {
@@ -90,12 +100,10 @@ export default function RoomPage() {
90100
const roomData = (await joinRoom(roomId)) as RoomData;
91101
if (roomData) {
92102
setMessages(roomData.messages || []);
93-
if (roomData.videoUrl) {
94-
setVideoUrl(roomData.videoUrl);
95-
}
96-
if (roomData.createdAt) {
97-
setCreatedAt(roomData.createdAt);
98-
}
103+
setVideoUrl(roomData.videoUrl || '');
104+
setCreatedAt(roomData.createdAt);
105+
setPlaylist(roomData.playlist || []);
106+
setCurrentVideoIndex(roomData.currentVideoIndex);
99107
}
100108
} catch (error) {
101109
console.error('Error joining room:', error);
@@ -131,6 +139,7 @@ export default function RoomPage() {
131139
if (url && typeof url === 'string') {
132140
setVideoUrl(url);
133141
playerInitializedRef.current = false;
142+
setIsPlayingFromPlaylist(false);
134143
}
135144
});
136145

@@ -259,18 +268,30 @@ export default function RoomPage() {
259268
);
260269
};
261270

262-
// Update immediately
263271
updateElapsedTime();
264272

265-
// Update every minute
266273
const interval = setInterval(updateElapsedTime, 60000);
267274

268275
return () => clearInterval(interval);
269276
}, [createdAt]);
270277

278+
useEffect(() => {
279+
if (!isConnected) return;
280+
281+
const unsubscribe = subscribeToPlaylistUpdates((data) => {
282+
setPlaylist(data.playlist);
283+
setCurrentVideoIndex(data.currentVideoIndex);
284+
});
285+
286+
return unsubscribe;
287+
}, [isConnected, subscribeToPlaylistUpdates]);
288+
271289
return (
272290
<div className="flex flex-col min-h-screen">
273-
<RoomHeader participants={participants} roomId={roomId} />
291+
<RoomHeader
292+
participants={participants}
293+
onAddVideo={(url) => addToPlaylist(roomId, url)}
294+
/>
274295
<main className="flex-1 grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-0 relative mt-16">
275296
<div className="flex flex-col overflow-y-auto">
276297
<div
@@ -290,7 +311,31 @@ export default function RoomPage() {
290311
<div className="flex items-center gap-2 mt-2">
291312
<p className="text-sm text-gray-500">{videoMetadata.creator}</p>
292313
<span className="text-sm text-gray-500"></span>
293-
<p className="text-sm text-gray-500">{elapsedTime}</p>
314+
<p className="text-sm text-gray-500">Created {elapsedTime}</p>
315+
</div>
316+
</div>
317+
)}
318+
319+
{playlist.length > 0 && (
320+
<div className="p-4 border-t">
321+
<h2 className="font-semibold mb-4">
322+
Playlist ({playlist.length} videos)
323+
</h2>
324+
<div className="space-y-2">
325+
{playlist.map((videoUrl, index) => (
326+
<div
327+
key={videoUrl}
328+
className={`p-2 rounded ${
329+
index === currentVideoIndex && isPlayingFromPlaylist
330+
? 'bg-accent'
331+
: 'hover:bg-accent/50'
332+
}`}
333+
>
334+
<p className="text-sm">
335+
{index + 1}. {videoUrl}
336+
</p>
337+
</div>
338+
))}
294339
</div>
295340
</div>
296341
)}

apps/web/src/components/room/header.tsx

Lines changed: 8 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,17 @@ import { toast } from 'sonner';
33
import { Input } from '../ui/input';
44
import { Button } from '../ui/button';
55
import Link from 'next/link';
6-
import { useSocket } from '@/hooks/useSocket';
76
import { useState } from 'react';
87

98
interface RoomHeaderProps {
109
participants: number;
11-
roomId: string;
10+
onAddVideo: (url: string) => void;
1211
}
1312

14-
export default function RoomHeader({ participants, roomId }: RoomHeaderProps) {
15-
const { setVideo } = useSocket();
13+
export default function RoomHeader({
14+
participants,
15+
onAddVideo,
16+
}: RoomHeaderProps) {
1617
const [videoUrl, setVideoUrl] = useState('');
1718

1819
const handleSetVideo = () => {
@@ -21,29 +22,8 @@ export default function RoomHeader({ participants, roomId }: RoomHeaderProps) {
2122
return;
2223
}
2324

24-
let videoId = '';
25-
try {
26-
const url = new URL(videoUrl);
27-
if (url.hostname.includes('youtube.com')) {
28-
videoId = url.searchParams.get('v') || '';
29-
} else if (url.hostname.includes('youtu.be')) {
30-
videoId = url.pathname.substring(1);
31-
}
32-
} catch (error) {
33-
// If not a valid URL, check if it's just a video ID
34-
if (/^[a-zA-Z0-9_-]{11}$/.test(videoUrl)) {
35-
videoId = videoUrl;
36-
}
37-
}
38-
39-
if (!videoId) {
40-
toast.error('Invalid YouTube URL or video ID');
41-
return;
42-
}
43-
44-
const embedUrl = `https://www.youtube.com/embed/${videoId}?enablejsapi=1`;
45-
setVideo(roomId, embedUrl);
46-
toast.success('Video updated!');
25+
onAddVideo(videoUrl);
26+
toast.success('Video added to room!');
4727
setVideoUrl('');
4828
};
4929

@@ -56,7 +36,7 @@ export default function RoomHeader({ participants, roomId }: RoomHeaderProps) {
5636
<div>
5737
<Input
5838
className="bg-muted/50 min-w-96"
59-
placeholder="Enter Video Url here"
39+
placeholder="Enter Video URL here"
6040
value={videoUrl}
6141
onChange={(e) => setVideoUrl(e.target.value)}
6242
onKeyDown={(e) => e.key === 'Enter' && handleSetVideo()}

0 commit comments

Comments
 (0)