Compare commits

..

7 commits

Author SHA1 Message Date
fbedc0ba23 whoops i forgor a newline 2022-02-18 17:33:19 -05:00
437004fb9b fix votekiss 2022-02-18 17:28:56 -05:00
fb136a1899 oh no aaaa 2022-02-18 17:14:54 -05:00
1bf13d9776 fix broken things 2022-02-18 17:13:49 -05:00
a514241bee fix emoji name overflow (again) and sorting 2022-02-18 16:52:37 -05:00
cdec8b72a9 Merge branch 'main' of lavender.software:lavender/watch-party 2022-02-18 14:48:56 -05:00
92860f1ae6 add votekiss 2022-02-18 14:48:36 -05:00
23 changed files with 2095 additions and 2331 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=048af96" />
</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=048af96"></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=048af96";
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);
} }

BIN
frontend/emojis/blobcat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View file

@ -1,85 +1,69 @@
<!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=048af96" />
<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 for="join-session-colour">Colour:</label>
<label id="join-session-colour-label" for="join-session-colour"> <input type="color" id="join-session-colour" value="#7ed0ff" required />
Personal Colour:
</label> <label for="join-session-id">Session ID:</label>
<input type="color" id="join-session-colour" value="#ffffff" required /> <input
type="text"
<label for="join-session-id">Session ID:</label> id="join-session-id"
<input placeholder="123e4567-e89b-12d3-a456-426614174000"
type="text" required
id="join-session-id" />
placeholder="123e4567-e89b-12d3-a456-426614174000" <button id="join-session-button">Join</button>
required
/> <p>
<button id="join-session-button">Join</button> No session to join?
<a href="/create.html">Create a session</a> instead.
<p> </p>
No session to join? </form>
<a href="/create.html">Create a session</a> instead. </div>
</p>
</form> <div id="video-container"></div>
</div> <div id="chatbox-container">
<div id="viewer-list"></div>
<div id="video-container"></div> <div id="chatbox"></div>
<div id="chatbox-container"> <form id="chatbox-send">
<div id="viewer-list"></div> <input
<div id="chatbox"></div> type="text"
<form id="chatbox-send"> placeholder="Message... (/help for commands)"
<input list="emoji-autocomplete"
type="text" />
placeholder="Message... (/help for commands)" <div id="emoji-autocomplete"></div>
list="emoji-autocomplete" <!-- DO NOT ADD SPACING INSIDE THE TAG IT WILL BREAK THE CSS kthxbye -->
/> </form>
<div id="emoji-autocomplete"></div> </div>
<!-- DO NOT ADD SPACING INSIDE THE TAG IT WILL BREAK THE CSS kthxbye -->
</form> <script type="module" src="/main.mjs?v=048af96"></script>
</div> </body>
</html>
<script type="module" src="/main.mjs?v=4b61c4"></script>
<script>
const updateColourLabel = () => {
const colour = document.querySelector("#join-session-colour").value;
document.querySelector(
"#join-session-colour-label"
).textContent = `Personal Colour: ${colour}`;
};
document
.querySelector("#join-session-colour")
.addEventListener("input", updateColourLabel);
updateColourLabel();
</script>
</body>
</html>

View file

@ -1,453 +1,505 @@
import { import {
setDebounce, setDebounce,
setVideoTime, setVideoTime,
setPlaying, setPlaying,
sync, } from "./watch-session.mjs?v=048af96";
} from "./watch-session.mjs?v=4b61c4"; import { emojify, findEmojis } from "./emojis.mjs?v=048af96";
import { emojify, findEmojis } from "./emojis.mjs?v=4b61c4";
import { linkify } from "./links.mjs?v=4b61c4"; let nickname = "";
import { joinSession } from "./watch-session.mjs?v=4b61c4"; let kisses = {};
import { pling } from "./pling.mjs?v=4b61c4";
import { state } from "./state.mjs"; function setCaretPosition(elem, caretPos) {
if (elem.createTextRange) {
function setCaretPosition(elem, caretPos) { var range = elem.createTextRange();
if (elem.createTextRange) { range.move("character", caretPos);
var range = elem.createTextRange(); range.select();
range.move("character", caretPos); } else {
range.select(); if (elem.selectionStart) {
} else { elem.focus();
if (elem.selectionStart) { elem.setSelectionRange(caretPos, caretPos);
elem.focus(); } else elem.focus();
elem.setSelectionRange(caretPos, caretPos); }
} else elem.focus(); }
}
} const setupChatboxEvents = (socket) => {
// clear events by just reconstructing the form
const setupChatboxEvents = (socket) => { const oldChatForm = document.querySelector("#chatbox-send");
// clear events by just reconstructing the form const chatForm = oldChatForm.cloneNode(true);
const oldChatForm = document.querySelector("#chatbox-send"); const messageInput = chatForm.querySelector("input");
const chatForm = oldChatForm.cloneNode(true); const emojiAutocomplete = chatForm.querySelector("#emoji-autocomplete");
const messageInput = chatForm.querySelector("input"); oldChatForm.replaceWith(chatForm);
const emojiAutocomplete = chatForm.querySelector("#emoji-autocomplete");
oldChatForm.replaceWith(chatForm); let autocompleting = false,
showListTimer;
let autocompleting = false,
showListTimer; const replaceMessage = (message) => () => {
messageInput.value = message;
const replaceMessage = (message) => () => { autocomplete();
messageInput.value = message; };
autocomplete(); async function autocomplete(fromListTimeout) {
}; if (autocompleting) return;
async function autocomplete(fromListTimeout) { clearInterval(showListTimer);
if (autocompleting) return; emojiAutocomplete.textContent = "";
try { autocompleting = true;
clearInterval(showListTimer); let text = messageInput.value.slice(0, messageInput.selectionStart);
emojiAutocomplete.textContent = ""; const match = text.match(/(:[^\s:]+)?:([^\s:]*)$/);
autocompleting = true; if (!match || match[1]) return (autocompleting = false); // We don't need to autocomplete.
let text = messageInput.value.slice(0, messageInput.selectionStart); const prefix = text.slice(0, match.index);
const match = text.match(/(:[^\s:]+)?:([^\s:]{2,})$/); const search = text.slice(match.index + 1);
if (!match || match[1]) return (autocompleting = false); // We don't need to autocomplete. if (search.length < 1 && !fromListTimeout) {
const prefix = text.slice(0, match.index); autocompleting = false;
const search = text.slice(match.index + 1); showListTimer = setTimeout(() => autocomplete(true), 500);
if (search.length < 1 && !fromListTimeout) { return;
autocompleting = false; }
showListTimer = setTimeout(() => autocomplete(true), 500); const suffix = messageInput.value.slice(messageInput.selectionStart);
return; let selected;
} const select = (button) => {
const suffix = messageInput.value.slice(messageInput.selectionStart); if (selected) selected.classList.remove("selected");
let selected; selected = button;
const select = (button) => { button.classList.add("selected");
if (selected) selected.classList.remove("selected"); };
selected = button; let results = await findEmojis(search);
button.classList.add("selected"); let yieldAt = performance.now() + 13;
}; for (let i = 0; i < results.length; i += 100) {
let results = await findEmojis(search); emojiAutocomplete.append.apply(
let yieldAt = performance.now() + 13; emojiAutocomplete,
for (let i = 0; i < results.length; i += 100) { results.slice(i, i + 100).map(([name, replaceWith, ext], i) => {
emojiAutocomplete.append.apply( const button = Object.assign(document.createElement("button"), {
emojiAutocomplete, className: "emoji-option",
results.slice(i, i + 100).map(([name, replaceWith, ext], i) => { onmousedown: (e) => e.preventDefault(),
const button = Object.assign(document.createElement("button"), { onclick: () => {
className: "emoji-option", messageInput.value = prefix + replaceWith + " " + suffix;
onmousedown: (e) => e.preventDefault(), setCaretPosition(
onclick: () => { messageInput,
messageInput.value = prefix + replaceWith + " " + suffix; (prefix + " " + replaceWith).length
setCaretPosition( );
messageInput, },
(prefix + " " + replaceWith).length onmouseover: () => select(button),
); onfocus: () => select(button),
}, type: "button",
onmouseover: () => select(button), title: name,
onfocus: () => select(button), });
type: "button", button.append(
title: name, replaceWith[0] !== ":"
}); ? Object.assign(document.createElement("span"), {
button.append( textContent: replaceWith,
replaceWith[0] !== ":" className: "emoji",
? Object.assign(document.createElement("span"), { })
textContent: replaceWith, : Object.assign(new Image(), {
className: "emoji", loading: "lazy",
}) src: `/emojis/${name}${ext}`,
: Object.assign(new Image(), { className: "emoji",
loading: "lazy", }),
src: `/emojis/${name}${ext}`, Object.assign(document.createElement("span"), {
className: "emoji", textContent: name,
}), className: "emoji-name",
Object.assign(document.createElement("span"), { })
textContent: name, );
className: "emoji-name", return button;
}) })
); );
return button; if (i == 0 && emojiAutocomplete.children[0]) {
}) emojiAutocomplete.children[0].scrollIntoView();
); select(emojiAutocomplete.children[0]);
if (i == 0 && emojiAutocomplete.children[0]) { }
emojiAutocomplete.children[0].scrollIntoView(); const now = performance.now();
select(emojiAutocomplete.children[0]); if (now > yieldAt) {
} yieldAt = now + 13;
const now = performance.now(); await new Promise((cb) => setTimeout(cb, 0));
if (now > yieldAt) { }
yieldAt = now + 13; }
await new Promise((cb) => setTimeout(cb, 0)); autocompleting = false;
} }
} messageInput.addEventListener("input", () => autocomplete());
autocompleting = false; messageInput.addEventListener("selectionchange", () => autocomplete());
} catch (e) { messageInput.addEventListener("keydown", (event) => {
autocompleting = false; if (event.key == "ArrowUp" || event.key == "ArrowDown") {
} let selected = document.querySelector(".emoji-option.selected");
} if (!selected) return;
messageInput.addEventListener("input", () => autocomplete()); event.preventDefault();
messageInput.addEventListener("selectionchange", () => autocomplete()); selected.classList.remove("selected");
messageInput.addEventListener("keydown", (event) => { selected =
if (event.key == "ArrowUp" || event.key == "ArrowDown") { event.key == "ArrowDown"
let selected = document.querySelector(".emoji-option.selected"); ? selected.nextElementSibling || selected.parentElement.children[0]
if (!selected) return; : selected.previousElementSibling ||
event.preventDefault(); selected.parentElement.children[
selected.classList.remove("selected"); selected.parentElement.children.length - 1
selected = ];
event.key == "ArrowDown" selected.classList.add("selected");
? selected.nextElementSibling || selected.parentElement.children[0] selected.scrollIntoView({ scrollMode: "if-needed", block: "nearest" });
: selected.previousElementSibling || }
selected.parentElement.children[ if (event.key == "Tab") {
selected.parentElement.children.length - 1 let selected = document.querySelector(".emoji-option.selected");
]; if (!selected) return;
selected.classList.add("selected"); event.preventDefault();
selected.scrollIntoView({ scrollMode: "if-needed", block: "nearest" }); selected.onclick();
} }
if (event.key == "Tab" || event.key == "Enter") { });
let selected = document.querySelector(".emoji-option.selected");
if (!selected) return; chatForm.addEventListener("submit", async (e) => {
event.preventDefault(); e.preventDefault();
selected.onclick(); const content = messageInput.value;
} if (content.trim().length) {
}); messageInput.value = "";
chatForm.addEventListener("submit", async (e) => { // handle commands
e.preventDefault(); if (content.startsWith("/")) {
const content = messageInput.value; const command = content.toLowerCase().match(/^\/\S+/)[0];
if (content.trim().length) { const args = content.slice(command.length).trim();
messageInput.value = "";
let handled = false;
// handle commands switch (command) {
if (content.startsWith("/")) { case "/ping":
const command = content.toLowerCase().match(/^\/\S+/)[0]; socket.send(
const args = content.slice(command.length).trim(); JSON.stringify({
op: "Ping",
let handled = false; data: args,
switch (command) { })
case "/ping": );
socket.send( handled = true;
JSON.stringify({ break;
op: "Ping", case "/sync":
data: args, const sessionId = window.location.hash.slice(1);
}) const { current_time_ms, is_playing } = await fetch(
); `/sess/${sessionId}`
handled = true; ).then((r) => r.json());
break;
case "/sync": setDebounce();
await sync(); setPlaying(is_playing);
setVideoTime(current_time_ms);
const syncMessageContent = document.createElement("span");
syncMessageContent.appendChild( const syncMessageContent = document.createElement("span");
document.createTextNode("resynced you to ") syncMessageContent.appendChild(
); document.createTextNode("resynced you to ")
syncMessageContent.appendChild( );
document.createTextNode(formatTime(current_time_ms)) syncMessageContent.appendChild(
); document.createTextNode(formatTime(current_time_ms))
printChatMessage("set-time", "/sync", "b57fdc", syncMessageContent); );
handled = true; printChatMessage("set-time", "/sync", "b57fdc", syncMessageContent);
break; handled = true;
case "/shrug": break;
socket.send( case "/shrug":
JSON.stringify({ socket.send(
op: "ChatMessage", JSON.stringify({
data: `${args} ¯\\_(ツ)_/¯`.trim(), op: "ChatMessage",
}) data: `${args} ¯\\_(ツ)_/¯`.trim(),
); })
handled = true; );
break; handled = true;
case "/join": break;
state().sessionId = args; case "/votekiss":
joinSession(); if(kisses[args]&&kisses[args][nickname])
handled = true; printChatMessage(
break; "vote-kiss",
case "/help": "/votekiss",
const helpMessageContent = document.createElement("span"); "b57fdc",
helpMessageContent.innerHTML = document.createTextNode("you already voted to kiss " + args)
"Available commands:<br>" + );
"&emsp;<code>/help</code> - display this help message<br>" + else
"&emsp;<code>/ping [message]</code> - ping all viewers<br>" + printChatMessage(
"&emsp;<code>/sync</code> - resyncs you with other viewers<br>" + "vote-kiss",
"&emsp;<code>/shrug</code> - appends ¯\\_(ツ)_/¯ to your message<br>" + "/votekiss",
"&emsp;<code>/join [session id]</code> - joins another session"; "b57fdc",
document.createTextNode("you voted to kiss " + args)
printChatMessage( );
"command-message", handled = false;
"/help", // we also handle this on receive
"b57fdc", break;
helpMessageContent case "/help":
); const helpMessageContent = document.createElement("span");
handled = true; helpMessageContent.innerHTML =
break; "Available commands:<br>" +
default: "&emsp;<code>/help</code> - display this help message<br>" +
break; "&emsp;<code>/ping [message]</code> - ping all viewers<br>" +
} "&emsp;<code>/sync</code> - resyncs you with other viewers<br>" +
"&emsp;<code>/shrug</code> - appends ¯\\_(ツ)_/¯ to your message<br>" +
if (handled) { "&emsp;<code>/votekiss</code> - like votekick but gay";
return;
} printChatMessage(
} "command-message",
"/help",
// handle regular chat messages "b57fdc",
socket.send( helpMessageContent
JSON.stringify({ );
op: "ChatMessage", handled = true;
data: content, break;
}) default:
); break;
} }
});
}; if (handled) {
return;
/** }
* @param {WebSocket} socket }
*/
export const setupChat = async (socket) => { // handle regular chat messages
document.querySelector("#chatbox-container").style["display"] = "flex"; socket.send(
setupChatboxEvents(socket); JSON.stringify({
}; op: "ChatMessage",
data: content,
const addToChat = (node) => { })
const chatbox = document.querySelector("#chatbox"); );
chatbox.appendChild(node); }
chatbox.scrollTop = chatbox.scrollHeight; });
}; };
let lastTimeMs = null; /**
let lastPlaying = false; * @param {WebSocket} socket
*/
const checkDebounce = (event) => { export const setupChat = async (socket, _nickname) => {
let timeMs = null; nickname = _nickname; // We need this for commands
let playing = null; document.querySelector("#chatbox-container").style["display"] = "flex";
if (event.op == "SetTime") { setupChatboxEvents(socket);
timeMs = event.data;
} else if (event.op == "SetPlaying") { window.addEventListener("keydown", (event) => {
timeMs = event.data.time; try {
playing = event.data.playing; const isSelectionEmpty = window.getSelection().toString().length === 0;
} if (event.code.match(/Key\w/) && isSelectionEmpty) messageInput.focus();
} catch (_err) {}
let shouldIgnore = false; });
};
if (timeMs != null) {
if (lastTimeMs && Math.abs(lastTimeMs - timeMs) < 500) { const addToChat = (node) => {
shouldIgnore = true; const chatbox = document.querySelector("#chatbox");
} chatbox.appendChild(node);
lastTimeMs = timeMs; chatbox.scrollTop = chatbox.scrollHeight;
} };
if (playing != null) { let lastTimeMs = null;
if (lastPlaying != playing) { let lastPlaying = false;
shouldIgnore = false;
} const checkDebounce = (event) => {
lastPlaying = playing; let timeMs = null;
} let playing = null;
if (event.op == "SetTime") {
return shouldIgnore; timeMs = event.data;
}; } else if (event.op == "SetPlaying") {
timeMs = event.data.time;
/** playing = event.data.playing;
* @returns {string} }
*/
const getCurrentTimestamp = () => { let shouldIgnore = false;
const t = new Date();
return `${matpad(t.getHours())}:${matpad(t.getMinutes())}:${matpad( if (timeMs != null) {
t.getSeconds() if (lastTimeMs && Math.abs(lastTimeMs - timeMs) < 500) {
)}`; shouldIgnore = true;
}; }
lastTimeMs = timeMs;
/** }
* https://media.discordapp.net/attachments/834541919568527361/931678814751301632/66d2c68c48daa414c96951381665ec2e.png
*/ if (playing != null) {
const matpad = (n) => { if (lastPlaying != playing) {
return ("00" + n).slice(-2); shouldIgnore = false;
}; }
lastPlaying = playing;
/** }
* @param {string} eventType
* @param {string?} user return shouldIgnore;
* @param {Node?} content };
*/
export const printChatMessage = (eventType, user, colour, content) => { /**
const chatMessage = document.createElement("div"); * @returns {string}
chatMessage.classList.add("chat-message"); */
chatMessage.classList.add(eventType); const getCurrentTimestamp = () => {
chatMessage.title = getCurrentTimestamp(); const t = new Date();
return `${matpad(t.getHours())}:${matpad(t.getMinutes())}:${matpad(
if (user != null) { t.getSeconds()
const userName = document.createElement("strong"); )}`;
userName.style = `--user-color: #${colour}`; };
userName.textContent = user + " ";
chatMessage.appendChild(userName); /**
} * https://media.discordapp.net/attachments/834541919568527361/931678814751301632/66d2c68c48daa414c96951381665ec2e.png
*/
if (content != null) { const matpad = (n) => {
chatMessage.appendChild(content); return ("00" + n).slice(-2);
} };
addToChat(chatMessage); /**
* @param {string} eventType
return chatMessage; * @param {string?} user
}; * @param {Node?} content
*/
const formatTime = (ms) => { const printChatMessage = (eventType, user, colour, content) => {
const seconds = Math.floor((ms / 1000) % 60); const chatMessage = document.createElement("div");
const minutes = Math.floor((ms / (60 * 1000)) % 60); chatMessage.classList.add("chat-message");
const hours = Math.floor((ms / (3600 * 1000)) % 3600); chatMessage.classList.add(eventType);
return `${hours < 10 ? "0" + hours : hours}:${ chatMessage.title = getCurrentTimestamp();
minutes < 10 ? "0" + minutes : minutes
}:${seconds < 10 ? "0" + seconds : seconds}`; if (user != null) {
}; const userName = document.createElement("strong");
userName.style = `--user-color: #${colour}`;
export const logEventToChat = async (event) => { userName.textContent = user + " ";
if (checkDebounce(event)) { chatMessage.appendChild(userName);
return; }
}
if (content != null) {
switch (event.op) { chatMessage.appendChild(content);
case "UserJoin": { }
printChatMessage(
"user-join", addToChat(chatMessage);
event.user,
event.colour, return chatMessage;
document.createTextNode("joined") };
);
break; const formatTime = (ms) => {
} const seconds = Math.floor((ms / 1000) % 60);
case "UserLeave": { const minutes = Math.floor((ms / (60 * 1000)) % 60);
printChatMessage( const hours = Math.floor((ms / (3600 * 1000)) % 3600);
"user-leave", return `${hours < 10 ? "0" + hours : hours}:${
event.user, minutes < 10 ? "0" + minutes : minutes
event.colour, }:${seconds < 10 ? "0" + seconds : seconds}`;
document.createTextNode("left") };
);
break; function handleClientCommand(content, user) {
} let handled = false;
case "ChatMessage": { if (content.startsWith("/")) {
const messageContent = document.createElement("span"); const command = content.toLowerCase().match(/^\/\S+/)[0];
messageContent.classList.add("message-content"); const args = content.slice(command.length).trim();
messageContent.append(...(await linkify(event.data, emojify))); switch (command) {
printChatMessage( case "/votekiss":
"chat-message", kisses[args] = kisses[args] || {};
event.user, kisses[args][user] = true;
event.colour, if (Object.keys(kisses[args]).length >= 3) {
messageContent printChatMessage(
); "user-kissed",
break; args,
} "ff6094",
case "SetTime": { document.createTextNode("was kissed 💋")
const messageContent = document.createElement("span"); );
if (event.data.from != undefined) { kisses[args] = {};
messageContent.appendChild( }
document.createTextNode("set the time from ") handled = true;
); break;
}
messageContent.appendChild( }
document.createTextNode(formatTime(event.data.from)) return handled;
); }
messageContent.appendChild(document.createTextNode(" to ")); export const logEventToChat = async (event) => {
} else { if (checkDebounce(event)) {
messageContent.appendChild(document.createTextNode("set the time to ")); return;
} }
messageContent.appendChild( switch (event.op) {
document.createTextNode(formatTime(event.data.to)) case "UserJoin": {
); printChatMessage(
"user-join",
printChatMessage("set-time", event.user, event.colour, messageContent); event.user,
break; event.colour,
} document.createTextNode("joined")
case "SetPlaying": { );
const messageContent = document.createElement("span"); break;
messageContent.appendChild( }
document.createTextNode( case "UserLeave": {
event.data.playing ? "started playing" : "paused" printChatMessage(
) "user-leave",
); event.user,
messageContent.appendChild(document.createTextNode(" at ")); event.colour,
messageContent.appendChild( document.createTextNode("left")
document.createTextNode(formatTime(event.data.time)) );
); for (let kissed in kisses) delete kisses[kissed][event.user];
break;
printChatMessage("set-playing", event.user, event.colour, messageContent); }
break; case "ChatMessage": {
} const messageContent = document.createElement("span");
case "Ping": { messageContent.classList.add("message-content");
const messageContent = document.createElement("span"); if (handleClientCommand(event.data, event.user)) break;
if (event.data) { messageContent.append(...(await emojify(event.data)));
messageContent.appendChild(document.createTextNode("pinged saying: "));
messageContent.appendChild(document.createTextNode(event.data)); printChatMessage(
} else { "chat-message",
messageContent.appendChild(document.createTextNode("pinged")); event.user,
} event.colour,
messageContent
printChatMessage("ping", event.user, event.colour, messageContent); );
pling(); break;
if ("Notification" in window) { }
const title = "watch party :)"; case "SetTime": {
const options = { const messageContent = document.createElement("span");
body: event.data if (event.data.from != undefined) {
? `${event.user} pinged saying: ${event.data}` messageContent.appendChild(
: `${event.user} pinged`, document.createTextNode("set the time from ")
}; );
if (Notification.permission === "granted") {
new Notification(title, options); messageContent.appendChild(
} else if (Notification.permission !== "denied") { document.createTextNode(formatTime(event.data.from))
Notification.requestPermission().then(function (permission) { );
if (permission === "granted") {
new Notification(title, options); messageContent.appendChild(document.createTextNode(" to "));
} } else {
}); messageContent.appendChild(document.createTextNode("set the time to "));
} }
}
break; messageContent.appendChild(
} document.createTextNode(formatTime(event.data.to))
} );
};
printChatMessage("set-time", event.user, event.colour, messageContent);
export const updateViewerList = (viewers) => { break;
const listContainer = document.querySelector("#viewer-list"); }
case "SetPlaying": {
// empty out the current list const messageContent = document.createElement("span");
listContainer.innerHTML = ""; messageContent.appendChild(
document.createTextNode(
// display the updated list event.data.playing ? "started playing" : "paused"
for (const viewer of viewers) { )
const viewerElem = document.createElement("div"); );
const content = document.createElement("strong"); messageContent.appendChild(document.createTextNode(" at "));
content.textContent = viewer.nickname; messageContent.appendChild(
content.style = `--user-color: #${viewer.colour}`; document.createTextNode(formatTime(event.data.time))
viewerElem.appendChild(content); );
listContainer.appendChild(viewerElem);
} printChatMessage("set-playing", event.user, event.colour, messageContent);
}; break;
}
case "Ping": {
const messageContent = document.createElement("span");
if (event.data) {
messageContent.appendChild(document.createTextNode("pinged saying: "));
messageContent.appendChild(document.createTextNode(event.data));
} else {
messageContent.appendChild(document.createTextNode("pinged"));
}
printChatMessage("ping", event.user, event.colour, messageContent);
beep();
break;
}
}
};
const beep = () => {
const context = new AudioContext();
const gain = context.createGain();
gain.connect(context.destination);
gain.gain.value = 0.1;
const oscillator = context.createOscillator();
oscillator.connect(gain);
oscillator.frequency.value = 520;
oscillator.type = "square";
oscillator.start(context.currentTime);
oscillator.stop(context.currentTime + 0.22);
};
let viewers = [];
export const updateViewerList = (_viewers) => {
viewers = _viewers;
const listContainer = document.querySelector("#viewer-list");
// empty out the current list
listContainer.innerHTML = "";
// display the updated list
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=048af96";
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,69 @@
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 = emojis[name.toLowerCase()[0]].find((e) => e[0] == name);
try { if (!emoji) {
emoji = emojis[name.toLowerCase()[0]].find((e) => e[0] == name); nodes.push(document.createTextNode(match));
} catch (e) {} } else {
if (!emoji) { if (emoji[1][0] !== ":") {
nodes.push(document.createTextNode(match)); nodes.push(document.createTextNode(emoji[1]));
} else { } else {
if (emoji[1][0] !== ":") { nodes.push(
nodes.push(document.createTextNode(emoji[1])); Object.assign(new Image(), {
} else { src: `/emojis/${name}${emoji[2]}`,
nodes.push( className: "emoji",
Object.assign(new Image(), { alt: name,
src: `/emojis/${name}${emoji[2]}`, })
className: "emoji", );
alt: name, }
}) }
); last = index + match.length;
} });
} if (last < text.length) nodes.push(document.createTextNode(text.slice(last)));
last = index + match.length; return nodes;
}); }
if (last < text.length) nodes.push(document.createTextNode(text.slice(last))); const emojis = {};
return nodes;
} export const emojisLoaded = Promise.all([
const emojis = {}; fetch("/emojis")
.then((e) => e.json())
export const emojisLoaded = Promise.all([ .then((a) => {
fetch("/emojis/unicode.json") for (let e of a) {
.then((e) => e.json()) const name = e.slice(0, -4),
.then((a) => { lower = name.toLowerCase();
for (let e of a) { emojis[lower[0]] = emojis[lower[0]] || [];
emojis[e[0][0]] = emojis[e[0][0]] || []; emojis[lower[0]].push([name, ":" + name + ":", e.slice(-4), lower]);
emojis[e[0][0]].push([e[0], e[1], null, e[0]]); }
} }),
}), fetch("/emojis/unicode.json")
fetch("/emojos") .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]] || [];
const name = e.slice(0, -4), emojis[e[0][0]].push([e[0], e[1], null, e[0]]);
lower = name.toLowerCase(); }
emojis[lower[0]] = emojis[lower[0]] || []; }),
emojis[lower[0]].push([name, ":" + name + ":", e.slice(-4), lower]); ]);
}
}), export async function findEmojis(search) {
]); await emojisLoaded;
let groups = [[], []];
export async function findEmojis(search) { if (search.length < 1) {
await emojisLoaded; for (let letter of Object.keys(emojis).sort())
let groups = [[], []]; for (let emoji of emojis[letter]) {
if (search.length < 1) { (emoji[1][0] === ":" ? groups[0] : groups[1]).push(emoji);
for (let letter of Object.keys(emojis).sort()) }
for (let emoji of emojis[letter]) { } else {
(emoji[1][0] === ":" ? groups[0] : groups[1]).push(emoji); search = search.toLowerCase();
} for (let emoji of emojis[search[0]]) {
} else { if (search.length == 1 || emoji[3].startsWith(search)) {
search = search.toLowerCase(); (emoji[1][0] === ":" ? groups[0] : groups[1]).push(emoji);
for (let emoji of emojis[search[0]]) { }
if (search.length == 1 || emoji[3].startsWith(search)) { }
(emoji[1][0] === ":" ? groups[0] : groups[1]).push(emoji); }
} return [...groups[0], ...groups[1]];
} }
}
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=048af96";
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 form = document.querySelector("#join-session-form");
const created = displayPostCreateMessage(); const nickname = form.querySelector("#join-session-nickname");
const colour = form.querySelector("#join-session-colour");
const form = document.querySelector("#join-session-form"); const sessionId = form.querySelector("#join-session-id");
const nickname = form.querySelector("#join-session-nickname"); const button = form.querySelector("#join-session-button");
const colour = form.querySelector("#join-session-colour");
const sessionId = form.querySelector("#join-session-id"); loadNickname(nickname);
const button = form.querySelector("#join-session-button"); loadColour(colour);
loadNickname(nickname); if (window.location.hash.match(/#[0-9a-f\-]+/)) {
loadColour(colour); 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) => { button.disabled = true;
event.preventDefault();
saveNickname(nickname);
button.disabled = true; saveColour(colour);
try {
saveNickname(nickname); await joinSession(
saveColour(colour); nickname.value,
try { sessionId.value,
state().nickname = nickname.value; colour.value.replace(/^#/, "")
state().sessionId = sessionId.value; );
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 +0,0 @@
import { joinSession } from "./watch-session.mjs?v=4b61c4";
import { state } from "./state.mjs";
export async function linkify(
text,
next = async (t) => [document.createTextNode(t)]
) {
let last = 0;
let nodes = [];
let promise = Promise.resolve();
// matching non-urls isn't a problem, we use the browser's url parser to filter them out
text.replace(
/[^:/?#\s]+:\/\/\S+/g,
(match, index) =>
(promise = promise.then(async () => {
if (last <= index) nodes.push(...(await next(text.slice(last, index))));
let url;
try {
url = new URL(match);
if (url.protocol === "javascript:") throw new Error();
} catch (e) {
url = null;
}
if (!url) {
nodes.push(...(await next(match)));
} else {
let s;
if (
url.origin == location.origin &&
url.pathname == "/" &&
url.hash.length > 1
) {
nodes.push(
Object.assign(document.createElement("a"), {
textContent: "Join Session",
className: "chip join-chip",
onclick: () => {
state().sessionId = url.hash.substring(1);
joinSession();
},
})
);
} else if (
url.hostname == "xiv.st" &&
(s = url.pathname.match(/(\d?\d).?(\d\d)/))
) {
if (s) {
const date = new Date();
date.setUTCSeconds(0);
date.setUTCMilliseconds(0);
date.setUTCHours(s[1]), date.setUTCMinutes(s[2]);
nodes.push(
Object.assign(document.createElement("a"), {
href: url.href,
textContent: date.toLocaleString([], {
hour: "2-digit",
minute: "2-digit",
}),
className: "chip time-chip",
target: "_blank",
})
);
}
} else {
nodes.push(
Object.assign(document.createElement("a"), {
href: url.href,
textContent: url.href,
target: "_blank",
})
);
}
}
last = index + match.length;
}))
);
await promise;
if (last < text.length) nodes.push(...(await next(text.slice(last))));
return nodes;
}
const emojis = {};
export const emojisLoaded = Promise.all([
fetch("/emojis")
.then((e) => e.json())
.then((a) => {
for (let e of a) {
const name = e.slice(0, -4),
lower = name.toLowerCase();
emojis[lower[0]] = emojis[lower[0]] || [];
emojis[lower[0]].push([name, ":" + name + ":", e.slice(-4), lower]);
}
}),
fetch("/emojis/unicode.json")
.then((e) => e.json())
.then((a) => {
for (let e of a) {
emojis[e[0][0]] = emojis[e[0][0]] || [];
emojis[e[0][0]].push([e[0], e[1], null, e[0]]);
}
}),
]);
export async function findEmojis(search) {
await emojisLoaded;
let groups = [[], []];
if (search.length < 1) {
for (let letter of Object.keys(emojis).sort())
for (let emoji of emojis[letter]) {
(emoji[1][0] === ":" ? groups[0] : groups[1]).push(emoji);
}
} else {
search = search.toLowerCase();
for (let emoji of emojis[search[0]]) {
if (search.length == 1 || emoji[3].startsWith(search)) {
(emoji[1][0] === ":" ? groups[0] : groups[1]).push(emoji);
}
}
}
return [...groups[0], ...groups[1]];
}

View file

@ -1,79 +0,0 @@
export const pling = () => {
const maxGain = 0.3;
const duration = 0.22;
const fadeDuration = 0.1;
const secondBeepOffset = 0.05;
const thirdBeepOffset = 2 * secondBeepOffset;
const ctx = new AudioContext();
const firstBeepGain = ctx.createGain();
firstBeepGain.connect(ctx.destination);
firstBeepGain.gain.setValueAtTime(0.01, ctx.currentTime);
firstBeepGain.gain.exponentialRampToValueAtTime(
maxGain,
ctx.currentTime + fadeDuration
);
firstBeepGain.gain.setValueAtTime(
maxGain,
ctx.currentTime + (duration - fadeDuration)
);
firstBeepGain.gain.exponentialRampToValueAtTime(
0.01,
ctx.currentTime + duration
);
const firstBeep = ctx.createOscillator();
firstBeep.connect(firstBeepGain);
firstBeep.frequency.value = 400;
firstBeep.type = "sine";
const secondBeepGain = ctx.createGain();
secondBeepGain.connect(ctx.destination);
secondBeepGain.gain.setValueAtTime(0.01, ctx.currentTime + secondBeepOffset);
secondBeepGain.gain.exponentialRampToValueAtTime(
maxGain,
ctx.currentTime + secondBeepOffset + fadeDuration
);
secondBeepGain.gain.setValueAtTime(
maxGain,
ctx.currentTime + secondBeepOffset + (duration - fadeDuration)
);
secondBeepGain.gain.exponentialRampToValueAtTime(
0.01,
ctx.currentTime + secondBeepOffset + duration
);
const secondBeep = ctx.createOscillator();
secondBeep.connect(secondBeepGain);
secondBeep.frequency.value = 600;
secondBeep.type = "sine";
const thirdBeepGain = ctx.createGain();
thirdBeepGain.connect(ctx.destination);
thirdBeepGain.gain.setValueAtTime(0.01, ctx.currentTime + thirdBeepOffset);
thirdBeepGain.gain.exponentialRampToValueAtTime(
maxGain,
ctx.currentTime + thirdBeepOffset + fadeDuration
);
thirdBeepGain.gain.setValueAtTime(
maxGain,
ctx.currentTime + thirdBeepOffset + (duration - fadeDuration)
);
thirdBeepGain.gain.exponentialRampToValueAtTime(
0.01,
ctx.currentTime + thirdBeepOffset + duration
);
const thirdBeep = ctx.createOscillator();
thirdBeep.connect(thirdBeepGain);
thirdBeep.frequency.value = 900;
thirdBeep.type = "sine";
firstBeep.start(ctx.currentTime);
firstBeep.stop(ctx.currentTime + duration);
secondBeep.start(ctx.currentTime + secondBeepOffset);
secondBeep.stop(ctx.currentTime + (secondBeepOffset + duration));
thirdBeep.start(ctx.currentTime + thirdBeepOffset);
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,65 @@
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._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._reconnecting) return;
if (this._closing) return; this._eventTarget.dispatchEvent(new Event("reconnecting"));
if (this._reconnecting) return; this._reconnecting = true;
this._eventTarget.dispatchEvent(new Event("reconnecting")); this.connected = false;
this._reconnecting = true; this._backoff *= 2; // exponential backoff
this.connected = false; setTimeout(() => {
this._backoff *= 2; // exponential backoff this._connect();
setTimeout(() => { }, Math.floor(this._backoff + Math.random() * this._backoff * 0.25 - this._backoff * 0.125));
this._connect(); }
}, Math.floor(this._backoff + Math.random() * this._backoff * 0.25 - this._backoff * 0.125)); send(message) {
} if (this.connected) {
send(message) { this._socket.send(message);
if (this.connected) { } else {
this._socket.send(message); this._unsent.push(message);
} else { }
this._unsent.push(message); }
} addEventListener(...a) {
} return this._eventTarget.addEventListener(...a);
close() { }
this._closing = true; removeEventListener(...a) {
this._socket.close(); return this._eventTarget.removeEventListener(...a);
} }
addEventListener(...a) { }
return this._eventTarget.addEventListener(...a);
}
removeEventListener(...a) {
return this._eventTarget.removeEventListener(...a);
}
}

View file

@ -1,7 +0,0 @@
let instance = null;
export const state = () => {
if (!instance) {
instance = {};
}
return instance;
};

View file

@ -1,112 +1,158 @@
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 video = document.createElement("video");
navigator.mediaSession.setActionHandler("play", null); video.controls = true;
navigator.mediaSession.setActionHandler("pause", null); video.autoplay = false;
navigator.mediaSession.setActionHandler("stop", null); video.volume = loadVolume();
navigator.mediaSession.setActionHandler("seekbackward", null); video.crossOrigin = "anonymous";
navigator.mediaSession.setActionHandler("seekforward", null);
navigator.mediaSession.setActionHandler("seekto", null); video.addEventListener("volumechange", async () => {
navigator.mediaSession.setActionHandler("previoustrack", null); saveVolume(video.volume);
navigator.mediaSession.setActionHandler("nexttrack", null); });
} else {
// disable media button support by ignoring the events const source = document.createElement("source");
navigator.mediaSession.setActionHandler("play", () => {}); source.src = videoUrl;
navigator.mediaSession.setActionHandler("pause", () => {});
navigator.mediaSession.setActionHandler("stop", () => {}); video.appendChild(source);
navigator.mediaSession.setActionHandler("seekbackward", () => {});
navigator.mediaSession.setActionHandler("seekforward", () => {}); const storedTrack = loadCaptionTrack();
navigator.mediaSession.setActionHandler("seekto", () => {}); let id = 0;
navigator.mediaSession.setActionHandler("previoustrack", () => {}); for (const { name, url } of subtitles) {
navigator.mediaSession.setActionHandler("nexttrack", () => {}); const track = document.createElement("track");
} track.label = name;
}; track.src = url;
setControlsEnabled(controlsEnabled); track.kind = "captions";
lockButton.addEventListener("click", () =>
setControlsEnabled(!controlsEnabled) if (id == storedTrack) {
); track.default = true;
window.__plyr = player; }
return player; video.appendChild(track);
}; id++;
}
/**
* @param {string} videoUrl video.textTracks.addEventListener("change", async () => {
* @param {{name: string, url: string}[]} subtitles let id = 0;
* @param {number} currentTime for (const track of video.textTracks) {
* @param {boolean} playing if (track.mode != "disabled") {
*/ saveCaptionsTrack(id);
export const setupVideo = async ( return;
videoUrl, }
subtitles, id++;
currentTime, }
playing, saveCaptionsTrack(-1);
created });
) => {
document.querySelector("#pre-join-controls").style["display"] = "none"; // watch for attribute changes on the video object to detect hiding/showing of controls
const player = createVideoElement(videoUrl, subtitles, created); // as far as i can tell this is the least hacky solutions to get control visibility change events
player.currentTime = currentTime / 1000.0; const observer = new MutationObserver(async (mutations) => {
for (const mutation of mutations) {
try { if (mutation.attributeName == "controls") {
if (playing) { if (video.controls) {
player.play(); // enable media button support
} else { navigator.mediaSession.setActionHandler("play", null);
player.pause(); navigator.mediaSession.setActionHandler("pause", null);
} navigator.mediaSession.setActionHandler("stop", null);
} catch (err) { navigator.mediaSession.setActionHandler("seekbackward", null);
// Auto-play is probably disabled, we should uhhhhhhh do something about it navigator.mediaSession.setActionHandler("seekforward", null);
} navigator.mediaSession.setActionHandler("seekto", null);
navigator.mediaSession.setActionHandler("previoustrack", null);
return player; 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,251 @@
import { setupVideo } from "./video.mjs?v=4b61c4"; import { setupVideo } from "./video.mjs?v=048af96";
import { import {
setupChat, setupChat,
logEventToChat, logEventToChat,
updateViewerList, updateViewerList,
printChatMessage, } from "./chat.mjs?v=048af96";
} from "./chat.mjs?v=4b61c4"; import ReconnectingWebSocket from "./reconnecting-web-socket.mjs";
import ReconnectingWebSocket from "./reconnecting-web-socket.mjs";
import { state } from "./state.mjs"; /**
let player; * @param {string} sessionId
/** * @param {string} nickname
* @param {string} sessionId * @returns {ReconnectingWebSocket}
* @param {string} nickname */
* @returns {ReconnectingWebSocket} const createWebSocket = (sessionId, nickname, colour) => {
*/ const wsUrl = new URL(
const createWebSocket = () => { `/sess/${sessionId}/subscribe` +
const wsUrl = new URL( `?nickname=${encodeURIComponent(nickname)}` +
`/sess/${state().sessionId}/subscribe` + `&colour=${encodeURIComponent(colour)}`,
`?nickname=${encodeURIComponent(state().nickname)}` + window.location.href
`&colour=${encodeURIComponent(state().colour)}`, );
window.location.href wsUrl.protocol = "ws" + window.location.protocol.slice(4);
); const socket = new ReconnectingWebSocket(wsUrl);
wsUrl.protocol = "ws" + window.location.protocol.slice(4);
const socket = new ReconnectingWebSocket(wsUrl); return socket;
};
return socket;
}; let outgoingDebounce = false;
let outgoingDebounceCallbackId = null;
let outgoingDebounce = false;
let outgoingDebounceCallbackId = null; export const setDebounce = () => {
outgoingDebounce = true;
export const setDebounce = () => {
outgoingDebounce = true; if (outgoingDebounceCallbackId) {
cancelIdleCallback(outgoingDebounceCallbackId);
if (outgoingDebounceCallbackId) { outgoingDebounceCallbackId = null;
cancelIdleCallback(outgoingDebounceCallbackId); }
outgoingDebounceCallbackId = null;
} outgoingDebounceCallbackId = setTimeout(() => {
outgoingDebounce = false;
outgoingDebounceCallbackId = setTimeout(() => { }, 500);
outgoingDebounce = false; };
}, 500);
}; export const setVideoTime = (time, video = null) => {
if (video == null) {
export const setVideoTime = (time) => { video = document.querySelector("video");
const timeSecs = time / 1000.0; }
if (Math.abs(player.currentTime - timeSecs) > 0.5) {
player.currentTime = timeSecs; const timeSecs = time / 1000.0;
} if (Math.abs(video.currentTime - timeSecs) > 0.5) {
}; video.currentTime = timeSecs;
}
export const setPlaying = async (playing) => { };
if (playing) {
await player.play(); export const setPlaying = async (playing, video = null) => {
} else { if (video == null) {
player.pause(); video = document.querySelector("video");
} }
};
if (playing) {
/** await video.play();
* @param {HTMLVideoElement} video } else {
* @param {ReconnectingWebSocket} socket video.pause();
*/ }
const setupIncomingEvents = (player, socket) => { };
socket.addEventListener("message", async (messageEvent) => {
try { /**
const event = JSON.parse(messageEvent.data); * @param {HTMLVideoElement} video
if (!event.reflected) { * @param {ReconnectingWebSocket} socket
switch (event.op) { */
case "SetPlaying": const setupIncomingEvents = (video, socket) => {
setDebounce(); socket.addEventListener("message", async (messageEvent) => {
try {
if (event.data.playing) { const event = JSON.parse(messageEvent.data);
await player.play(); if (!event.reflected) {
} else { switch (event.op) {
player.pause(); case "SetPlaying":
} setDebounce();
setVideoTime(event.data.time); if (event.data.playing) {
break; await video.play();
case "SetTime": } else {
setDebounce(); video.pause();
setVideoTime(event.data); }
break;
case "UpdateViewerList": setVideoTime(event.data.time, video);
updateViewerList(event.data); break;
break; case "SetTime":
} setDebounce();
} setVideoTime(event.data, video);
break;
logEventToChat(event); case "UpdateViewerList":
} catch (_err) {} updateViewerList(event.data);
}); break;
}; }
}
/**
* @param {Plyr} player logEventToChat(event);
* @param {ReconnectingWebSocket} socket } catch (_err) {}
*/ });
const setupOutgoingEvents = (player, socket) => { };
const currentVideoTime = () => (player.currentTime * 1000) | 0;
/**
player.on("pause", async () => { * @param {HTMLVideoElement} video
if (outgoingDebounce || player.elements.inputs.seek.disabled) { * @param {ReconnectingWebSocket} socket
return; */
} const setupOutgoingEvents = (video, socket) => {
const currentVideoTime = () => (video.currentTime * 1000) | 0;
// don't send a pause event for the video ending
if (player.currentTime == player.duration) { video.addEventListener("pause", async (event) => {
return; if (outgoingDebounce || !video.controls) {
} return;
}
socket.send(
JSON.stringify({ // don't send a pause event for the video ending
op: "SetPlaying", if (video.currentTime == video.duration) {
data: { return;
playing: false, }
time: currentVideoTime(),
}, socket.send(
}) JSON.stringify({
); op: "SetPlaying",
}); data: {
playing: false,
player.on("play", () => { time: currentVideoTime(),
if (outgoingDebounce || player.elements.inputs.seek.disabled) { },
return; })
} );
});
socket.send(
JSON.stringify({ video.addEventListener("play", (event) => {
op: "SetPlaying", if (outgoingDebounce || !video.controls) {
data: { return;
playing: true, }
time: currentVideoTime(),
}, socket.send(
}) JSON.stringify({
); op: "SetPlaying",
}); data: {
playing: true,
let firstSeekComplete = false; time: currentVideoTime(),
player.on("seeked", async (event) => { },
if (!firstSeekComplete) { })
// The first seeked event is performed by the browser when the video is loading );
firstSeekComplete = true; });
return;
} let firstSeekComplete = false;
video.addEventListener("seeked", async (event) => {
if (outgoingDebounce || player.elements.inputs.seek.disabled) { if (!firstSeekComplete) {
return; // The first seeked event is performed by the browser when the video is loading
} firstSeekComplete = true;
return;
socket.send( }
JSON.stringify({
op: "SetTime", if (outgoingDebounce || !video.controls) {
data: { return;
to: currentVideoTime(), }
},
}) socket.send(
); JSON.stringify({
}); op: "SetTime",
}; data: {
to: currentVideoTime(),
export const joinSession = async (created) => { },
if (state().activeSession) { })
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"); * @param {string} nickname
messageContent.appendChild(document.createTextNode("joining new session ")); * @param {string} sessionId
messageContent.appendChild(document.createTextNode(state().sessionId)); */
export const joinSession = async (nickname, sessionId, colour) => {
printChatMessage("join-session", "watch-party", "#fffff", messageContent); // try { // we are handling errors in the join form.
} const genericConnectionError = new Error(
state().activeSession = state().sessionId; "There was an issue getting the session information."
);
// try { // we are handling errors in the join form. window.location.hash = sessionId;
const genericConnectionError = new Error( let response, video_url, subtitle_tracks, current_time_ms, is_playing;
"There was an issue getting the session information." try {
); response = await fetch(`/sess/${sessionId}`);
window.location.hash = state().sessionId; } catch (e) {
let response, video_url, subtitle_tracks, current_time_ms, is_playing; console.error(e);
try { throw genericConnectionError;
response = await fetch(`/sess/${state().sessionId}`); }
} catch (e) { if (!response.ok) {
console.error(e); let error;
throw genericConnectionError; try {
} ({ error } = await response.json());
if (!response.ok) { if (!error) throw new Error();
let error; } catch (e) {
try { console.error(e);
({ error } = await response.json()); throw genericConnectionError;
if (!error) throw new Error(); }
} catch (e) { throw new Error(error);
console.error(e); }
throw genericConnectionError; try {
} ({ video_url, subtitle_tracks, current_time_ms, is_playing } =
throw new Error(error); await response.json());
} } catch (e) {
try { console.error(e);
({ video_url, subtitle_tracks, current_time_ms, is_playing } = throw genericConnectionError;
await response.json()); }
} catch (e) {
console.error(e); const socket = createWebSocket(sessionId, nickname, colour);
throw genericConnectionError; socket.addEventListener("open", async () => {
} const video = await setupVideo(
video_url,
if (state().socket) { subtitle_tracks,
state().socket.close(); current_time_ms,
state().socket = null; is_playing
} );
const socket = createWebSocket();
state().socket = socket; // By default, we should disable video controls if the video is already playing.
socket.addEventListener("open", async () => { // This solves an issue where Safari users join and seek to 00:00:00 because of
player = await setupVideo( // outgoing events.
video_url, if (current_time_ms != 0) {
subtitle_tracks, video.controls = false;
current_time_ms, }
is_playing,
created setupOutgoingEvents(video, socket);
); setupIncomingEvents(video, socket);
setupChat(socket, nickname);
player.on("canplay", () => { });
sync(); socket.addEventListener("reconnecting", (e) => {
}); console.log("Reconnecting...");
});
setupOutgoingEvents(player, socket); socket.addEventListener("reconnected", (e) => {
setupIncomingEvents(player, socket); console.log("Reconnected.");
setupChat(socket); });
}); //} catch (e) {
socket.addEventListener("reconnecting", (e) => { // alert(e.message)
console.log("Reconnecting..."); //}
}); };
socket.addEventListener("reconnected", (e) => {
console.log("Reconnected."); /**
}); * @param {string} videoUrl
//} catch (e) { * @param {Array} subtitleTracks
// alert(e.message) */
//} export const createSession = async (videoUrl, subtitleTracks) => {
}; const { id } = await fetch("/start_session", {
method: "POST",
/** headers: { "Content-Type": "application/json" },
* @param {string} videoUrl body: JSON.stringify({
* @param {Array} subtitleTracks video_url: videoUrl,
*/ subtitle_tracks: subtitleTracks,
export const createSession = async (videoUrl, subtitleTracks) => { }),
const { id } = await fetch("/start_session", { }).then((r) => r.json());
method: "POST",
headers: { "Content-Type": "application/json" }, window.location = `/?created=true#${id}`;
body: JSON.stringify({ };
video_url: videoUrl,
subtitle_tracks: subtitleTracks,
}),
}).then((r) => r.json());
window.location = `/?created=true#${id}`;
};
export const sync = async () => {
setDebounce();
await setPlaying(false);
const { current_time_ms, is_playing } = await fetch(
`/sess/${state().sessionId}`
).then((r) => r.json());
setDebounce();
setVideoTime(current_time_ms);
if (is_playing) await setPlaying(is_playing);
};

View file

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