Compare commits

..

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

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

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 +1,115 @@
import { joinSession } from "./watch-session.mjs?v=4b61c4"; export async function linkify(
import { state } from "./state.mjs"; text,
next = async (t) => [document.createTextNode(t)]
export async function linkify( ) {
text, let last = 0;
next = async (t) => [document.createTextNode(t)] let nodes = [];
) { let promise = Promise.resolve();
let last = 0; // matching non-urls isn't a problem, we use the browser's url parser to filter them out
let nodes = []; text.replace(
let promise = Promise.resolve(); /[^:/?#\s]+:\/\/\S+/g,
// matching non-urls isn't a problem, we use the browser's url parser to filter them out (match, index) =>
text.replace( (promise = promise.then(async () => {
/[^:/?#\s]+:\/\/\S+/g, if (last <= index) nodes.push(...(await next(text.slice(last, index))));
(match, index) => let url;
(promise = promise.then(async () => { try {
if (last <= index) nodes.push(...(await next(text.slice(last, index)))); url = new URL(match);
let url; if (url.protocol === "javascript:") throw new Error();
try { } catch (e) {
url = new URL(match); url = null;
if (url.protocol === "javascript:") throw new Error(); }
} catch (e) { if (!url) {
url = null; nodes.push(...(await next(match)));
} } else {
if (!url) { let s;
nodes.push(...(await next(match))); if (
} else { url.origin == location.origin &&
let s; url.pathname == "/" &&
if ( url.hash.length > 1
url.origin == location.origin && ) {
url.pathname == "/" && nodes.push(
url.hash.length > 1 Object.assign(document.createElement("a"), {
) { href: url.href,
nodes.push( textContent: "Join Session",
Object.assign(document.createElement("a"), { className: "chip join-chip",
textContent: "Join Session", })
className: "chip join-chip", );
onclick: () => { } else if (
state().sessionId = url.hash.substring(1); url.hostname == "xiv.st" &&
joinSession(); (s = url.pathname.match(/(\d?\d).?(\d\d)/))
}, ) {
}) if (s) {
); const date = new Date();
} else if ( date.setUTCSeconds(0);
url.hostname == "xiv.st" && date.setUTCMilliseconds(0);
(s = url.pathname.match(/(\d?\d).?(\d\d)/)) date.setUTCHours(s[1]), date.setUTCMinutes(s[2]);
) { nodes.push(
if (s) { Object.assign(document.createElement("a"), {
const date = new Date(); href: url.href,
date.setUTCSeconds(0); textContent: date.toLocaleString([], {
date.setUTCMilliseconds(0); hour: "2-digit",
date.setUTCHours(s[1]), date.setUTCMinutes(s[2]); minute: "2-digit",
nodes.push( }),
Object.assign(document.createElement("a"), { className: "chip time-chip",
href: url.href, target: "_blank",
textContent: date.toLocaleString([], { })
hour: "2-digit", );
minute: "2-digit", }
}), } else {
className: "chip time-chip", nodes.push(
target: "_blank", Object.assign(document.createElement("a"), {
}) href: url.href,
); textContent: url.href,
} target: "_blank",
} else { })
nodes.push( );
Object.assign(document.createElement("a"), { }
href: url.href, }
textContent: url.href, last = index + match.length;
target: "_blank", }))
}) );
); await promise;
} if (last < text.length) nodes.push(...(await next(text.slice(last))));
} return nodes;
last = index + match.length; }
})) const emojis = {};
);
await promise; export const emojisLoaded = Promise.all([
if (last < text.length) nodes.push(...(await next(text.slice(last)))); fetch("/emojis")
return nodes; .then((e) => e.json())
} .then((a) => {
const emojis = {}; for (let e of a) {
const name = e.slice(0, -4),
export const emojisLoaded = Promise.all([ lower = name.toLowerCase();
fetch("/emojis") emojis[lower[0]] = emojis[lower[0]] || [];
.then((e) => e.json()) emojis[lower[0]].push([name, ":" + name + ":", e.slice(-4), lower]);
.then((a) => { }
for (let e of a) { }),
const name = e.slice(0, -4), fetch("/emojis/unicode.json")
lower = name.toLowerCase(); .then((e) => e.json())
emojis[lower[0]] = emojis[lower[0]] || []; .then((a) => {
emojis[lower[0]].push([name, ":" + name + ":", e.slice(-4), lower]); 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]]);
fetch("/emojis/unicode.json") }
.then((e) => e.json()) }),
.then((a) => { ]);
for (let e of a) {
emojis[e[0][0]] = emojis[e[0][0]] || []; export async function findEmojis(search) {
emojis[e[0][0]].push([e[0], e[1], null, e[0]]); await emojisLoaded;
} let groups = [[], []];
}), if (search.length < 1) {
]); for (let letter of Object.keys(emojis).sort())
for (let emoji of emojis[letter]) {
export async function findEmojis(search) { (emoji[1][0] === ":" ? groups[0] : groups[1]).push(emoji);
await emojisLoaded; }
let groups = [[], []]; } else {
if (search.length < 1) { search = search.toLowerCase();
for (let letter of Object.keys(emojis).sort()) for (let emoji of emojis[search[0]]) {
for (let emoji of emojis[letter]) { if (search.length == 1 || emoji[3].startsWith(search)) {
(emoji[1][0] === ":" ? groups[0] : groups[1]).push(emoji); (emoji[1][0] === ":" ? groups[0] : groups[1]).push(emoji);
} }
} else { }
search = search.toLowerCase(); }
for (let emoji of emojis[search[0]]) { return [...groups[0], ...groups[1]];
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);
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,386 @@
*, *,
*:before, *:before,
*:after { *:after {
box-sizing: border-box; box-sizing: border-box;
} }
:root { :root {
--bg-rgb: 28, 23, 36; --bg-rgb: 28, 23, 36;
--fg-rgb: 234, 234, 248; --fg-rgb: 234, 234, 248;
--accent-rgb: 181, 127, 220; --accent-rgb: 181, 127, 220;
--fg: rgb(var(--fg-rgb)); --fg: rgb(var(--fg-rgb));
--bg: rgb(var(--bg-rgb)); --bg: rgb(var(--bg-rgb));
--default-user-color: rgb(126, 208, 255); --default-user-color: rgb(126, 208, 255);
--accent: rgb(var(--accent-rgb)); --accent: rgb(var(--accent-rgb));
--fg-transparent: rgba(var(--fg-rgb), 0.25); --fg-transparent: rgba(var(--fg-rgb), 0.25);
--bg-transparent: rgba(var(--bg-rgb), 0.25); --bg-transparent: rgba(var(--bg-rgb), 0.25);
--autocomplete-bg: linear-gradient( --autocomplete-bg: linear-gradient(
var(--fg-transparent), var(--fg-transparent),
var(--fg-transparent) var(--fg-transparent)
), ),
linear-gradient(var(--bg), var(--bg)); linear-gradient(var(--bg), var(--bg));
--chip-bg: linear-gradient( --chip-bg: linear-gradient(
var(--accent-transparent), var(--accent-transparent),
var(--accent-transparent) var(--accent-transparent)
), ),
linear-gradient(var(--bg), var(--bg)); linear-gradient(var(--bg), var(--bg));
--accent-transparent: rgba(var(--accent-rgb), 0.25); --accent-transparent: rgba(var(--accent-rgb), 0.25);
--plyr-color-main: var(--accent); }
--plyr-control-radius: 6px;
--plyr-menu-radius: 6px; html {
--plyr-menu-background: var(--autocomplete-bg); background-color: var(--bg);
--plyr-menu-color: var(--fg); color: var(--fg);
--plyr-menu-arrow-color: var(--fg); font-size: 1.125rem;
--plyr-menu-back-border-color: var(--fg-transparent); font-family: sans-serif;
--plyr-menu-back-border-shadow-color: transparent; }
}
html,
html { body {
background-color: var(--bg); margin: 0;
color: var(--fg); padding: 0;
font-size: 1.125rem; overflow: hidden;
font-family: sans-serif; overscroll-behavior: none;
} width: 100%;
height: 100%;
html, }
body {
margin: 0; body {
padding: 0; display: flex;
overflow: hidden; flex-direction: column;
overscroll-behavior: none; }
width: 100%;
height: 100%; video {
} display: block;
width: 100%;
body { height: 100%;
display: flex; object-fit: contain;
flex-direction: column; }
}
#video-container {
.lock-controls.plyr__control--pressed svg { flex-grow: 0;
opacity: 0.5; flex-shrink: 1;
} display: none;
}
.plyr {
width: 100%; a {
height: 100%; color: var(--accent);
} }
.plyr__menu__container { .chip {
--plyr-video-control-background-hover: var(--fg-transparent); color: var(--fg);
--plyr-video-control-color-hover: var(--fg); background: var(--chip-bg);
--plyr-control-radius: 4px; text-decoration: none;
--plyr-control-spacing: calc(0.25rem / 0.7); padding: 0 0.5rem 0 1.45rem;
--plyr-font-size-menu: 0.75rem; display: inline-flex;
--plyr-menu-arrow-size: 0; position: relative;
margin-bottom: 0.48rem; font-size: 0.9rem;
max-height: 27vmin; height: 1.125rem;
clip-path: inset(0 0 0 0 round 4px); align-items: center;
scrollbar-width: thin; border-radius: 2rem;
} overflow: hidden;
}
.plyr__menu__container .plyr__control[role="menuitemradio"]::after {
left: 10px; .chip::before {
} content: "";
position: absolute;
.plyr__menu__container left: 0;
.plyr__control[role="menuitemradio"][aria-checked="true"].plyr__tab-focus::before, top: 0;
.plyr__menu__container width: 1.125rem;
.plyr__control[role="menuitemradio"][aria-checked="true"]:hover::before { height: 100%;
background: var(--accent); display: flex;
} align-items: center;
justify-content: center;
[data-plyr="language"] .plyr__menu__value { text-align: center;
display: none; background: var(--accent-transparent);
} background-repeat: no-repeat;
background-size: 18px;
#video-container { background-position: center;
flex-grow: 0; }
flex-shrink: 1;
display: none; .join-chip::before {
} background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI2ZmZiI+PHBhdGggZD0iTTggNXYxNGwxMS03eiIvPjwvc3ZnPg==");
}
a {
color: var(--accent); .time-chip::before {
} background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI2ZmZiI+PHBhdGggZD0iTTExLjk5IDJDNi40NyAyIDIgNi40OCAyIDEyczQuNDcgMTAgOS45OSAxMEMxNy41MiAyMiAyMiAxNy41MiAyMiAxMlMxNy41MiAyIDExLjk5IDJ6TTEyIDIwYy00LjQyIDAtOC0zLjU4LTgtOHMzLjU4LTggOC04IDggMy41OCA4IDgtMy41OCA4LTggOHoiLz48cGF0aCBkPSJNMTIuNSA3SDExdjZsNS4yNSAzLjE1Ljc1LTEuMjMtNC41LTIuNjd6Ii8+PC9zdmc+");
}
.chip {
color: var(--fg); label {
background: var(--chip-bg); display: block;
text-decoration: none; }
padding: 0 0.5rem 0 1.45rem;
display: inline-flex; input[type="url"],
position: relative; input[type="text"] {
font-size: 0.9rem; background: #fff;
height: 1.125rem; background-clip: padding-box;
align-items: center; border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 2rem; border-radius: 6px;
overflow: hidden; color: rgba(0, 0, 0, 0.8);
} display: block;
.chip::before { margin: 0.5em 0;
content: ""; padding: 0.5em 1em;
position: absolute; line-height: 1.5;
left: 0;
top: 0; font-family: sans-serif;
width: 1.125rem; font-size: 1em;
height: 100%; width: 100%;
display: flex;
align-items: center; resize: none;
justify-content: center; overflow-x: wrap;
text-align: center; overflow-y: scroll;
background: var(--accent-transparent); }
background-repeat: no-repeat;
background-size: 18px; button {
background-position: center; background-color: var(--accent);
} border: var(--accent);
border-radius: 6px;
.join-chip::before { color: #fff;
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI2ZmZiI+PHBhdGggZD0iTTggNXYxNGwxMS03eiIvPjwvc3ZnPg=="); padding: 0.5em 1em;
} display: inline-block;
font-weight: 400;
.time-chip::before { text-align: center;
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI2ZmZiI+PHBhdGggZD0iTTExLjk5IDJDNi40NyAyIDIgNi40OCAyIDEyczQuNDcgMTAgOS45OSAxMEMxNy41MiAyMiAyMiAxNy41MiAyMiAxMlMxNy41MiAyIDExLjk5IDJ6TTEyIDIwYy00LjQyIDAtOC0zLjU4LTgtOHMzLjU4LTggOC04IDggMy41OCA4IDgtMy41OCA4LTggOHoiLz48cGF0aCBkPSJNMTIuNSA3SDExdjZsNS4yNSAzLjE1Ljc1LTEuMjMtNC41LTIuNjd6Ii8+PC9zdmc+"); white-space: nowrap;
} vertical-align: middle;
label { font-family: sans-serif;
display: block; font-size: 1em;
} width: 100%;
input[type="url"], user-select: none;
input[type="text"] { border: 1px solid rgba(0, 0, 0, 0);
background: #fff; line-height: 1.5;
background-clip: padding-box; cursor: pointer;
border: 1px solid rgba(0, 0, 0, 0.12); margin: 0.5em 0;
border-radius: 6px; }
color: rgba(0, 0, 0, 0.8);
display: block; button:disabled {
filter: saturate(0.75);
margin: 0.5em 0; opacity: 0.75;
padding: 0.5em 1em; cursor: default;
line-height: 1.5; }
font-family: sans-serif; button.small-button {
font-size: 1em; font-size: 0.75em;
width: 100%; padding-top: 0;
padding-bottom: 0;
resize: none; }
overflow-x: wrap;
overflow-y: scroll; .subtitle-track-group {
} display: flex;
}
button:not(.plyr button) {
background-color: var(--accent); .subtitle-track-group > * {
border: var(--accent); margin-top: 0 !important;
border-radius: 6px; margin-bottom: 0 !important;
color: #fff; margin-right: 1ch !important;
padding: 0.5em 1em; }
display: inline-block;
font-weight: 400; #pre-join-controls,
text-align: center; #create-controls {
white-space: nowrap; margin: 0;
vertical-align: middle; flex-grow: 1;
overflow-y: auto;
font-family: sans-serif; display: flex;
font-size: 1em; flex-direction: column;
width: 100%; align-items: center;
justify-content: center;
user-select: none; }
border: 1px solid rgba(0, 0, 0, 0);
line-height: 1.5; #join-session-form,
cursor: pointer; #create-session-form {
margin: 0.5em 0; width: 500px;
} max-width: 100%;
padding: 1rem;
button:disabled { }
filter: saturate(0.75);
opacity: 0.75; #join-session-form > *:first-child,
cursor: default; #create-session-form > *:first-child {
} margin-top: 0;
}
button.small-button {
font-size: 0.75em; #post-create-message {
padding-top: 0; display: none;
padding-bottom: 0; width: 100%;
} font-size: 0.85em;
}
.subtitle-track-group {
display: flex; #chatbox-container {
} display: none;
}
.subtitle-track-group > * {
margin-top: 0 !important; .chat-message {
margin-bottom: 0 !important; overflow-wrap: break-word;
margin-right: 1ch !important; margin-bottom: 0.125rem;
} }
#pre-join-controls, .chat-message > strong,
#create-controls { #viewer-list strong {
margin: 0; color: var(--user-color, var(--default-user-color));
flex-grow: 1; }
overflow-y: auto;
display: flex; .chat-message.user-join,
flex-direction: column; .chat-message.user-leave,
align-items: center; .chat-message.ping {
justify-content: center; font-style: italic;
} }
#join-session-form, .chat-message.set-time,
#create-session-form { .chat-message.set-playing {
width: 500px; font-style: italic;
max-width: 100%; text-align: right;
padding: 1rem; font-size: 0.85em;
} }
#join-session-form > *:first-child, .chat-message.command-message {
#create-session-form > *:first-child { font-size: 0.85em;
margin-top: 0; }
}
.chat-message.set-time > strong,
#post-create-message { .chat-message.set-playing > strong {
display: none; color: unset !important;
width: 100%; }
font-size: 0.85em;
} .emoji {
width: 2ch;
#chatbox-container { height: 2ch;
display: none; object-fit: contain;
} margin-bottom: -0.35ch;
}
.chat-message {
overflow-wrap: break-word; #chatbox {
margin-bottom: 0.125rem; padding: 0.5em 1em;
} overflow-y: scroll;
flex-shrink: 1;
.chat-message > strong, flex-grow: 1;
#viewer-list strong { }
color: var(--user-color, var(--default-user-color));
} #viewer-list {
padding: 0.5em 1em;
.chat-message.user-join, /* TODO: turn this into max-height instead of fixed height without breaking the chatbox height */
.chat-message.user-leave, overflow-y: scroll;
.chat-message.ping { border-bottom: var(--fg-transparent);
font-style: italic; border-bottom-style: solid;
} max-height: 4rem;
flex-shrink: 0;
.chat-message.set-time, }
.chat-message.set-playing,
.chat-message.join-session { #chatbox-container {
font-style: italic; background-color: var(--bg);
text-align: right; flex-direction: column;
font-size: 0.85em; flex-grow: 1;
} flex-shrink: 1;
flex-basis: 36ch;
.chat-message.command-message { min-width: 36ch;
font-size: 0.85em; overflow: hidden;
} }
.chat-message.set-time > strong, #chatbox-send {
.chat-message.set-playing > strong, padding: 0 1em;
.chat-message.join-session > strong { padding-bottom: 0.5em;
color: unset !important; position: relative;
} }
.emoji { #chatbox-send > input {
width: 2ch; font-size: 0.75em;
height: 2ch; width: 100%;
object-fit: contain; }
margin-bottom: -0.35ch;
} #emoji-autocomplete {
position: absolute;
#chatbox { bottom: 3.25rem;
padding: 0.5em 1em; background-image: var(--autocomplete-bg);
overflow-y: scroll; border-radius: 6px;
flex-shrink: 1; width: calc(100% - 2rem);
flex-grow: 1; max-height: 8.5rem;
} overflow-y: auto;
clip-path: inset(0 0 0 0 round 8px);
#viewer-list { }
padding: 0.5em 1em;
/* TODO: turn this into max-height instead of fixed height without breaking the chatbox height */ #emoji-autocomplete:empty {
overflow-y: scroll; display: none;
border-bottom: var(--fg-transparent); }
border-bottom-style: solid;
max-height: 4rem; .emoji-option {
flex-shrink: 0; background: transparent;
} font-size: 0.75rem;
text-align: left;
#chatbox-container { margin: 0 0.25rem;
background-color: var(--bg); border-radius: 4px;
flex-direction: column; width: calc(100% - 0.5rem);
flex-grow: 1; display: flex;
flex-shrink: 1; align-items: center;
flex-basis: 36ch; padding: 0.25rem 0.5rem;
min-width: 36ch; scroll-margin: 0.25rem;
overflow: hidden; }
}
.emoji-option:first-child {
#chatbox-send { margin-top: 0.25rem;
padding: 0 1em; }
padding-bottom: 0.5em;
position: relative; .emoji-option:last-child {
} margin-bottom: 0.25rem;
}
#chatbox-send > input {
font-size: 0.75em; .emoji-option .emoji {
width: 100%; width: 1.25rem;
} height: 1.25rem;
margin: 0 0.5rem 0 0;
#emoji-autocomplete { font-size: 2.25ch;
position: absolute; display: flex;
bottom: 3.25rem; align-items: center;
background-image: var(--autocomplete-bg); justify-content: center;
border-radius: 6px; overflow: hidden;
width: calc(100% - 2rem); flex-shrink: 0;
max-height: 8.5rem; }
overflow-y: auto;
clip-path: inset(0 0 0 0 round 8px); .emoji-name {
} overflow: hidden;
text-overflow: ellipsis;
#emoji-autocomplete:empty { }
display: none;
} .emoji-option.selected {
background: var(--fg-transparent);
.emoji-option:not(:root) { }
background: transparent;
font-size: 0.75rem; #join-session-colour {
text-align: left; -moz-appearance: none;
margin: 0 0.25rem; -webkit-appearance: none;
border-radius: 4px; appearance: none;
width: calc(100% - 0.5rem); border: none;
display: flex; padding: 0;
align-items: center; border-radius: 6px;
padding: 0.25rem 0.5rem; overflow: hidden;
scroll-margin: 0.25rem; margin: 0.5em 0;
} height: 2rem;
width: 2.5rem;
.emoji-option:first-child { cursor: pointer;
margin-top: 0.25rem; }
}
input[type="color"]::-moz-color-swatch,
.emoji-option:last-child { input[type="color"]::-webkit-color-swatch,
margin-bottom: 0.25rem; input[type="color"]::-webkit-color-swatch-wrapper {
} /* This *should* be working in Chrome, but it doesn't for reasons that are beyond me. */
border: none;
.emoji-option .emoji { margin: 0;
width: 1.25rem; padding: 0;
height: 1.25rem; }
margin: 0 0.5rem 0 0;
font-size: 2.25ch; @media (min-aspect-ratio: 4/3) {
display: flex; body {
align-items: center; flex-direction: row;
justify-content: center; }
overflow: hidden;
flex-shrink: 0; #chatbox-container {
} height: 100vh !important;
flex-grow: 0;
.emoji-name { }
overflow: hidden;
text-overflow: ellipsis; #video-container {
} flex-grow: 1;
}
.emoji-option.selected {
background: var(--fg-transparent); #chatbox {
} height: calc(100vh - 5em - 4em) !important;
}
#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());
} }