Compare commits

..

No commits in common. "903fd535ce6d53ced972d441104f77e548283503" and "8da286fad9579659ee058b085f2e7418e4e7d94f" have entirely different histories.

13 changed files with 123 additions and 396 deletions

View file

@ -1,52 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>watch party :D</title>
<link rel="stylesheet" href="/styles.css?v=3" />
</head>
<body>
<noscript>
This site will <em>not</em> work without JavaScript, and there's not
really any way around that :(
</noscript>
<div id="create-controls">
<form id="create-session-form">
<h2>Create a session</h2>
<label for="create-session-video">Video:</label>
<input
type="text"
id="create-session-video"
placeholder="https://video.example.com/example.mp4"
required
/>
<!-- TODO: Ability to add multiple subtitles for different languages -->
<label for="create-session-subs">Subtitles:</label>
<input
type="text"
id="create-session-subs"
placeholder="https://video.example.com/example.vtt"
/>
<label for="create-session-subs-name">Subtitle track name:</label>
<input
type="text"
id="create-session-subs-name"
placeholder="English"
/>
<button>Create</button>
</form>
<p>
Already have a session?
<a href="/">Join your session</a> instead.
</p>
</div>
<script type="module" src="/create.mjs?v=1"></script>
</body>
</html>

View file

@ -1,11 +0,0 @@
import { setupCreateSessionForm } from "./lib/create-session.mjs";
const main = () => {
setupCreateSessionForm();
};
if (document.readyState === "complete") {
main();
} else {
document.addEventListener("DOMContentLoaded", main);
}

View file

@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>watch party :D</title> <title>watch party :D</title>
<link rel="stylesheet" href="/styles.css?v=3" /> <link rel="stylesheet" href="/styles.css?v=2" />
</head> </head>
<body> <body>
@ -16,12 +16,7 @@
<form id="join-session-form"> <form id="join-session-form">
<h2>Join a session</h2> <h2>Join a session</h2>
<p id="post-create-message"> <label for="nickname">Nickname:</label>
Your session has been created successfully. Copy the current url or
the Session ID below and share it with your friends. :)
</p>
<label for="join-session-nickname">Nickname:</label>
<input <input
type="text" type="text"
id="join-session-nickname" id="join-session-nickname"
@ -29,7 +24,7 @@
required required
/> />
<label for="join-session-id">Session ID:</label> <label for="session-id">Session ID:</label>
<input <input
type="text" type="text"
id="join-session-id" id="join-session-id"
@ -52,6 +47,6 @@
</form> </form>
</div> </div>
<script type="module" src="/main.mjs?v=2"></script> <script type="module" src="/main.mjs?v=1"></script>
</body> </body>
</html> </html>

View file

@ -15,7 +15,9 @@ const setupChatboxEvents = (socket) => {
socket.send( socket.send(
JSON.stringify({ JSON.stringify({
op: "ChatMessage", op: "ChatMessage",
data: content, data: {
message: content,
},
}) })
); );
} }
@ -49,134 +51,51 @@ export const setupChat = async (socket) => {
}); });
}; };
const addToChat = (node) => { const printToChat = (elem) => {
const chatbox = document.querySelector("#chatbox"); const chatbox = document.querySelector("#chatbox");
chatbox.appendChild(node); chatbox.appendChild(elem);
chatbox.scrollTop = chatbox.scrollHeight; chatbox.scrollTop = chatbox.scrollHeight;
}; };
let lastTimeMs = null; export const handleChatEvent = (event) => {
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) { switch (event.op) {
case "UserJoin": { case "UserJoin": {
printChatMessage( // print something to the chat
"user-join", const chatMessage = document.createElement("div");
event.user, chatMessage.classList.add("chat-message");
document.createTextNode("joined") chatMessage.classList.add("user-join");
); const userName = document.createElement("strong");
userName.textContent = event.data;
chatMessage.appendChild(userName);
chatMessage.appendChild(document.createTextNode(" joined"));
printToChat(chatMessage);
break; break;
} }
case "UserLeave": { case "UserLeave": {
printChatMessage( const chatMessage = document.createElement("div");
"user-leave", chatMessage.classList.add("chat-message");
event.user, chatMessage.classList.add("user-leave");
document.createTextNode("left") const userName = document.createElement("strong");
); userName.textContent = event.data;
chatMessage.appendChild(userName);
chatMessage.appendChild(document.createTextNode(" left"));
printToChat(chatMessage);
break; break;
} }
case "ChatMessage": { 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"); const messageContent = document.createElement("span");
messageContent.classList.add("message-content"); messageContent.classList.add("message-content");
messageContent.textContent = event.data; messageContent.textContent = event.data.message;
printChatMessage("chat-message", event.user, messageContent); chatMessage.appendChild(messageContent);
break; printToChat(chatMessage);
}
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; break;
} }
} }

View file

@ -1,18 +0,0 @@
import { createSession } from "./watch-session.mjs?v=3";
export const setupCreateSessionForm = () => {
const form = document.querySelector("#create-session-form");
const videoUrl = form.querySelector("#create-session-video");
const subsUrl = form.querySelector("#create-session-subs");
const subsName = form.querySelector("#create-session-subs-name");
form.addEventListener("submit", (event) => {
event.preventDefault();
let subs = [];
if (subsUrl.value) {
subs.push({ url: subsUrl.value, name: subsName.value || "default" });
}
createSession(videoUrl.value, subs);
});
};

View file

@ -1,4 +1,4 @@
import { joinSession } from "./watch-session.mjs?v=3"; import { joinSession } from "./watch-session.mjs";
/** /**
* @param {HTMLInputElement} field * @param {HTMLInputElement} field
@ -23,17 +23,7 @@ const saveNickname = (field) => {
} }
}; };
const displayPostCreateMessage = () => {
const params = new URLSearchParams(window.location.search);
if (params.get("created") == "true") {
document.querySelector("#post-create-message").style["display"] = "block";
window.history.replaceState({}, document.title, `/${window.location.hash}`);
}
};
export const setupJoinSessionForm = () => { export const setupJoinSessionForm = () => {
displayPostCreateMessage();
const form = document.querySelector("#join-session-form"); const form = document.querySelector("#join-session-form");
const nickname = form.querySelector("#join-session-nickname"); const nickname = form.querySelector("#join-session-nickname");
const sessionId = form.querySelector("#join-session-id"); const sessionId = form.querySelector("#join-session-id");

View file

@ -1,5 +1,5 @@
import { setupVideo } from "./video.mjs?v=2"; import { setupVideo } from "./video.mjs";
import { setupChat, logEventToChat } from "./chat.mjs?v=2"; import { setupChat, handleChatEvent } from "./chat.mjs";
/** /**
* @param {string} sessionId * @param {string} sessionId
@ -50,29 +50,31 @@ const setupIncomingEvents = (video, socket) => {
socket.addEventListener("message", async (messageEvent) => { socket.addEventListener("message", async (messageEvent) => {
try { try {
const event = JSON.parse(messageEvent.data); const event = JSON.parse(messageEvent.data);
// console.log(event);
if (!event.reflected) { switch (event.op) {
switch (event.op) { case "SetPlaying":
case "SetPlaying": setDebounce();
setDebounce();
if (event.data.playing) { if (event.data.playing) {
await video.play(); await video.play();
} else { } else {
video.pause(); video.pause();
} }
setVideoTime(event.data.time); setVideoTime(event.data.time);
break; break;
case "SetTime": case "SetTime":
setDebounce(); setDebounce();
setVideoTime(event.data); setVideoTime(event.data);
break; break;
} case "UserJoin":
case "UserLeave":
case "ChatMessage":
handleChatEvent(event);
break;
} }
logEventToChat(event);
} catch (_err) {} } catch (_err) {}
}); });
}; };
@ -167,20 +169,3 @@ export const joinSession = async (nickname, sessionId) => {
console.error(err); console.error(err);
} }
}; };
/**
* @param {string} videoUrl
* @param {Array} subtitleTracks
*/
export const createSession = async (videoUrl, subtitleTracks) => {
const { id } = await fetch("/start_session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
video_url: videoUrl,
subtitle_tracks: subtitleTracks,
}),
}).then((r) => r.json());
window.location = `/?created=true#${id}`;
};

View file

@ -1,4 +1,4 @@
import { setupJoinSessionForm } from "./lib/join-session.mjs?v=2"; import { setupJoinSessionForm } from "./lib/join-session.mjs";
const main = () => { const main = () => {
setupJoinSessionForm(); setupJoinSessionForm();

View file

@ -61,7 +61,6 @@ input[type="text"] {
font-family: sans-serif; font-family: sans-serif;
font-size: 1em; font-size: 1em;
width: 500px; width: 500px;
max-width: 100%;
resize: none; resize: none;
overflow-x: wrap; overflow-x: wrap;
@ -83,7 +82,6 @@ button {
font-family: sans-serif; font-family: sans-serif;
font-size: 1em; font-size: 1em;
width: 500px; width: 500px;
max-width: 100%;
user-select: none; user-select: none;
border: 1px solid rgba(0, 0, 0, 0); border: 1px solid rgba(0, 0, 0, 0);
@ -106,50 +104,29 @@ button.small-button {
margin-right: 1ch !important; margin-right: 1ch !important;
} }
#pre-join-controls, #pre-join-controls {
#create-controls {
width: 60%; width: 60%;
margin: 0 auto; margin: 0 auto;
margin-top: 4em; margin-top: 4em;
} }
#join-session-form, #join-session-form {
#create-session-form {
margin-bottom: 4em; margin-bottom: 4em;
} }
#post-create-message {
display: none;
width: 500px;
max-width: 100%;
font-size: 0.85em;
}
#chatbox-container { #chatbox-container {
display: none; display: none;
} }
.user-join,
.user-leave {
font-style: italic;
}
.chat-message > strong { .chat-message > strong {
color: rgb(126, 208, 255); color: rgb(126, 208, 255);
} }
.chat-message.user-join,
.chat-message.user-leave {
font-style: italic;
}
.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 { #chatbox {
padding: 0.5em 2em; padding: 0.5em 2em;
min-height: 8em; min-height: 8em;
@ -167,30 +144,4 @@ button.small-button {
#chatbox-send > input { #chatbox-send > input {
font-size: 0.75em; 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;
}
} }

View file

@ -2,31 +2,18 @@ use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
#[serde(tag = "op", content = "data")] #[serde(tag = "op", content = "data")]
pub enum WatchEventData { pub enum WatchEvent {
SetPlaying { playing: bool, time: u64 }, SetPlaying {
playing: bool,
time: u64,
},
SetTime(u64), SetTime(u64),
UserJoin, UserJoin(String),
UserLeave, UserLeave(String),
ChatMessage(String), ChatMessage {
} #[serde(default = "String::new")]
user: String,
#[derive(Clone, Serialize, Deserialize)] message: String,
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,
}
}
} }

View file

@ -12,9 +12,9 @@ mod watch_session;
use serde::Deserialize; use serde::Deserialize;
use crate::{ use crate::{
events::{WatchEvent, WatchEventData}, events::WatchEvent,
viewer_connection::{ws_publish, ws_subscribe}, viewer_connection::{ws_publish, ws_subscribe},
watch_session::{get_session, handle_watch_event_data, SubtitleTrack, WatchSession, SESSIONS}, watch_session::{get_session, handle_watch_event, SubtitleTrack, WatchSession, SESSIONS},
}; };
#[derive(Deserialize)] #[derive(Deserialize)]
@ -85,21 +85,13 @@ async fn main() {
.and(warb::body::json()) .and(warb::body::json())
.map(|requested_session, playing: bool| match requested_session { .map(|requested_session, playing: bool| match requested_session {
RequestedSession::Session(uuid, mut sess) => { RequestedSession::Session(uuid, mut sess) => {
let data = WatchEventData::SetPlaying { let event = WatchEvent::SetPlaying {
playing, playing,
time: sess.get_time_ms(), time: sess.get_time_ms(),
}; };
handle_watch_event_data(uuid, &mut sess, data.clone()); handle_watch_event(uuid, &mut sess, event.clone());
tokio::spawn(ws_publish( tokio::spawn(ws_publish(uuid, None, event));
uuid,
None,
WatchEvent {
user: None,
data,
reflected: false,
},
));
warb::reply::with_status(warb::reply::json(&sess.view()), StatusCode::OK) warb::reply::with_status(warb::reply::json(&sess.view()), StatusCode::OK)
} }
@ -113,18 +105,10 @@ async fn main() {
.map( .map(
|requested_session, current_time_ms: u64| match requested_session { |requested_session, current_time_ms: u64| match requested_session {
RequestedSession::Session(uuid, mut sess) => { RequestedSession::Session(uuid, mut sess) => {
let data = WatchEventData::SetTime(current_time_ms); let event = WatchEvent::SetTime(current_time_ms);
handle_watch_event_data(uuid, &mut sess, data.clone()); handle_watch_event(uuid, &mut sess, event.clone());
tokio::spawn(ws_publish( tokio::spawn(ws_publish(uuid, None, event));
uuid,
None,
WatchEvent {
user: None,
data,
reflected: false,
},
));
warb::reply::with_status(warb::reply::json(&sess.view()), StatusCode::OK) warb::reply::with_status(warb::reply::json(&sess.view()), StatusCode::OK)
} }

View file

@ -15,8 +15,8 @@ use uuid::Uuid;
use warp::ws::{Message, WebSocket}; use warp::ws::{Message, WebSocket};
use crate::{ use crate::{
events::{WatchEvent, WatchEventData}, events::WatchEvent,
watch_session::{get_session, handle_watch_event_data}, watch_session::{get_session, handle_watch_event},
}; };
static CONNECTED_VIEWERS: Lazy<RwLock<HashMap<usize, ConnectedViewer>>> = static CONNECTED_VIEWERS: Lazy<RwLock<HashMap<usize, ConnectedViewer>>> =
@ -27,7 +27,6 @@ pub struct ConnectedViewer {
pub session: Uuid, pub session: Uuid,
pub viewer_id: usize, pub viewer_id: usize,
pub tx: UnboundedSender<WatchEvent>, pub tx: UnboundedSender<WatchEvent>,
pub nickname: Option<String>,
} }
pub async fn ws_subscribe(session_uuid: Uuid, nickname: String, ws: WebSocket) { pub async fn ws_subscribe(session_uuid: Uuid, nickname: String, ws: WebSocket) {
@ -54,19 +53,13 @@ pub async fn ws_subscribe(session_uuid: Uuid, nickname: String, ws: WebSocket) {
viewer_id, viewer_id,
session: session_uuid, session: session_uuid,
tx, tx,
nickname: Some(nickname.clone()),
}, },
); );
ws_publish( ws_publish(session_uuid, None, WatchEvent::UserJoin(nickname.clone())).await;
session_uuid,
None,
WatchEvent::new(nickname.clone(), WatchEventData::UserJoin),
)
.await;
while let Some(Ok(message)) = viewer_ws_rx.next().await { while let Some(Ok(message)) = viewer_ws_rx.next().await {
let event: WatchEventData = match message let mut event: WatchEvent = match message
.to_str() .to_str()
.ok() .ok()
.and_then(|s| serde_json::from_str(s).ok()) .and_then(|s| serde_json::from_str(s).ok())
@ -75,39 +68,47 @@ pub async fn ws_subscribe(session_uuid: Uuid, nickname: String, ws: WebSocket) {
None => continue, None => continue,
}; };
handle_watch_event_data( // 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(
session_uuid, session_uuid,
&mut get_session(session_uuid).unwrap(), &mut get_session(session_uuid).unwrap(),
event.clone(), event.clone(),
); );
ws_publish( ws_publish(session_uuid, Some(viewer_id), event).await;
session_uuid,
Some(viewer_id),
WatchEvent::new(nickname.clone(), event),
)
.await;
} }
ws_publish( ws_publish(session_uuid, None, WatchEvent::UserLeave(nickname.clone())).await;
session_uuid,
None,
WatchEvent::new(nickname.clone(), WatchEventData::UserLeave),
)
.await;
CONNECTED_VIEWERS.write().await.remove(&viewer_id); CONNECTED_VIEWERS.write().await.remove(&viewer_id);
} }
pub async fn ws_publish(session_uuid: Uuid, skip_viewer_id: Option<usize>, event: WatchEvent) { pub async fn ws_publish(session_uuid: Uuid, viewer_id: Option<usize>, event: WatchEvent) {
for viewer in CONNECTED_VIEWERS.read().await.values() { for viewer in CONNECTED_VIEWERS.read().await.values() {
if viewer_id == Some(viewer.viewer_id) {
continue;
}
if viewer.session != session_uuid { if viewer.session != session_uuid {
continue; continue;
} }
let _ = viewer.tx.send(WatchEvent { let _ = viewer.tx.send(event.clone());
reflected: skip_viewer_id == Some(viewer.viewer_id),
..event.clone()
});
} }
} }

View file

@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize};
use std::{collections::HashMap, sync::Mutex, time::Instant}; use std::{collections::HashMap, sync::Mutex, time::Instant};
use uuid::Uuid; use uuid::Uuid;
use crate::events::WatchEventData; use crate::events::WatchEvent;
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]
pub struct SubtitleTrack { pub struct SubtitleTrack {
@ -75,17 +75,13 @@ pub fn get_session(uuid: Uuid) -> Option<WatchSession> {
SESSIONS.lock().unwrap().get(&uuid).cloned() SESSIONS.lock().unwrap().get(&uuid).cloned()
} }
pub fn handle_watch_event_data( pub fn handle_watch_event(uuid: Uuid, watch_session: &mut WatchSession, event: WatchEvent) {
uuid: Uuid,
watch_session: &mut WatchSession,
event: WatchEventData,
) {
match event { match event {
WatchEventData::SetPlaying { playing, time } => { WatchEvent::SetPlaying { playing, time } => {
watch_session.set_playing(playing, time); watch_session.set_playing(playing, time);
} }
WatchEventData::SetTime(time) => { WatchEvent::SetTime(time) => {
watch_session.set_time_ms(time); watch_session.set_time_ms(time);
} }