Compare commits

..

1 Commits

27 changed files with 1236 additions and 2368 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

1
.gitignore vendored
View File

@ -1,2 +1 @@
/target /target
/frontend/emojis/*

View File

@ -10,6 +10,6 @@ once_cell = "1.8.0"
serde = { version = "1.0.130", features = ["derive"] } serde = { version = "1.0.130", features = ["derive"] }
serde_json = "1.0.68" serde_json = "1.0.68"
tokio = { version = "1.12.0", features = ["full"] } tokio = { version = "1.12.0", features = ["full"] }
tokio-stream = { version = "0.1.7", features = ["fs"] } tokio-stream = "0.1.7"
uuid = { version = "0.8.2", features = ["v4"] } uuid = { version = "0.8.2", features = ["v4"] }
warp = "0.3.1" warp = "0.3.1"

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=5" />
</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>
</form>
<p>
Already have a session? <p>
<a href="/">Join your session</a> instead. Already have a session?
</p> <a href="/">Join your session</a> instead.
</form> </p>
</div> </div>
<script type="module" src="/create.mjs?v=4b61c4"></script> <script type="module" src="/create.mjs?v=5"></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=5";
const main = () => { const main = () => {
setupCreateSessionForm(); setupCreateSessionForm();
}; };
if (document.readyState === "complete") { if (document.readyState === "complete") {
main(); main();
} else { } else {
document.addEventListener("DOMContentLoaded", main); document.addEventListener("DOMContentLoaded", main);
} }

View File

@ -1,85 +1,57 @@
<!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=5" />
<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" required
maxlength="50" />
required
/> <label for="join-session-id">Session ID:</label>
<input
<label id="join-session-colour-label" for="join-session-colour"> type="text"
Personal Colour: id="join-session-id"
</label> placeholder="123e4567-e89b-12d3-a456-426614174000"
<input type="color" id="join-session-colour" value="#ffffff" required /> required
/>
<label for="join-session-id">Session ID:</label> <button id="join-session-button">Join</button>
<input </form>
type="text"
id="join-session-id" <p>
placeholder="123e4567-e89b-12d3-a456-426614174000" No session to join? <a href="/create.html">Create a session</a> instead.
required </p>
/> </div>
<button id="join-session-button">Join</button>
<div id="video-container"></div>
<p> <div id="chatbox-container">
No session to join? <div id="chatbox"></div>
<a href="/create.html">Create a session</a> instead. <form id="chatbox-send">
</p> <input type="text" placeholder="Message..." />
</form> </form>
</div> </div>
<div id="video-container"></div> <script type="module" src="/main.mjs?v=5"></script>
<div id="chatbox-container"> </body>
<div id="viewer-list"></div> </html>
<div id="chatbox"></div>
<form id="chatbox-send">
<input
type="text"
placeholder="Message... (/help for commands)"
list="emoji-autocomplete"
/>
<div id="emoji-autocomplete"></div>
<!-- DO NOT ADD SPACING INSIDE THE TAG IT WILL BREAK THE CSS kthxbye -->
</form>
</div>
<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,183 @@
import { const setupChatboxEvents = (socket) => {
setDebounce, // clear events by just reconstructing the form
setVideoTime, const oldChatForm = document.querySelector("#chatbox-send");
setPlaying, const chatForm = oldChatForm.cloneNode(true);
sync, oldChatForm.replaceWith(chatForm);
} from "./watch-session.mjs?v=4b61c4";
import { emojify, findEmojis } from "./emojis.mjs?v=4b61c4"; chatForm.addEventListener("submit", (e) => {
import { linkify } from "./links.mjs?v=4b61c4"; e.preventDefault();
import { joinSession } from "./watch-session.mjs?v=4b61c4";
import { pling } from "./pling.mjs?v=4b61c4"; const input = chatForm.querySelector("input");
import { state } from "./state.mjs"; const content = input.value;
if (content.trim().length) {
function setCaretPosition(elem, caretPos) { input.value = "";
if (elem.createTextRange) {
var range = elem.createTextRange(); socket.send(
range.move("character", caretPos); JSON.stringify({
range.select(); op: "ChatMessage",
} else { data: content,
if (elem.selectionStart) { })
elem.focus(); );
elem.setSelectionRange(caretPos, caretPos); }
} else elem.focus(); });
} };
}
const fixChatSize = () => {
const setupChatboxEvents = (socket) => { const video = document.querySelector("video");
// clear events by just reconstructing the form const chatbox = document.querySelector("#chatbox");
const oldChatForm = document.querySelector("#chatbox-send"); const chatboxContainer = document.querySelector("#chatbox-container");
const chatForm = oldChatForm.cloneNode(true);
const messageInput = chatForm.querySelector("input"); if (video && chatbox && chatboxContainer) {
const emojiAutocomplete = chatForm.querySelector("#emoji-autocomplete"); const delta = chatboxContainer.clientHeight - chatbox.clientHeight;
oldChatForm.replaceWith(chatForm);
chatbox.style["height"] = `calc(${
let autocompleting = false, window.innerHeight - video.clientHeight
showListTimer; }px - ${delta}px - 1em)`;
}
const replaceMessage = (message) => () => { };
messageInput.value = message;
autocomplete(); /**
}; * @param {WebSocket} socket
async function autocomplete(fromListTimeout) { */
if (autocompleting) return; export const setupChat = async (socket) => {
try { document.querySelector("#chatbox-container").style["display"] = "block";
clearInterval(showListTimer); setupChatboxEvents(socket);
emojiAutocomplete.textContent = "";
autocompleting = true; fixChatSize();
let text = messageInput.value.slice(0, messageInput.selectionStart); window.addEventListener("resize", () => {
const match = text.match(/(:[^\s:]+)?:([^\s:]{2,})$/); fixChatSize();
if (!match || match[1]) return (autocompleting = false); // We don't need to autocomplete. });
const prefix = text.slice(0, match.index); };
const search = text.slice(match.index + 1);
if (search.length < 1 && !fromListTimeout) { const addToChat = (node) => {
autocompleting = false; const chatbox = document.querySelector("#chatbox");
showListTimer = setTimeout(() => autocomplete(true), 500); chatbox.appendChild(node);
return; chatbox.scrollTop = chatbox.scrollHeight;
} };
const suffix = messageInput.value.slice(messageInput.selectionStart);
let selected; let lastTimeMs = null;
const select = (button) => { let lastPlaying = false;
if (selected) selected.classList.remove("selected");
selected = button; const checkDebounce = (event) => {
button.classList.add("selected"); let timeMs = null;
}; let playing = null;
let results = await findEmojis(search); if (event.op == "SetTime") {
let yieldAt = performance.now() + 13; timeMs = event.data;
for (let i = 0; i < results.length; i += 100) { } else if (event.op == "SetPlaying") {
emojiAutocomplete.append.apply( timeMs = event.data.time;
emojiAutocomplete, playing = event.data.playing;
results.slice(i, i + 100).map(([name, replaceWith, ext], i) => { }
const button = Object.assign(document.createElement("button"), {
className: "emoji-option", let shouldIgnore = false;
onmousedown: (e) => e.preventDefault(),
onclick: () => { if (timeMs != null) {
messageInput.value = prefix + replaceWith + " " + suffix; if (lastTimeMs && Math.abs(lastTimeMs - timeMs) < 500) {
setCaretPosition( shouldIgnore = true;
messageInput, }
(prefix + " " + replaceWith).length lastTimeMs = timeMs;
); }
},
onmouseover: () => select(button), if (playing != null) {
onfocus: () => select(button), if (lastPlaying != playing) {
type: "button", shouldIgnore = false;
title: name, }
}); lastPlaying = playing;
button.append( }
replaceWith[0] !== ":"
? Object.assign(document.createElement("span"), { return shouldIgnore;
textContent: replaceWith, };
className: "emoji",
}) /**
: Object.assign(new Image(), { * @param {string} eventType
loading: "lazy", * @param {string?} user
src: `/emojis/${name}${ext}`, * @param {Node?} content
className: "emoji", */
}), const printChatMessage = (eventType, user, content) => {
Object.assign(document.createElement("span"), { const chatMessage = document.createElement("div");
textContent: name, chatMessage.classList.add("chat-message");
className: "emoji-name", chatMessage.classList.add(eventType);
})
); if (user != null) {
return button; const userName = document.createElement("strong");
}) userName.textContent = user;
); chatMessage.appendChild(userName);
if (i == 0 && emojiAutocomplete.children[0]) { }
emojiAutocomplete.children[0].scrollIntoView();
select(emojiAutocomplete.children[0]); chatMessage.appendChild(document.createTextNode(" "));
}
const now = performance.now(); if (content != null) {
if (now > yieldAt) { chatMessage.appendChild(content);
yieldAt = now + 13; }
await new Promise((cb) => setTimeout(cb, 0));
} addToChat(chatMessage);
}
autocompleting = false; return chatMessage;
} catch (e) { };
autocompleting = false;
} const formatTime = (ms) => {
} const seconds = Math.floor((ms / 1000) % 60);
messageInput.addEventListener("input", () => autocomplete()); const minutes = Math.floor((ms / (60 * 1000)) % 60);
messageInput.addEventListener("selectionchange", () => autocomplete()); const hours = Math.floor((ms / (3600 * 1000)) % 3600);
messageInput.addEventListener("keydown", (event) => { return `${hours < 10 ? "0" + hours : hours}:${
if (event.key == "ArrowUp" || event.key == "ArrowDown") { minutes < 10 ? "0" + minutes : minutes
let selected = document.querySelector(".emoji-option.selected"); }:${seconds < 10 ? "0" + seconds : seconds}`;
if (!selected) return; };
event.preventDefault();
selected.classList.remove("selected"); export const logEventToChat = (event) => {
selected = if (checkDebounce(event)) {
event.key == "ArrowDown" return;
? selected.nextElementSibling || selected.parentElement.children[0] }
: selected.previousElementSibling ||
selected.parentElement.children[ switch (event.op) {
selected.parentElement.children.length - 1 case "UserJoin": {
]; printChatMessage(
selected.classList.add("selected"); "user-join",
selected.scrollIntoView({ scrollMode: "if-needed", block: "nearest" }); event.user,
} document.createTextNode("joined")
if (event.key == "Tab" || event.key == "Enter") { );
let selected = document.querySelector(".emoji-option.selected"); break;
if (!selected) return; }
event.preventDefault(); case "UserLeave": {
selected.onclick(); printChatMessage(
} "user-leave",
}); event.user,
document.createTextNode("left")
chatForm.addEventListener("submit", async (e) => { );
e.preventDefault(); break;
const content = messageInput.value; }
if (content.trim().length) { case "ChatMessage": {
messageInput.value = ""; const messageContent = document.createElement("span");
messageContent.classList.add("message-content");
// handle commands messageContent.textContent = event.data;
if (content.startsWith("/")) { printChatMessage("chat-message", event.user, messageContent);
const command = content.toLowerCase().match(/^\/\S+/)[0]; break;
const args = content.slice(command.length).trim(); }
case "SetTime": {
let handled = false; const messageContent = document.createElement("span");
switch (command) { messageContent.appendChild(document.createTextNode("set the time to "));
case "/ping":
socket.send( messageContent.appendChild(
JSON.stringify({ document.createTextNode(formatTime(event.data))
op: "Ping", );
data: args,
}) printChatMessage("set-time", event.user, messageContent);
); break;
handled = true; }
break; case "SetPlaying": {
case "/sync": const messageContent = document.createElement("span");
await sync(); messageContent.appendChild(
document.createTextNode(
const syncMessageContent = document.createElement("span"); event.data.playing ? "started playing" : "paused"
syncMessageContent.appendChild( )
document.createTextNode("resynced you to ") );
); messageContent.appendChild(document.createTextNode(" at "));
syncMessageContent.appendChild( messageContent.appendChild(
document.createTextNode(formatTime(current_time_ms)) document.createTextNode(formatTime(event.data.time))
); );
printChatMessage("set-time", "/sync", "b57fdc", syncMessageContent);
handled = true; printChatMessage("set-playing", event.user, messageContent);
break;
case "/shrug": break;
socket.send( }
JSON.stringify({ }
op: "ChatMessage", };
data: `${args} ¯\\_(ツ)_/¯`.trim(),
})
);
handled = true;
break;
case "/join":
state().sessionId = args;
joinSession();
handled = true;
break;
case "/help":
const helpMessageContent = document.createElement("span");
helpMessageContent.innerHTML =
"Available commands:<br>" +
"&emsp;<code>/help</code> - display this help message<br>" +
"&emsp;<code>/ping [message]</code> - ping all viewers<br>" +
"&emsp;<code>/sync</code> - resyncs you with other viewers<br>" +
"&emsp;<code>/shrug</code> - appends ¯\\_(ツ)_/¯ to your message<br>" +
"&emsp;<code>/join [session id]</code> - joins another session";
printChatMessage(
"command-message",
"/help",
"b57fdc",
helpMessageContent
);
handled = true;
break;
default:
break;
}
if (handled) {
return;
}
}
// handle regular chat messages
socket.send(
JSON.stringify({
op: "ChatMessage",
data: content,
})
);
}
});
};
/**
* @param {WebSocket} socket
*/
export const setupChat = async (socket) => {
document.querySelector("#chatbox-container").style["display"] = "flex";
setupChatboxEvents(socket);
};
const addToChat = (node) => {
const chatbox = document.querySelector("#chatbox");
chatbox.appendChild(node);
chatbox.scrollTop = chatbox.scrollHeight;
};
let lastTimeMs = null;
let lastPlaying = false;
const checkDebounce = (event) => {
let timeMs = null;
let playing = null;
if (event.op == "SetTime") {
timeMs = event.data;
} else if (event.op == "SetPlaying") {
timeMs = event.data.time;
playing = event.data.playing;
}
let shouldIgnore = false;
if (timeMs != null) {
if (lastTimeMs && Math.abs(lastTimeMs - timeMs) < 500) {
shouldIgnore = true;
}
lastTimeMs = timeMs;
}
if (playing != null) {
if (lastPlaying != playing) {
shouldIgnore = false;
}
lastPlaying = playing;
}
return shouldIgnore;
};
/**
* @returns {string}
*/
const getCurrentTimestamp = () => {
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) => {
return ("00" + n).slice(-2);
};
/**
* @param {string} eventType
* @param {string?} user
* @param {Node?} content
*/
export const printChatMessage = (eventType, user, colour, content) => {
const chatMessage = document.createElement("div");
chatMessage.classList.add("chat-message");
chatMessage.classList.add(eventType);
chatMessage.title = getCurrentTimestamp();
if (user != null) {
const userName = document.createElement("strong");
userName.style = `--user-color: #${colour}`;
userName.textContent = user + " ";
chatMessage.appendChild(userName);
}
if (content != null) {
chatMessage.appendChild(content);
}
addToChat(chatMessage);
return chatMessage;
};
const formatTime = (ms) => {
const seconds = Math.floor((ms / 1000) % 60);
const minutes = Math.floor((ms / (60 * 1000)) % 60);
const hours = Math.floor((ms / (3600 * 1000)) % 3600);
return `${hours < 10 ? "0" + hours : hours}:${
minutes < 10 ? "0" + minutes : minutes
}:${seconds < 10 ? "0" + seconds : seconds}`;
};
export const logEventToChat = async (event) => {
if (checkDebounce(event)) {
return;
}
switch (event.op) {
case "UserJoin": {
printChatMessage(
"user-join",
event.user,
event.colour,
document.createTextNode("joined")
);
break;
}
case "UserLeave": {
printChatMessage(
"user-leave",
event.user,
event.colour,
document.createTextNode("left")
);
break;
}
case "ChatMessage": {
const messageContent = document.createElement("span");
messageContent.classList.add("message-content");
messageContent.append(...(await linkify(event.data, emojify)));
printChatMessage(
"chat-message",
event.user,
event.colour,
messageContent
);
break;
}
case "SetTime": {
const messageContent = document.createElement("span");
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(" to "));
} else {
messageContent.appendChild(document.createTextNode("set the time to "));
}
messageContent.appendChild(
document.createTextNode(formatTime(event.data.to))
);
printChatMessage("set-time", event.user, event.colour, messageContent);
break;
}
case "SetPlaying": {
const messageContent = document.createElement("span");
messageContent.appendChild(
document.createTextNode(
event.data.playing ? "started playing" : "paused"
)
);
messageContent.appendChild(document.createTextNode(" at "));
messageContent.appendChild(
document.createTextNode(formatTime(event.data.time))
);
printChatMessage("set-playing", event.user, event.colour, messageContent);
break;
}
case "Ping": {
const messageContent = document.createElement("span");
if (event.data) {
messageContent.appendChild(document.createTextNode("pinged saying: "));
messageContent.appendChild(document.createTextNode(event.data));
} else {
messageContent.appendChild(document.createTextNode("pinged"));
}
printChatMessage("ping", event.user, event.colour, messageContent);
pling();
if ("Notification" in window) {
const title = "watch party :)";
const options = {
body: event.data
? `${event.user} pinged saying: ${event.data}`
: `${event.user} pinged`,
};
if (Notification.permission === "granted") {
new Notification(title, options);
} else if (Notification.permission !== "denied") {
Notification.requestPermission().then(function (permission) {
if (permission === "granted") {
new Notification(title, options);
}
});
}
}
break;
}
}
};
export const updateViewerList = (viewers) => {
const listContainer = document.querySelector("#viewer-list");
// empty out the current list
listContainer.innerHTML = "";
// display the updated list
for (const viewer of viewers) {
const viewerElem = document.createElement("div");
const content = document.createElement("strong");
content.textContent = viewer.nickname;
content.style = `--user-color: #${viewer.colour}`;
viewerElem.appendChild(content);
listContainer.appendChild(viewerElem);
}
};

View File

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

View File

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

View File

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

View File

@ -1,79 +0,0 @@
export const pling = () => {
const maxGain = 0.3;
const duration = 0.22;
const fadeDuration = 0.1;
const secondBeepOffset = 0.05;
const thirdBeepOffset = 2 * secondBeepOffset;
const ctx = new AudioContext();
const firstBeepGain = ctx.createGain();
firstBeepGain.connect(ctx.destination);
firstBeepGain.gain.setValueAtTime(0.01, ctx.currentTime);
firstBeepGain.gain.exponentialRampToValueAtTime(
maxGain,
ctx.currentTime + fadeDuration
);
firstBeepGain.gain.setValueAtTime(
maxGain,
ctx.currentTime + (duration - fadeDuration)
);
firstBeepGain.gain.exponentialRampToValueAtTime(
0.01,
ctx.currentTime + duration
);
const firstBeep = ctx.createOscillator();
firstBeep.connect(firstBeepGain);
firstBeep.frequency.value = 400;
firstBeep.type = "sine";
const secondBeepGain = ctx.createGain();
secondBeepGain.connect(ctx.destination);
secondBeepGain.gain.setValueAtTime(0.01, ctx.currentTime + secondBeepOffset);
secondBeepGain.gain.exponentialRampToValueAtTime(
maxGain,
ctx.currentTime + secondBeepOffset + fadeDuration
);
secondBeepGain.gain.setValueAtTime(
maxGain,
ctx.currentTime + secondBeepOffset + (duration - fadeDuration)
);
secondBeepGain.gain.exponentialRampToValueAtTime(
0.01,
ctx.currentTime + secondBeepOffset + duration
);
const secondBeep = ctx.createOscillator();
secondBeep.connect(secondBeepGain);
secondBeep.frequency.value = 600;
secondBeep.type = "sine";
const thirdBeepGain = ctx.createGain();
thirdBeepGain.connect(ctx.destination);
thirdBeepGain.gain.setValueAtTime(0.01, ctx.currentTime + thirdBeepOffset);
thirdBeepGain.gain.exponentialRampToValueAtTime(
maxGain,
ctx.currentTime + thirdBeepOffset + fadeDuration
);
thirdBeepGain.gain.setValueAtTime(
maxGain,
ctx.currentTime + thirdBeepOffset + (duration - fadeDuration)
);
thirdBeepGain.gain.exponentialRampToValueAtTime(
0.01,
ctx.currentTime + thirdBeepOffset + duration
);
const thirdBeep = ctx.createOscillator();
thirdBeep.connect(thirdBeepGain);
thirdBeep.frequency.value = 900;
thirdBeep.type = "sine";
firstBeep.start(ctx.currentTime);
firstBeep.stop(ctx.currentTime + duration);
secondBeep.start(ctx.currentTime + secondBeepOffset);
secondBeep.stop(ctx.currentTime + (secondBeepOffset + duration));
thirdBeep.start(ctx.currentTime + thirdBeepOffset);
thirdBeep.stop(ctx.currentTime + (thirdBeepOffset + duration));
};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,71 +0,0 @@
export default class ReconnectingWebSocket {
constructor(url) {
if (url instanceof URL) {
this.url = url;
} else {
this.url = new URL(url);
}
this.connected = false;
this._eventTarget = new EventTarget();
this._backoff = 250; // milliseconds, doubled before use
this._lastConnect = 0;
this._socket = null;
this._unsent = [];
this._closing = false;
this._connect(true);
}
_connect(first) {
if (this._socket)
try {
this._socket.close();
} catch (e) {}
try {
this._socket = new WebSocket(this.url.href);
} catch (e) {
this._reconnecting = false;
return this._reconnect();
}
this._socket.addEventListener("close", () => this._reconnect());
this._socket.addEventListener("error", () => this._reconnect());
this._socket.addEventListener("message", ({ data }) => {
this._eventTarget.dispatchEvent(new MessageEvent("message", { data }));
});
this._socket.addEventListener("open", (e) => {
if (first) this._eventTarget.dispatchEvent(new Event("open"));
if (this._reconnecting)
this._eventTarget.dispatchEvent(new Event("reconnected"));
this._reconnecting = false;
this._backoff = 250;
this.connected = true;
while (this._unsent.length > 0) this._socket.send(this._unsent.shift());
});
}
_reconnect() {
if (this._closing) return;
if (this._reconnecting) return;
this._eventTarget.dispatchEvent(new Event("reconnecting"));
this._reconnecting = true;
this.connected = false;
this._backoff *= 2; // exponential backoff
setTimeout(() => {
this._connect();
}, Math.floor(this._backoff + Math.random() * this._backoff * 0.25 - this._backoff * 0.125));
}
send(message) {
if (this.connected) {
this._socket.send(message);
} else {
this._unsent.push(message);
}
}
close() {
this._closing = true;
this._socket.close();
}
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,93 @@
import Plyr from "./plyr-3.7.3.min.esm.js"; /**
* @param {string} videoUrl
/** * @param {{name: string, url: string}[]} subtitles
* @param {string} videoUrl */
* @param {{name: string, url: string}[]} subtitles const createVideoElement = (videoUrl, subtitles) => {
*/ const video = document.createElement("video");
const createVideoElement = (videoUrl, subtitles, created) => { video.controls = true;
const oldVideo = document.getElementById(".plyr"); video.autoplay = false;
if (oldVideo) { video.volume = 0.5;
oldVideo.remove(); video.crossOrigin = "anonymous";
}
const video = document.createElement("video"); const source = document.createElement("source");
video.id = "video"; source.src = videoUrl;
video.crossOrigin = "anonymous";
video.appendChild(source);
const source = document.createElement("source");
source.src = videoUrl; let first = true;
for (const { name, url } of subtitles) {
video.appendChild(source); const track = document.createElement("track");
track.label = name;
for (const { name, url } of subtitles) { track.src = url;
const track = document.createElement("track"); track.kind = "captions";
track.label = name;
track.srclang = "xx-" + name.toLowerCase(); if (first) {
track.src = url; track.default = true;
track.kind = "captions"; first = false;
video.appendChild(track); }
}
video.appendChild(track);
const videoContainer = document.querySelector("#video-container"); }
videoContainer.style.display = "block";
videoContainer.appendChild(video); // watch for attribute changes on the video object to detect hiding/showing of controls
// as far as i can tell this is the least hacky solutions to get control visibility change events
const player = new Plyr(video, { const observer = new MutationObserver(async (mutations) => {
clickToPlay: false, for (const mutation of mutations) {
settings: ["captions", "quality"], if (mutation.attributeName == "controls") {
autopause: false, if (video.controls) {
}); // enable media button support
player.elements.controls.insertAdjacentHTML( navigator.mediaSession.setActionHandler("play", null);
"afterbegin", navigator.mediaSession.setActionHandler("pause", null);
`<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>` navigator.mediaSession.setActionHandler("stop", null);
); navigator.mediaSession.setActionHandler("seekbackward", null);
const lockButton = player.elements.controls.children[0]; navigator.mediaSession.setActionHandler("seekforward", null);
let controlsEnabled = created; navigator.mediaSession.setActionHandler("seekto", null);
const setControlsEnabled = (enabled) => { navigator.mediaSession.setActionHandler("previoustrack", null);
controlsEnabled = enabled; navigator.mediaSession.setActionHandler("nexttrack", null);
lockButton.setAttribute("aria-pressed", enabled); navigator.mediaSession.setActionHandler("skipad", null);
lockButton.classList.toggle("plyr__control--pressed", enabled); } else {
player.elements.buttons.play[0].disabled = // disable media button support by ignoring the events
player.elements.buttons.play[1].disabled = navigator.mediaSession.setActionHandler("play", () => { });
player.elements.inputs.seek.disabled = navigator.mediaSession.setActionHandler("pause", () => { });
!enabled; navigator.mediaSession.setActionHandler("stop", () => { });
if (!enabled) { navigator.mediaSession.setActionHandler("seekbackward", () => { });
// enable media button support navigator.mediaSession.setActionHandler("seekforward", () => { });
navigator.mediaSession.setActionHandler("play", null); navigator.mediaSession.setActionHandler("seekto", () => { });
navigator.mediaSession.setActionHandler("pause", null); navigator.mediaSession.setActionHandler("previoustrack", () => { });
navigator.mediaSession.setActionHandler("stop", null); navigator.mediaSession.setActionHandler("nexttrack", () => { });
navigator.mediaSession.setActionHandler("seekbackward", null); navigator.mediaSession.setActionHandler("skipad", () => { });
navigator.mediaSession.setActionHandler("seekforward", null); }
navigator.mediaSession.setActionHandler("seekto", null); return;
navigator.mediaSession.setActionHandler("previoustrack", null); }
navigator.mediaSession.setActionHandler("nexttrack", null); }
} else { });
// disable media button support by ignoring the events observer.observe(video, { attributes: true });
navigator.mediaSession.setActionHandler("play", () => {});
navigator.mediaSession.setActionHandler("pause", () => {}); return video;
navigator.mediaSession.setActionHandler("stop", () => {}); };
navigator.mediaSession.setActionHandler("seekbackward", () => {});
navigator.mediaSession.setActionHandler("seekforward", () => {}); /**
navigator.mediaSession.setActionHandler("seekto", () => {}); * @param {string} videoUrl
navigator.mediaSession.setActionHandler("previoustrack", () => {}); * @param {{name: string, url: string}[]} subtitles
navigator.mediaSession.setActionHandler("nexttrack", () => {}); * @param {number} currentTime
} * @param {boolean} playing
}; */
setControlsEnabled(controlsEnabled); export const setupVideo = async (videoUrl, subtitles, currentTime, playing) => {
lockButton.addEventListener("click", () => document.querySelector("#pre-join-controls").style["display"] = "none";
setControlsEnabled(!controlsEnabled) const video = createVideoElement(videoUrl, subtitles);
); document.querySelector("#video-container").appendChild(video);
window.__plyr = player;
video.currentTime = currentTime / 1000.0;
return player;
}; try {
if (playing) {
/** await video.play();
* @param {string} videoUrl } else {
* @param {{name: string, url: string}[]} subtitles video.pause();
* @param {number} currentTime }
* @param {boolean} playing } catch (err) {
*/ // Auto-play is probably disabled, we should uhhhhhhh do something about it
export const setupVideo = async ( }
videoUrl,
subtitles, return video;
currentTime, };
playing,
created
) => {
document.querySelector("#pre-join-controls").style["display"] = "none";
const player = createVideoElement(videoUrl, subtitles, created);
player.currentTime = currentTime / 1000.0;
try {
if (playing) {
player.play();
} else {
player.pause();
}
} catch (err) {
// Auto-play is probably disabled, we should uhhhhhhh do something about it
}
return player;
};

View File

@ -1,270 +1,201 @@
import { setupVideo } from "./video.mjs?v=4b61c4"; import { setupVideo } from "./video.mjs?v=5";
import { import { setupChat, logEventToChat } from "./chat.mjs?v=5";
setupChat,
logEventToChat, /**
updateViewerList, * @param {string} sessionId
printChatMessage, * @param {string} nickname
} from "./chat.mjs?v=4b61c4"; * @returns {WebSocket}
import ReconnectingWebSocket from "./reconnecting-web-socket.mjs"; */
import { state } from "./state.mjs"; const createWebSocket = (sessionId, nickname) => {
let player; const wsUrl = new URL(
/** `/sess/${sessionId}/subscribe` +
* @param {string} sessionId `?nickname=${encodeURIComponent(nickname)}`,
* @param {string} nickname window.location.href
* @returns {ReconnectingWebSocket} );
*/ wsUrl.protocol = { "http:": "ws:", "https:": "wss:" }[wsUrl.protocol];
const createWebSocket = () => { const socket = new WebSocket(wsUrl.toString());
const wsUrl = new URL(
`/sess/${state().sessionId}/subscribe` + return socket;
`?nickname=${encodeURIComponent(state().nickname)}` + };
`&colour=${encodeURIComponent(state().colour)}`,
window.location.href let outgoingDebounce = false;
); let outgoingDebounceCallbackId = null;
wsUrl.protocol = "ws" + window.location.protocol.slice(4);
const socket = new ReconnectingWebSocket(wsUrl); const setDebounce = () => {
outgoingDebounce = true;
return socket;
}; if (outgoingDebounceCallbackId) {
cancelIdleCallback(outgoingDebounceCallbackId);
let outgoingDebounce = false; outgoingDebounceCallbackId = null;
let outgoingDebounceCallbackId = null; }
export const setDebounce = () => { outgoingDebounceCallbackId = setTimeout(() => {
outgoingDebounce = true; outgoingDebounce = false;
}, 500);
if (outgoingDebounceCallbackId) { };
cancelIdleCallback(outgoingDebounceCallbackId);
outgoingDebounceCallbackId = null; /**
} * @param {HTMLVideoElement} video
* @param {WebSocket} socket
outgoingDebounceCallbackId = setTimeout(() => { */
outgoingDebounce = false; const setupIncomingEvents = (video, socket) => {
}, 500); const setVideoTime = (time) => {
}; const timeSecs = time / 1000.0;
export const setVideoTime = (time) => { if (Math.abs(video.currentTime - timeSecs) > 0.5) {
const timeSecs = time / 1000.0; video.currentTime = timeSecs;
if (Math.abs(player.currentTime - timeSecs) > 0.5) { }
player.currentTime = timeSecs; };
}
}; socket.addEventListener("message", async (messageEvent) => {
try {
export const setPlaying = async (playing) => { const event = JSON.parse(messageEvent.data);
if (playing) {
await player.play(); if (!event.reflected) {
} else { switch (event.op) {
player.pause(); case "SetPlaying":
} setDebounce();
};
if (event.data.playing) {
/** await video.play();
* @param {HTMLVideoElement} video } else {
* @param {ReconnectingWebSocket} socket video.pause();
*/ }
const setupIncomingEvents = (player, socket) => {
socket.addEventListener("message", async (messageEvent) => { setVideoTime(event.data.time);
try {
const event = JSON.parse(messageEvent.data); break;
if (!event.reflected) { case "SetTime":
switch (event.op) { setDebounce();
case "SetPlaying": setVideoTime(event.data);
setDebounce(); break;
}
if (event.data.playing) { }
await player.play();
} else { logEventToChat(event);
player.pause(); } catch (_err) {}
} });
};
setVideoTime(event.data.time);
break; /**
case "SetTime": * @param {HTMLVideoElement} video
setDebounce(); * @param {WebSocket} socket
setVideoTime(event.data); */
break; const setupOutgoingEvents = (video, socket) => {
case "UpdateViewerList": const currentVideoTime = () => (video.currentTime * 1000) | 0;
updateViewerList(event.data);
break; video.addEventListener("pause", async (event) => {
} if (outgoingDebounce || !video.controls) {
} event.preventDefault();
return;
logEventToChat(event); }
} catch (_err) {}
}); // don't send a pause event for the video ending
}; if (video.currentTime == video.duration) {
return;
/** }
* @param {Plyr} player
* @param {ReconnectingWebSocket} socket socket.send(
*/ JSON.stringify({
const setupOutgoingEvents = (player, socket) => { op: "SetPlaying",
const currentVideoTime = () => (player.currentTime * 1000) | 0; data: {
playing: false,
player.on("pause", async () => { time: currentVideoTime(),
if (outgoingDebounce || player.elements.inputs.seek.disabled) { },
return; })
} );
});
// don't send a pause event for the video ending
if (player.currentTime == player.duration) { video.addEventListener("play", (event) => {
return; if (outgoingDebounce || !video.controls) {
} event.preventDefault();
return;
socket.send( }
JSON.stringify({
op: "SetPlaying", socket.send(
data: { JSON.stringify({
playing: false, op: "SetPlaying",
time: currentVideoTime(), data: {
}, playing: true,
}) time: currentVideoTime(),
); },
}); })
);
player.on("play", () => { });
if (outgoingDebounce || player.elements.inputs.seek.disabled) {
return; let firstSeekComplete = false;
} video.addEventListener("seeked", async (event) => {
if (!firstSeekComplete) {
socket.send( // The first seeked event is performed by the browser when the video is loading
JSON.stringify({ firstSeekComplete = true;
op: "SetPlaying", return;
data: { }
playing: true,
time: currentVideoTime(), if (outgoingDebounce || !video.controls) {
}, event.preventDefault();
}) return;
); }
});
socket.send(
let firstSeekComplete = false; JSON.stringify({
player.on("seeked", async (event) => { op: "SetTime",
if (!firstSeekComplete) { data: currentVideoTime(),
// The first seeked event is performed by the browser when the video is loading })
firstSeekComplete = true; );
return; });
} };
if (outgoingDebounce || player.elements.inputs.seek.disabled) { /**
return; * @param {string} nickname
} * @param {string} sessionId
*/
socket.send( export const joinSession = async (nickname, sessionId) => {
JSON.stringify({ try {
op: "SetTime", window.location.hash = sessionId;
data: {
to: currentVideoTime(), const { video_url, subtitle_tracks, current_time_ms, is_playing } =
}, await fetch(`/sess/${sessionId}`).then((r) => r.json());
})
); const socket = createWebSocket(sessionId, nickname);
}); socket.addEventListener("open", async () => {
}; const video = await setupVideo(
video_url,
export const joinSession = async (created) => { subtitle_tracks,
if (state().activeSession) { current_time_ms,
if (state().activeSession === state().sessionId) { is_playing
// we are already in this session, dont rejoin );
return;
} // By default, we should disable video controls if the video is already playing.
// we are joining a new session from an existing session // This solves an issue where Safari users join and seek to 00:00:00 because of
const messageContent = document.createElement("span"); // outgoing events.
messageContent.appendChild(document.createTextNode("joining new session ")); if (current_time_ms != 0) {
messageContent.appendChild(document.createTextNode(state().sessionId)); video.controls = false;
}
printChatMessage("join-session", "watch-party", "#fffff", messageContent);
} setupOutgoingEvents(video, socket);
state().activeSession = state().sessionId; setupIncomingEvents(video, socket);
setupChat(socket);
// try { // we are handling errors in the join form. });
const genericConnectionError = new Error( // TODO: Close listener ?
"There was an issue getting the session information." } catch (err) {
); // TODO: Show an error on the screen
window.location.hash = state().sessionId; console.error(err);
let response, video_url, subtitle_tracks, current_time_ms, is_playing; }
try { };
response = await fetch(`/sess/${state().sessionId}`);
} catch (e) { /**
console.error(e); * @param {string} videoUrl
throw genericConnectionError; * @param {Array} subtitleTracks
} */
if (!response.ok) { export const createSession = async (videoUrl, subtitleTracks) => {
let error; const { id } = await fetch("/start_session", {
try { method: "POST",
({ error } = await response.json()); headers: { "Content-Type": "application/json" },
if (!error) throw new Error(); body: JSON.stringify({
} catch (e) { video_url: videoUrl,
console.error(e); subtitle_tracks: subtitleTracks,
throw genericConnectionError; }),
} }).then((r) => r.json());
throw new Error(error);
} window.location = `/?created=true#${id}`;
try { };
({ video_url, subtitle_tracks, current_time_ms, is_playing } =
await response.json());
} catch (e) {
console.error(e);
throw genericConnectionError;
}
if (state().socket) {
state().socket.close();
state().socket = null;
}
const socket = createWebSocket();
state().socket = socket;
socket.addEventListener("open", async () => {
player = await setupVideo(
video_url,
subtitle_tracks,
current_time_ms,
is_playing,
created
);
player.on("canplay", () => {
sync();
});
setupOutgoingEvents(player, socket);
setupIncomingEvents(player, socket);
setupChat(socket);
});
socket.addEventListener("reconnecting", (e) => {
console.log("Reconnecting...");
});
socket.addEventListener("reconnected", (e) => {
console.log("Reconnected.");
});
//} catch (e) {
// alert(e.message)
//}
};
/**
* @param {string} videoUrl
* @param {Array} subtitleTracks
*/
export const createSession = async (videoUrl, subtitleTracks) => {
const { id } = await fetch("/start_session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
video_url: videoUrl,
subtitle_tracks: subtitleTracks,
}),
}).then((r) => r.json());
window.location = `/?created=true#${id}`;
};
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=5";
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,200 @@
*, :root {
*:before, --bg: rgb(28, 23, 36);
*:after { --fg: rgb(234, 234, 248);
box-sizing: border-box; --accent: hsl(275, 57%, 68%);
} }
:root { html {
--bg-rgb: 28, 23, 36; background-color: var(--bg);
--fg-rgb: 234, 234, 248; color: var(--fg);
--accent-rgb: 181, 127, 220; font-size: 1.125rem;
--fg: rgb(var(--fg-rgb)); font-family: sans-serif;
--bg: rgb(var(--bg-rgb));
--default-user-color: rgb(126, 208, 255); overflow-y: scroll;
--accent: rgb(var(--accent-rgb)); scrollbar-width: none;
--fg-transparent: rgba(var(--fg-rgb), 0.25); -ms-overflow-style: none;
--bg-transparent: rgba(var(--bg-rgb), 0.25); }
--autocomplete-bg: linear-gradient(
var(--fg-transparent), ::-webkit-scrollbar {
var(--fg-transparent) width: 0;
), background: transparent;
linear-gradient(var(--bg), var(--bg)); }
--chip-bg: linear-gradient(
var(--accent-transparent), html,
var(--accent-transparent) body {
), margin: 0;
linear-gradient(var(--bg), var(--bg)); }
--accent-transparent: rgba(var(--accent-rgb), 0.25);
--plyr-color-main: var(--accent); video {
--plyr-control-radius: 6px; display: block;
--plyr-menu-radius: 6px;
--plyr-menu-background: var(--autocomplete-bg); width: 100vw;
--plyr-menu-color: var(--fg); height: auto;
--plyr-menu-arrow-color: var(--fg);
--plyr-menu-back-border-color: var(--fg-transparent); max-width: auto;
--plyr-menu-back-border-shadow-color: transparent; max-height: 100vh;
} }
html { a {
background-color: var(--bg); color: var(--accent);
color: var(--fg); }
font-size: 1.125rem;
font-family: sans-serif; label {
} display: block;
}
html,
body { input[type="url"],
margin: 0; input[type="text"] {
padding: 0; box-sizing: border-box;
overflow: hidden;
overscroll-behavior: none; background: #fff;
width: 100%; background-clip: padding-box;
height: 100%; border: 1px solid rgba(0, 0, 0, 0.12);
} border-radius: 6px;
color: rgba(0, 0, 0, 0.8);
body { display: block;
display: flex;
flex-direction: column; margin: 0.5em 0;
} padding: 0.5em 1em;
line-height: 1.5;
.lock-controls.plyr__control--pressed svg {
opacity: 0.5; font-family: sans-serif;
} font-size: 1em;
width: 500px;
.plyr { max-width: 100%;
width: 100%;
height: 100%; resize: none;
} overflow-x: wrap;
overflow-y: scroll;
.plyr__menu__container { }
--plyr-video-control-background-hover: var(--fg-transparent);
--plyr-video-control-color-hover: var(--fg); button {
--plyr-control-radius: 4px; background-color: var(--accent);
--plyr-control-spacing: calc(0.25rem / 0.7); border: var(--accent);
--plyr-font-size-menu: 0.75rem; border-radius: 6px;
--plyr-menu-arrow-size: 0; color: #fff;
margin-bottom: 0.48rem; padding: 0.5em 1em;
max-height: 27vmin; display: inline-block;
clip-path: inset(0 0 0 0 round 4px); font-weight: 400;
scrollbar-width: thin; text-align: center;
} white-space: nowrap;
vertical-align: middle;
.plyr__menu__container .plyr__control[role="menuitemradio"]::after {
left: 10px; font-family: sans-serif;
} font-size: 1em;
width: 500px;
.plyr__menu__container max-width: 100%;
.plyr__control[role="menuitemradio"][aria-checked="true"].plyr__tab-focus::before,
.plyr__menu__container user-select: none;
.plyr__control[role="menuitemradio"][aria-checked="true"]:hover::before { border: 1px solid rgba(0, 0, 0, 0);
background: var(--accent); line-height: 1.5;
} }
[data-plyr="language"] .plyr__menu__value { button.small-button {
display: none; font-size: 0.75em;
} padding-top: 0;
padding-bottom: 0;
#video-container { }
flex-grow: 0;
flex-shrink: 1; .subtitle-track-group {
display: none; display: flex;
} }
a { .subtitle-track-group > * {
color: var(--accent); margin-top: 0 !important;
} margin-bottom: 0 !important;
margin-right: 1ch !important;
.chip { }
color: var(--fg);
background: var(--chip-bg); #pre-join-controls,
text-decoration: none; #create-controls {
padding: 0 0.5rem 0 1.45rem; width: 60%;
display: inline-flex; margin: 0 auto;
position: relative; margin-top: 4em;
font-size: 0.9rem; }
height: 1.125rem;
align-items: center; #join-session-form,
border-radius: 2rem; #create-session-form {
overflow: hidden; margin-bottom: 4em;
} }
.chip::before { #post-create-message {
content: ""; display: none;
position: absolute; width: 500px;
left: 0; max-width: 100%;
top: 0; font-size: 0.85em;
width: 1.125rem; }
height: 100%;
display: flex; #chatbox-container {
align-items: center; display: none;
justify-content: center; }
text-align: center;
background: var(--accent-transparent); .chat-message {
background-repeat: no-repeat; overflow-wrap: break-word;
background-size: 18px; }
background-position: center;
} .chat-message > strong {
color: rgb(126, 208, 255);
.join-chip::before { }
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI2ZmZiI+PHBhdGggZD0iTTggNXYxNGwxMS03eiIvPjwvc3ZnPg==");
} .chat-message.user-join,
.chat-message.user-leave {
.time-chip::before { font-style: italic;
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI2ZmZiI+PHBhdGggZD0iTTExLjk5IDJDNi40NyAyIDIgNi40OCAyIDEyczQuNDcgMTAgOS45OSAxMEMxNy41MiAyMiAyMiAxNy41MiAyMiAxMlMxNy41MiAyIDExLjk5IDJ6TTEyIDIwYy00LjQyIDAtOC0zLjU4LTgtOHMzLjU4LTggOC04IDggMy41OCA4IDgtMy41OCA4LTggOHoiLz48cGF0aCBkPSJNMTIuNSA3SDExdjZsNS4yNSAzLjE1Ljc1LTEuMjMtNC41LTIuNjd6Ii8+PC9zdmc+"); }
}
.chat-message.set-time,
label { .chat-message.set-playing {
display: block; font-style: italic;
} text-align: right;
font-size: 0.85em;
input[type="url"], }
input[type="text"] {
background: #fff; .chat-message.set-time > strong,
background-clip: padding-box; .chat-message.set-playing > strong {
border: 1px solid rgba(0, 0, 0, 0.12); color: unset;
border-radius: 6px; }
color: rgba(0, 0, 0, 0.8);
display: block; #chatbox {
padding: 0.5em 2em;
margin: 0.5em 0; min-height: 8em;
padding: 0.5em 1em; overflow-y: scroll;
line-height: 1.5; }
font-family: sans-serif; #chatbox-container {
font-size: 1em; background-color: #222;
width: 100%; }
resize: none; #chatbox-send {
overflow-x: wrap; padding: 0 2em;
overflow-y: scroll; padding-bottom: 0.5em;
} }
button:not(.plyr button) { #chatbox-send > input {
background-color: var(--accent); font-size: 0.75em;
border: var(--accent); width: 100%;
border-radius: 6px; }
color: #fff;
padding: 0.5em 1em; @media (min-aspect-ratio: 4/3) {
display: inline-block; #video-container video {
font-weight: 400; width: calc(100vw - 400px);
text-align: center; position: absolute;
white-space: nowrap; height: 100vh;
vertical-align: middle; background-color: black;
}
font-family: sans-serif;
font-size: 1em; #video-container {
width: 100%; float: left;
height: 100vh;
user-select: none; position: relative;
border: 1px solid rgba(0, 0, 0, 0); }
line-height: 1.5;
cursor: pointer; #chatbox-container {
margin: 0.5em 0; float: right;
} width: 400px;
height: 100vh !important;
button:disabled { }
filter: saturate(0.75);
opacity: 0.75; #chatbox {
cursor: default; height: calc(100vh - 5em) !important;
} }
}
button.small-button {
font-size: 0.75em;
padding-top: 0;
padding-bottom: 0;
}
.subtitle-track-group {
display: flex;
}
.subtitle-track-group > * {
margin-top: 0 !important;
margin-bottom: 0 !important;
margin-right: 1ch !important;
}
#pre-join-controls,
#create-controls {
margin: 0;
flex-grow: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
#join-session-form,
#create-session-form {
width: 500px;
max-width: 100%;
padding: 1rem;
}
#join-session-form > *:first-child,
#create-session-form > *:first-child {
margin-top: 0;
}
#post-create-message {
display: none;
width: 100%;
font-size: 0.85em;
}
#chatbox-container {
display: none;
}
.chat-message {
overflow-wrap: break-word;
margin-bottom: 0.125rem;
}
.chat-message > strong,
#viewer-list strong {
color: var(--user-color, var(--default-user-color));
}
.chat-message.user-join,
.chat-message.user-leave,
.chat-message.ping {
font-style: italic;
}
.chat-message.set-time,
.chat-message.set-playing,
.chat-message.join-session {
font-style: italic;
text-align: right;
font-size: 0.85em;
}
.chat-message.command-message {
font-size: 0.85em;
}
.chat-message.set-time > strong,
.chat-message.set-playing > strong,
.chat-message.join-session > strong {
color: unset !important;
}
.emoji {
width: 2ch;
height: 2ch;
object-fit: contain;
margin-bottom: -0.35ch;
}
#chatbox {
padding: 0.5em 1em;
overflow-y: scroll;
flex-shrink: 1;
flex-grow: 1;
}
#viewer-list {
padding: 0.5em 1em;
/* TODO: turn this into max-height instead of fixed height without breaking the chatbox height */
overflow-y: scroll;
border-bottom: var(--fg-transparent);
border-bottom-style: solid;
max-height: 4rem;
flex-shrink: 0;
}
#chatbox-container {
background-color: var(--bg);
flex-direction: column;
flex-grow: 1;
flex-shrink: 1;
flex-basis: 36ch;
min-width: 36ch;
overflow: hidden;
}
#chatbox-send {
padding: 0 1em;
padding-bottom: 0.5em;
position: relative;
}
#chatbox-send > input {
font-size: 0.75em;
width: 100%;
}
#emoji-autocomplete {
position: absolute;
bottom: 3.25rem;
background-image: var(--autocomplete-bg);
border-radius: 6px;
width: calc(100% - 2rem);
max-height: 8.5rem;
overflow-y: auto;
clip-path: inset(0 0 0 0 round 8px);
}
#emoji-autocomplete:empty {
display: none;
}
.emoji-option:not(:root) {
background: transparent;
font-size: 0.75rem;
text-align: left;
margin: 0 0.25rem;
border-radius: 4px;
width: calc(100% - 0.5rem);
display: flex;
align-items: center;
padding: 0.25rem 0.5rem;
scroll-margin: 0.25rem;
}
.emoji-option:first-child {
margin-top: 0.25rem;
}
.emoji-option:last-child {
margin-bottom: 0.25rem;
}
.emoji-option .emoji {
width: 1.25rem;
height: 1.25rem;
margin: 0 0.5rem 0 0;
font-size: 2.25ch;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
}
.emoji-name {
overflow: hidden;
text-overflow: ellipsis;
}
.emoji-option.selected {
background: var(--fg-transparent);
}
#join-session-colour {
-moz-appearance: none;
-webkit-appearance: none;
appearance: none;
border: none;
padding: 0;
border-radius: 6px;
overflow: hidden;
margin: 0.5em 0;
height: 2rem;
width: 2.5rem;
cursor: pointer;
}
input[type="color"]::-moz-color-swatch {
border: none;
margin: 0;
padding: 0;
}
input[type="color"]::-webkit-color-swatch {
border: none;
margin: 0;
padding: 0;
}
input[type="color"]::-webkit-color-swatch-wrapper {
border: none;
margin: 0;
padding: 0;
}
@media (min-aspect-ratio: 4/3) {
body {
flex-direction: row;
}
#chatbox-container {
height: 100vh !important;
flex-grow: 0;
}
#video-container {
flex-grow: 1;
}
#chatbox {
height: calc(100vh - 5em - 4em) !important;
}
}

View File

@ -1,23 +0,0 @@
#!/bin/sh
set -eu
# Get guild ID
if [ ! "$1" ];then
echo "You need to provide a Discord Guild ID."
exit 1
else
guild="$1"
fi
# Get emoji folder
emojiFolder="$(readlink -f "$(dirname $0)/../frontend/emojis/")"
# Get Discord token
printf "Token: " 1>&2
trap 'stty echo' INT EXIT
stty -echo
read token
printf "\n" 1>&2
stty echo
curl "https://discord.com/api/v9/guilds/${guild}/emojis" -H "Authorization: $token" | jq --raw-output 'map((if .animated then ".gif" else ".png" end) as $ext | "curl '"'"'https://cdn.discordapp.com/emojis/" + .id + $ext + "?size=48&quality=lossless'"'"' -o '"'${emojiFolder}/"'" + .name + $ext + "'"'"'") | join("\n")' | sh

View File

@ -1,6 +0,0 @@
#!/bin/sh
# Get emoji folder
emojiFolder="$(readlink -f "$(dirname $0)/../frontend/emojis/")"
curl 'https://raw.githubusercontent.com/iamcal/emoji-data/master/emoji.json' | jq '. | map(. as $emoji | .short_names | map([., ($emoji.unified | split("-") | map(. | split("") | map(. as $nibble | (["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F"] | index($nibble))) | reduce .[] as $item (0; (. * 16) + $item)) | map(if (. < 65536) then (.) else [55296 - 64 + (. / 1024 | floor), 56320 + (((. / 1024) - (. / 1024 | floor)) * 1024)] end) | flatten(1) | map(("\\u" + ("0000" + ({"str": "", "num": .} | until(.num < 1; {"str": (["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F"][((.num / 16) - (.num / 16 | floor)) * 16] + .str), "num": (.num / 16) | floor})).str)[-4:])) | join("") | "\"" + . + "\"" | fromjson)])) | flatten(1)' --raw-output >"$emojiFolder/unicode.json"

View File

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

View File

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

View File

@ -1,6 +0,0 @@
pub fn truncate_str(s: &str, max_chars: usize) -> &str {
match s.char_indices().nth(max_chars) {
None => s,
Some((idx, _)) => &s[..idx],
}
}

View File

@ -1,156 +1,113 @@
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::{WatchEvent, WatchEventData},
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 async fn ws_subscribe(session_uuid: Uuid, nickname: String, ws: WebSocket) {
let viewer_id = NEXT_VIEWER_ID.fetch_add(1, Ordering::Relaxed);
pub async fn ws_subscribe(session_uuid: Uuid, nickname: String, colour: String, ws: WebSocket) { let (mut viewer_ws_tx, mut viewer_ws_rx) = ws.split();
let viewer_id = NEXT_VIEWER_ID.fetch_add(1, Ordering::Relaxed);
let (mut viewer_ws_tx, mut viewer_ws_rx) = ws.split(); let (tx, rx) = mpsc::unbounded_channel::<WatchEvent>();
let mut rx = UnboundedReceiverStream::new(rx);
let (tx, rx) = mpsc::unbounded_channel::<WatchEvent>();
let mut rx = UnboundedReceiverStream::new(rx); tokio::task::spawn(async move {
while let Some(event) = rx.next().await {
tokio::task::spawn(async move { viewer_ws_tx
while let Some(event) = rx.next().await { .send(Message::text(
viewer_ws_tx serde_json::to_string(&event).expect("couldn't convert WatchEvent into JSON"),
.send(Message::text( ))
serde_json::to_string(&event).expect("couldn't convert WatchEvent into JSON"), .unwrap_or_else(|e| eprintln!("ws send error: {}", e))
)) .await;
.unwrap_or_else(|e| eprintln!("ws send error: {}", e)) }
.await; });
}
}); CONNECTED_VIEWERS.write().await.insert(
viewer_id,
let mut colour = colour; ConnectedViewer {
if colour.len() != 6 || !colour.chars().all(|x| x.is_ascii_hexdigit()) { viewer_id,
colour = String::from("7ed0ff"); session: session_uuid,
} tx,
let nickname = truncate_str(&nickname, 50).to_string(); nickname: Some(nickname.clone()),
},
CONNECTED_VIEWERS.write().await.insert( );
viewer_id,
ConnectedViewer { ws_publish(
viewer_id, session_uuid,
session: session_uuid, None,
tx, WatchEvent::new(nickname.clone(), WatchEventData::UserJoin),
nickname: Some(nickname.clone()), )
colour: Some(colour.clone()), .await;
},
); while let Some(Ok(message)) = viewer_ws_rx.next().await {
let event: WatchEventData = match message
ws_publish( .to_str()
session_uuid, .ok()
None, .and_then(|s| serde_json::from_str(s).ok())
WatchEvent::new(nickname.clone(), colour.clone(), WatchEventData::UserJoin), {
) Some(e) => e,
.await; None => continue,
};
update_viewer_list(session_uuid).await;
handle_watch_event_data(
while let Some(Ok(message)) = viewer_ws_rx.next().await { session_uuid,
let event: WatchEventData = match message &mut get_session(session_uuid).unwrap(),
.to_str() event.clone(),
.ok() );
.and_then(|s| serde_json::from_str(s).ok())
{ ws_publish(
Some(e) => e, session_uuid,
None => continue, Some(viewer_id),
}; WatchEvent::new(nickname.clone(), event),
)
let session = &mut get_session(session_uuid).unwrap(); .await;
}
// server side event modification where neccessary
let event: WatchEventData = match event { ws_publish(
WatchEventData::SetTime { from: _, to } => WatchEventData::SetTime { session_uuid,
from: Some(session.get_time_ms()), None,
to, WatchEvent::new(nickname.clone(), WatchEventData::UserLeave),
}, )
_ => event, .await;
};
CONNECTED_VIEWERS.write().await.remove(&viewer_id);
handle_watch_event_data(session_uuid, session, event.clone()); }
ws_publish( pub async fn ws_publish(session_uuid: Uuid, skip_viewer_id: Option<usize>, event: WatchEvent) {
session_uuid, for viewer in CONNECTED_VIEWERS.read().await.values() {
Some(viewer_id), if viewer.session != session_uuid {
WatchEvent::new(nickname.clone(), colour.clone(), event), continue;
) }
.await;
} let _ = viewer.tx.send(WatchEvent {
reflected: skip_viewer_id == Some(viewer.viewer_id),
ws_publish( ..event.clone()
session_uuid, });
None, }
WatchEvent::new(nickname.clone(), colour.clone(), WatchEventData::UserLeave), }
)
.await;
CONNECTED_VIEWERS.write().await.remove(&viewer_id);
update_viewer_list(session_uuid).await;
}
pub async fn ws_publish(session_uuid: Uuid, skip_viewer_id: Option<usize>, event: WatchEvent) {
for viewer in CONNECTED_VIEWERS.read().await.values() {
if viewer.session != session_uuid {
continue;
}
let _ = viewer.tx.send(WatchEvent {
reflected: skip_viewer_id == Some(viewer.viewer_id),
..event.clone()
});
}
}
async fn update_viewer_list(session_uuid: Uuid) {
let mut viewers = Vec::new();
for viewer in CONNECTED_VIEWERS.read().await.values() {
if viewer.session == session_uuid {
viewers.push(Viewer {
nickname: viewer.nickname.clone(),
colour: viewer.colour.clone(),
})
}
}
ws_publish(
session_uuid,
None,
WatchEvent::new(
String::from("server"),
String::from(""),
WatchEventData::UpdateViewerList(viewers),
),
)
.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(time) => {
watch_session.set_time_ms(to); watch_session.set_time_ms(time);
} }
_ => {} _ => {}
}; };
let _ = SESSIONS.lock().unwrap().insert(uuid, watch_session.clone()); let _ = SESSIONS.lock().unwrap().insert(uuid, watch_session.clone());
} }