From c5250dc438e0f7d46aa438e368d7b1f951cde6cf Mon Sep 17 00:00:00 2001 From: Nikhil Cheerla Date: Wed, 16 Oct 2024 23:49:23 -0700 Subject: [PATCH 01/10] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 443a4b8..32ac25b 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ Every Youtube player has a custom slider with a play / pause button. Whenever th ## Instructions -1. There is a significant bug in this implementation of a collaborative watch party. Find the bug. -2. Figure out the best strategy to fix the bug and implement the fix! +1. There is a significant bug in this implementation of a collaborative watch party. This bug can be replicated with just two people in the session. Find the bug. +2. Figure out the best strategy to fix the bug (should only require modifying backend code) and implement the fix! ## Recommended Reading Order From d60b777d2625e09d83a5d39f9b9de37c95afc84f Mon Sep 17 00:00:00 2001 From: Nikhil Cheerla Date: Thu, 17 Oct 2024 00:09:55 -0700 Subject: [PATCH 02/10] Minor updates --- server/src/app.ts | 57 +++++++--------------------- src/components/VideoPlayer.tsx | 69 +++++++++++++++++----------------- 2 files changed, 47 insertions(+), 79 deletions(-) diff --git a/server/src/app.ts b/server/src/app.ts index 3a6bc98..8b41fbc 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -23,19 +23,17 @@ const io = new Server(server, { app.use(cors()); type VideoControlEvent = { - type: "PLAY" | "PAUSE" | "END"; + type: "PLAY" | "PAUSE"; progress: number; - createdAt: number; }; type SessionProps = { videoUrl: string; users: Set; - lastVideoControlEvent?: VideoControlEvent; + events: VideoControlEvent[]; }; const sessionData = new Map(); -const sessions = new Map(); // Creates a new session with a URL app.post("/session", async (req, res) => { @@ -43,37 +41,11 @@ app.post("/session", async (req, res) => { sessionData.set(sessionId, { videoUrl: url, users: new Set(), + events: [], }); res.sendStatus(201); }); -// Gets the last video control event from a session (for joining late) -app.get("/session/:sessionId/lastVideoEvent", async (req, res) => { - const { sessionId } = req.params; - - const lastEvent = sessionData.get(sessionId)?.lastVideoControlEvent; - - // If there is no recent event, send undefined so the frontend knows to play from start - res.send(lastEvent).status(200); -}); - -// Ends a live session -app.post("/session/:sessionId/end", async (req, res) => { - const { sessionId } = req.params; - const currentSession = sessionData.get(sessionId); - - if (!currentSession) return; - - // Write an "END" event so the video doesn't keep playing when last user leaves the page - currentSession.lastVideoControlEvent = { - type: "END", - progress: 0, - createdAt: Date.now(), - }; - - res.sendStatus(200); -}); - io.on("connection", (socket) => { console.log(`A user has connected with socket id ${socket.id}`); @@ -86,18 +58,20 @@ io.on("connection", (socket) => { currentSession.users.add(socket.id); socket.join(sessionId); - // Broadcast to all users in the session that a new user has joined - io.to(sessionId).emit("userJoined", socket.id, [...currentSession.users]); + const lastEvent = currentSession.events[currentSession.events.length - 1]; // Return the session's data to the user that just joined - callback({ + const responseData = { videoUrl: currentSession.videoUrl, - users: [...currentSession.users], - }); + progress: lastEvent?.progress ?? 0, + isPlaying: lastEvent?.type === "PLAY" ?? false, + }; + console.log(`Sending session state to user ${socket.id}:`, responseData); + callback(responseData); }); // Handle video control events from the client - socket.on("videoControl", (sessionId, videoControl) => { + socket.on("videoControl", (sessionId, videoControl: VideoControlEvent) => { console.log( `Received video control from client ${socket.id} in session ${sessionId}:`, videoControl @@ -107,12 +81,8 @@ io.on("connection", (socket) => { if (!currentSession) return; - // Store last event (needed for late to the party) - currentSession.lastVideoControlEvent = { - type: videoControl.type, - progress: videoControl.progress, - createdAt: Date.now(), - }; + // Store the event in the session + currentSession.events.push(videoControl); // Broadcast the video control event to all watchers in the session except the sender socket.to(sessionId).emit("videoControl", socket.id, videoControl); @@ -126,7 +96,6 @@ io.on("connection", (socket) => { const { users } = sessionState; if (users.delete(socket.id)) { socket.leave(sessionId); - io.to(sessionId).emit("userLeft", socket.id, [...users]); } } }); diff --git a/src/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx index 89b018e..9c509a9 100644 --- a/src/components/VideoPlayer.tsx +++ b/src/components/VideoPlayer.tsx @@ -1,5 +1,5 @@ import { Box, Button } from "@mui/material"; -import React, { useRef, useState } from "react"; +import React, { useRef, useState, useEffect } from "react"; import ReactPlayer from "react-player"; import { Socket } from "socket.io-client"; @@ -18,7 +18,8 @@ interface IVideoControlProps { interface JoinSessionResponse { videoUrl: string; - users: String[]; + progress: number; + isPlaying: boolean; } const VideoPlayer: React.FC = ({ socket, sessionId }) => { @@ -26,36 +27,52 @@ const VideoPlayer: React.FC = ({ socket, sessionId }) => { const [hasJoined, setHasJoined] = useState(false); const [isReady, setIsReady] = useState(false); const [playingVideo, setPlayingVideo] = useState(false); - const [seeking, setSeeking] = useState(false); const [played, setPlayed] = useState(0); const player = useRef(null); - React.useEffect(() => { - // join session on init - + useEffect(() => { socket.emit("joinSession", sessionId, (response: JoinSessionResponse) => { console.log("Response after joining session: ", response); setUrl(response.videoUrl); + setPlayed(response.progress); + setPlayingVideo(response.isPlaying); }); }, []); - const handleWatchStart = async () => { - // register to listen to video control events from socket + useEffect(() => { socket.on( "videoControl", - (senderId: string, control: IVideoControlProps) => { - if (control.type === "PLAY") { - playVideoAtProgress(control.progress); - setPlayed(control.progress); - } else if (control.type === "PAUSE") { - pauseVideoAtProgress(control.progress); - setPlayed(control.progress); + (userId: string, videoControl: IVideoControlProps) => { + console.log("Received video control event: ", userId, videoControl); + if (videoControl.type === "PLAY") { + playVideoAtProgress(videoControl.progress); + } else if (videoControl.type === "PAUSE") { + pauseVideoAtProgress(videoControl.progress); } + setPlayed(videoControl.progress); } ); + return () => { + socket.off("videoControl"); + }; + }, [socket]); + + useEffect(() => { + console.log("Played: ", played); + }, [played]); + + const handleWatchStart = async () => { setHasJoined(true); + console.log("Played: ", played); + if (played > 0) { + console.log("Played: ", played); + seekToVideo(played); + if (playingVideo) { + playVideo(); + } + } }; function playVideo() { @@ -67,7 +84,7 @@ const VideoPlayer: React.FC = ({ socket, sessionId }) => { } function seekToVideo(progress: number) { - player.current?.seekTo(progress); + player.current?.seekTo(progress, "fraction"); } function playVideoAtProgress(progress: number) { @@ -103,16 +120,11 @@ const VideoPlayer: React.FC = ({ socket, sessionId }) => { setPlayingVideo(!playingVideo); }; - const handleSeekMouseDown = () => { - setSeeking(true); - }; - const handleSeekChange = (e: any) => { setPlayed(parseFloat(e.target.value)); }; const handleSeekMouseUp = (e: any) => { - setSeeking(false); const progress = parseFloat(e.target.value); socket!.emit("videoControl", sessionId, { type: "PLAY", @@ -121,17 +133,6 @@ const VideoPlayer: React.FC = ({ socket, sessionId }) => { playVideoAtProgress(progress); }; - const handleProgress = (state: { - played: number; - playedSeconds: number; - loaded: number; - loadedSeconds: number; - }) => { - if (!seeking) { - setPlayed(state.played === 1 ? MAX_VIDEO_PROGRESS : state.played); - } - }; - return ( = ({ socket, sessionId }) => { = ({ socket, sessionId }) => { max={MAX_VIDEO_PROGRESS} step="any" value={played} - onMouseDown={() => handleSeekMouseDown()} onChange={(e) => handleSeekChange(e)} onMouseUp={(e) => handleSeekMouseUp(e)} /> From b1f4aeb5768cb72962d71a7e9ecc8788da8cf657 Mon Sep 17 00:00:00 2001 From: Nikhil Cheerla Date: Thu, 17 Oct 2024 00:11:37 -0700 Subject: [PATCH 03/10] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 32ac25b..c0fb364 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ Every Youtube player has a custom slider with a play / pause button. Whenever th `server/src/app.ts` - has all the server logic that implements the watch party collaboration functionality `src/VideoPlayer.ts` - makes API calls to the server and listens to socket events to control the users progress through the video +If you'd prefer to work with a backend written entirely in Python (same frontend code) - check out the `nikhil/python-version` branch instead. + ## How to Run Locally - Make sure nodeJS (I am using v19.7.0) and npm is installed on your local machine From 38ac7e258a914141786aab3445c269ac88425903 Mon Sep 17 00:00:00 2001 From: Nikhil Cheerla Date: Mon, 21 Oct 2024 12:53:56 -0700 Subject: [PATCH 04/10] Improve duration calc --- src/components/VideoPlayer.tsx | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx index 9c509a9..7b81fc2 100644 --- a/src/components/VideoPlayer.tsx +++ b/src/components/VideoPlayer.tsx @@ -3,9 +3,6 @@ import React, { useRef, useState, useEffect } from "react"; import ReactPlayer from "react-player"; import { Socket } from "socket.io-client"; -const MIN_VIDEO_PROGRESS = 0; -const MAX_VIDEO_PROGRESS = 0.999999; - interface IVideoPlayerProps { sessionId: string; socket: Socket; @@ -65,9 +62,8 @@ const VideoPlayer: React.FC = ({ socket, sessionId }) => { const handleWatchStart = async () => { setHasJoined(true); - console.log("Played: ", played); + console.log("On watch start: ", played); if (played > 0) { - console.log("Played: ", played); seekToVideo(played); if (playingVideo) { playVideo(); @@ -84,7 +80,7 @@ const VideoPlayer: React.FC = ({ socket, sessionId }) => { } function seekToVideo(progress: number) { - player.current?.seekTo(progress, "fraction"); + player.current?.seekTo(progress, "seconds"); } function playVideoAtProgress(progress: number) { @@ -103,6 +99,14 @@ const VideoPlayer: React.FC = ({ socket, sessionId }) => { setIsReady(true); }; + const handleEnded = () => { + socket!.emit("videoControl", sessionId, { + type: "PAUSE", + progress: played, + }); + pauseVideo(); + }; + const handlePlayPause = () => { if (playingVideo) { socket!.emit("videoControl", sessionId, { @@ -155,6 +159,7 @@ const VideoPlayer: React.FC = ({ socket, sessionId }) => { playing={playingVideo} controls={false} onReady={handleReady} + onEnded={handleEnded} width="100%" height="100%" style={{ pointerEvents: "none" }} @@ -169,8 +174,8 @@ const VideoPlayer: React.FC = ({ socket, sessionId }) => { handleSeekChange(e)} From 8cc3ad1da5db339c57473b62b5619e0069de09cf Mon Sep 17 00:00:00 2001 From: Nikhil Cheerla Date: Mon, 21 Oct 2024 20:35:51 -0700 Subject: [PATCH 05/10] Play resets to 0 --- src/components/VideoPlayer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx index 7b81fc2..1faf00d 100644 --- a/src/components/VideoPlayer.tsx +++ b/src/components/VideoPlayer.tsx @@ -102,9 +102,9 @@ const VideoPlayer: React.FC = ({ socket, sessionId }) => { const handleEnded = () => { socket!.emit("videoControl", sessionId, { type: "PAUSE", - progress: played, + progress: 0, }); - pauseVideo(); + pauseVideoAtProgress(0); }; const handlePlayPause = () => { From 1415ffda6c072ea2ca624d284177bfce83769fda Mon Sep 17 00:00:00 2001 From: Nikhil Cheerla Date: Mon, 21 Oct 2024 20:47:27 -0700 Subject: [PATCH 06/10] Modify readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index c0fb364..0d40b61 100644 --- a/README.md +++ b/README.md @@ -29,3 +29,4 @@ If you'd prefer to work with a backend written entirely in Python (same frontend `$ npm run deps` - In your terminal at project root, start the server and the client simultaneously `$ npm run start` +- Unfortunately the node backend will not restart automatically when you make changes to the code, so you will need to restart the server manually if you want to see your changes take effect. From a47b73b0b5db1e6f867accf565feceaaa3633149 Mon Sep 17 00:00:00 2001 From: Nikhil Cheerla Date: Mon, 21 Oct 2024 20:51:16 -0700 Subject: [PATCH 07/10] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 0d40b61..e385363 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Here is a demo of how the watch party works: https://www.loom.com/share/c6bb194d55b749919a308e7c5fd89521 +Here is the customer's bug report: https://www.loom.com/share/b5dc95868b8840e1bbfe89523c53ff59 + ## System Overview This is a simple implementation of a collaborative Youtube Watch Party, backed by a socket.io backend that enables users to send messages to each other via our server in real time. From ea9457699b8dc2e91a6b01e7d207f5b09e23baae Mon Sep 17 00:00:00 2001 From: Nikhil Cheerla Date: Wed, 23 Oct 2024 11:10:23 -0700 Subject: [PATCH 08/10] Fix issue with watch session --- server/src/app.ts | 12 +++++++++ src/Api.tsx | 11 +++++++++ src/components/VideoPlayer.tsx | 45 +++++++++++++++++----------------- src/routes/WatchSession.tsx | 25 +++++++++++++++++-- 4 files changed, 69 insertions(+), 24 deletions(-) diff --git a/server/src/app.ts b/server/src/app.ts index 8b41fbc..c667691 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -46,6 +46,18 @@ app.post("/session", async (req, res) => { res.sendStatus(201); }); +// Gets a session's data +app.get("/session/:sessionId", (req, res) => { + const { sessionId } = req.params; + const session = sessionData.get(sessionId); + + if (!session) { + return res.status(404).json({ error: "Session not found" }); + } + + res.json({ videoUrl: session.videoUrl }); +}); + io.on("connection", (socket) => { console.log(`A user has connected with socket id ${socket.id}`); diff --git a/src/Api.tsx b/src/Api.tsx index 3a3ef2c..ea64b19 100644 --- a/src/Api.tsx +++ b/src/Api.tsx @@ -35,3 +35,14 @@ export const createSession = async ( throw error; } }; + +export const getSession = async ( + sessionId: string +): Promise<{ data: { videoUrl: string } }> => { + try { + return await apiClient.get(`/session/${sessionId}`); + } catch (error) { + console.error("Error creating video:", error); + throw error; + } +}; diff --git a/src/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx index 1faf00d..9e20fd6 100644 --- a/src/components/VideoPlayer.tsx +++ b/src/components/VideoPlayer.tsx @@ -3,13 +3,14 @@ import React, { useRef, useState, useEffect } from "react"; import ReactPlayer from "react-player"; import { Socket } from "socket.io-client"; -interface IVideoPlayerProps { - sessionId: string; +interface VideoPlayerProps { socket: Socket; + sessionId: string; + url: string; } interface IVideoControlProps { - type: "PLAY" | "PAUSE" | "END"; + type: "PLAY" | "PAUSE"; progress: number; } @@ -19,23 +20,19 @@ interface JoinSessionResponse { isPlaying: boolean; } -const VideoPlayer: React.FC = ({ socket, sessionId }) => { - const [url, setUrl] = useState(null); - const [hasJoined, setHasJoined] = useState(false); +const VideoPlayer: React.FC = ({ + socket, + sessionId, + url, +}) => { const [isReady, setIsReady] = useState(false); + const [hasJoined, setHasJoined] = useState(false); const [playingVideo, setPlayingVideo] = useState(false); const [played, setPlayed] = useState(0); const player = useRef(null); - useEffect(() => { - socket.emit("joinSession", sessionId, (response: JoinSessionResponse) => { - console.log("Response after joining session: ", response); - setUrl(response.videoUrl); - setPlayed(response.progress); - setPlayingVideo(response.isPlaying); - }); - }, []); + console.log("VideoPlayer: ", url); useEffect(() => { socket.on( @@ -61,14 +58,18 @@ const VideoPlayer: React.FC = ({ socket, sessionId }) => { }, [played]); const handleWatchStart = async () => { - setHasJoined(true); - console.log("On watch start: ", played); - if (played > 0) { - seekToVideo(played); - if (playingVideo) { - playVideo(); + socket.emit("joinSession", sessionId, (response: JoinSessionResponse) => { + setHasJoined(true); + console.log("Received join session response: ", response); + if (response.progress > 0) { + seekToVideo(response.progress); + if (response.isPlaying) { + playVideo(); + } + setPlayed(response.progress); + setPlayingVideo(response.isPlaying); } - } + }); }; function playVideo() { @@ -184,7 +185,7 @@ const VideoPlayer: React.FC = ({ socket, sessionId }) => { )} - {!hasJoined && isReady && ( + {isReady && !hasJoined && ( // Youtube doesn't allow autoplay unless you've interacted with the page already // So we make the user click "Join Session" button and then start playing the video immediately after - {sessionId && } + {sessionId && sessionData && ( + + )} ); }; From 83397458dc800bb81252f7b9face6512aada5308 Mon Sep 17 00:00:00 2001 From: Sishaar Rao Date: Wed, 23 Oct 2024 11:54:10 -0700 Subject: [PATCH 09/10] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e385363..6bbcb3d 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Every Youtube player has a custom slider with a play / pause button. Whenever th ## Instructions 1. There is a significant bug in this implementation of a collaborative watch party. This bug can be replicated with just two people in the session. Find the bug. -2. Figure out the best strategy to fix the bug (should only require modifying backend code) and implement the fix! +2. Figure out the best strategy to fix the bug and implement the fix! ## Recommended Reading Order From 8f3b923e9d907b95416f65508e39e6f005fe394c Mon Sep 17 00:00:00 2001 From: Jesse Zhang Date: Wed, 23 Oct 2024 13:02:49 -0700 Subject: [PATCH 10/10] bug fixes for play/pause and skipping watch session if joining late --- src/components/VideoPlayer.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx index 1faf00d..d864c87 100644 --- a/src/components/VideoPlayer.tsx +++ b/src/components/VideoPlayer.tsx @@ -37,6 +37,12 @@ const VideoPlayer: React.FC = ({ socket, sessionId }) => { }); }, []); + useEffect(() => { + if (playingVideo && !!url && !!player.current?.getInternalPlayer() && !hasJoined) { + handleWatchStart(); + } + }, [played, playingVideo, url, player.current?.getInternalPlayer()]); // Triggers when user is joining late and video is already playing. Waits for player to initialize, otherwise plays on a black screen. + useEffect(() => { socket.on( "videoControl", @@ -111,13 +117,13 @@ const VideoPlayer: React.FC = ({ socket, sessionId }) => { if (playingVideo) { socket!.emit("videoControl", sessionId, { type: "PAUSE", - progress: played, + progress: player.current?.getCurrentTime(), }); pauseVideo(); } else { socket!.emit("videoControl", sessionId, { type: "PLAY", - progress: played, + progress: player.current?.getCurrentTime(), }); playVideo(); } @@ -184,7 +190,7 @@ const VideoPlayer: React.FC = ({ socket, sessionId }) => { )} - {!hasJoined && isReady && ( + {!hasJoined && isReady && !playingVideo && ( // Youtube doesn't allow autoplay unless you've interacted with the page already // So we make the user click "Join Session" button and then start playing the video immediately after