forked from lavender/watch-party
		
	Big changes: All events are reported to chat, new layout options
This commit is contained in:
		
							parent
							
								
									8da286fad9
								
							
						
					
					
						commit
						be5a05e0fd
					
				
					 10 changed files with 268 additions and 117 deletions
				
			
		|  | @ -47,6 +47,6 @@ | |||
|       </form> | ||||
|     </div> | ||||
| 
 | ||||
|     <script type="module" src="/main.mjs?v=1"></script> | ||||
|     <script type="module" src="/main.mjs?v=2"></script> | ||||
|   </body> | ||||
| </html> | ||||
|  |  | |||
|  | @ -15,9 +15,7 @@ const setupChatboxEvents = (socket) => { | |||
|       socket.send( | ||||
|         JSON.stringify({ | ||||
|           op: "ChatMessage", | ||||
|           data: { | ||||
|             message: content, | ||||
|           }, | ||||
|           data: content, | ||||
|         }) | ||||
|       ); | ||||
|     } | ||||
|  | @ -51,51 +49,134 @@ export const setupChat = async (socket) => { | |||
|   }); | ||||
| }; | ||||
| 
 | ||||
| const printToChat = (elem) => { | ||||
| const addToChat = (node) => { | ||||
|   const chatbox = document.querySelector("#chatbox"); | ||||
|   chatbox.appendChild(elem); | ||||
|   chatbox.appendChild(node); | ||||
|   chatbox.scrollTop = chatbox.scrollHeight; | ||||
| }; | ||||
| 
 | ||||
| export const handleChatEvent = (event) => { | ||||
| let lastTimeMs = null; | ||||
| let lastPlaying = false; | ||||
| 
 | ||||
| const checkDebounce = (event) => { | ||||
|   let timeMs = null; | ||||
|   let playing = null; | ||||
|   if (event.op == "SetTime") { | ||||
|     timeMs = event.data; | ||||
|   } else if (event.op == "SetPlaying") { | ||||
|     timeMs = event.data.time; | ||||
|     playing = event.data.playing; | ||||
|   } | ||||
| 
 | ||||
|   let shouldIgnore = false; | ||||
| 
 | ||||
|   if (timeMs != null) { | ||||
|     if (lastTimeMs && Math.abs(lastTimeMs - timeMs) < 500) { | ||||
|       shouldIgnore = true; | ||||
|     } | ||||
|     lastTimeMs = timeMs; | ||||
|   } | ||||
| 
 | ||||
|   if (playing != null) { | ||||
|     if (lastPlaying != playing) { | ||||
|       shouldIgnore = false; | ||||
|     } | ||||
|     lastPlaying = playing; | ||||
|   } | ||||
| 
 | ||||
|   return shouldIgnore; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * @param {string} eventType | ||||
|  * @param {string?} user | ||||
|  * @param {Node?} content | ||||
|  */ | ||||
| const printChatMessage = (eventType, user, content) => { | ||||
|   const chatMessage = document.createElement("div"); | ||||
|   chatMessage.classList.add("chat-message"); | ||||
|   chatMessage.classList.add(eventType); | ||||
| 
 | ||||
|   if (user != null) { | ||||
|     const userName = document.createElement("strong"); | ||||
|     userName.textContent = user; | ||||
|     chatMessage.appendChild(userName); | ||||
|   } | ||||
| 
 | ||||
|   chatMessage.appendChild(document.createTextNode(" ")); | ||||
| 
 | ||||
|   if (content != null) { | ||||
|     chatMessage.appendChild(content); | ||||
|   } | ||||
| 
 | ||||
|   addToChat(chatMessage); | ||||
| 
 | ||||
|   return chatMessage; | ||||
| }; | ||||
| 
 | ||||
| const formatTime = (ms) => { | ||||
|   const seconds = Math.floor((ms / 1000) % 60); | ||||
|   const minutes = Math.floor((ms / (60 * 1000)) % 60); | ||||
|   const hours = Math.floor((ms / (3600 * 1000)) % 3600); | ||||
|   return `${hours < 10 ? "0" + hours : hours}:${ | ||||
|     minutes < 10 ? "0" + minutes : minutes | ||||
|   }:${seconds < 10 ? "0" + seconds : seconds}`;
 | ||||
| }; | ||||
| 
 | ||||
| export const logEventToChat = (event) => { | ||||
|   if (checkDebounce(event)) { | ||||
|     return; | ||||
|   } | ||||
| 
 | ||||
|   switch (event.op) { | ||||
|     case "UserJoin": { | ||||
|       // print something to the chat
 | ||||
|       const chatMessage = document.createElement("div"); | ||||
|       chatMessage.classList.add("chat-message"); | ||||
|       chatMessage.classList.add("user-join"); | ||||
|       const userName = document.createElement("strong"); | ||||
|       userName.textContent = event.data; | ||||
|       chatMessage.appendChild(userName); | ||||
|       chatMessage.appendChild(document.createTextNode(" joined")); | ||||
|       printToChat(chatMessage); | ||||
| 
 | ||||
|       printChatMessage( | ||||
|         "user-join", | ||||
|         event.user, | ||||
|         document.createTextNode("joined") | ||||
|       ); | ||||
|       break; | ||||
|     } | ||||
|     case "UserLeave": { | ||||
|       const chatMessage = document.createElement("div"); | ||||
|       chatMessage.classList.add("chat-message"); | ||||
|       chatMessage.classList.add("user-leave"); | ||||
|       const userName = document.createElement("strong"); | ||||
|       userName.textContent = event.data; | ||||
|       chatMessage.appendChild(userName); | ||||
|       chatMessage.appendChild(document.createTextNode(" left")); | ||||
|       printToChat(chatMessage); | ||||
| 
 | ||||
|       printChatMessage( | ||||
|         "user-leave", | ||||
|         event.user, | ||||
|         document.createTextNode("left") | ||||
|       ); | ||||
|       break; | ||||
|     } | ||||
|     case "ChatMessage": { | ||||
|       const chatMessage = document.createElement("div"); | ||||
|       chatMessage.classList.add("chat-message"); | ||||
|       const userName = document.createElement("strong"); | ||||
|       userName.innerText = event.data.user; | ||||
|       chatMessage.appendChild(userName); | ||||
|       chatMessage.appendChild(document.createTextNode(" ")); | ||||
|       const messageContent = document.createElement("span"); | ||||
|       messageContent.classList.add("message-content"); | ||||
|       messageContent.textContent = event.data.message; | ||||
|       chatMessage.appendChild(messageContent); | ||||
|       printToChat(chatMessage); | ||||
|       messageContent.textContent = event.data; | ||||
|       printChatMessage("chat-message", event.user, messageContent); | ||||
|       break; | ||||
|     } | ||||
|     case "SetTime": { | ||||
|       const messageContent = document.createElement("span"); | ||||
|       messageContent.appendChild(document.createTextNode("set the time to ")); | ||||
| 
 | ||||
|       messageContent.appendChild( | ||||
|         document.createTextNode(formatTime(event.data)) | ||||
|       ); | ||||
| 
 | ||||
|       printChatMessage("set-time", event.user, messageContent); | ||||
|       break; | ||||
|     } | ||||
|     case "SetPlaying": { | ||||
|       const messageContent = document.createElement("span"); | ||||
|       messageContent.appendChild( | ||||
|         document.createTextNode( | ||||
|           event.data.playing ? "started playing" : "paused" | ||||
|         ) | ||||
|       ); | ||||
|       messageContent.appendChild(document.createTextNode(" at ")); | ||||
|       messageContent.appendChild( | ||||
|         document.createTextNode(formatTime(event.data.time)) | ||||
|       ); | ||||
| 
 | ||||
|       printChatMessage("set-playing", event.user, messageContent); | ||||
| 
 | ||||
|       break; | ||||
|     } | ||||
|   } | ||||
|  |  | |||
|  | @ -1,4 +1,4 @@ | |||
| import { joinSession } from "./watch-session.mjs"; | ||||
| import { joinSession } from "./watch-session.mjs?v=2"; | ||||
| 
 | ||||
| /** | ||||
|  * @param {HTMLInputElement} field | ||||
|  |  | |||
|  | @ -1,5 +1,5 @@ | |||
| import { setupVideo } from "./video.mjs"; | ||||
| import { setupChat, handleChatEvent } from "./chat.mjs"; | ||||
| import { setupVideo } from "./video.mjs?v=2"; | ||||
| import { setupChat, logEventToChat } from "./chat.mjs?v=2"; | ||||
| 
 | ||||
| /** | ||||
|  * @param {string} sessionId | ||||
|  | @ -50,31 +50,29 @@ const setupIncomingEvents = (video, socket) => { | |||
|   socket.addEventListener("message", async (messageEvent) => { | ||||
|     try { | ||||
|       const event = JSON.parse(messageEvent.data); | ||||
|       // console.log(event);
 | ||||
| 
 | ||||
|       switch (event.op) { | ||||
|         case "SetPlaying": | ||||
|           setDebounce(); | ||||
|       if (!event.reflected) { | ||||
|         switch (event.op) { | ||||
|           case "SetPlaying": | ||||
|             setDebounce(); | ||||
| 
 | ||||
|           if (event.data.playing) { | ||||
|             await video.play(); | ||||
|           } else { | ||||
|             video.pause(); | ||||
|           } | ||||
|             if (event.data.playing) { | ||||
|               await video.play(); | ||||
|             } else { | ||||
|               video.pause(); | ||||
|             } | ||||
| 
 | ||||
|           setVideoTime(event.data.time); | ||||
|             setVideoTime(event.data.time); | ||||
| 
 | ||||
|           break; | ||||
|         case "SetTime": | ||||
|           setDebounce(); | ||||
|           setVideoTime(event.data); | ||||
|           break; | ||||
|         case "UserJoin": | ||||
|         case "UserLeave": | ||||
|         case "ChatMessage": | ||||
|           handleChatEvent(event); | ||||
|           break; | ||||
|             break; | ||||
|           case "SetTime": | ||||
|             setDebounce(); | ||||
|             setVideoTime(event.data); | ||||
|             break; | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       logEventToChat(event); | ||||
|     } catch (_err) {} | ||||
|   }); | ||||
| }; | ||||
|  |  | |||
|  | @ -1,4 +1,4 @@ | |||
| import { setupJoinSessionForm } from "./lib/join-session.mjs"; | ||||
| import { setupJoinSessionForm } from "./lib/join-session.mjs?v=2"; | ||||
| 
 | ||||
| const main = () => { | ||||
|   setupJoinSessionForm(); | ||||
|  |  | |||
|  | @ -61,6 +61,7 @@ input[type="text"] { | |||
|   font-family: sans-serif; | ||||
|   font-size: 1em; | ||||
|   width: 500px; | ||||
|   max-width: 100%; | ||||
| 
 | ||||
|   resize: none; | ||||
|   overflow-x: wrap; | ||||
|  | @ -82,6 +83,7 @@ button { | |||
|   font-family: sans-serif; | ||||
|   font-size: 1em; | ||||
|   width: 500px; | ||||
|   max-width: 100%; | ||||
| 
 | ||||
|   user-select: none; | ||||
|   border: 1px solid rgba(0, 0, 0, 0); | ||||
|  | @ -118,13 +120,25 @@ button.small-button { | |||
|   display: none; | ||||
| } | ||||
| 
 | ||||
| .user-join, | ||||
| .user-leave { | ||||
| .chat-message > strong { | ||||
|   color: rgb(126, 208, 255); | ||||
| } | ||||
| 
 | ||||
| .chat-message.user-join, | ||||
| .chat-message.user-leave { | ||||
|   font-style: italic; | ||||
| } | ||||
| 
 | ||||
| .chat-message > strong { | ||||
|   color: rgb(126, 208, 255); | ||||
| .chat-message.set-time, | ||||
| .chat-message.set-playing { | ||||
|   font-style: italic; | ||||
|   text-align: right; | ||||
|   font-size: 0.85em; | ||||
| } | ||||
| 
 | ||||
| .chat-message.set-time > strong, | ||||
| .chat-message.set-playing > strong { | ||||
|   color: unset; | ||||
| } | ||||
| 
 | ||||
| #chatbox { | ||||
|  | @ -144,4 +158,30 @@ button.small-button { | |||
| 
 | ||||
| #chatbox-send > input { | ||||
|   font-size: 0.75em; | ||||
|   width: 100%; | ||||
| } | ||||
| 
 | ||||
| @media (min-aspect-ratio: 4/3) { | ||||
|   #video-container video { | ||||
|     width: calc(100vw - 400px); | ||||
|     position: absolute; | ||||
|     height: 100vh; | ||||
|     background-color: black; | ||||
|   } | ||||
| 
 | ||||
|   #video-container { | ||||
|     float: left; | ||||
|     height: 100vh; | ||||
|     position: relative; | ||||
|   } | ||||
| 
 | ||||
|   #chatbox-container { | ||||
|     float: right; | ||||
|     width: 400px; | ||||
|     height: 100vh !important; | ||||
|   } | ||||
| 
 | ||||
|   #chatbox { | ||||
|     height: calc(100vh - 5em) !important; | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -2,18 +2,31 @@ use serde::{Deserialize, Serialize}; | |||
| 
 | ||||
| #[derive(Clone, Serialize, Deserialize)] | ||||
| #[serde(tag = "op", content = "data")] | ||||
| pub enum WatchEvent { | ||||
|     SetPlaying { | ||||
|         playing: bool, | ||||
|         time: u64, | ||||
|     }, | ||||
| pub enum WatchEventData { | ||||
|     SetPlaying { playing: bool, time: u64 }, | ||||
|     SetTime(u64), | ||||
| 
 | ||||
|     UserJoin(String), | ||||
|     UserLeave(String), | ||||
|     ChatMessage { | ||||
|         #[serde(default = "String::new")] | ||||
|         user: String, | ||||
|         message: String, | ||||
|     }, | ||||
|     UserJoin, | ||||
|     UserLeave, | ||||
|     ChatMessage(String), | ||||
| } | ||||
| 
 | ||||
| #[derive(Clone, Serialize, Deserialize)] | ||||
| pub struct WatchEvent { | ||||
|     #[serde(default, skip_serializing_if = "Option::is_none")] | ||||
|     pub user: Option<String>, | ||||
|     #[serde(flatten)] | ||||
|     pub data: WatchEventData, | ||||
|     #[serde(default)] | ||||
|     pub reflected: bool, | ||||
| } | ||||
| 
 | ||||
| impl WatchEvent { | ||||
|     pub fn new(user: String, data: WatchEventData) -> Self { | ||||
|         WatchEvent { | ||||
|             user: Some(user), | ||||
|             data, | ||||
|             reflected: false, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  |  | |||
							
								
								
									
										32
									
								
								src/main.rs
									
									
									
									
									
								
							
							
						
						
									
										32
									
								
								src/main.rs
									
									
									
									
									
								
							|  | @ -12,9 +12,9 @@ mod watch_session; | |||
| use serde::Deserialize; | ||||
| 
 | ||||
| use crate::{ | ||||
|     events::WatchEvent, | ||||
|     events::{WatchEvent, WatchEventData}, | ||||
|     viewer_connection::{ws_publish, ws_subscribe}, | ||||
|     watch_session::{get_session, handle_watch_event, SubtitleTrack, WatchSession, SESSIONS}, | ||||
|     watch_session::{get_session, handle_watch_event_data, SubtitleTrack, WatchSession, SESSIONS}, | ||||
| }; | ||||
| 
 | ||||
| #[derive(Deserialize)] | ||||
|  | @ -85,13 +85,21 @@ async fn main() { | |||
|         .and(warb::body::json()) | ||||
|         .map(|requested_session, playing: bool| match requested_session { | ||||
|             RequestedSession::Session(uuid, mut sess) => { | ||||
|                 let event = WatchEvent::SetPlaying { | ||||
|                 let data = WatchEventData::SetPlaying { | ||||
|                     playing, | ||||
|                     time: sess.get_time_ms(), | ||||
|                 }; | ||||
| 
 | ||||
|                 handle_watch_event(uuid, &mut sess, event.clone()); | ||||
|                 tokio::spawn(ws_publish(uuid, None, event)); | ||||
|                 handle_watch_event_data(uuid, &mut sess, data.clone()); | ||||
|                 tokio::spawn(ws_publish( | ||||
|                     uuid, | ||||
|                     None, | ||||
|                     WatchEvent { | ||||
|                         user: None, | ||||
|                         data, | ||||
|                         reflected: false, | ||||
|                     }, | ||||
|                 )); | ||||
| 
 | ||||
|                 warb::reply::with_status(warb::reply::json(&sess.view()), StatusCode::OK) | ||||
|             } | ||||
|  | @ -105,10 +113,18 @@ async fn main() { | |||
|         .map( | ||||
|             |requested_session, current_time_ms: u64| match requested_session { | ||||
|                 RequestedSession::Session(uuid, mut sess) => { | ||||
|                     let event = WatchEvent::SetTime(current_time_ms); | ||||
|                     let data = WatchEventData::SetTime(current_time_ms); | ||||
| 
 | ||||
|                     handle_watch_event(uuid, &mut sess, event.clone()); | ||||
|                     tokio::spawn(ws_publish(uuid, None, event)); | ||||
|                     handle_watch_event_data(uuid, &mut sess, data.clone()); | ||||
|                     tokio::spawn(ws_publish( | ||||
|                         uuid, | ||||
|                         None, | ||||
|                         WatchEvent { | ||||
|                             user: None, | ||||
|                             data, | ||||
|                             reflected: false, | ||||
|                         }, | ||||
|                     )); | ||||
| 
 | ||||
|                     warb::reply::with_status(warb::reply::json(&sess.view()), StatusCode::OK) | ||||
|                 } | ||||
|  |  | |||
|  | @ -15,8 +15,8 @@ use uuid::Uuid; | |||
| use warp::ws::{Message, WebSocket}; | ||||
| 
 | ||||
| use crate::{ | ||||
|     events::WatchEvent, | ||||
|     watch_session::{get_session, handle_watch_event}, | ||||
|     events::{WatchEvent, WatchEventData}, | ||||
|     watch_session::{get_session, handle_watch_event_data}, | ||||
| }; | ||||
| 
 | ||||
| static CONNECTED_VIEWERS: Lazy<RwLock<HashMap<usize, ConnectedViewer>>> = | ||||
|  | @ -27,6 +27,7 @@ pub struct ConnectedViewer { | |||
|     pub session: Uuid, | ||||
|     pub viewer_id: usize, | ||||
|     pub tx: UnboundedSender<WatchEvent>, | ||||
|     pub nickname: Option<String>, | ||||
| } | ||||
| 
 | ||||
| pub async fn ws_subscribe(session_uuid: Uuid, nickname: String, ws: WebSocket) { | ||||
|  | @ -53,13 +54,19 @@ pub async fn ws_subscribe(session_uuid: Uuid, nickname: String, ws: WebSocket) { | |||
|             viewer_id, | ||||
|             session: session_uuid, | ||||
|             tx, | ||||
|             nickname: Some(nickname.clone()), | ||||
|         }, | ||||
|     ); | ||||
| 
 | ||||
|     ws_publish(session_uuid, None, WatchEvent::UserJoin(nickname.clone())).await; | ||||
|     ws_publish( | ||||
|         session_uuid, | ||||
|         None, | ||||
|         WatchEvent::new(nickname.clone(), WatchEventData::UserJoin), | ||||
|     ) | ||||
|     .await; | ||||
| 
 | ||||
|     while let Some(Ok(message)) = viewer_ws_rx.next().await { | ||||
|         let mut event: WatchEvent = match message | ||||
|         let event: WatchEventData = match message | ||||
|             .to_str() | ||||
|             .ok() | ||||
|             .and_then(|s| serde_json::from_str(s).ok()) | ||||
|  | @ -68,47 +75,39 @@ pub async fn ws_subscribe(session_uuid: Uuid, nickname: String, ws: WebSocket) { | |||
|             None => continue, | ||||
|         }; | ||||
| 
 | ||||
|         // Make sure people don't spoof their nicknames to pretend to be others
 | ||||
|         // If a nickname change is required, I guess reconnect idk
 | ||||
|         if let WatchEvent::ChatMessage { user: _, message } = event { | ||||
|             event = WatchEvent::ChatMessage { | ||||
|                 user: nickname.clone(), | ||||
|                 message, | ||||
|             }; | ||||
| 
 | ||||
|             // Don't pass through the viewer_id because we want the chat message
 | ||||
|             // to be reflected to the user.
 | ||||
|             ws_publish(session_uuid, None, event).await; | ||||
| 
 | ||||
|             // We don't need to handle() chat messages,
 | ||||
|             // and we are already publishing them ourselves.
 | ||||
|             continue; | ||||
|         } | ||||
| 
 | ||||
|         handle_watch_event( | ||||
|         handle_watch_event_data( | ||||
|             session_uuid, | ||||
|             &mut get_session(session_uuid).unwrap(), | ||||
|             event.clone(), | ||||
|         ); | ||||
| 
 | ||||
|         ws_publish(session_uuid, Some(viewer_id), event).await; | ||||
|         ws_publish( | ||||
|             session_uuid, | ||||
|             Some(viewer_id), | ||||
|             WatchEvent::new(nickname.clone(), event), | ||||
|         ) | ||||
|         .await; | ||||
|     } | ||||
| 
 | ||||
|     ws_publish(session_uuid, None, WatchEvent::UserLeave(nickname.clone())).await; | ||||
|     ws_publish( | ||||
|         session_uuid, | ||||
|         None, | ||||
|         WatchEvent::new(nickname.clone(), WatchEventData::UserLeave), | ||||
|     ) | ||||
|     .await; | ||||
| 
 | ||||
|     CONNECTED_VIEWERS.write().await.remove(&viewer_id); | ||||
| } | ||||
| 
 | ||||
| pub async fn ws_publish(session_uuid: Uuid, viewer_id: Option<usize>, event: WatchEvent) { | ||||
| pub async fn ws_publish(session_uuid: Uuid, skip_viewer_id: Option<usize>, event: WatchEvent) { | ||||
|     for viewer in CONNECTED_VIEWERS.read().await.values() { | ||||
|         if viewer_id == Some(viewer.viewer_id) { | ||||
|             continue; | ||||
|         } | ||||
| 
 | ||||
|         if viewer.session != session_uuid { | ||||
|             continue; | ||||
|         } | ||||
| 
 | ||||
|         let _ = viewer.tx.send(event.clone()); | ||||
|         let _ = viewer.tx.send(WatchEvent { | ||||
|             reflected: skip_viewer_id == Some(viewer.viewer_id), | ||||
|             ..event.clone() | ||||
|         }); | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; | |||
| use std::{collections::HashMap, sync::Mutex, time::Instant}; | ||||
| use uuid::Uuid; | ||||
| 
 | ||||
| use crate::events::WatchEvent; | ||||
| use crate::events::WatchEventData; | ||||
| 
 | ||||
| #[derive(Serialize, Deserialize, Clone)] | ||||
| pub struct SubtitleTrack { | ||||
|  | @ -75,13 +75,17 @@ pub fn get_session(uuid: Uuid) -> Option<WatchSession> { | |||
|     SESSIONS.lock().unwrap().get(&uuid).cloned() | ||||
| } | ||||
| 
 | ||||
| pub fn handle_watch_event(uuid: Uuid, watch_session: &mut WatchSession, event: WatchEvent) { | ||||
| pub fn handle_watch_event_data( | ||||
|     uuid: Uuid, | ||||
|     watch_session: &mut WatchSession, | ||||
|     event: WatchEventData, | ||||
| ) { | ||||
|     match event { | ||||
|         WatchEvent::SetPlaying { playing, time } => { | ||||
|         WatchEventData::SetPlaying { playing, time } => { | ||||
|             watch_session.set_playing(playing, time); | ||||
|         } | ||||
| 
 | ||||
|         WatchEvent::SetTime(time) => { | ||||
|         WatchEventData::SetTime(time) => { | ||||
|             watch_session.set_time_ms(time); | ||||
|         } | ||||
| 
 | ||||
|  |  | |||
		Loading…
	
		Reference in a new issue