Compare commits

..

No commits in common. "main" and "main" have entirely different histories.
main ... main

21 changed files with 2257 additions and 2245 deletions

View File

@ -3,7 +3,7 @@ root = true
[*] [*]
indent_style = space indent_style = space
indent_size = 2 indent_size = 2
end_of_line = lf end_of_line = crlf
charset = utf-8 charset = utf-8
trim_trailing_whitespace = false trim_trailing_whitespace = false
insert_final_newline = true insert_final_newline = true

View File

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

View File

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

View File

@ -1,85 +1,84 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>watch party :D</title> <title>watch party :D</title>
<link rel="stylesheet" href="/lib/plyr-3.7.3.css" /> <link rel="stylesheet" href="/styles.css?v=bfdcf2" />
<link rel="stylesheet" href="/styles.css?v=4b61c4" /> </head>
</head>
<body>
<body> <noscript>
<noscript> This site will <em>not</em> work without JavaScript, and there's not
This site will <em>not</em> work without JavaScript, and there's not really any way around that :(
really any way around that :( </noscript>
</noscript>
<div id="pre-join-controls">
<div id="pre-join-controls"> <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">
<p id="post-create-message"> Your session has been created successfully. Copy the current url or
Your session has been created successfully. Copy the current url or the Session ID below and share it with your friends. :)
the Session ID below and share it with your friends. :) </p>
</p>
<label for="join-session-nickname">Nickname:</label>
<label for="join-session-nickname">Nickname:</label> <input
<input type="text"
type="text" id="join-session-nickname"
id="join-session-nickname" placeholder="Nickname"
placeholder="Nickname" maxlength="50"
maxlength="50" required
required />
/>
<label id="join-session-colour-label" for="join-session-colour">
<label id="join-session-colour-label" for="join-session-colour"> Personal Colour:
Personal Colour: </label>
</label> <input type="color" id="join-session-colour" value="#ffffff" required />
<input type="color" id="join-session-colour" value="#ffffff" required />
<label for="join-session-id">Session ID:</label>
<label for="join-session-id">Session ID:</label> <input
<input type="text"
type="text" id="join-session-id"
id="join-session-id" placeholder="123e4567-e89b-12d3-a456-426614174000"
placeholder="123e4567-e89b-12d3-a456-426614174000" required
required />
/> <button id="join-session-button">Join</button>
<button id="join-session-button">Join</button>
<p>
<p> No session to join?
No session to join? <a href="/create.html">Create a session</a> instead.
<a href="/create.html">Create a session</a> instead. </p>
</p> </form>
</form> </div>
</div>
<div id="video-container"></div>
<div id="video-container"></div> <div id="chatbox-container">
<div id="chatbox-container"> <div id="viewer-list"></div>
<div id="viewer-list"></div> <div id="chatbox"></div>
<div id="chatbox"></div> <form id="chatbox-send">
<form id="chatbox-send"> <input
<input type="text"
type="text" placeholder="Message... (/help for commands)"
placeholder="Message... (/help for commands)" list="emoji-autocomplete"
list="emoji-autocomplete" />
/> <div id="emoji-autocomplete"></div>
<div id="emoji-autocomplete"></div> <!-- DO NOT ADD SPACING INSIDE THE TAG IT WILL BREAK THE CSS kthxbye -->
<!-- DO NOT ADD SPACING INSIDE THE TAG IT WILL BREAK THE CSS kthxbye --> </form>
</form> </div>
</div>
<script type="module" src="/main.mjs?v=bfdcf2"></script>
<script type="module" src="/main.mjs?v=4b61c4"></script> <script>
<script> const updateColourLabel = () => {
const updateColourLabel = () => { const colour = document.querySelector("#join-session-colour").value;
const colour = document.querySelector("#join-session-colour").value; document.querySelector(
document.querySelector( "#join-session-colour-label"
"#join-session-colour-label" ).textContent = `Personal Colour: ${colour}`;
).textContent = `Personal Colour: ${colour}`; };
};
document
document .querySelector("#join-session-colour")
.querySelector("#join-session-colour") .addEventListener("input", updateColourLabel);
.addEventListener("input", updateColourLabel); updateColourLabel();
updateColourLabel(); </script>
</script> </body>
</body> </html>
</html>

View File

@ -1,453 +1,459 @@
import { import {
setDebounce, setDebounce,
setVideoTime, setVideoTime,
setPlaying, setPlaying,
sync, } from "./watch-session.mjs?v=bfdcf2";
} from "./watch-session.mjs?v=4b61c4"; import { emojify, findEmojis } from "./emojis.mjs?v=bfdcf2";
import { emojify, findEmojis } from "./emojis.mjs?v=4b61c4"; import { linkify } from "./links.mjs?v=bfdcf2";
import { linkify } from "./links.mjs?v=4b61c4"; import { joinSession } from "./watch-session.mjs?v=bfdcf2";
import { joinSession } from "./watch-session.mjs?v=4b61c4"; import { pling } from "./pling.mjs?v=bfdcf2";
import { pling } from "./pling.mjs?v=4b61c4"; import { state } from "./state.mjs";
import { state } from "./state.mjs";
function setCaretPosition(elem, caretPos) {
function setCaretPosition(elem, caretPos) { if (elem.createTextRange) {
if (elem.createTextRange) { var range = elem.createTextRange();
var range = elem.createTextRange(); range.move("character", caretPos);
range.move("character", caretPos); range.select();
range.select(); } else {
} else { if (elem.selectionStart) {
if (elem.selectionStart) { elem.focus();
elem.focus(); elem.setSelectionRange(caretPos, caretPos);
elem.setSelectionRange(caretPos, caretPos); } else elem.focus();
} else elem.focus(); }
} }
}
const setupChatboxEvents = (socket) => {
const setupChatboxEvents = (socket) => { // clear events by just reconstructing the form
// clear events by just reconstructing the form const oldChatForm = document.querySelector("#chatbox-send");
const oldChatForm = document.querySelector("#chatbox-send"); const chatForm = oldChatForm.cloneNode(true);
const chatForm = oldChatForm.cloneNode(true); const messageInput = chatForm.querySelector("input");
const messageInput = chatForm.querySelector("input"); const emojiAutocomplete = chatForm.querySelector("#emoji-autocomplete");
const emojiAutocomplete = chatForm.querySelector("#emoji-autocomplete"); oldChatForm.replaceWith(chatForm);
oldChatForm.replaceWith(chatForm);
let autocompleting = false,
let autocompleting = false, showListTimer;
showListTimer;
const replaceMessage = (message) => () => {
const replaceMessage = (message) => () => { messageInput.value = message;
messageInput.value = message; autocomplete();
autocomplete(); };
}; async function autocomplete(fromListTimeout) {
async function autocomplete(fromListTimeout) { if (autocompleting) return;
if (autocompleting) return; try {
try { clearInterval(showListTimer);
clearInterval(showListTimer); emojiAutocomplete.textContent = "";
emojiAutocomplete.textContent = ""; autocompleting = true;
autocompleting = true; let text = messageInput.value.slice(0, messageInput.selectionStart);
let text = messageInput.value.slice(0, messageInput.selectionStart); const match = text.match(/(:[^\s:]+)?:([^\s:]{2,})$/);
const match = text.match(/(:[^\s:]+)?:([^\s:]{2,})$/); if (!match || match[1]) return (autocompleting = false); // We don't need to autocomplete.
if (!match || match[1]) return (autocompleting = false); // We don't need to autocomplete. const prefix = text.slice(0, match.index);
const prefix = text.slice(0, match.index); const search = text.slice(match.index + 1);
const search = text.slice(match.index + 1); if (search.length < 1 && !fromListTimeout) {
if (search.length < 1 && !fromListTimeout) { autocompleting = false;
autocompleting = false; showListTimer = setTimeout(() => autocomplete(true), 500);
showListTimer = setTimeout(() => autocomplete(true), 500); return;
return; }
} const suffix = messageInput.value.slice(messageInput.selectionStart);
const suffix = messageInput.value.slice(messageInput.selectionStart); let selected;
let selected; const select = (button) => {
const select = (button) => { if (selected) selected.classList.remove("selected");
if (selected) selected.classList.remove("selected"); selected = button;
selected = button; button.classList.add("selected");
button.classList.add("selected"); };
}; let results = await findEmojis(search);
let results = await findEmojis(search); let yieldAt = performance.now() + 13;
let yieldAt = performance.now() + 13; for (let i = 0; i < results.length; i += 100) {
for (let i = 0; i < results.length; i += 100) { emojiAutocomplete.append.apply(
emojiAutocomplete.append.apply( emojiAutocomplete,
emojiAutocomplete, results.slice(i, i + 100).map(([name, replaceWith, ext], i) => {
results.slice(i, i + 100).map(([name, replaceWith, ext], i) => { const button = Object.assign(document.createElement("button"), {
const button = Object.assign(document.createElement("button"), { className: "emoji-option",
className: "emoji-option", onmousedown: (e) => e.preventDefault(),
onmousedown: (e) => e.preventDefault(), onclick: () => {
onclick: () => { messageInput.value = prefix + replaceWith + " " + suffix;
messageInput.value = prefix + replaceWith + " " + suffix; setCaretPosition(
setCaretPosition( messageInput,
messageInput, (prefix + " " + replaceWith).length
(prefix + " " + replaceWith).length );
); },
}, onmouseover: () => select(button),
onmouseover: () => select(button), onfocus: () => select(button),
onfocus: () => select(button), type: "button",
type: "button", title: name,
title: name, });
}); button.append(
button.append( replaceWith[0] !== ":"
replaceWith[0] !== ":" ? Object.assign(document.createElement("span"), {
? Object.assign(document.createElement("span"), { textContent: replaceWith,
textContent: replaceWith, className: "emoji",
className: "emoji", })
}) : Object.assign(new Image(), {
: Object.assign(new Image(), { loading: "lazy",
loading: "lazy", src: `/emojis/${name}${ext}`,
src: `/emojis/${name}${ext}`, className: "emoji",
className: "emoji", }),
}), Object.assign(document.createElement("span"), {
Object.assign(document.createElement("span"), { textContent: name,
textContent: name, className: "emoji-name",
className: "emoji-name", })
}) );
); return button;
return button; })
}) );
); if (i == 0 && emojiAutocomplete.children[0]) {
if (i == 0 && emojiAutocomplete.children[0]) { emojiAutocomplete.children[0].scrollIntoView();
emojiAutocomplete.children[0].scrollIntoView(); select(emojiAutocomplete.children[0]);
select(emojiAutocomplete.children[0]); }
} const now = performance.now();
const now = performance.now(); if (now > yieldAt) {
if (now > yieldAt) { yieldAt = now + 13;
yieldAt = now + 13; await new Promise((cb) => setTimeout(cb, 0));
await new Promise((cb) => setTimeout(cb, 0)); }
} }
} autocompleting = false;
autocompleting = false; } catch (e) {
} catch (e) { autocompleting = false;
autocompleting = false; }
} }
} messageInput.addEventListener("input", () => autocomplete());
messageInput.addEventListener("input", () => autocomplete()); messageInput.addEventListener("selectionchange", () => autocomplete());
messageInput.addEventListener("selectionchange", () => autocomplete()); messageInput.addEventListener("keydown", (event) => {
messageInput.addEventListener("keydown", (event) => { if (event.key == "ArrowUp" || event.key == "ArrowDown") {
if (event.key == "ArrowUp" || event.key == "ArrowDown") { let selected = document.querySelector(".emoji-option.selected");
let selected = document.querySelector(".emoji-option.selected"); if (!selected) return;
if (!selected) return; event.preventDefault();
event.preventDefault(); selected.classList.remove("selected");
selected.classList.remove("selected"); selected =
selected = event.key == "ArrowDown"
event.key == "ArrowDown" ? selected.nextElementSibling || selected.parentElement.children[0]
? selected.nextElementSibling || selected.parentElement.children[0] : selected.previousElementSibling ||
: selected.previousElementSibling || selected.parentElement.children[
selected.parentElement.children[ selected.parentElement.children.length - 1
selected.parentElement.children.length - 1 ];
]; selected.classList.add("selected");
selected.classList.add("selected"); selected.scrollIntoView({ scrollMode: "if-needed", block: "nearest" });
selected.scrollIntoView({ scrollMode: "if-needed", block: "nearest" }); }
} if (event.key == "Tab" || event.key == "Enter") {
if (event.key == "Tab" || event.key == "Enter") { let selected = document.querySelector(".emoji-option.selected");
let selected = document.querySelector(".emoji-option.selected"); if (!selected) return;
if (!selected) return; event.preventDefault();
event.preventDefault(); selected.onclick();
selected.onclick(); }
} });
});
chatForm.addEventListener("submit", async (e) => {
chatForm.addEventListener("submit", async (e) => { e.preventDefault();
e.preventDefault(); const content = messageInput.value;
const content = messageInput.value; if (content.trim().length) {
if (content.trim().length) { messageInput.value = "";
messageInput.value = "";
// handle commands
// handle commands if (content.startsWith("/")) {
if (content.startsWith("/")) { const command = content.toLowerCase().match(/^\/\S+/)[0];
const command = content.toLowerCase().match(/^\/\S+/)[0]; const args = content.slice(command.length).trim();
const args = content.slice(command.length).trim();
let handled = false;
let handled = false; switch (command) {
switch (command) { case "/ping":
case "/ping": socket.send(
socket.send( JSON.stringify({
JSON.stringify({ op: "Ping",
op: "Ping", data: args,
data: args, })
}) );
); handled = true;
handled = true; break;
break; case "/sync":
case "/sync": const sessionId = window.location.hash.slice(1);
await sync(); const { current_time_ms, is_playing } = await fetch(
`/sess/${sessionId}`
const syncMessageContent = document.createElement("span"); ).then((r) => r.json());
syncMessageContent.appendChild(
document.createTextNode("resynced you to ") setDebounce();
); setPlaying(is_playing);
syncMessageContent.appendChild( setVideoTime(current_time_ms);
document.createTextNode(formatTime(current_time_ms))
); const syncMessageContent = document.createElement("span");
printChatMessage("set-time", "/sync", "b57fdc", syncMessageContent); syncMessageContent.appendChild(
handled = true; document.createTextNode("resynced you to ")
break; );
case "/shrug": syncMessageContent.appendChild(
socket.send( document.createTextNode(formatTime(current_time_ms))
JSON.stringify({ );
op: "ChatMessage", printChatMessage("set-time", "/sync", "b57fdc", syncMessageContent);
data: `${args} ¯\\_(ツ)_/¯`.trim(), handled = true;
}) break;
); case "/shrug":
handled = true; socket.send(
break; JSON.stringify({
case "/join": op: "ChatMessage",
state().sessionId = args; data: `${args} ¯\\_(ツ)_/¯`.trim(),
joinSession(); })
handled = true; );
break; handled = true;
case "/help": break;
const helpMessageContent = document.createElement("span"); case "/join":
helpMessageContent.innerHTML = state().sessionId = args;
"Available commands:<br>" + joinSession();
"&emsp;<code>/help</code> - display this help message<br>" + handled = true;
"&emsp;<code>/ping [message]</code> - ping all viewers<br>" + break;
"&emsp;<code>/sync</code> - resyncs you with other viewers<br>" + case "/help":
"&emsp;<code>/shrug</code> - appends ¯\\_(ツ)_/¯ to your message<br>" + const helpMessageContent = document.createElement("span");
"&emsp;<code>/join [session id]</code> - joins another session"; helpMessageContent.innerHTML =
"Available commands:<br>" +
printChatMessage( "&emsp;<code>/help</code> - display this help message<br>" +
"command-message", "&emsp;<code>/ping [message]</code> - ping all viewers<br>" +
"/help", "&emsp;<code>/sync</code> - resyncs you with other viewers<br>" +
"b57fdc", "&emsp;<code>/shrug</code> - appends ¯\\_(ツ)_/¯ to your message<br>" +
helpMessageContent "&emsp;<code>/join [session id]</code> - joins another session";
);
handled = true; printChatMessage(
break; "command-message",
default: "/help",
break; "b57fdc",
} helpMessageContent
);
if (handled) { handled = true;
return; break;
} default:
} break;
}
// handle regular chat messages
socket.send( if (handled) {
JSON.stringify({ return;
op: "ChatMessage", }
data: content, }
})
); // handle regular chat messages
} socket.send(
}); JSON.stringify({
}; op: "ChatMessage",
data: content,
/** })
* @param {WebSocket} socket );
*/ }
export const setupChat = async (socket) => { });
document.querySelector("#chatbox-container").style["display"] = "flex"; };
setupChatboxEvents(socket);
}; /**
* @param {WebSocket} socket
const addToChat = (node) => { */
const chatbox = document.querySelector("#chatbox"); export const setupChat = async (socket) => {
chatbox.appendChild(node); document.querySelector("#chatbox-container").style["display"] = "flex";
chatbox.scrollTop = chatbox.scrollHeight; setupChatboxEvents(socket);
}; };
let lastTimeMs = null; const addToChat = (node) => {
let lastPlaying = false; const chatbox = document.querySelector("#chatbox");
chatbox.appendChild(node);
const checkDebounce = (event) => { chatbox.scrollTop = chatbox.scrollHeight;
let timeMs = null; };
let playing = null;
if (event.op == "SetTime") { let lastTimeMs = null;
timeMs = event.data; let lastPlaying = false;
} else if (event.op == "SetPlaying") {
timeMs = event.data.time; const checkDebounce = (event) => {
playing = event.data.playing; let timeMs = null;
} let playing = null;
if (event.op == "SetTime") {
let shouldIgnore = false; timeMs = event.data;
} else if (event.op == "SetPlaying") {
if (timeMs != null) { timeMs = event.data.time;
if (lastTimeMs && Math.abs(lastTimeMs - timeMs) < 500) { playing = event.data.playing;
shouldIgnore = true; }
}
lastTimeMs = timeMs; let shouldIgnore = false;
}
if (timeMs != null) {
if (playing != null) { if (lastTimeMs && Math.abs(lastTimeMs - timeMs) < 500) {
if (lastPlaying != playing) { shouldIgnore = true;
shouldIgnore = false; }
} lastTimeMs = timeMs;
lastPlaying = playing; }
}
if (playing != null) {
return shouldIgnore; if (lastPlaying != playing) {
}; shouldIgnore = false;
}
/** lastPlaying = playing;
* @returns {string} }
*/
const getCurrentTimestamp = () => { return shouldIgnore;
const t = new Date(); };
return `${matpad(t.getHours())}:${matpad(t.getMinutes())}:${matpad(
t.getSeconds() /**
)}`; * @returns {string}
}; */
const getCurrentTimestamp = () => {
/** const t = new Date();
* https://media.discordapp.net/attachments/834541919568527361/931678814751301632/66d2c68c48daa414c96951381665ec2e.png return `${matpad(t.getHours())}:${matpad(t.getMinutes())}:${matpad(
*/ t.getSeconds()
const matpad = (n) => { )}`;
return ("00" + n).slice(-2); };
};
/**
/** * https://media.discordapp.net/attachments/834541919568527361/931678814751301632/66d2c68c48daa414c96951381665ec2e.png
* @param {string} eventType */
* @param {string?} user const matpad = (n) => {
* @param {Node?} content return ("00" + n).slice(-2);
*/ };
export const printChatMessage = (eventType, user, colour, content) => {
const chatMessage = document.createElement("div"); /**
chatMessage.classList.add("chat-message"); * @param {string} eventType
chatMessage.classList.add(eventType); * @param {string?} user
chatMessage.title = getCurrentTimestamp(); * @param {Node?} content
*/
if (user != null) { export const printChatMessage = (eventType, user, colour, content) => {
const userName = document.createElement("strong"); const chatMessage = document.createElement("div");
userName.style = `--user-color: #${colour}`; chatMessage.classList.add("chat-message");
userName.textContent = user + " "; chatMessage.classList.add(eventType);
chatMessage.appendChild(userName); chatMessage.title = getCurrentTimestamp();
}
if (user != null) {
if (content != null) { const userName = document.createElement("strong");
chatMessage.appendChild(content); userName.style = `--user-color: #${colour}`;
} userName.textContent = user + " ";
chatMessage.appendChild(userName);
addToChat(chatMessage); }
return chatMessage; if (content != null) {
}; chatMessage.appendChild(content);
}
const formatTime = (ms) => {
const seconds = Math.floor((ms / 1000) % 60); addToChat(chatMessage);
const minutes = Math.floor((ms / (60 * 1000)) % 60);
const hours = Math.floor((ms / (3600 * 1000)) % 3600); return chatMessage;
return `${hours < 10 ? "0" + hours : hours}:${ };
minutes < 10 ? "0" + minutes : minutes
}:${seconds < 10 ? "0" + seconds : seconds}`; const formatTime = (ms) => {
}; const seconds = Math.floor((ms / 1000) % 60);
const minutes = Math.floor((ms / (60 * 1000)) % 60);
export const logEventToChat = async (event) => { const hours = Math.floor((ms / (3600 * 1000)) % 3600);
if (checkDebounce(event)) { return `${hours < 10 ? "0" + hours : hours}:${
return; minutes < 10 ? "0" + minutes : minutes
} }:${seconds < 10 ? "0" + seconds : seconds}`;
};
switch (event.op) {
case "UserJoin": { export const logEventToChat = async (event) => {
printChatMessage( if (checkDebounce(event)) {
"user-join", return;
event.user, }
event.colour,
document.createTextNode("joined") switch (event.op) {
); case "UserJoin": {
break; printChatMessage(
} "user-join",
case "UserLeave": { event.user,
printChatMessage( event.colour,
"user-leave", document.createTextNode("joined")
event.user, );
event.colour, break;
document.createTextNode("left") }
); case "UserLeave": {
break; printChatMessage(
} "user-leave",
case "ChatMessage": { event.user,
const messageContent = document.createElement("span"); event.colour,
messageContent.classList.add("message-content"); document.createTextNode("left")
messageContent.append(...(await linkify(event.data, emojify))); );
printChatMessage( break;
"chat-message", }
event.user, case "ChatMessage": {
event.colour, const messageContent = document.createElement("span");
messageContent messageContent.classList.add("message-content");
); messageContent.append(...(await linkify(event.data, emojify)));
break; printChatMessage(
} "chat-message",
case "SetTime": { event.user,
const messageContent = document.createElement("span"); event.colour,
if (event.data.from != undefined) { messageContent
messageContent.appendChild( );
document.createTextNode("set the time from ") break;
); }
case "SetTime": {
messageContent.appendChild( const messageContent = document.createElement("span");
document.createTextNode(formatTime(event.data.from)) if (event.data.from != undefined) {
); messageContent.appendChild(
document.createTextNode("set the time from ")
messageContent.appendChild(document.createTextNode(" to ")); );
} else {
messageContent.appendChild(document.createTextNode("set the time to ")); messageContent.appendChild(
} document.createTextNode(formatTime(event.data.from))
);
messageContent.appendChild(
document.createTextNode(formatTime(event.data.to)) messageContent.appendChild(document.createTextNode(" to "));
); } else {
messageContent.appendChild(document.createTextNode("set the time to "));
printChatMessage("set-time", event.user, event.colour, messageContent); }
break;
} messageContent.appendChild(
case "SetPlaying": { document.createTextNode(formatTime(event.data.to))
const messageContent = document.createElement("span"); );
messageContent.appendChild(
document.createTextNode( printChatMessage("set-time", event.user, event.colour, messageContent);
event.data.playing ? "started playing" : "paused" break;
) }
); case "SetPlaying": {
messageContent.appendChild(document.createTextNode(" at ")); const messageContent = document.createElement("span");
messageContent.appendChild( messageContent.appendChild(
document.createTextNode(formatTime(event.data.time)) document.createTextNode(
); event.data.playing ? "started playing" : "paused"
)
printChatMessage("set-playing", event.user, event.colour, messageContent); );
break; messageContent.appendChild(document.createTextNode(" at "));
} messageContent.appendChild(
case "Ping": { document.createTextNode(formatTime(event.data.time))
const messageContent = document.createElement("span"); );
if (event.data) {
messageContent.appendChild(document.createTextNode("pinged saying: ")); printChatMessage("set-playing", event.user, event.colour, messageContent);
messageContent.appendChild(document.createTextNode(event.data)); break;
} else { }
messageContent.appendChild(document.createTextNode("pinged")); case "Ping": {
} const messageContent = document.createElement("span");
if (event.data) {
printChatMessage("ping", event.user, event.colour, messageContent); messageContent.appendChild(document.createTextNode("pinged saying: "));
pling(); messageContent.appendChild(document.createTextNode(event.data));
if ("Notification" in window) { } else {
const title = "watch party :)"; messageContent.appendChild(document.createTextNode("pinged"));
const options = { }
body: event.data
? `${event.user} pinged saying: ${event.data}` printChatMessage("ping", event.user, event.colour, messageContent);
: `${event.user} pinged`, pling();
}; if ("Notification" in window) {
if (Notification.permission === "granted") { const title = "watch party :)";
new Notification(title, options); const options = {
} else if (Notification.permission !== "denied") { body: event.data
Notification.requestPermission().then(function (permission) { ? `${event.user} pinged saying: ${event.data}`
if (permission === "granted") { : `${event.user} pinged`,
new Notification(title, options); };
} if (Notification.permission === "granted") {
}); new Notification(title, options);
} } else if (Notification.permission !== "denied") {
} Notification.requestPermission().then(function (permission) {
break; if (permission === "granted") {
} new Notification(title, options);
} }
}; });
}
export const updateViewerList = (viewers) => { }
const listContainer = document.querySelector("#viewer-list"); break;
}
// empty out the current list }
listContainer.innerHTML = ""; };
// display the updated list export const updateViewerList = (viewers) => {
for (const viewer of viewers) { const listContainer = document.querySelector("#viewer-list");
const viewerElem = document.createElement("div");
const content = document.createElement("strong"); // empty out the current list
content.textContent = viewer.nickname; listContainer.innerHTML = "";
content.style = `--user-color: #${viewer.colour}`;
viewerElem.appendChild(content); // display the updated list
listContainer.appendChild(viewerElem); for (const viewer of viewers) {
} const viewerElem = document.createElement("div");
}; const content = document.createElement("strong");
content.textContent = viewer.nickname;
content.style = `--user-color: #${viewer.colour}`;
viewerElem.appendChild(content);
listContainer.appendChild(viewerElem);
}
};

View File

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

View File

@ -1,72 +1,72 @@
export async function emojify(text) { export async function emojify(text) {
await emojisLoaded; await emojisLoaded;
let last = 0; let last = 0;
let nodes = []; let nodes = [];
text.replace(/:([^\s:]+):/g, (match, name, index) => { text.replace(/:([^\s:]+):/g, (match, name, index) => {
if (last <= index) if (last <= index)
nodes.push(document.createTextNode(text.slice(last, index))); nodes.push(document.createTextNode(text.slice(last, index)));
let emoji; let emoji;
try { try {
emoji = emojis[name.toLowerCase()[0]].find((e) => e[0] == name); emoji = emojis[name.toLowerCase()[0]].find((e) => e[0] == name);
} catch (e) {} } catch (e) {}
if (!emoji) { if (!emoji) {
nodes.push(document.createTextNode(match)); nodes.push(document.createTextNode(match));
} else { } else {
if (emoji[1][0] !== ":") { if (emoji[1][0] !== ":") {
nodes.push(document.createTextNode(emoji[1])); nodes.push(document.createTextNode(emoji[1]));
} else { } else {
nodes.push( nodes.push(
Object.assign(new Image(), { Object.assign(new Image(), {
src: `/emojis/${name}${emoji[2]}`, src: `/emojis/${name}${emoji[2]}`,
className: "emoji", className: "emoji",
alt: name, alt: name,
}) })
); );
} }
} }
last = index + match.length; last = index + match.length;
}); });
if (last < text.length) nodes.push(document.createTextNode(text.slice(last))); if (last < text.length) nodes.push(document.createTextNode(text.slice(last)));
return nodes; return nodes;
} }
const emojis = {}; const emojis = {};
export const emojisLoaded = Promise.all([ export const emojisLoaded = Promise.all([
fetch("/emojis/unicode.json") fetch("/emojis/unicode.json")
.then((e) => e.json()) .then((e) => e.json())
.then((a) => { .then((a) => {
for (let e of a) { for (let e of a) {
emojis[e[0][0]] = emojis[e[0][0]] || []; emojis[e[0][0]] = emojis[e[0][0]] || [];
emojis[e[0][0]].push([e[0], e[1], null, e[0]]); emojis[e[0][0]].push([e[0], e[1], null, e[0]]);
} }
}), }),
fetch("/emojos") fetch("/emojos")
.then((e) => e.json()) .then((e) => e.json())
.then((a) => { .then((a) => {
for (let e of a) { for (let e of a) {
const name = e.slice(0, -4), const name = e.slice(0, -4),
lower = name.toLowerCase(); lower = name.toLowerCase();
emojis[lower[0]] = emojis[lower[0]] || []; emojis[lower[0]] = emojis[lower[0]] || [];
emojis[lower[0]].push([name, ":" + name + ":", e.slice(-4), lower]); emojis[lower[0]].push([name, ":" + name + ":", e.slice(-4), lower]);
} }
}), }),
]); ]);
export async function findEmojis(search) { export async function findEmojis(search) {
await emojisLoaded; await emojisLoaded;
let groups = [[], []]; let groups = [[], []];
if (search.length < 1) { if (search.length < 1) {
for (let letter of Object.keys(emojis).sort()) for (let letter of Object.keys(emojis).sort())
for (let emoji of emojis[letter]) { for (let emoji of emojis[letter]) {
(emoji[1][0] === ":" ? groups[0] : groups[1]).push(emoji); (emoji[1][0] === ":" ? groups[0] : groups[1]).push(emoji);
} }
} else { } else {
search = search.toLowerCase(); search = search.toLowerCase();
for (let emoji of emojis[search[0]]) { for (let emoji of emojis[search[0]]) {
if (search.length == 1 || emoji[3].startsWith(search)) { if (search.length == 1 || emoji[3].startsWith(search)) {
(emoji[1][0] === ":" ? groups[0] : groups[1]).push(emoji); (emoji[1][0] === ":" ? groups[0] : groups[1]).push(emoji);
} }
} }
} }
return [...groups[1], ...groups[0]]; return [...groups[1], ...groups[0]];
} }

View File

@ -1,95 +1,93 @@
import { joinSession } from "./watch-session.mjs?v=4b61c4"; import { joinSession } from "./watch-session.mjs?v=bfdcf2";
import { state } from "./state.mjs"; import { state } from "./state.mjs";
/** /**
* @param {HTMLInputElement} field * @param {HTMLInputElement} field
*/ */
const loadNickname = (field) => { const loadNickname = (field) => {
try { try {
const savedNickname = localStorage.getItem("watch-party-nickname"); const savedNickname = localStorage.getItem("watch-party-nickname");
field.value = savedNickname; field.value = savedNickname;
} catch (_err) { } catch (_err) {
// Sometimes localStorage is blocked from use // Sometimes localStorage is blocked from use
} }
}; };
/** /**
* @param {HTMLInputElement} field * @param {HTMLInputElement} field
*/ */
const saveNickname = (field) => { const saveNickname = (field) => {
try { try {
localStorage.setItem("watch-party-nickname", field.value); localStorage.setItem("watch-party-nickname", field.value);
} catch (_err) { } catch (_err) {
// see loadNickname // see loadNickname
} }
}; };
/** /**
* @param {HTMLInputElement} field * @param {HTMLInputElement} field
*/ */
const loadColour = (field) => { const loadColour = (field) => {
try { try {
const savedColour = localStorage.getItem("watch-party-colour"); const savedColour = localStorage.getItem("watch-party-colour");
if (savedColour != null && savedColour != "") { if (savedColour != null && savedColour != "") {
field.value = savedColour; field.value = savedColour;
} }
} catch (_err) { } catch (_err) {
// Sometimes localStorage is blocked from use // Sometimes localStorage is blocked from use
} }
}; };
/** /**
* @param {HTMLInputElement} field * @param {HTMLInputElement} field
*/ */
const saveColour = (field) => { const saveColour = (field) => {
try { try {
localStorage.setItem("watch-party-colour", field.value); localStorage.setItem("watch-party-colour", field.value);
} catch (_err) { } catch (_err) {
// see loadColour // see loadColour
} }
}; };
const displayPostCreateMessage = () => { const displayPostCreateMessage = () => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
if (params.get("created") == "true") { if (params.get("created") == "true") {
document.querySelector("#post-create-message").style["display"] = "block"; document.querySelector("#post-create-message").style["display"] = "block";
window.history.replaceState({}, document.title, `/${window.location.hash}`); window.history.replaceState({}, document.title, `/${window.location.hash}`);
return true; }
} };
return false;
}; export const setupJoinSessionForm = () => {
displayPostCreateMessage();
export const setupJoinSessionForm = () => {
const created = displayPostCreateMessage(); const form = document.querySelector("#join-session-form");
const nickname = form.querySelector("#join-session-nickname");
const form = document.querySelector("#join-session-form"); const colour = form.querySelector("#join-session-colour");
const nickname = form.querySelector("#join-session-nickname"); const sessionId = form.querySelector("#join-session-id");
const colour = form.querySelector("#join-session-colour"); const button = form.querySelector("#join-session-button");
const sessionId = form.querySelector("#join-session-id");
const button = form.querySelector("#join-session-button"); loadNickname(nickname);
loadColour(colour);
loadNickname(nickname);
loadColour(colour); if (window.location.hash.match(/#[0-9a-f\-]+/)) {
sessionId.value = window.location.hash.substring(1);
if (window.location.hash.match(/#[0-9a-f\-]+/)) { }
sessionId.value = window.location.hash.substring(1);
} form.addEventListener("submit", async (event) => {
event.preventDefault();
form.addEventListener("submit", async (event) => {
event.preventDefault(); button.disabled = true;
button.disabled = true; saveNickname(nickname);
saveColour(colour);
saveNickname(nickname); try {
saveColour(colour); state().nickname = nickname.value;
try { state().sessionId = sessionId.value;
state().nickname = nickname.value; state().colour = colour.value.replace(/^#/, "");
state().sessionId = sessionId.value; await joinSession();
state().colour = colour.value.replace(/^#/, ""); } catch (e) {
await joinSession(created); alert(e.message);
} catch (e) { button.disabled = false;
alert(e.message); }
button.disabled = false; });
} };
});
};

View File

@ -1,121 +1,121 @@
import { joinSession } from "./watch-session.mjs?v=4b61c4"; import { joinSession } from "./watch-session.mjs?v=bfdcf2";
import { state } from "./state.mjs"; import { state } from "./state.mjs";
export async function linkify( export async function linkify(
text, text,
next = async (t) => [document.createTextNode(t)] next = async (t) => [document.createTextNode(t)]
) { ) {
let last = 0; let last = 0;
let nodes = []; let nodes = [];
let promise = Promise.resolve(); let promise = Promise.resolve();
// matching non-urls isn't a problem, we use the browser's url parser to filter them out // matching non-urls isn't a problem, we use the browser's url parser to filter them out
text.replace( text.replace(
/[^:/?#\s]+:\/\/\S+/g, /[^:/?#\s]+:\/\/\S+/g,
(match, index) => (match, index) =>
(promise = promise.then(async () => { (promise = promise.then(async () => {
if (last <= index) nodes.push(...(await next(text.slice(last, index)))); if (last <= index) nodes.push(...(await next(text.slice(last, index))));
let url; let url;
try { try {
url = new URL(match); url = new URL(match);
if (url.protocol === "javascript:") throw new Error(); if (url.protocol === "javascript:") throw new Error();
} catch (e) { } catch (e) {
url = null; url = null;
} }
if (!url) { if (!url) {
nodes.push(...(await next(match))); nodes.push(...(await next(match)));
} else { } else {
let s; let s;
if ( if (
url.origin == location.origin && url.origin == location.origin &&
url.pathname == "/" && url.pathname == "/" &&
url.hash.length > 1 url.hash.length > 1
) { ) {
nodes.push( nodes.push(
Object.assign(document.createElement("a"), { Object.assign(document.createElement("a"), {
textContent: "Join Session", textContent: "Join Session",
className: "chip join-chip", className: "chip join-chip",
onclick: () => { onclick: () => {
state().sessionId = url.hash.substring(1); state().sessionId = url.hash.substring(1);
joinSession(); joinSession();
}, },
}) })
); );
} else if ( } else if (
url.hostname == "xiv.st" && url.hostname == "xiv.st" &&
(s = url.pathname.match(/(\d?\d).?(\d\d)/)) (s = url.pathname.match(/(\d?\d).?(\d\d)/))
) { ) {
if (s) { if (s) {
const date = new Date(); const date = new Date();
date.setUTCSeconds(0); date.setUTCSeconds(0);
date.setUTCMilliseconds(0); date.setUTCMilliseconds(0);
date.setUTCHours(s[1]), date.setUTCMinutes(s[2]); date.setUTCHours(s[1]), date.setUTCMinutes(s[2]);
nodes.push( nodes.push(
Object.assign(document.createElement("a"), { Object.assign(document.createElement("a"), {
href: url.href, href: url.href,
textContent: date.toLocaleString([], { textContent: date.toLocaleString([], {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
}), }),
className: "chip time-chip", className: "chip time-chip",
target: "_blank", target: "_blank",
}) })
); );
} }
} else { } else {
nodes.push( nodes.push(
Object.assign(document.createElement("a"), { Object.assign(document.createElement("a"), {
href: url.href, href: url.href,
textContent: url.href, textContent: url.href,
target: "_blank", target: "_blank",
}) })
); );
} }
} }
last = index + match.length; last = index + match.length;
})) }))
); );
await promise; await promise;
if (last < text.length) nodes.push(...(await next(text.slice(last)))); if (last < text.length) nodes.push(...(await next(text.slice(last))));
return nodes; return nodes;
} }
const emojis = {}; const emojis = {};
export const emojisLoaded = Promise.all([ export const emojisLoaded = Promise.all([
fetch("/emojis") fetch("/emojis")
.then((e) => e.json()) .then((e) => e.json())
.then((a) => { .then((a) => {
for (let e of a) { for (let e of a) {
const name = e.slice(0, -4), const name = e.slice(0, -4),
lower = name.toLowerCase(); lower = name.toLowerCase();
emojis[lower[0]] = emojis[lower[0]] || []; emojis[lower[0]] = emojis[lower[0]] || [];
emojis[lower[0]].push([name, ":" + name + ":", e.slice(-4), lower]); emojis[lower[0]].push([name, ":" + name + ":", e.slice(-4), lower]);
} }
}), }),
fetch("/emojis/unicode.json") fetch("/emojis/unicode.json")
.then((e) => e.json()) .then((e) => e.json())
.then((a) => { .then((a) => {
for (let e of a) { for (let e of a) {
emojis[e[0][0]] = emojis[e[0][0]] || []; emojis[e[0][0]] = emojis[e[0][0]] || [];
emojis[e[0][0]].push([e[0], e[1], null, e[0]]); emojis[e[0][0]].push([e[0], e[1], null, e[0]]);
} }
}), }),
]); ]);
export async function findEmojis(search) { export async function findEmojis(search) {
await emojisLoaded; await emojisLoaded;
let groups = [[], []]; let groups = [[], []];
if (search.length < 1) { if (search.length < 1) {
for (let letter of Object.keys(emojis).sort()) for (let letter of Object.keys(emojis).sort())
for (let emoji of emojis[letter]) { for (let emoji of emojis[letter]) {
(emoji[1][0] === ":" ? groups[0] : groups[1]).push(emoji); (emoji[1][0] === ":" ? groups[0] : groups[1]).push(emoji);
} }
} else { } else {
search = search.toLowerCase(); search = search.toLowerCase();
for (let emoji of emojis[search[0]]) { for (let emoji of emojis[search[0]]) {
if (search.length == 1 || emoji[3].startsWith(search)) { if (search.length == 1 || emoji[3].startsWith(search)) {
(emoji[1][0] === ":" ? groups[0] : groups[1]).push(emoji); (emoji[1][0] === ":" ? groups[0] : groups[1]).push(emoji);
} }
} }
} }
return [...groups[0], ...groups[1]]; return [...groups[0], ...groups[1]];
} }

View File

@ -77,3 +77,4 @@ export const pling = () => {
thirdBeep.start(ctx.currentTime + thirdBeepOffset); thirdBeep.start(ctx.currentTime + thirdBeepOffset);
thirdBeep.stop(ctx.currentTime + (thirdBeepOffset + duration)); thirdBeep.stop(ctx.currentTime + (thirdBeepOffset + duration));
}; };

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,71 +1,71 @@
export default class ReconnectingWebSocket { export default class ReconnectingWebSocket {
constructor(url) { constructor(url) {
if (url instanceof URL) { if (url instanceof URL) {
this.url = url; this.url = url;
} else { } else {
this.url = new URL(url); this.url = new URL(url);
} }
this.connected = false; this.connected = false;
this._eventTarget = new EventTarget(); this._eventTarget = new EventTarget();
this._backoff = 250; // milliseconds, doubled before use this._backoff = 250; // milliseconds, doubled before use
this._lastConnect = 0; this._lastConnect = 0;
this._socket = null; this._socket = null;
this._unsent = []; this._unsent = [];
this._closing = false; this._closing = false;
this._connect(true); this._connect(true);
} }
_connect(first) { _connect(first) {
if (this._socket) if (this._socket)
try { try {
this._socket.close(); this._socket.close();
} catch (e) {} } catch (e) {}
try { try {
this._socket = new WebSocket(this.url.href); this._socket = new WebSocket(this.url.href);
} catch (e) { } catch (e) {
this._reconnecting = false; this._reconnecting = false;
return this._reconnect(); return this._reconnect();
} }
this._socket.addEventListener("close", () => this._reconnect()); this._socket.addEventListener("close", () => this._reconnect());
this._socket.addEventListener("error", () => this._reconnect()); this._socket.addEventListener("error", () => this._reconnect());
this._socket.addEventListener("message", ({ data }) => { this._socket.addEventListener("message", ({ data }) => {
this._eventTarget.dispatchEvent(new MessageEvent("message", { data })); this._eventTarget.dispatchEvent(new MessageEvent("message", { data }));
}); });
this._socket.addEventListener("open", (e) => { this._socket.addEventListener("open", (e) => {
if (first) this._eventTarget.dispatchEvent(new Event("open")); if (first) this._eventTarget.dispatchEvent(new Event("open"));
if (this._reconnecting) if (this._reconnecting)
this._eventTarget.dispatchEvent(new Event("reconnected")); this._eventTarget.dispatchEvent(new Event("reconnected"));
this._reconnecting = false; this._reconnecting = false;
this._backoff = 250; this._backoff = 250;
this.connected = true; this.connected = true;
while (this._unsent.length > 0) this._socket.send(this._unsent.shift()); while (this._unsent.length > 0) this._socket.send(this._unsent.shift());
}); });
} }
_reconnect() { _reconnect() {
if (this._closing) return; if (this._closing) return;
if (this._reconnecting) return; if (this._reconnecting) return;
this._eventTarget.dispatchEvent(new Event("reconnecting")); this._eventTarget.dispatchEvent(new Event("reconnecting"));
this._reconnecting = true; this._reconnecting = true;
this.connected = false; this.connected = false;
this._backoff *= 2; // exponential backoff this._backoff *= 2; // exponential backoff
setTimeout(() => { setTimeout(() => {
this._connect(); this._connect();
}, Math.floor(this._backoff + Math.random() * this._backoff * 0.25 - this._backoff * 0.125)); }, Math.floor(this._backoff + Math.random() * this._backoff * 0.25 - this._backoff * 0.125));
} }
send(message) { send(message) {
if (this.connected) { if (this.connected) {
this._socket.send(message); this._socket.send(message);
} else { } else {
this._unsent.push(message); this._unsent.push(message);
} }
} }
close() { close() {
this._closing = true; this._closing = true;
this._socket.close(); this._socket.close();
} }
addEventListener(...a) { addEventListener(...a) {
return this._eventTarget.addEventListener(...a); return this._eventTarget.addEventListener(...a);
} }
removeEventListener(...a) { removeEventListener(...a) {
return this._eventTarget.removeEventListener(...a); return this._eventTarget.removeEventListener(...a);
} }
} }

View File

@ -1,112 +1,163 @@
import Plyr from "./plyr-3.7.3.min.esm.js"; const loadVolume = () => {
try {
/** const savedVolume = localStorage.getItem("watch-party-volume");
* @param {string} videoUrl if (savedVolume != null && savedVolume != "") {
* @param {{name: string, url: string}[]} subtitles return +savedVolume;
*/ }
const createVideoElement = (videoUrl, subtitles, created) => { } catch (_err) {
const oldVideo = document.getElementById(".plyr"); // Sometimes localStorage is blocked from use
if (oldVideo) { }
oldVideo.remove(); // default
} return 0.5;
const video = document.createElement("video"); };
video.id = "video";
video.crossOrigin = "anonymous"; /**
* @param {number} volume
const source = document.createElement("source"); */
source.src = videoUrl; const saveVolume = (volume) => {
try {
video.appendChild(source); localStorage.setItem("watch-party-volume", volume);
} catch (_err) {
for (const { name, url } of subtitles) { // see loadVolume
const track = document.createElement("track"); }
track.label = name; };
track.srclang = "xx-" + name.toLowerCase();
track.src = url; const loadCaptionTrack = () => {
track.kind = "captions"; try {
video.appendChild(track); const savedTrack = localStorage.getItem("watch-party-captions");
} if (savedTrack != null && savedTrack != "") {
return +savedTrack;
const videoContainer = document.querySelector("#video-container"); }
videoContainer.style.display = "block"; } catch (_err) {
videoContainer.appendChild(video); // Sometimes localStorage is blocked from use
}
const player = new Plyr(video, { // default
clickToPlay: false, return -1;
settings: ["captions", "quality"], };
autopause: false,
}); /**
player.elements.controls.insertAdjacentHTML( * @param {number} track
"afterbegin", */
`<button type="button" aria-pressed="false" class="plyr__controls__item plyr__control lock-controls"><svg aria-hidden="true" focusable="false" viewBox="0 0 24 24"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zM9 8V6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9z"></path></svg><span class="label--pressed plyr__sr-only">Unlock controls</span><span class="label--not-pressed plyr__sr-only">Lock controls</span></button>` const saveCaptionsTrack = (track) => {
); try {
const lockButton = player.elements.controls.children[0]; localStorage.setItem("watch-party-captions", track);
let controlsEnabled = created; } catch (_err) {
const setControlsEnabled = (enabled) => { // see loadCaptionsTrack
controlsEnabled = enabled; }
lockButton.setAttribute("aria-pressed", enabled); };
lockButton.classList.toggle("plyr__control--pressed", enabled);
player.elements.buttons.play[0].disabled = /**
player.elements.buttons.play[1].disabled = * @param {string} videoUrl
player.elements.inputs.seek.disabled = * @param {{name: string, url: string}[]} subtitles
!enabled; */
if (!enabled) { const createVideoElement = (videoUrl, subtitles) => {
// enable media button support const oldVideo = document.getElementById("video");
navigator.mediaSession.setActionHandler("play", null); if (oldVideo) {
navigator.mediaSession.setActionHandler("pause", null); oldVideo.remove();
navigator.mediaSession.setActionHandler("stop", null); }
navigator.mediaSession.setActionHandler("seekbackward", null); const video = document.createElement("video");
navigator.mediaSession.setActionHandler("seekforward", null); video.id = "video";
navigator.mediaSession.setActionHandler("seekto", null); video.controls = true;
navigator.mediaSession.setActionHandler("previoustrack", null); video.autoplay = false;
navigator.mediaSession.setActionHandler("nexttrack", null); video.volume = loadVolume();
} else { video.crossOrigin = "anonymous";
// disable media button support by ignoring the events
navigator.mediaSession.setActionHandler("play", () => {}); video.addEventListener("volumechange", async () => {
navigator.mediaSession.setActionHandler("pause", () => {}); saveVolume(video.volume);
navigator.mediaSession.setActionHandler("stop", () => {}); });
navigator.mediaSession.setActionHandler("seekbackward", () => {});
navigator.mediaSession.setActionHandler("seekforward", () => {}); const source = document.createElement("source");
navigator.mediaSession.setActionHandler("seekto", () => {}); source.src = videoUrl;
navigator.mediaSession.setActionHandler("previoustrack", () => {});
navigator.mediaSession.setActionHandler("nexttrack", () => {}); video.appendChild(source);
}
}; const storedTrack = loadCaptionTrack();
setControlsEnabled(controlsEnabled); let id = 0;
lockButton.addEventListener("click", () => for (const { name, url } of subtitles) {
setControlsEnabled(!controlsEnabled) const track = document.createElement("track");
); track.label = name;
window.__plyr = player; track.src = url;
track.kind = "captions";
return player;
}; if (id == storedTrack || storedTrack == -1) {
track.default = true;
/** }
* @param {string} videoUrl
* @param {{name: string, url: string}[]} subtitles video.appendChild(track);
* @param {number} currentTime id++;
* @param {boolean} playing }
*/
export const setupVideo = async ( video.textTracks.addEventListener("change", async () => {
videoUrl, let id = 0;
subtitles, for (const track of video.textTracks) {
currentTime, if (track.mode != "disabled") {
playing, saveCaptionsTrack(id);
created return;
) => { }
document.querySelector("#pre-join-controls").style["display"] = "none"; id++;
const player = createVideoElement(videoUrl, subtitles, created); }
player.currentTime = currentTime / 1000.0; saveCaptionsTrack(-1);
});
try {
if (playing) { // watch for attribute changes on the video object to detect hiding/showing of controls
player.play(); // as far as i can tell this is the least hacky solutions to get control visibility change events
} else { const observer = new MutationObserver(async (mutations) => {
player.pause(); for (const mutation of mutations) {
} if (mutation.attributeName == "controls") {
} catch (err) { if (video.controls) {
// Auto-play is probably disabled, we should uhhhhhhh do something about it // enable media button support
} navigator.mediaSession.setActionHandler("play", null);
navigator.mediaSession.setActionHandler("pause", null);
return player; navigator.mediaSession.setActionHandler("stop", null);
}; navigator.mediaSession.setActionHandler("seekbackward", null);
navigator.mediaSession.setActionHandler("seekforward", null);
navigator.mediaSession.setActionHandler("seekto", null);
navigator.mediaSession.setActionHandler("previoustrack", null);
navigator.mediaSession.setActionHandler("nexttrack", null);
} else {
// disable media button support by ignoring the events
navigator.mediaSession.setActionHandler("play", () => {});
navigator.mediaSession.setActionHandler("pause", () => {});
navigator.mediaSession.setActionHandler("stop", () => {});
navigator.mediaSession.setActionHandler("seekbackward", () => {});
navigator.mediaSession.setActionHandler("seekforward", () => {});
navigator.mediaSession.setActionHandler("seekto", () => {});
navigator.mediaSession.setActionHandler("previoustrack", () => {});
navigator.mediaSession.setActionHandler("nexttrack", () => {});
}
return;
}
}
});
observer.observe(video, { attributes: true });
return video;
};
/**
* @param {string} videoUrl
* @param {{name: string, url: string}[]} subtitles
* @param {number} currentTime
* @param {boolean} playing
*/
export const setupVideo = async (videoUrl, subtitles, currentTime, playing) => {
document.querySelector("#pre-join-controls").style["display"] = "none";
const video = createVideoElement(videoUrl, subtitles);
const videoContainer = document.querySelector("#video-container");
videoContainer.style.display = "block";
videoContainer.appendChild(video);
video.currentTime = currentTime / 1000.0;
try {
if (playing) {
await video.play();
} else {
video.pause();
}
} catch (err) {
// Auto-play is probably disabled, we should uhhhhhhh do something about it
}
return video;
};

View File

@ -1,270 +1,276 @@
import { setupVideo } from "./video.mjs?v=4b61c4"; import { setupVideo } from "./video.mjs?v=bfdcf2";
import { import {
setupChat, setupChat,
logEventToChat, logEventToChat,
updateViewerList, updateViewerList,
printChatMessage, printChatMessage,
} from "./chat.mjs?v=4b61c4"; } from "./chat.mjs?v=bfdcf2";
import ReconnectingWebSocket from "./reconnecting-web-socket.mjs"; import ReconnectingWebSocket from "./reconnecting-web-socket.mjs";
import { state } from "./state.mjs"; import { state } from "./state.mjs";
let player;
/** /**
* @param {string} sessionId * @param {string} sessionId
* @param {string} nickname * @param {string} nickname
* @returns {ReconnectingWebSocket} * @returns {ReconnectingWebSocket}
*/ */
const createWebSocket = () => { const createWebSocket = () => {
const wsUrl = new URL( const wsUrl = new URL(
`/sess/${state().sessionId}/subscribe` + `/sess/${state().sessionId}/subscribe` +
`?nickname=${encodeURIComponent(state().nickname)}` + `?nickname=${encodeURIComponent(state().nickname)}` +
`&colour=${encodeURIComponent(state().colour)}`, `&colour=${encodeURIComponent(state().colour)}`,
window.location.href window.location.href
); );
wsUrl.protocol = "ws" + window.location.protocol.slice(4); wsUrl.protocol = "ws" + window.location.protocol.slice(4);
const socket = new ReconnectingWebSocket(wsUrl); const socket = new ReconnectingWebSocket(wsUrl);
return socket; return socket;
}; };
let outgoingDebounce = false; let outgoingDebounce = false;
let outgoingDebounceCallbackId = null; let outgoingDebounceCallbackId = null;
export const setDebounce = () => { export const setDebounce = () => {
outgoingDebounce = true; outgoingDebounce = true;
if (outgoingDebounceCallbackId) { if (outgoingDebounceCallbackId) {
cancelIdleCallback(outgoingDebounceCallbackId); cancelIdleCallback(outgoingDebounceCallbackId);
outgoingDebounceCallbackId = null; outgoingDebounceCallbackId = null;
} }
outgoingDebounceCallbackId = setTimeout(() => { outgoingDebounceCallbackId = setTimeout(() => {
outgoingDebounce = false; outgoingDebounce = false;
}, 500); }, 500);
}; };
export const setVideoTime = (time) => { export const setVideoTime = (time, video = null) => {
const timeSecs = time / 1000.0; if (video == null) {
if (Math.abs(player.currentTime - timeSecs) > 0.5) { video = document.querySelector("video");
player.currentTime = timeSecs; }
}
}; const timeSecs = time / 1000.0;
if (Math.abs(video.currentTime - timeSecs) > 0.5) {
export const setPlaying = async (playing) => { video.currentTime = timeSecs;
if (playing) { }
await player.play(); };
} else {
player.pause(); export const setPlaying = async (playing, video = null) => {
} if (video == null) {
}; video = document.querySelector("video");
}
/**
* @param {HTMLVideoElement} video if (playing) {
* @param {ReconnectingWebSocket} socket await video.play();
*/ } else {
const setupIncomingEvents = (player, socket) => { video.pause();
socket.addEventListener("message", async (messageEvent) => { }
try { };
const event = JSON.parse(messageEvent.data);
if (!event.reflected) { /**
switch (event.op) { * @param {HTMLVideoElement} video
case "SetPlaying": * @param {ReconnectingWebSocket} socket
setDebounce(); */
const setupIncomingEvents = (video, socket) => {
if (event.data.playing) { socket.addEventListener("message", async (messageEvent) => {
await player.play(); try {
} else { const event = JSON.parse(messageEvent.data);
player.pause(); if (!event.reflected) {
} switch (event.op) {
case "SetPlaying":
setVideoTime(event.data.time); setDebounce();
break;
case "SetTime": if (event.data.playing) {
setDebounce(); await video.play();
setVideoTime(event.data); } else {
break; video.pause();
case "UpdateViewerList": }
updateViewerList(event.data);
break; setVideoTime(event.data.time, video);
} break;
} case "SetTime":
setDebounce();
logEventToChat(event); setVideoTime(event.data, video);
} catch (_err) {} break;
}); case "UpdateViewerList":
}; updateViewerList(event.data);
break;
/** }
* @param {Plyr} player }
* @param {ReconnectingWebSocket} socket
*/ logEventToChat(event);
const setupOutgoingEvents = (player, socket) => { } catch (_err) {}
const currentVideoTime = () => (player.currentTime * 1000) | 0; });
};
player.on("pause", async () => {
if (outgoingDebounce || player.elements.inputs.seek.disabled) { /**
return; * @param {HTMLVideoElement} video
} * @param {ReconnectingWebSocket} socket
*/
// don't send a pause event for the video ending const setupOutgoingEvents = (video, socket) => {
if (player.currentTime == player.duration) { const currentVideoTime = () => (video.currentTime * 1000) | 0;
return;
} video.addEventListener("pause", async (event) => {
if (outgoingDebounce || !video.controls) {
socket.send( return;
JSON.stringify({ }
op: "SetPlaying",
data: { // don't send a pause event for the video ending
playing: false, if (video.currentTime == video.duration) {
time: currentVideoTime(), return;
}, }
})
); socket.send(
}); JSON.stringify({
op: "SetPlaying",
player.on("play", () => { data: {
if (outgoingDebounce || player.elements.inputs.seek.disabled) { playing: false,
return; time: currentVideoTime(),
} },
})
socket.send( );
JSON.stringify({ });
op: "SetPlaying",
data: { video.addEventListener("play", (event) => {
playing: true, if (outgoingDebounce || !video.controls) {
time: currentVideoTime(), return;
}, }
})
); socket.send(
}); JSON.stringify({
op: "SetPlaying",
let firstSeekComplete = false; data: {
player.on("seeked", async (event) => { playing: true,
if (!firstSeekComplete) { time: currentVideoTime(),
// The first seeked event is performed by the browser when the video is loading },
firstSeekComplete = true; })
return; );
} });
if (outgoingDebounce || player.elements.inputs.seek.disabled) { let firstSeekComplete = false;
return; video.addEventListener("seeked", async (event) => {
} if (!firstSeekComplete) {
// The first seeked event is performed by the browser when the video is loading
socket.send( firstSeekComplete = true;
JSON.stringify({ return;
op: "SetTime", }
data: {
to: currentVideoTime(), if (outgoingDebounce || !video.controls) {
}, return;
}) }
);
}); socket.send(
}; JSON.stringify({
op: "SetTime",
export const joinSession = async (created) => { data: {
if (state().activeSession) { to: currentVideoTime(),
if (state().activeSession === state().sessionId) { },
// we are already in this session, dont rejoin })
return; );
} });
// we are joining a new session from an existing session };
const messageContent = document.createElement("span");
messageContent.appendChild(document.createTextNode("joining new session ")); export const joinSession = async () => {
messageContent.appendChild(document.createTextNode(state().sessionId)); if (state().activeSession) {
if (state().activeSession === state().sessionId) {
printChatMessage("join-session", "watch-party", "#fffff", messageContent); // we are already in this session, dont rejoin
} return;
state().activeSession = state().sessionId; }
// we are joining a new session from an existing session
// try { // we are handling errors in the join form. const messageContent = document.createElement("span");
const genericConnectionError = new Error( messageContent.appendChild(document.createTextNode("joining new session "));
"There was an issue getting the session information." messageContent.appendChild(document.createTextNode(state().sessionId));
);
window.location.hash = state().sessionId; printChatMessage("join-session", "watch-party", "#fffff", messageContent);
let response, video_url, subtitle_tracks, current_time_ms, is_playing; }
try { state().activeSession = state().sessionId;
response = await fetch(`/sess/${state().sessionId}`);
} catch (e) { // try { // we are handling errors in the join form.
console.error(e); const genericConnectionError = new Error(
throw genericConnectionError; "There was an issue getting the session information."
} );
if (!response.ok) { window.location.hash = state().sessionId;
let error; let response, video_url, subtitle_tracks, current_time_ms, is_playing;
try { try {
({ error } = await response.json()); response = await fetch(`/sess/${state().sessionId}`);
if (!error) throw new Error(); } catch (e) {
} catch (e) { console.error(e);
console.error(e); throw genericConnectionError;
throw genericConnectionError; }
} if (!response.ok) {
throw new Error(error); let error;
} try {
try { ({ error } = await response.json());
({ video_url, subtitle_tracks, current_time_ms, is_playing } = if (!error) throw new Error();
await response.json()); } catch (e) {
} catch (e) { console.error(e);
console.error(e); throw genericConnectionError;
throw genericConnectionError; }
} throw new Error(error);
}
if (state().socket) { try {
state().socket.close(); ({ video_url, subtitle_tracks, current_time_ms, is_playing } =
state().socket = null; await response.json());
} } catch (e) {
const socket = createWebSocket(); console.error(e);
state().socket = socket; throw genericConnectionError;
socket.addEventListener("open", async () => { }
player = await setupVideo(
video_url, if (state().socket) {
subtitle_tracks, state().socket.close();
current_time_ms, state().socket = null;
is_playing, }
created const socket = createWebSocket();
); state().socket = socket;
socket.addEventListener("open", async () => {
player.on("canplay", () => { const video = await setupVideo(
sync(); video_url,
}); subtitle_tracks,
current_time_ms,
setupOutgoingEvents(player, socket); is_playing
setupIncomingEvents(player, socket); );
setupChat(socket);
}); // TODO: Allow the user to set this somewhere
socket.addEventListener("reconnecting", (e) => { let defaultAllowControls = false;
console.log("Reconnecting..."); try {
}); defaultAllowControls = localStorage.getItem(
socket.addEventListener("reconnected", (e) => { "watch-party-default-allow-controls"
console.log("Reconnected."); );
}); } catch (_err) {}
//} catch (e) {
// alert(e.message) // By default, we should disable video controls if the video is already playing.
//} // This solves an issue where Safari users join and seek to 00:00:00 because of
}; // outgoing events.
if (current_time_ms != 0 || !defaultAllowControls) {
/** video.controls = false;
* @param {string} videoUrl }
* @param {Array} subtitleTracks
*/ setupOutgoingEvents(video, socket);
export const createSession = async (videoUrl, subtitleTracks) => { setupIncomingEvents(video, socket);
const { id } = await fetch("/start_session", { setupChat(socket);
method: "POST", });
headers: { "Content-Type": "application/json" }, socket.addEventListener("reconnecting", (e) => {
body: JSON.stringify({ console.log("Reconnecting...");
video_url: videoUrl, });
subtitle_tracks: subtitleTracks, socket.addEventListener("reconnected", (e) => {
}), console.log("Reconnected.");
}).then((r) => r.json()); });
//} catch (e) {
window.location = `/?created=true#${id}`; // alert(e.message)
}; //}
};
export const sync = async () => {
setDebounce(); /**
await setPlaying(false); * @param {string} videoUrl
const { current_time_ms, is_playing } = await fetch( * @param {Array} subtitleTracks
`/sess/${state().sessionId}` */
).then((r) => r.json()); export const createSession = async (videoUrl, subtitleTracks) => {
const { id } = await fetch("/start_session", {
setDebounce(); method: "POST",
setVideoTime(current_time_ms); headers: { "Content-Type": "application/json" },
if (is_playing) await setPlaying(is_playing); body: JSON.stringify({
}; video_url: videoUrl,
subtitle_tracks: subtitleTracks,
}),
}).then((r) => r.json());
window.location = `/?created=true#${id}`;
};

View File

@ -1,11 +1,11 @@
import { setupJoinSessionForm } from "./lib/join-session.mjs?v=4b61c4"; import { setupJoinSessionForm } from "./lib/join-session.mjs?v=bfdcf2";
const main = () => { const main = () => {
setupJoinSessionForm(); setupJoinSessionForm();
}; };
if (document.readyState === "complete") { if (document.readyState === "complete") {
main(); main();
} else { } else {
document.addEventListener("DOMContentLoaded", main); document.addEventListener("DOMContentLoaded", main);
} }

View File

@ -1,435 +1,388 @@
*, *,
*:before, *:before,
*:after { *:after {
box-sizing: border-box; box-sizing: border-box;
} }
:root { :root {
--bg-rgb: 28, 23, 36; --bg-rgb: 28, 23, 36;
--fg-rgb: 234, 234, 248; --fg-rgb: 234, 234, 248;
--accent-rgb: 181, 127, 220; --accent-rgb: 181, 127, 220;
--fg: rgb(var(--fg-rgb)); --fg: rgb(var(--fg-rgb));
--bg: rgb(var(--bg-rgb)); --bg: rgb(var(--bg-rgb));
--default-user-color: rgb(126, 208, 255); --default-user-color: rgb(126, 208, 255);
--accent: rgb(var(--accent-rgb)); --accent: rgb(var(--accent-rgb));
--fg-transparent: rgba(var(--fg-rgb), 0.25); --fg-transparent: rgba(var(--fg-rgb), 0.25);
--bg-transparent: rgba(var(--bg-rgb), 0.25); --bg-transparent: rgba(var(--bg-rgb), 0.25);
--autocomplete-bg: linear-gradient( --autocomplete-bg: linear-gradient(
var(--fg-transparent), var(--fg-transparent),
var(--fg-transparent) var(--fg-transparent)
), ),
linear-gradient(var(--bg), var(--bg)); linear-gradient(var(--bg), var(--bg));
--chip-bg: linear-gradient( --chip-bg: linear-gradient(
var(--accent-transparent), var(--accent-transparent),
var(--accent-transparent) var(--accent-transparent)
), ),
linear-gradient(var(--bg), var(--bg)); linear-gradient(var(--bg), var(--bg));
--accent-transparent: rgba(var(--accent-rgb), 0.25); --accent-transparent: rgba(var(--accent-rgb), 0.25);
--plyr-color-main: var(--accent); }
--plyr-control-radius: 6px;
--plyr-menu-radius: 6px; html {
--plyr-menu-background: var(--autocomplete-bg); background-color: var(--bg);
--plyr-menu-color: var(--fg); color: var(--fg);
--plyr-menu-arrow-color: var(--fg); font-size: 1.125rem;
--plyr-menu-back-border-color: var(--fg-transparent); font-family: sans-serif;
--plyr-menu-back-border-shadow-color: transparent; }
}
html,
html { body {
background-color: var(--bg); margin: 0;
color: var(--fg); padding: 0;
font-size: 1.125rem; overflow: hidden;
font-family: sans-serif; overscroll-behavior: none;
} width: 100%;
height: 100%;
html, }
body {
margin: 0; body {
padding: 0; display: flex;
overflow: hidden; flex-direction: column;
overscroll-behavior: none; }
width: 100%;
height: 100%; video {
} display: block;
width: 100%;
body { height: 100%;
display: flex; object-fit: contain;
flex-direction: column; }
}
#video-container {
.lock-controls.plyr__control--pressed svg { flex-grow: 0;
opacity: 0.5; flex-shrink: 1;
} display: none;
}
.plyr {
width: 100%; a {
height: 100%; color: var(--accent);
} }
.plyr__menu__container { .chip {
--plyr-video-control-background-hover: var(--fg-transparent); color: var(--fg);
--plyr-video-control-color-hover: var(--fg); background: var(--chip-bg);
--plyr-control-radius: 4px; text-decoration: none;
--plyr-control-spacing: calc(0.25rem / 0.7); padding: 0 0.5rem 0 1.45rem;
--plyr-font-size-menu: 0.75rem; display: inline-flex;
--plyr-menu-arrow-size: 0; position: relative;
margin-bottom: 0.48rem; font-size: 0.9rem;
max-height: 27vmin; height: 1.125rem;
clip-path: inset(0 0 0 0 round 4px); align-items: center;
scrollbar-width: thin; border-radius: 2rem;
} overflow: hidden;
}
.plyr__menu__container .plyr__control[role="menuitemradio"]::after {
left: 10px; .chip::before {
} content: "";
position: absolute;
.plyr__menu__container left: 0;
.plyr__control[role="menuitemradio"][aria-checked="true"].plyr__tab-focus::before, top: 0;
.plyr__menu__container width: 1.125rem;
.plyr__control[role="menuitemradio"][aria-checked="true"]:hover::before { height: 100%;
background: var(--accent); display: flex;
} align-items: center;
justify-content: center;
[data-plyr="language"] .plyr__menu__value { text-align: center;
display: none; background: var(--accent-transparent);
} background-repeat: no-repeat;
background-size: 18px;
#video-container { background-position: center;
flex-grow: 0; }
flex-shrink: 1;
display: none; .join-chip::before {
} background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI2ZmZiI+PHBhdGggZD0iTTggNXYxNGwxMS03eiIvPjwvc3ZnPg==");
}
a {
color: var(--accent); .time-chip::before {
} background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI2ZmZiI+PHBhdGggZD0iTTExLjk5IDJDNi40NyAyIDIgNi40OCAyIDEyczQuNDcgMTAgOS45OSAxMEMxNy41MiAyMiAyMiAxNy41MiAyMiAxMlMxNy41MiAyIDExLjk5IDJ6TTEyIDIwYy00LjQyIDAtOC0zLjU4LTgtOHMzLjU4LTggOC04IDggMy41OCA4IDgtMy41OCA4LTggOHoiLz48cGF0aCBkPSJNMTIuNSA3SDExdjZsNS4yNSAzLjE1Ljc1LTEuMjMtNC41LTIuNjd6Ii8+PC9zdmc+");
}
.chip {
color: var(--fg); label {
background: var(--chip-bg); display: block;
text-decoration: none; }
padding: 0 0.5rem 0 1.45rem;
display: inline-flex; input[type="url"],
position: relative; input[type="text"] {
font-size: 0.9rem; background: #fff;
height: 1.125rem; background-clip: padding-box;
align-items: center; border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 2rem; border-radius: 6px;
overflow: hidden; color: rgba(0, 0, 0, 0.8);
} display: block;
.chip::before { margin: 0.5em 0;
content: ""; padding: 0.5em 1em;
position: absolute; line-height: 1.5;
left: 0;
top: 0; font-family: sans-serif;
width: 1.125rem; font-size: 1em;
height: 100%; width: 100%;
display: flex;
align-items: center; resize: none;
justify-content: center; overflow-x: wrap;
text-align: center; overflow-y: scroll;
background: var(--accent-transparent); }
background-repeat: no-repeat;
background-size: 18px; button {
background-position: center; background-color: var(--accent);
} border: var(--accent);
border-radius: 6px;
.join-chip::before { color: #fff;
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI2ZmZiI+PHBhdGggZD0iTTggNXYxNGwxMS03eiIvPjwvc3ZnPg=="); padding: 0.5em 1em;
} display: inline-block;
font-weight: 400;
.time-chip::before { text-align: center;
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI2ZmZiI+PHBhdGggZD0iTTExLjk5IDJDNi40NyAyIDIgNi40OCAyIDEyczQuNDcgMTAgOS45OSAxMEMxNy41MiAyMiAyMiAxNy41MiAyMiAxMlMxNy41MiAyIDExLjk5IDJ6TTEyIDIwYy00LjQyIDAtOC0zLjU4LTgtOHMzLjU4LTggOC04IDggMy41OCA4IDgtMy41OCA4LTggOHoiLz48cGF0aCBkPSJNMTIuNSA3SDExdjZsNS4yNSAzLjE1Ljc1LTEuMjMtNC41LTIuNjd6Ii8+PC9zdmc+"); white-space: nowrap;
} vertical-align: middle;
label { font-family: sans-serif;
display: block; font-size: 1em;
} width: 100%;
input[type="url"], user-select: none;
input[type="text"] { border: 1px solid rgba(0, 0, 0, 0);
background: #fff; line-height: 1.5;
background-clip: padding-box; cursor: pointer;
border: 1px solid rgba(0, 0, 0, 0.12); margin: 0.5em 0;
border-radius: 6px; }
color: rgba(0, 0, 0, 0.8);
display: block; button:disabled {
filter: saturate(0.75);
margin: 0.5em 0; opacity: 0.75;
padding: 0.5em 1em; cursor: default;
line-height: 1.5; }
font-family: sans-serif; button.small-button {
font-size: 1em; font-size: 0.75em;
width: 100%; padding-top: 0;
padding-bottom: 0;
resize: none; }
overflow-x: wrap;
overflow-y: scroll; .subtitle-track-group {
} display: flex;
}
button:not(.plyr button) {
background-color: var(--accent); .subtitle-track-group > * {
border: var(--accent); margin-top: 0 !important;
border-radius: 6px; margin-bottom: 0 !important;
color: #fff; margin-right: 1ch !important;
padding: 0.5em 1em; }
display: inline-block;
font-weight: 400; #pre-join-controls,
text-align: center; #create-controls {
white-space: nowrap; margin: 0;
vertical-align: middle; flex-grow: 1;
overflow-y: auto;
font-family: sans-serif; display: flex;
font-size: 1em; flex-direction: column;
width: 100%; align-items: center;
justify-content: center;
user-select: none; }
border: 1px solid rgba(0, 0, 0, 0);
line-height: 1.5; #join-session-form,
cursor: pointer; #create-session-form {
margin: 0.5em 0; width: 500px;
} max-width: 100%;
padding: 1rem;
button:disabled { }
filter: saturate(0.75);
opacity: 0.75; #join-session-form > *:first-child,
cursor: default; #create-session-form > *:first-child {
} margin-top: 0;
}
button.small-button {
font-size: 0.75em; #post-create-message {
padding-top: 0; display: none;
padding-bottom: 0; width: 100%;
} font-size: 0.85em;
}
.subtitle-track-group {
display: flex; #chatbox-container {
} display: none;
}
.subtitle-track-group > * {
margin-top: 0 !important; .chat-message {
margin-bottom: 0 !important; overflow-wrap: break-word;
margin-right: 1ch !important; margin-bottom: 0.125rem;
} }
#pre-join-controls, .chat-message > strong,
#create-controls { #viewer-list strong {
margin: 0; color: var(--user-color, var(--default-user-color));
flex-grow: 1; }
overflow-y: auto;
display: flex; .chat-message.user-join,
flex-direction: column; .chat-message.user-leave,
align-items: center; .chat-message.ping {
justify-content: center; font-style: italic;
} }
#join-session-form, .chat-message.set-time,
#create-session-form { .chat-message.set-playing,
width: 500px; .chat-message.join-session {
max-width: 100%; font-style: italic;
padding: 1rem; text-align: right;
} font-size: 0.85em;
}
#join-session-form > *:first-child,
#create-session-form > *:first-child { .chat-message.command-message {
margin-top: 0; font-size: 0.85em;
} }
#post-create-message { .chat-message.set-time > strong,
display: none; .chat-message.set-playing > strong,
width: 100%; .chat-message.join-session > strong {
font-size: 0.85em; color: unset !important;
} }
#chatbox-container { .emoji {
display: none; width: 2ch;
} height: 2ch;
object-fit: contain;
.chat-message { margin-bottom: -0.35ch;
overflow-wrap: break-word; }
margin-bottom: 0.125rem;
} #chatbox {
padding: 0.5em 1em;
.chat-message > strong, overflow-y: scroll;
#viewer-list strong { flex-shrink: 1;
color: var(--user-color, var(--default-user-color)); flex-grow: 1;
} }
.chat-message.user-join, #viewer-list {
.chat-message.user-leave, padding: 0.5em 1em;
.chat-message.ping { /* TODO: turn this into max-height instead of fixed height without breaking the chatbox height */
font-style: italic; overflow-y: scroll;
} border-bottom: var(--fg-transparent);
border-bottom-style: solid;
.chat-message.set-time, max-height: 4rem;
.chat-message.set-playing, flex-shrink: 0;
.chat-message.join-session { }
font-style: italic;
text-align: right; #chatbox-container {
font-size: 0.85em; background-color: var(--bg);
} flex-direction: column;
flex-grow: 1;
.chat-message.command-message { flex-shrink: 1;
font-size: 0.85em; flex-basis: 36ch;
} min-width: 36ch;
overflow: hidden;
.chat-message.set-time > strong, }
.chat-message.set-playing > strong,
.chat-message.join-session > strong { #chatbox-send {
color: unset !important; padding: 0 1em;
} padding-bottom: 0.5em;
position: relative;
.emoji { }
width: 2ch;
height: 2ch; #chatbox-send > input {
object-fit: contain; font-size: 0.75em;
margin-bottom: -0.35ch; width: 100%;
} }
#chatbox { #emoji-autocomplete {
padding: 0.5em 1em; position: absolute;
overflow-y: scroll; bottom: 3.25rem;
flex-shrink: 1; background-image: var(--autocomplete-bg);
flex-grow: 1; border-radius: 6px;
} width: calc(100% - 2rem);
max-height: 8.5rem;
#viewer-list { overflow-y: auto;
padding: 0.5em 1em; clip-path: inset(0 0 0 0 round 8px);
/* TODO: turn this into max-height instead of fixed height without breaking the chatbox height */ }
overflow-y: scroll;
border-bottom: var(--fg-transparent); #emoji-autocomplete:empty {
border-bottom-style: solid; display: none;
max-height: 4rem; }
flex-shrink: 0;
} .emoji-option {
background: transparent;
#chatbox-container { font-size: 0.75rem;
background-color: var(--bg); text-align: left;
flex-direction: column; margin: 0 0.25rem;
flex-grow: 1; border-radius: 4px;
flex-shrink: 1; width: calc(100% - 0.5rem);
flex-basis: 36ch; display: flex;
min-width: 36ch; align-items: center;
overflow: hidden; padding: 0.25rem 0.5rem;
} scroll-margin: 0.25rem;
}
#chatbox-send {
padding: 0 1em; .emoji-option:first-child {
padding-bottom: 0.5em; margin-top: 0.25rem;
position: relative; }
}
.emoji-option:last-child {
#chatbox-send > input { margin-bottom: 0.25rem;
font-size: 0.75em; }
width: 100%;
} .emoji-option .emoji {
width: 1.25rem;
#emoji-autocomplete { height: 1.25rem;
position: absolute; margin: 0 0.5rem 0 0;
bottom: 3.25rem; font-size: 2.25ch;
background-image: var(--autocomplete-bg); display: flex;
border-radius: 6px; align-items: center;
width: calc(100% - 2rem); justify-content: center;
max-height: 8.5rem; overflow: hidden;
overflow-y: auto; flex-shrink: 0;
clip-path: inset(0 0 0 0 round 8px); }
}
.emoji-name {
#emoji-autocomplete:empty { overflow: hidden;
display: none; text-overflow: ellipsis;
} }
.emoji-option:not(:root) { .emoji-option.selected {
background: transparent; background: var(--fg-transparent);
font-size: 0.75rem; }
text-align: left;
margin: 0 0.25rem; #join-session-colour {
border-radius: 4px; -moz-appearance: none;
width: calc(100% - 0.5rem); -webkit-appearance: none;
display: flex; appearance: none;
align-items: center; border: none;
padding: 0.25rem 0.5rem; padding: 0;
scroll-margin: 0.25rem; border-radius: 6px;
} overflow: hidden;
margin: 0.5em 0;
.emoji-option:first-child { height: 2rem;
margin-top: 0.25rem; width: 2.5rem;
} cursor: pointer;
}
.emoji-option:last-child {
margin-bottom: 0.25rem; input[type="color"]::-moz-color-swatch,
} input[type="color"]::-webkit-color-swatch,
input[type="color"]::-webkit-color-swatch-wrapper {
.emoji-option .emoji { /* This *should* be working in Chrome, but it doesn't for reasons that are beyond me. */
width: 1.25rem; border: none;
height: 1.25rem; margin: 0;
margin: 0 0.5rem 0 0; padding: 0;
font-size: 2.25ch; }
display: flex;
align-items: center; @media (min-aspect-ratio: 4/3) {
justify-content: center; body {
overflow: hidden; flex-direction: row;
flex-shrink: 0; }
}
#chatbox-container {
.emoji-name { height: 100vh !important;
overflow: hidden; flex-grow: 0;
text-overflow: ellipsis; }
}
#video-container {
.emoji-option.selected { flex-grow: 1;
background: var(--fg-transparent); }
}
#chatbox {
#join-session-colour { height: calc(100vh - 5em - 4em) !important;
-moz-appearance: none; }
-webkit-appearance: none; }
appearance: none;
border: none;
padding: 0;
border-radius: 6px;
overflow: hidden;
margin: 0.5em 0;
height: 2rem;
width: 2.5rem;
cursor: pointer;
}
input[type="color"]::-moz-color-swatch {
border: none;
margin: 0;
padding: 0;
}
input[type="color"]::-webkit-color-swatch {
border: none;
margin: 0;
padding: 0;
}
input[type="color"]::-webkit-color-swatch-wrapper {
border: none;
margin: 0;
padding: 0;
}
@media (min-aspect-ratio: 4/3) {
body {
flex-direction: row;
}
#chatbox-container {
height: 100vh !important;
flex-grow: 0;
}
#video-container {
flex-grow: 1;
}
#chatbox {
height: calc(100vh - 5em - 4em) !important;
}
}

View File

@ -1,52 +1,52 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
pub struct Viewer { pub struct Viewer {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub nickname: Option<String>, pub nickname: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub colour: Option<String>, pub colour: Option<String>,
} }
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
#[serde(tag = "op", content = "data")] #[serde(tag = "op", content = "data")]
pub enum WatchEventData { pub enum WatchEventData {
SetPlaying { SetPlaying {
playing: bool, playing: bool,
time: u64, time: u64,
}, },
SetTime { SetTime {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
from: Option<u64>, from: Option<u64>,
to: u64, to: u64,
}, },
UserJoin, UserJoin,
UserLeave, UserLeave,
ChatMessage(String), ChatMessage(String),
Ping(String), Ping(String),
UpdateViewerList(Vec<Viewer>), UpdateViewerList(Vec<Viewer>),
} }
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
pub struct WatchEvent { pub struct WatchEvent {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub user: Option<String>, pub user: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub colour: Option<String>, pub colour: Option<String>,
#[serde(flatten)] #[serde(flatten)]
pub data: WatchEventData, pub data: WatchEventData,
#[serde(default)] #[serde(default)]
pub reflected: bool, pub reflected: bool,
} }
impl WatchEvent { impl WatchEvent {
pub fn new(user: String, colour: String, data: WatchEventData) -> Self { pub fn new(user: String, colour: String, data: WatchEventData) -> Self {
WatchEvent { WatchEvent {
user: Some(user), user: Some(user),
colour: Some(colour), colour: Some(colour),
data, data,
reflected: false, reflected: false,
} }
} }
} }

View File

@ -1,132 +1,132 @@
use serde_json::json; use serde_json::json;
use std::net::IpAddr; use std::net::IpAddr;
use uuid::Uuid; use uuid::Uuid;
use warb::{hyper::StatusCode, Filter, Reply}; use warb::{hyper::StatusCode, Filter, Reply};
use warp as warb; // i think it's funny use warp as warb; // i think it's funny
mod events; mod events;
mod utils; mod utils;
mod viewer_connection; mod viewer_connection;
mod watch_session; mod watch_session;
use serde::Deserialize; use serde::Deserialize;
use crate::{ use crate::{
viewer_connection::ws_subscribe, viewer_connection::ws_subscribe,
watch_session::{get_session, SubtitleTrack, WatchSession, SESSIONS}, watch_session::{get_session, SubtitleTrack, WatchSession, SESSIONS},
}; };
#[derive(Deserialize)] #[derive(Deserialize)]
struct StartSessionBody { struct StartSessionBody {
video_url: String, video_url: String,
#[serde(default = "Vec::new")] #[serde(default = "Vec::new")]
subtitle_tracks: Vec<SubtitleTrack>, subtitle_tracks: Vec<SubtitleTrack>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct SubscribeQuery { struct SubscribeQuery {
nickname: String, nickname: String,
colour: String, colour: String,
} }
async fn get_emoji_list() -> Result<impl warb::Reply, warb::Rejection> { async fn get_emoji_list() -> Result<impl warb::Reply, warb::Rejection> {
use tokio_stream::{wrappers::ReadDirStream, StreamExt}; use tokio_stream::{wrappers::ReadDirStream, StreamExt};
let dir = tokio::fs::read_dir("frontend/emojis") let dir = tokio::fs::read_dir("frontend/emojis")
.await .await
.expect("Couldn't read emojis directory!"); .expect("Couldn't read emojis directory!");
let files = ReadDirStream::new(dir) let files = ReadDirStream::new(dir)
.filter_map(|r| r.ok()) .filter_map(|r| r.ok())
.map(|e| e.file_name().to_string_lossy().to_string()) .map(|e| e.file_name().to_string_lossy().to_string())
.collect::<Vec<_>>() .collect::<Vec<_>>()
.await; .await;
Ok(warb::reply::json(&files)) Ok(warb::reply::json(&files))
} }
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let start_session_route = warb::path!("start_session") let start_session_route = warb::path!("start_session")
.and(warb::path::end()) .and(warb::path::end())
.and(warb::post()) .and(warb::post())
.and(warb::body::json()) .and(warb::body::json())
.map(|body: StartSessionBody| { .map(|body: StartSessionBody| {
let mut sessions = SESSIONS.lock().unwrap(); let mut sessions = SESSIONS.lock().unwrap();
let session_uuid = Uuid::new_v4(); let session_uuid = Uuid::new_v4();
let session = WatchSession::new(body.video_url, body.subtitle_tracks); let session = WatchSession::new(body.video_url, body.subtitle_tracks);
let session_view = session.view(); let session_view = session.view();
sessions.insert(session_uuid, session); sessions.insert(session_uuid, session);
warb::reply::json(&json!({ "id": session_uuid.to_string(), "session": session_view })) warb::reply::json(&json!({ "id": session_uuid.to_string(), "session": session_view }))
}); });
let get_emoji_route = warb::path!("emojos").and_then(get_emoji_list); let get_emoji_route = warb::path!("emojos").and_then(get_emoji_list);
enum RequestedSession { enum RequestedSession {
Session(Uuid, WatchSession), Session(Uuid, WatchSession),
Error(warb::reply::WithStatus<warb::reply::Json>), Error(warb::reply::WithStatus<warb::reply::Json>),
} }
let get_running_session = warb::path::path("sess") let get_running_session = warb::path::path("sess")
.and(warb::path::param::<String>()) .and(warb::path::param::<String>())
.map(|session_id: String| { .map(|session_id: String| {
if let Ok(uuid) = Uuid::parse_str(&session_id) { if let Ok(uuid) = Uuid::parse_str(&session_id) {
get_session(uuid) get_session(uuid)
.map(|sess| RequestedSession::Session(uuid, sess)) .map(|sess| RequestedSession::Session(uuid, sess))
.unwrap_or_else(|| { .unwrap_or_else(|| {
RequestedSession::Error(warb::reply::with_status( RequestedSession::Error(warb::reply::with_status(
warb::reply::json(&json!({ "error": "session does not exist" })), warb::reply::json(&json!({ "error": "session does not exist" })),
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
)) ))
}) })
} else { } else {
RequestedSession::Error(warb::reply::with_status( RequestedSession::Error(warb::reply::with_status(
warb::reply::json(&json!({ "error": "invalid session UUID" })), warb::reply::json(&json!({ "error": "invalid session UUID" })),
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
)) ))
} }
}); });
let get_status_route = get_running_session let get_status_route = get_running_session
.and(warb::path::end()) .and(warb::path::end())
.map(|requested_session| match requested_session { .map(|requested_session| match requested_session {
RequestedSession::Session(_, sess) => { RequestedSession::Session(_, sess) => {
warb::reply::with_status(warb::reply::json(&sess.view()), StatusCode::OK) warb::reply::with_status(warb::reply::json(&sess.view()), StatusCode::OK)
} }
RequestedSession::Error(e) => e, RequestedSession::Error(e) => e,
}); });
let ws_subscribe_route = get_running_session let ws_subscribe_route = get_running_session
.and(warb::path!("subscribe")) .and(warb::path!("subscribe"))
.and(warb::query()) .and(warb::query())
.and(warb::ws()) .and(warb::ws())
.map( .map(
|requested_session, query: SubscribeQuery, ws: warb::ws::Ws| match requested_session { |requested_session, query: SubscribeQuery, ws: warb::ws::Ws| match requested_session {
RequestedSession::Session(uuid, _) => ws RequestedSession::Session(uuid, _) => ws
.on_upgrade(move |ws| ws_subscribe(uuid, query.nickname, query.colour, ws)) .on_upgrade(move |ws| ws_subscribe(uuid, query.nickname, query.colour, ws))
.into_response(), .into_response(),
RequestedSession::Error(error_response) => error_response.into_response(), RequestedSession::Error(error_response) => error_response.into_response(),
}, },
); );
let routes = start_session_route let routes = start_session_route
.or(get_status_route) .or(get_status_route)
.or(ws_subscribe_route) .or(ws_subscribe_route)
.or(get_emoji_route) .or(get_emoji_route)
.or(warb::path::end().and(warb::fs::file("frontend/index.html"))) .or(warb::path::end().and(warb::fs::file("frontend/index.html")))
.or(warb::fs::dir("frontend")); .or(warb::fs::dir("frontend"));
let ip = std::env::var("IP") let ip = std::env::var("IP")
.ok() .ok()
.and_then(|s| s.parse::<IpAddr>().ok()) .and_then(|s| s.parse::<IpAddr>().ok())
.unwrap_or_else(|| [127, 0, 0, 1].into()); .unwrap_or_else(|| [127, 0, 0, 1].into());
let port = std::env::var("PORT") let port = std::env::var("PORT")
.ok() .ok()
.and_then(|s| s.parse::<u16>().ok()) .and_then(|s| s.parse::<u16>().ok())
.unwrap_or(3000); .unwrap_or(3000);
println!("Listening at http://{}:{} ...", &ip, &port); println!("Listening at http://{}:{} ...", &ip, &port);
warb::serve(routes).run((ip, port)).await; warb::serve(routes).run((ip, port)).await;
} }

View File

@ -1,156 +1,156 @@
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use std::{ use std::{
collections::HashMap, collections::HashMap,
sync::atomic::{AtomicUsize, Ordering}, sync::atomic::{AtomicUsize, Ordering},
}; };
use futures::{SinkExt, StreamExt, TryFutureExt}; use futures::{SinkExt, StreamExt, TryFutureExt};
use tokio::sync::{ use tokio::sync::{
mpsc::{self, UnboundedSender}, mpsc::{self, UnboundedSender},
RwLock, RwLock,
}; };
use tokio_stream::wrappers::UnboundedReceiverStream; use tokio_stream::wrappers::UnboundedReceiverStream;
use uuid::Uuid; use uuid::Uuid;
use warp::ws::{Message, WebSocket}; use warp::ws::{Message, WebSocket};
use crate::{ use crate::{
events::{Viewer, WatchEvent, WatchEventData}, events::{Viewer, WatchEvent, WatchEventData},
utils::truncate_str, utils::truncate_str,
watch_session::{get_session, handle_watch_event_data}, watch_session::{get_session, handle_watch_event_data},
}; };
static CONNECTED_VIEWERS: Lazy<RwLock<HashMap<usize, ConnectedViewer>>> = static CONNECTED_VIEWERS: Lazy<RwLock<HashMap<usize, ConnectedViewer>>> =
Lazy::new(|| RwLock::new(HashMap::new())); Lazy::new(|| RwLock::new(HashMap::new()));
static NEXT_VIEWER_ID: AtomicUsize = AtomicUsize::new(1); static NEXT_VIEWER_ID: AtomicUsize = AtomicUsize::new(1);
pub struct ConnectedViewer { 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 nickname: Option<String>,
pub colour: Option<String>, pub colour: Option<String>,
} }
pub async fn ws_subscribe(session_uuid: Uuid, nickname: String, colour: String, ws: WebSocket) { pub async fn ws_subscribe(session_uuid: Uuid, nickname: String, colour: String, ws: WebSocket) {
let viewer_id = NEXT_VIEWER_ID.fetch_add(1, Ordering::Relaxed); let viewer_id = NEXT_VIEWER_ID.fetch_add(1, Ordering::Relaxed);
let (mut viewer_ws_tx, mut viewer_ws_rx) = ws.split(); let (mut viewer_ws_tx, mut viewer_ws_rx) = ws.split();
let (tx, rx) = mpsc::unbounded_channel::<WatchEvent>(); let (tx, rx) = mpsc::unbounded_channel::<WatchEvent>();
let mut rx = UnboundedReceiverStream::new(rx); let mut rx = UnboundedReceiverStream::new(rx);
tokio::task::spawn(async move { tokio::task::spawn(async move {
while let Some(event) = rx.next().await { while let Some(event) = rx.next().await {
viewer_ws_tx viewer_ws_tx
.send(Message::text( .send(Message::text(
serde_json::to_string(&event).expect("couldn't convert WatchEvent into JSON"), serde_json::to_string(&event).expect("couldn't convert WatchEvent into JSON"),
)) ))
.unwrap_or_else(|e| eprintln!("ws send error: {}", e)) .unwrap_or_else(|e| eprintln!("ws send error: {}", e))
.await; .await;
} }
}); });
let mut colour = colour; let mut colour = colour;
if colour.len() != 6 || !colour.chars().all(|x| x.is_ascii_hexdigit()) { if colour.len() != 6 || !colour.chars().all(|x| x.is_ascii_hexdigit()) {
colour = String::from("7ed0ff"); colour = String::from("7ed0ff");
} }
let nickname = truncate_str(&nickname, 50).to_string(); let nickname = truncate_str(&nickname, 50).to_string();
CONNECTED_VIEWERS.write().await.insert( CONNECTED_VIEWERS.write().await.insert(
viewer_id, viewer_id,
ConnectedViewer { ConnectedViewer {
viewer_id, viewer_id,
session: session_uuid, session: session_uuid,
tx, tx,
nickname: Some(nickname.clone()), nickname: Some(nickname.clone()),
colour: Some(colour.clone()), colour: Some(colour.clone()),
}, },
); );
ws_publish( ws_publish(
session_uuid, session_uuid,
None, None,
WatchEvent::new(nickname.clone(), colour.clone(), WatchEventData::UserJoin), WatchEvent::new(nickname.clone(), colour.clone(), WatchEventData::UserJoin),
) )
.await; .await;
update_viewer_list(session_uuid).await; update_viewer_list(session_uuid).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 event: WatchEventData = 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())
{ {
Some(e) => e, Some(e) => e,
None => continue, None => continue,
}; };
let session = &mut get_session(session_uuid).unwrap(); let session = &mut get_session(session_uuid).unwrap();
// server side event modification where neccessary // server side event modification where neccessary
let event: WatchEventData = match event { let event: WatchEventData = match event {
WatchEventData::SetTime { from: _, to } => WatchEventData::SetTime { WatchEventData::SetTime { from: _, to } => WatchEventData::SetTime {
from: Some(session.get_time_ms()), from: Some(session.get_time_ms()),
to, to,
}, },
_ => event, _ => event,
}; };
handle_watch_event_data(session_uuid, session, event.clone()); handle_watch_event_data(session_uuid, session, event.clone());
ws_publish( ws_publish(
session_uuid, session_uuid,
Some(viewer_id), Some(viewer_id),
WatchEvent::new(nickname.clone(), colour.clone(), event), WatchEvent::new(nickname.clone(), colour.clone(), event),
) )
.await; .await;
} }
ws_publish( ws_publish(
session_uuid, session_uuid,
None, None,
WatchEvent::new(nickname.clone(), colour.clone(), WatchEventData::UserLeave), WatchEvent::new(nickname.clone(), colour.clone(), WatchEventData::UserLeave),
) )
.await; .await;
CONNECTED_VIEWERS.write().await.remove(&viewer_id); CONNECTED_VIEWERS.write().await.remove(&viewer_id);
update_viewer_list(session_uuid).await; update_viewer_list(session_uuid).await;
} }
pub async fn ws_publish(session_uuid: Uuid, skip_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() { for viewer in CONNECTED_VIEWERS.read().await.values() {
if viewer.session != session_uuid { if viewer.session != session_uuid {
continue; continue;
} }
let _ = viewer.tx.send(WatchEvent { let _ = viewer.tx.send(WatchEvent {
reflected: skip_viewer_id == Some(viewer.viewer_id), reflected: skip_viewer_id == Some(viewer.viewer_id),
..event.clone() ..event.clone()
}); });
} }
} }
async fn update_viewer_list(session_uuid: Uuid) { async fn update_viewer_list(session_uuid: Uuid) {
let mut viewers = Vec::new(); let mut viewers = Vec::new();
for viewer in CONNECTED_VIEWERS.read().await.values() { for viewer in CONNECTED_VIEWERS.read().await.values() {
if viewer.session == session_uuid { if viewer.session == session_uuid {
viewers.push(Viewer { viewers.push(Viewer {
nickname: viewer.nickname.clone(), nickname: viewer.nickname.clone(),
colour: viewer.colour.clone(), colour: viewer.colour.clone(),
}) })
} }
} }
ws_publish( ws_publish(
session_uuid, session_uuid,
None, None,
WatchEvent::new( WatchEvent::new(
String::from("server"), String::from("server"),
String::from(""), String::from(""),
WatchEventData::UpdateViewerList(viewers), WatchEventData::UpdateViewerList(viewers),
), ),
) )
.await; .await;
} }

View File

@ -1,96 +1,96 @@
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize}; 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::WatchEventData;
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]
pub struct SubtitleTrack { pub struct SubtitleTrack {
pub url: String, pub url: String,
pub name: String, pub name: String,
} }
#[derive(Clone)] #[derive(Clone)]
pub struct WatchSession { pub struct WatchSession {
pub video_url: String, pub video_url: String,
pub subtitle_tracks: Vec<SubtitleTrack>, pub subtitle_tracks: Vec<SubtitleTrack>,
is_playing: bool, is_playing: bool,
playing_from_timestamp: u64, playing_from_timestamp: u64,
playing_from_instant: Instant, playing_from_instant: Instant,
} }
#[derive(Serialize)] #[derive(Serialize)]
pub struct WatchSessionView { pub struct WatchSessionView {
pub video_url: String, pub video_url: String,
pub subtitle_tracks: Vec<SubtitleTrack>, pub subtitle_tracks: Vec<SubtitleTrack>,
pub current_time_ms: u64, pub current_time_ms: u64,
pub is_playing: bool, pub is_playing: bool,
} }
impl WatchSession { impl WatchSession {
pub fn new(video_url: String, subtitle_tracks: Vec<SubtitleTrack>) -> Self { pub fn new(video_url: String, subtitle_tracks: Vec<SubtitleTrack>) -> Self {
WatchSession { WatchSession {
video_url, video_url,
subtitle_tracks, subtitle_tracks,
is_playing: false, is_playing: false,
playing_from_timestamp: 0, playing_from_timestamp: 0,
playing_from_instant: Instant::now(), playing_from_instant: Instant::now(),
} }
} }
pub fn view(&self) -> WatchSessionView { pub fn view(&self) -> WatchSessionView {
WatchSessionView { WatchSessionView {
video_url: self.video_url.clone(), video_url: self.video_url.clone(),
subtitle_tracks: self.subtitle_tracks.clone(), subtitle_tracks: self.subtitle_tracks.clone(),
current_time_ms: self.get_time_ms() as u64, current_time_ms: self.get_time_ms() as u64,
is_playing: self.is_playing, is_playing: self.is_playing,
} }
} }
pub fn get_time_ms(&self) -> u64 { pub fn get_time_ms(&self) -> u64 {
if !self.is_playing { if !self.is_playing {
return self.playing_from_timestamp; return self.playing_from_timestamp;
} }
self.playing_from_timestamp + self.playing_from_instant.elapsed().as_millis() as u64 self.playing_from_timestamp + self.playing_from_instant.elapsed().as_millis() as u64
} }
pub fn set_time_ms(&mut self, time_ms: u64) { pub fn set_time_ms(&mut self, time_ms: u64) {
self.playing_from_timestamp = time_ms; self.playing_from_timestamp = time_ms;
self.playing_from_instant = Instant::now(); self.playing_from_instant = Instant::now();
} }
pub fn set_playing(&mut self, playing: bool, time_ms: u64) { pub fn set_playing(&mut self, playing: bool, time_ms: u64) {
self.set_time_ms(time_ms); self.set_time_ms(time_ms);
self.is_playing = playing; self.is_playing = playing;
} }
} }
pub static SESSIONS: Lazy<Mutex<HashMap<Uuid, WatchSession>>> = pub static SESSIONS: Lazy<Mutex<HashMap<Uuid, WatchSession>>> =
Lazy::new(|| Mutex::new(HashMap::new())); Lazy::new(|| Mutex::new(HashMap::new()));
pub fn get_session(uuid: Uuid) -> Option<WatchSession> { 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_data(
uuid: Uuid, uuid: Uuid,
watch_session: &mut WatchSession, watch_session: &mut WatchSession,
event: WatchEventData, event: WatchEventData,
) { ) {
match event { match event {
WatchEventData::SetPlaying { playing, time } => { WatchEventData::SetPlaying { playing, time } => {
watch_session.set_playing(playing, time); watch_session.set_playing(playing, time);
} }
WatchEventData::SetTime { from: _, to } => { WatchEventData::SetTime { from: _, to } => {
watch_session.set_time_ms(to); watch_session.set_time_ms(to);
} }
_ => {} _ => {}
}; };
let _ = SESSIONS.lock().unwrap().insert(uuid, watch_session.clone()); let _ = SESSIONS.lock().unwrap().insert(uuid, watch_session.clone());
} }