Compare commits

...

79 Commits

Author SHA1 Message Date
Charlotte Som e6d09b14c5 Update cachebust version 2023-02-02 06:38:45 +00:00
Charlotte Som 4b61c44d6a Update editorconfig if using LF endings 2023-02-02 06:36:01 +00:00
easrng f3ee2ecc83 fuck it, converting the rest to unix line endings 2023-02-01 20:29:47 -05:00
easrng 1bd7071cec use plyr for video controls 2023-02-01 20:20:07 -05:00
easrng e43184ab49 fix the color input styles on chrome 2022-07-12 20:21:36 +00:00
Charlotte Som 61c456eceb Default captions to enabled 2022-04-29 23:50:35 +01:00
Charlotte Som 0fc8953a69 Textual feedback for personal colour 2022-04-29 23:41:33 +01:00
Charlotte Som 8e19d7b34d Don't allow controls by default 2022-04-29 23:28:11 +01:00
Charlotte Som 8717e0dff2 Bust cache 2022-04-29 23:25:31 +01:00
Charlotte Som bfdcf2afed Default personal colour to white 2022-04-29 23:24:37 +01:00
Charlotte Som 8531c83574 Sort unicode emoji before emojos, let enter fill an emoji 2022-04-29 23:22:23 +01:00
Charlotte Som 0d555adf21 Remove blobcat.png
It should be gitignored anyway
2022-04-29 23:08:10 +01:00
maia arson crimew b72a7d11d7 bust cache 2022-03-30 16:28:10 +02:00
maia arson crimew ee93fb84af turn the beep into a pling 2022-03-30 16:26:34 +02:00
maia arson crimew 40b20b2157 Improve global state handling 2022-03-30 15:02:08 +02:00
maia arson crimew 9a2ac1c432 make pings send browser notifications 2022-03-30 13:45:59 +02:00
maia arson crimew 2197d2b757 make the ping sound a lot more tolerable 2022-03-30 13:28:35 +02:00
maia arson crimew 3d4ea0773d Clicking the join chip will directly join the session without a reload
also adds a /join command (most likely still requires some further debugging)
2022-03-30 13:17:44 +02:00
easrng 2d544620ed add linkification and time and join chips 2022-03-09 18:38:22 -05:00
easrng 60672a04ef oh no aaaa 2022-03-08 15:35:12 -05:00
easrng c8ccb7afc4 fix broken things 2022-03-08 15:34:55 -05:00
easrng 2adc8b9d02 fix emoji name overflow (again) and sorting 2022-03-08 15:33:14 -05:00
maia arson crimew bc434bfaed bustin it down cache style 2022-02-18 20:24:29 +01:00
easrng 048af96a19 Merge branch 'main' of lavender.software:lavender/watch-party 2022-02-18 14:22:01 -05:00
easrng ae87f2abe0 actually fix perf 2022-02-18 14:21:54 -05:00
easrng 98e1393441 tweak append stuff 2022-02-18 14:11:54 -05:00
easrng 7b1defe010 yield between appending 100 item chunks 2022-02-18 14:04:55 -05:00
maia arson crimew d1d030ede6 update cache busting tag 2022-02-18 19:50:38 +01:00
easrng a6a856c6a5 faster emoji search (kinda) 2022-02-18 13:39:53 -05:00
easrng d8d22ed99e fix emoji overflow 2022-02-18 12:31:34 -05:00
easrng d1e4acf6e8 oh good i didn't break things 2022-02-18 12:23:32 -05:00
easrng b0df07b064 i think i did something weird with git 2022-02-18 12:20:08 -05:00
maia arson crimew 2c992d49f0 add /shrug command 2022-02-18 15:19:28 +01:00
Charlotte Som eae224e3d5 Make the chat box thinner 2022-02-17 22:41:05 +00:00
Charlotte Som 1655484d89 Update cache busting tag 2022-02-17 06:25:54 +00:00
Charlotte Som 19ef7911ae Make the chatbox take up more space in the vertical layout 2022-02-17 06:24:52 +00:00
Charlotte Som ed953facb3 Don't setActionHandler with skipad
Chrome does not support the 'skipad' action, and it's not like we're
really doing anything on a media event for that anyways, so we're just
removing it here.
2022-02-17 06:17:16 +00:00
easrng b3d2e7c568 smol fixes (fix unsupported ZWJ emojis and italic text cutoff) 2022-02-16 10:51:59 -05:00
easrng 74f5ef76fd fix emojis (i broke them) 2022-02-16 10:48:34 -05:00
easrng 65212087e3 add unicode emoji autocomplete 2022-02-16 10:30:47 -05:00
easrng c7efd725b3 tweak colors 2022-02-16 08:48:25 -05:00
easrng a5e04340dd don't add emoji on rightclick 2022-02-16 08:22:11 -05:00
Charlotte Som 35329a9fbd Style tweaks: Chatbox contrast ratio, chatbox width
This makes reading the chat a little more comfortable
2022-02-16 06:09:56 +00:00
Charlotte Som ba24dbd0f7 Eliminate top-margin for nicer centering 2022-02-16 05:59:10 +00:00
Charlotte Som 941949906d Update cache busting tag (again) 2022-02-16 05:56:55 +00:00
Charlotte Som 1e57e6a615 Shrink chatbox horizontal padding
This was designed for the vertical layout
2022-02-16 05:55:53 +00:00
Charlotte Som fba47e5943 Update cache-busting tag 2022-02-16 05:53:54 +00:00
easrng e9a1b762e7 only emojify actual emojis 2022-02-15 19:42:16 -05:00
easrng e6699e05dd ui and emoji changes 2022-02-15 19:30:22 -05:00
easrng 362c990d22 Merge branch 'main' of https://git.lavender.software/charlotte/watch-party 2022-02-15 18:25:11 -05:00
Charlotte Som 24f5560d8d Fix clippy warnings & run cargo fmt 2022-02-15 23:22:24 +00:00
Charlotte Som 1f78f03b68 Add an emoji list endpoint 2022-02-15 23:15:06 +00:00
easrng 558617f644 lotsa frontend changes 2022-02-15 17:19:48 -05:00
easrng 1e73e0df72 CSS updates (flexbox and theming) and emoji autocomplete 2022-02-14 15:58:59 -05:00
easrng c0d02a9990 add emoji downloader script 2022-02-14 19:50:24 +01:00
easrng 6d57cbc4a1 add downloader script 2022-02-14 13:47:05 -05:00
easrng 0ce6b32a12 Squash: Emojis!
* Emojis!
* fix bug
* no more discord
* maia: proper cache bustin
2022-02-14 18:58:46 +01:00
maia arson crimew c9330bdb5c remember selected captions track between sessions 2022-02-14 18:05:20 +01:00
maia arson crimew af35f9a5cb remember volume between sessions 2022-02-14 17:34:56 +01:00
maia arson crimew ef50f2c4d9 add /sync command
this command resyncs you with the watch party

also added: /help command
2022-02-14 15:30:42 +01:00
maia arson crimew af4b23e879 handle chat commands better
in preparation for other commands such as for resyncing
2022-02-13 19:35:52 +01:00
maia arson crimew 72c212a100 implement a viewer list 2022-02-13 18:23:20 +01:00
maia arson crimew 951007df2a show where a user seeked from 2022-02-13 18:01:01 +01:00
maia arson crimew 852270c63f add /ping feature
this is useful for ready checks
2022-02-13 17:32:28 +01:00
maia arson crimew 1944b2824c add color picker for username color
TODO: style properly
2022-01-18 13:06:57 +01:00
maia arson crimew 244145696c Limit nickname length to 50 unicode codepoints 2022-01-18 12:55:44 +01:00
maia arson crimew 152d51f4fc allow users to pick their username colour 2022-01-18 12:42:55 +01:00
maia arson crimew 727e72d89f Merge pull request 'add message timestamp on title' (#7) from annie/watch-party:timestamps into main
Reviewed-on: charlotte/watch-party#7
2022-01-15 23:06:24 +00:00
annieversary a91a0665cb add message timestamp on title
and also change cache busting thing on so many files wtf
2022-01-15 23:03:50 +00:00
Charlotte Som 20fecd6891 Focus the chat whenever a button is pressed anywhere 2021-12-24 00:42:17 +00:00
Charlotte Som d446869a28 Get rid of legacy state change endpoints 2021-12-03 20:51:06 +00:00
Charlotte Som 1892b32589 Bump cache-busting version to 5 2021-12-03 20:28:36 +00:00
Charlotte Som e4740c757f Initialize volume to 0.5 2021-12-03 20:24:57 +00:00
Charlotte Som f42200b0fe Make controls opt-in when joining a running session 2021-12-03 20:20:08 +00:00
maia arson crimew a69f0f7318 disable join button on first click 2021-12-03 20:45:27 +01:00
maia arson crimew 7a035c5a98 Merge pull request 'Some minor frontend improvements' (#5) from nyancrimew/watch-party:minor-improvements into main
Reviewed-on: charlotte/watch-party#5
2021-11-23 02:00:30 +00:00
maia arson crimew 2e64148912 don't send a pause event for the video ending 2021-11-23 02:57:06 +01:00
maia arson crimew 26b3f78920 ignore media button events while controls are hidden
this prevents local pausing (while we already stopped syncing local events to remote with controls hidden in an earlier commit)
2021-11-23 02:57:06 +01:00
maia arson crimew bece6a5d44 Fix chat message overflow wrapping 2021-11-23 02:56:58 +01:00
27 changed files with 2368 additions and 1237 deletions

View File

@ -3,7 +3,7 @@ root = true
[*] [*]
indent_style = space indent_style = space
indent_size = 2 indent_size = 2
end_of_line = crlf end_of_line = lf
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 +1,2 @@
/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 = "0.1.7" tokio-stream = { version = "0.1.7", features = ["fs"] }
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=3" /> <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="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>
<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>
</div> </div>
<script type="module" src="/create.mjs?v=1"></script> <script type="module" src="/create.mjs?v=4b61c4"></script>
</body> </body>
</html> </html>

View File

@ -1,11 +1,11 @@
import { setupCreateSessionForm } from "./lib/create-session.mjs?v=2"; import { setupCreateSessionForm } from "./lib/create-session.mjs?v=4b61c4";
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,57 +1,85 @@
<!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=3" /> <link rel="stylesheet" href="/lib/plyr-3.7.3.css" />
</head> <link rel="stylesheet" href="/styles.css?v=4b61c4" />
</head>
<body>
<noscript> <body>
This site will <em>not</em> work without JavaScript, and there's not <noscript>
really any way around that :( This site will <em>not</em> work without JavaScript, and there's not
</noscript> really any way around that :(
</noscript>
<div id="pre-join-controls">
<form id="join-session-form"> <div id="pre-join-controls">
<h2>Join a session</h2> <form id="join-session-form">
<h2>Join a session</h2>
<p id="post-create-message">
Your session has been created successfully. Copy the current url or <p id="post-create-message">
the Session ID below and share it with your friends. :) Your session has been created successfully. Copy the current url or
</p> the Session ID below and share it with your friends. :)
</p>
<label for="join-session-nickname">Nickname:</label>
<input <label for="join-session-nickname">Nickname:</label>
type="text" <input
id="join-session-nickname" type="text"
placeholder="Nickname" id="join-session-nickname"
required placeholder="Nickname"
/> maxlength="50"
required
<label for="join-session-id">Session ID:</label> />
<input
type="text" <label id="join-session-colour-label" for="join-session-colour">
id="join-session-id" Personal Colour:
placeholder="123e4567-e89b-12d3-a456-426614174000" </label>
required <input type="color" id="join-session-colour" value="#ffffff" required />
/>
<button>Join</button> <label for="join-session-id">Session ID:</label>
</form> <input
type="text"
<p> id="join-session-id"
No session to join? <a href="/create.html">Create a session</a> instead. placeholder="123e4567-e89b-12d3-a456-426614174000"
</p> required
</div> />
<button id="join-session-button">Join</button>
<div id="video-container"></div>
<div id="chatbox-container"> <p>
<div id="chatbox"></div> No session to join?
<form id="chatbox-send"> <a href="/create.html">Create a session</a> instead.
<input type="text" placeholder="Message..." /> </p>
</form> </form>
</div> </div>
<script type="module" src="/main.mjs?v=3"></script> <div id="video-container"></div>
</body> <div id="chatbox-container">
</html> <div id="viewer-list"></div>
<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,183 +1,453 @@
const setupChatboxEvents = (socket) => { import {
// clear events by just reconstructing the form setDebounce,
const oldChatForm = document.querySelector("#chatbox-send"); setVideoTime,
const chatForm = oldChatForm.cloneNode(true); setPlaying,
oldChatForm.replaceWith(chatForm); sync,
} from "./watch-session.mjs?v=4b61c4";
chatForm.addEventListener("submit", (e) => { import { emojify, findEmojis } from "./emojis.mjs?v=4b61c4";
e.preventDefault(); import { linkify } from "./links.mjs?v=4b61c4";
import { joinSession } from "./watch-session.mjs?v=4b61c4";
const input = chatForm.querySelector("input"); import { pling } from "./pling.mjs?v=4b61c4";
const content = input.value; import { state } from "./state.mjs";
if (content.trim().length) {
input.value = ""; function setCaretPosition(elem, caretPos) {
if (elem.createTextRange) {
socket.send( var range = elem.createTextRange();
JSON.stringify({ range.move("character", caretPos);
op: "ChatMessage", range.select();
data: content, } else {
}) if (elem.selectionStart) {
); elem.focus();
} elem.setSelectionRange(caretPos, caretPos);
}); } else elem.focus();
}; }
}
const fixChatSize = () => {
const video = document.querySelector("video"); const setupChatboxEvents = (socket) => {
const chatbox = document.querySelector("#chatbox"); // clear events by just reconstructing the form
const chatboxContainer = document.querySelector("#chatbox-container"); const oldChatForm = document.querySelector("#chatbox-send");
const chatForm = oldChatForm.cloneNode(true);
if (video && chatbox && chatboxContainer) { const messageInput = chatForm.querySelector("input");
const delta = chatboxContainer.clientHeight - chatbox.clientHeight; const emojiAutocomplete = chatForm.querySelector("#emoji-autocomplete");
oldChatForm.replaceWith(chatForm);
chatbox.style["height"] = `calc(${
window.innerHeight - video.clientHeight let autocompleting = false,
}px - ${delta}px - 1em)`; showListTimer;
}
}; const replaceMessage = (message) => () => {
messageInput.value = message;
/** autocomplete();
* @param {WebSocket} socket };
*/ async function autocomplete(fromListTimeout) {
export const setupChat = async (socket) => { if (autocompleting) return;
document.querySelector("#chatbox-container").style["display"] = "block"; try {
setupChatboxEvents(socket); clearInterval(showListTimer);
emojiAutocomplete.textContent = "";
fixChatSize(); autocompleting = true;
window.addEventListener("resize", () => { let text = messageInput.value.slice(0, messageInput.selectionStart);
fixChatSize(); const match = text.match(/(:[^\s:]+)?:([^\s:]{2,})$/);
}); 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);
const addToChat = (node) => { if (search.length < 1 && !fromListTimeout) {
const chatbox = document.querySelector("#chatbox"); autocompleting = false;
chatbox.appendChild(node); showListTimer = setTimeout(() => autocomplete(true), 500);
chatbox.scrollTop = chatbox.scrollHeight; return;
}; }
const suffix = messageInput.value.slice(messageInput.selectionStart);
let lastTimeMs = null; let selected;
let lastPlaying = false; const select = (button) => {
if (selected) selected.classList.remove("selected");
const checkDebounce = (event) => { selected = button;
let timeMs = null; button.classList.add("selected");
let playing = null; };
if (event.op == "SetTime") { let results = await findEmojis(search);
timeMs = event.data; let yieldAt = performance.now() + 13;
} else if (event.op == "SetPlaying") { for (let i = 0; i < results.length; i += 100) {
timeMs = event.data.time; emojiAutocomplete.append.apply(
playing = event.data.playing; emojiAutocomplete,
} results.slice(i, i + 100).map(([name, replaceWith, ext], i) => {
const button = Object.assign(document.createElement("button"), {
let shouldIgnore = false; className: "emoji-option",
onmousedown: (e) => e.preventDefault(),
if (timeMs != null) { onclick: () => {
if (lastTimeMs && Math.abs(lastTimeMs - timeMs) < 500) { messageInput.value = prefix + replaceWith + " " + suffix;
shouldIgnore = true; setCaretPosition(
} messageInput,
lastTimeMs = timeMs; (prefix + " " + replaceWith).length
} );
},
if (playing != null) { onmouseover: () => select(button),
if (lastPlaying != playing) { onfocus: () => select(button),
shouldIgnore = false; type: "button",
} title: name,
lastPlaying = playing; });
} button.append(
replaceWith[0] !== ":"
return shouldIgnore; ? Object.assign(document.createElement("span"), {
}; textContent: replaceWith,
className: "emoji",
/** })
* @param {string} eventType : Object.assign(new Image(), {
* @param {string?} user loading: "lazy",
* @param {Node?} content src: `/emojis/${name}${ext}`,
*/ className: "emoji",
const printChatMessage = (eventType, user, content) => { }),
const chatMessage = document.createElement("div"); Object.assign(document.createElement("span"), {
chatMessage.classList.add("chat-message"); textContent: name,
chatMessage.classList.add(eventType); className: "emoji-name",
})
if (user != null) { );
const userName = document.createElement("strong"); return button;
userName.textContent = user; })
chatMessage.appendChild(userName); );
} if (i == 0 && emojiAutocomplete.children[0]) {
emojiAutocomplete.children[0].scrollIntoView();
chatMessage.appendChild(document.createTextNode(" ")); select(emojiAutocomplete.children[0]);
}
if (content != null) { const now = performance.now();
chatMessage.appendChild(content); if (now > yieldAt) {
} yieldAt = now + 13;
await new Promise((cb) => setTimeout(cb, 0));
addToChat(chatMessage); }
}
return chatMessage; autocompleting = false;
}; } catch (e) {
autocompleting = false;
const formatTime = (ms) => { }
const seconds = Math.floor((ms / 1000) % 60); }
const minutes = Math.floor((ms / (60 * 1000)) % 60); messageInput.addEventListener("input", () => autocomplete());
const hours = Math.floor((ms / (3600 * 1000)) % 3600); messageInput.addEventListener("selectionchange", () => autocomplete());
return `${hours < 10 ? "0" + hours : hours}:${ messageInput.addEventListener("keydown", (event) => {
minutes < 10 ? "0" + minutes : minutes if (event.key == "ArrowUp" || event.key == "ArrowDown") {
}:${seconds < 10 ? "0" + seconds : seconds}`; let selected = document.querySelector(".emoji-option.selected");
}; if (!selected) return;
event.preventDefault();
export const logEventToChat = (event) => { selected.classList.remove("selected");
if (checkDebounce(event)) { selected =
return; event.key == "ArrowDown"
} ? selected.nextElementSibling || selected.parentElement.children[0]
: selected.previousElementSibling ||
switch (event.op) { selected.parentElement.children[
case "UserJoin": { selected.parentElement.children.length - 1
printChatMessage( ];
"user-join", selected.classList.add("selected");
event.user, selected.scrollIntoView({ scrollMode: "if-needed", block: "nearest" });
document.createTextNode("joined") }
); if (event.key == "Tab" || event.key == "Enter") {
break; let selected = document.querySelector(".emoji-option.selected");
} if (!selected) return;
case "UserLeave": { event.preventDefault();
printChatMessage( selected.onclick();
"user-leave", }
event.user, });
document.createTextNode("left")
); chatForm.addEventListener("submit", async (e) => {
break; e.preventDefault();
} const content = messageInput.value;
case "ChatMessage": { if (content.trim().length) {
const messageContent = document.createElement("span"); messageInput.value = "";
messageContent.classList.add("message-content");
messageContent.textContent = event.data; // handle commands
printChatMessage("chat-message", event.user, messageContent); if (content.startsWith("/")) {
break; const command = content.toLowerCase().match(/^\/\S+/)[0];
} const args = content.slice(command.length).trim();
case "SetTime": {
const messageContent = document.createElement("span"); let handled = false;
messageContent.appendChild(document.createTextNode("set the time to ")); switch (command) {
case "/ping":
messageContent.appendChild( socket.send(
document.createTextNode(formatTime(event.data)) JSON.stringify({
); op: "Ping",
data: args,
printChatMessage("set-time", event.user, messageContent); })
break; );
} handled = true;
case "SetPlaying": { break;
const messageContent = document.createElement("span"); case "/sync":
messageContent.appendChild( await sync();
document.createTextNode(
event.data.playing ? "started playing" : "paused" const syncMessageContent = document.createElement("span");
) syncMessageContent.appendChild(
); document.createTextNode("resynced you to ")
messageContent.appendChild(document.createTextNode(" at ")); );
messageContent.appendChild( syncMessageContent.appendChild(
document.createTextNode(formatTime(event.data.time)) document.createTextNode(formatTime(current_time_ms))
); );
printChatMessage("set-time", "/sync", "b57fdc", syncMessageContent);
printChatMessage("set-playing", event.user, messageContent); handled = true;
break;
break; case "/shrug":
} 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=4"; import { createSession } from "./watch-session.mjs?v=4b61c4";
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);
}); });
}; };

72
frontend/lib/emojis.mjs Normal file
View File

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

121
frontend/lib/links.mjs Normal file
View File

@ -0,0 +1,121 @@
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]];
}

79
frontend/lib/pling.mjs Normal file
View File

@ -0,0 +1,79 @@
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

@ -0,0 +1,71 @@
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);
}
}

7
frontend/lib/state.mjs Normal file
View File

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

View File

@ -1,58 +1,112 @@
/** import Plyr from "./plyr-3.7.3.min.esm.js";
* @param {string} videoUrl
* @param {{name: string, url: string}[]} subtitles /**
*/ * @param {string} videoUrl
const createVideoElement = (videoUrl, subtitles) => { * @param {{name: string, url: string}[]} subtitles
const video = document.createElement("video"); */
video.controls = true; const createVideoElement = (videoUrl, subtitles, created) => {
video.autoplay = false; const oldVideo = document.getElementById(".plyr");
video.crossOrigin = "anonymous"; if (oldVideo) {
oldVideo.remove();
const source = document.createElement("source"); }
source.src = videoUrl; const video = document.createElement("video");
video.id = "video";
video.appendChild(source); video.crossOrigin = "anonymous";
let first = true; const source = document.createElement("source");
for (const { name, url } of subtitles) { source.src = videoUrl;
const track = document.createElement("track");
track.label = name; video.appendChild(source);
track.src = url;
track.kind = "captions"; for (const { name, url } of subtitles) {
const track = document.createElement("track");
if (first) { track.label = name;
track.default = true; track.srclang = "xx-" + name.toLowerCase();
first = false; track.src = url;
} track.kind = "captions";
video.appendChild(track);
video.appendChild(track); }
}
const videoContainer = document.querySelector("#video-container");
return video; videoContainer.style.display = "block";
}; videoContainer.appendChild(video);
/** const player = new Plyr(video, {
* @param {string} videoUrl clickToPlay: false,
* @param {{name: string, url: string}[]} subtitles settings: ["captions", "quality"],
* @param {number} currentTime autopause: false,
* @param {boolean} playing });
*/ player.elements.controls.insertAdjacentHTML(
export const setupVideo = async (videoUrl, subtitles, currentTime, playing) => { "afterbegin",
document.querySelector("#pre-join-controls").style["display"] = "none"; `<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 video = createVideoElement(videoUrl, subtitles); );
document.querySelector("#video-container").appendChild(video); const lockButton = player.elements.controls.children[0];
let controlsEnabled = created;
video.currentTime = currentTime / 1000.0; const setControlsEnabled = (enabled) => {
controlsEnabled = enabled;
try { lockButton.setAttribute("aria-pressed", enabled);
if (playing) { lockButton.classList.toggle("plyr__control--pressed", enabled);
await video.play(); player.elements.buttons.play[0].disabled =
} else { player.elements.buttons.play[1].disabled =
video.pause(); player.elements.inputs.seek.disabled =
} !enabled;
} catch (err) { if (!enabled) {
// Auto-play is probably disabled, we should uhhhhhhh do something about it // enable media button support
} navigator.mediaSession.setActionHandler("play", null);
navigator.mediaSession.setActionHandler("pause", null);
return video; navigator.mediaSession.setActionHandler("stop", null);
}; navigator.mediaSession.setActionHandler("seekbackward", null);
navigator.mediaSession.setActionHandler("seekforward", null);
navigator.mediaSession.setActionHandler("seekto", null);
navigator.mediaSession.setActionHandler("previoustrack", null);
navigator.mediaSession.setActionHandler("nexttrack", null);
} else {
// disable media button support by ignoring the events
navigator.mediaSession.setActionHandler("play", () => {});
navigator.mediaSession.setActionHandler("pause", () => {});
navigator.mediaSession.setActionHandler("stop", () => {});
navigator.mediaSession.setActionHandler("seekbackward", () => {});
navigator.mediaSession.setActionHandler("seekforward", () => {});
navigator.mediaSession.setActionHandler("seekto", () => {});
navigator.mediaSession.setActionHandler("previoustrack", () => {});
navigator.mediaSession.setActionHandler("nexttrack", () => {});
}
};
setControlsEnabled(controlsEnabled);
lockButton.addEventListener("click", () =>
setControlsEnabled(!controlsEnabled)
);
window.__plyr = player;
return player;
};
/**
* @param {string} videoUrl
* @param {{name: string, url: string}[]} subtitles
* @param {number} currentTime
* @param {boolean} playing
*/
export const setupVideo = async (
videoUrl,
subtitles,
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,186 +1,270 @@
import { setupVideo } from "./video.mjs?v=2"; import { setupVideo } from "./video.mjs?v=4b61c4";
import { setupChat, logEventToChat } from "./chat.mjs?v=2"; import {
setupChat,
/** logEventToChat,
* @param {string} sessionId updateViewerList,
* @param {string} nickname printChatMessage,
* @returns {WebSocket} } from "./chat.mjs?v=4b61c4";
*/ import ReconnectingWebSocket from "./reconnecting-web-socket.mjs";
const createWebSocket = (sessionId, nickname) => { import { state } from "./state.mjs";
const wsUrl = new URL( let player;
`/sess/${sessionId}/subscribe` + /**
`?nickname=${encodeURIComponent(nickname)}`, * @param {string} sessionId
window.location.href * @param {string} nickname
); * @returns {ReconnectingWebSocket}
wsUrl.protocol = { "http:": "ws:", "https:": "wss:" }[wsUrl.protocol]; */
const socket = new WebSocket(wsUrl.toString()); const createWebSocket = () => {
const wsUrl = new URL(
return socket; `/sess/${state().sessionId}/subscribe` +
}; `?nickname=${encodeURIComponent(state().nickname)}` +
`&colour=${encodeURIComponent(state().colour)}`,
let outgoingDebounce = false; window.location.href
let outgoingDebounceCallbackId = null; );
wsUrl.protocol = "ws" + window.location.protocol.slice(4);
const setDebounce = () => { const socket = new ReconnectingWebSocket(wsUrl);
outgoingDebounce = true;
return socket;
if (outgoingDebounceCallbackId) { };
cancelIdleCallback(outgoingDebounceCallbackId);
outgoingDebounceCallbackId = null; let outgoingDebounce = false;
} let outgoingDebounceCallbackId = null;
outgoingDebounceCallbackId = setTimeout(() => { export const setDebounce = () => {
outgoingDebounce = false; outgoingDebounce = true;
}, 500);
}; if (outgoingDebounceCallbackId) {
cancelIdleCallback(outgoingDebounceCallbackId);
/** outgoingDebounceCallbackId = null;
* @param {HTMLVideoElement} video }
* @param {WebSocket} socket
*/ outgoingDebounceCallbackId = setTimeout(() => {
const setupIncomingEvents = (video, socket) => { outgoingDebounce = false;
const setVideoTime = (time) => { }, 500);
const timeSecs = time / 1000.0; };
if (Math.abs(video.currentTime - timeSecs) > 0.5) { export const setVideoTime = (time) => {
video.currentTime = timeSecs; const timeSecs = time / 1000.0;
} if (Math.abs(player.currentTime - timeSecs) > 0.5) {
}; player.currentTime = timeSecs;
}
socket.addEventListener("message", async (messageEvent) => { };
try {
const event = JSON.parse(messageEvent.data); export const setPlaying = async (playing) => {
if (playing) {
if (!event.reflected) { await player.play();
switch (event.op) { } else {
case "SetPlaying": player.pause();
setDebounce(); }
};
if (event.data.playing) {
await video.play(); /**
} else { * @param {HTMLVideoElement} video
video.pause(); * @param {ReconnectingWebSocket} socket
} */
const setupIncomingEvents = (player, socket) => {
setVideoTime(event.data.time); socket.addEventListener("message", async (messageEvent) => {
try {
break; const event = JSON.parse(messageEvent.data);
case "SetTime": if (!event.reflected) {
setDebounce(); switch (event.op) {
setVideoTime(event.data); case "SetPlaying":
break; setDebounce();
}
} if (event.data.playing) {
await player.play();
logEventToChat(event); } else {
} catch (_err) {} player.pause();
}); }
};
setVideoTime(event.data.time);
/** break;
* @param {HTMLVideoElement} video case "SetTime":
* @param {WebSocket} socket setDebounce();
*/ setVideoTime(event.data);
const setupOutgoingEvents = (video, socket) => { break;
const currentVideoTime = () => (video.currentTime * 1000) | 0; case "UpdateViewerList":
updateViewerList(event.data);
video.addEventListener("pause", async (event) => { break;
if (outgoingDebounce || !video.controls) { }
return; }
}
logEventToChat(event);
socket.send( } catch (_err) {}
JSON.stringify({ });
op: "SetPlaying", };
data: {
playing: false, /**
time: currentVideoTime(), * @param {Plyr} player
}, * @param {ReconnectingWebSocket} socket
}) */
); const setupOutgoingEvents = (player, socket) => {
}); const currentVideoTime = () => (player.currentTime * 1000) | 0;
video.addEventListener("play", (event) => { player.on("pause", async () => {
if (outgoingDebounce || !video.controls) { if (outgoingDebounce || player.elements.inputs.seek.disabled) {
return; return;
} }
socket.send( // don't send a pause event for the video ending
JSON.stringify({ if (player.currentTime == player.duration) {
op: "SetPlaying", return;
data: { }
playing: true,
time: currentVideoTime(), socket.send(
}, JSON.stringify({
}) op: "SetPlaying",
); data: {
}); playing: false,
time: currentVideoTime(),
let firstSeekComplete = false; },
video.addEventListener("seeked", async (event) => { })
if (!firstSeekComplete) { );
// The first seeked event is performed by the browser when the video is loading });
firstSeekComplete = true;
return; player.on("play", () => {
} if (outgoingDebounce || player.elements.inputs.seek.disabled) {
return;
if (outgoingDebounce || !video.controls) { }
return;
} socket.send(
JSON.stringify({
socket.send( op: "SetPlaying",
JSON.stringify({ data: {
op: "SetTime", playing: true,
data: currentVideoTime(), time: currentVideoTime(),
}) },
); })
}); );
}; });
/** let firstSeekComplete = false;
* @param {string} nickname player.on("seeked", async (event) => {
* @param {string} sessionId if (!firstSeekComplete) {
*/ // The first seeked event is performed by the browser when the video is loading
export const joinSession = async (nickname, sessionId) => { firstSeekComplete = true;
try { return;
window.location.hash = sessionId; }
const { video_url, subtitle_tracks, current_time_ms, is_playing } = if (outgoingDebounce || player.elements.inputs.seek.disabled) {
await fetch(`/sess/${sessionId}`).then((r) => r.json()); return;
}
const socket = createWebSocket(sessionId, nickname);
socket.addEventListener("open", async () => { socket.send(
const video = await setupVideo( JSON.stringify({
video_url, op: "SetTime",
subtitle_tracks, data: {
current_time_ms, to: currentVideoTime(),
is_playing },
); })
);
setupOutgoingEvents(video, socket); });
setupIncomingEvents(video, socket); };
setupChat(socket);
}); export const joinSession = async (created) => {
// TODO: Close listener ? if (state().activeSession) {
} catch (err) { if (state().activeSession === state().sessionId) {
// TODO: Show an error on the screen // we are already in this session, dont rejoin
console.error(err); return;
} }
}; // we are joining a new session from an existing session
const messageContent = document.createElement("span");
/** messageContent.appendChild(document.createTextNode("joining new session "));
* @param {string} videoUrl messageContent.appendChild(document.createTextNode(state().sessionId));
* @param {Array} subtitleTracks
*/ printChatMessage("join-session", "watch-party", "#fffff", messageContent);
export const createSession = async (videoUrl, subtitleTracks) => { }
const { id } = await fetch("/start_session", { state().activeSession = state().sessionId;
method: "POST",
headers: { "Content-Type": "application/json" }, // try { // we are handling errors in the join form.
body: JSON.stringify({ const genericConnectionError = new Error(
video_url: videoUrl, "There was an issue getting the session information."
subtitle_tracks: subtitleTracks, );
}), window.location.hash = state().sessionId;
}).then((r) => r.json()); let response, video_url, subtitle_tracks, current_time_ms, is_playing;
try {
window.location = `/?created=true#${id}`; response = await fetch(`/sess/${state().sessionId}`);
}; } catch (e) {
console.error(e);
throw genericConnectionError;
}
if (!response.ok) {
let error;
try {
({ error } = await response.json());
if (!error) throw new Error();
} catch (e) {
console.error(e);
throw genericConnectionError;
}
throw new Error(error);
}
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=3"; import { setupJoinSessionForm } from "./lib/join-session.mjs?v=4b61c4";
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,196 +1,435 @@
:root { *,
--bg: rgb(28, 23, 36); *:before,
--fg: rgb(234, 234, 248); *:after {
--accent: hsl(275, 57%, 68%); box-sizing: border-box;
} }
html { :root {
background-color: var(--bg); --bg-rgb: 28, 23, 36;
color: var(--fg); --fg-rgb: 234, 234, 248;
font-size: 1.125rem; --accent-rgb: 181, 127, 220;
font-family: sans-serif; --fg: rgb(var(--fg-rgb));
--bg: rgb(var(--bg-rgb));
overflow-y: scroll; --default-user-color: rgb(126, 208, 255);
scrollbar-width: none; --accent: rgb(var(--accent-rgb));
-ms-overflow-style: none; --fg-transparent: rgba(var(--fg-rgb), 0.25);
} --bg-transparent: rgba(var(--bg-rgb), 0.25);
--autocomplete-bg: linear-gradient(
::-webkit-scrollbar { var(--fg-transparent),
width: 0; var(--fg-transparent)
background: transparent; ),
} linear-gradient(var(--bg), var(--bg));
--chip-bg: linear-gradient(
html, var(--accent-transparent),
body { var(--accent-transparent)
margin: 0; ),
} linear-gradient(var(--bg), var(--bg));
--accent-transparent: rgba(var(--accent-rgb), 0.25);
video { --plyr-color-main: var(--accent);
display: block; --plyr-control-radius: 6px;
--plyr-menu-radius: 6px;
width: 100vw; --plyr-menu-background: var(--autocomplete-bg);
height: auto; --plyr-menu-color: var(--fg);
--plyr-menu-arrow-color: var(--fg);
max-width: auto; --plyr-menu-back-border-color: var(--fg-transparent);
max-height: 100vh; --plyr-menu-back-border-shadow-color: transparent;
} }
a { html {
color: var(--accent); background-color: var(--bg);
} color: var(--fg);
font-size: 1.125rem;
label { font-family: sans-serif;
display: block; }
}
html,
input[type="url"], body {
input[type="text"] { margin: 0;
box-sizing: border-box; padding: 0;
overflow: hidden;
background: #fff; overscroll-behavior: none;
background-clip: padding-box; width: 100%;
border: 1px solid rgba(0, 0, 0, 0.12); height: 100%;
border-radius: 6px; }
color: rgba(0, 0, 0, 0.8);
display: block; body {
display: flex;
margin: 0.5em 0; flex-direction: column;
padding: 0.5em 1em; }
line-height: 1.5;
.lock-controls.plyr__control--pressed svg {
font-family: sans-serif; opacity: 0.5;
font-size: 1em; }
width: 500px;
max-width: 100%; .plyr {
width: 100%;
resize: none; height: 100%;
overflow-x: wrap; }
overflow-y: scroll;
} .plyr__menu__container {
--plyr-video-control-background-hover: var(--fg-transparent);
button { --plyr-video-control-color-hover: var(--fg);
background-color: var(--accent); --plyr-control-radius: 4px;
border: var(--accent); --plyr-control-spacing: calc(0.25rem / 0.7);
border-radius: 6px; --plyr-font-size-menu: 0.75rem;
color: #fff; --plyr-menu-arrow-size: 0;
padding: 0.5em 1em; margin-bottom: 0.48rem;
display: inline-block; max-height: 27vmin;
font-weight: 400; clip-path: inset(0 0 0 0 round 4px);
text-align: center; scrollbar-width: thin;
white-space: nowrap; }
vertical-align: middle;
.plyr__menu__container .plyr__control[role="menuitemradio"]::after {
font-family: sans-serif; left: 10px;
font-size: 1em; }
width: 500px;
max-width: 100%; .plyr__menu__container
.plyr__control[role="menuitemradio"][aria-checked="true"].plyr__tab-focus::before,
user-select: none; .plyr__menu__container
border: 1px solid rgba(0, 0, 0, 0); .plyr__control[role="menuitemradio"][aria-checked="true"]:hover::before {
line-height: 1.5; background: var(--accent);
} }
button.small-button { [data-plyr="language"] .plyr__menu__value {
font-size: 0.75em; display: none;
padding-top: 0; }
padding-bottom: 0;
} #video-container {
flex-grow: 0;
.subtitle-track-group { flex-shrink: 1;
display: flex; display: none;
} }
.subtitle-track-group > * { a {
margin-top: 0 !important; color: var(--accent);
margin-bottom: 0 !important; }
margin-right: 1ch !important;
} .chip {
color: var(--fg);
#pre-join-controls, background: var(--chip-bg);
#create-controls { text-decoration: none;
width: 60%; padding: 0 0.5rem 0 1.45rem;
margin: 0 auto; display: inline-flex;
margin-top: 4em; position: relative;
} font-size: 0.9rem;
height: 1.125rem;
#join-session-form, align-items: center;
#create-session-form { border-radius: 2rem;
margin-bottom: 4em; overflow: hidden;
} }
#post-create-message { .chip::before {
display: none; content: "";
width: 500px; position: absolute;
max-width: 100%; left: 0;
font-size: 0.85em; top: 0;
} width: 1.125rem;
height: 100%;
#chatbox-container { display: flex;
display: none; align-items: center;
} justify-content: center;
text-align: center;
.chat-message > strong { background: var(--accent-transparent);
color: rgb(126, 208, 255); background-repeat: no-repeat;
} background-size: 18px;
background-position: center;
.chat-message.user-join, }
.chat-message.user-leave {
font-style: italic; .join-chip::before {
} background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI2ZmZiI+PHBhdGggZD0iTTggNXYxNGwxMS03eiIvPjwvc3ZnPg==");
}
.chat-message.set-time,
.chat-message.set-playing { .time-chip::before {
font-style: italic; background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI2ZmZiI+PHBhdGggZD0iTTExLjk5IDJDNi40NyAyIDIgNi40OCAyIDEyczQuNDcgMTAgOS45OSAxMEMxNy41MiAyMiAyMiAxNy41MiAyMiAxMlMxNy41MiAyIDExLjk5IDJ6TTEyIDIwYy00LjQyIDAtOC0zLjU4LTgtOHMzLjU4LTggOC04IDggMy41OCA4IDgtMy41OCA4LTggOHoiLz48cGF0aCBkPSJNMTIuNSA3SDExdjZsNS4yNSAzLjE1Ljc1LTEuMjMtNC41LTIuNjd6Ii8+PC9zdmc+");
text-align: right; }
font-size: 0.85em;
} label {
display: block;
.chat-message.set-time > strong, }
.chat-message.set-playing > strong {
color: unset; input[type="url"],
} input[type="text"] {
background: #fff;
#chatbox { background-clip: padding-box;
padding: 0.5em 2em; border: 1px solid rgba(0, 0, 0, 0.12);
min-height: 8em; border-radius: 6px;
overflow-y: scroll; color: rgba(0, 0, 0, 0.8);
} display: block;
#chatbox-container { margin: 0.5em 0;
background-color: #222; padding: 0.5em 1em;
} line-height: 1.5;
#chatbox-send { font-family: sans-serif;
padding: 0 2em; font-size: 1em;
padding-bottom: 0.5em; width: 100%;
}
resize: none;
#chatbox-send > input { overflow-x: wrap;
font-size: 0.75em; overflow-y: scroll;
width: 100%; }
}
button:not(.plyr button) {
@media (min-aspect-ratio: 4/3) { background-color: var(--accent);
#video-container video { border: var(--accent);
width: calc(100vw - 400px); border-radius: 6px;
position: absolute; color: #fff;
height: 100vh; padding: 0.5em 1em;
background-color: black; display: inline-block;
} font-weight: 400;
text-align: center;
#video-container { white-space: nowrap;
float: left; vertical-align: middle;
height: 100vh;
position: relative; font-family: sans-serif;
} font-size: 1em;
width: 100%;
#chatbox-container {
float: right; user-select: none;
width: 400px; border: 1px solid rgba(0, 0, 0, 0);
height: 100vh !important; line-height: 1.5;
} cursor: pointer;
margin: 0.5em 0;
#chatbox { }
height: calc(100vh - 5em) !important;
} button:disabled {
} filter: saturate(0.75);
opacity: 0.75;
cursor: default;
}
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;
}
}

23
scripts/get_emojis.sh Executable file
View File

@ -0,0 +1,23 @@
#!/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

6
scripts/get_unicode_emojis.sh Executable file
View File

@ -0,0 +1,6 @@
#!/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,32 +1,52 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
#[serde(tag = "op", content = "data")] pub struct Viewer {
pub enum WatchEventData { #[serde(default, skip_serializing_if = "Option::is_none")]
SetPlaying { playing: bool, time: u64 }, pub nickname: Option<String>,
SetTime(u64), #[serde(default, skip_serializing_if = "Option::is_none")]
pub colour: Option<String>,
UserJoin, }
UserLeave,
ChatMessage(String), #[derive(Clone, Serialize, Deserialize)]
} #[serde(tag = "op", content = "data")]
pub enum WatchEventData {
#[derive(Clone, Serialize, Deserialize)] SetPlaying {
pub struct WatchEvent { playing: bool,
#[serde(default, skip_serializing_if = "Option::is_none")] time: u64,
pub user: Option<String>, },
#[serde(flatten)] SetTime {
pub data: WatchEventData, #[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default)] from: Option<u64>,
pub reflected: bool, to: u64,
} },
impl WatchEvent { UserJoin,
pub fn new(user: String, data: WatchEventData) -> Self { UserLeave,
WatchEvent { ChatMessage(String),
user: Some(user), Ping(String),
data, UpdateViewerList(Vec<Viewer>),
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,167 +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 viewer_connection; mod utils;
mod watch_session; mod viewer_connection;
mod watch_session;
use serde::Deserialize;
use serde::Deserialize;
use crate::{
events::{WatchEvent, WatchEventData}, use crate::{
viewer_connection::{ws_publish, ws_subscribe}, viewer_connection::ws_subscribe,
watch_session::{get_session, handle_watch_event_data, 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 set_playing_route = get_running_session ))
.and(warb::path!("playing")) })
.and(warb::put()) } else {
.and(warb::body::json()) RequestedSession::Error(warb::reply::with_status(
.map(|requested_session, playing: bool| match requested_session { warb::reply::json(&json!({ "error": "invalid session UUID" })),
RequestedSession::Session(uuid, mut sess) => { StatusCode::BAD_REQUEST,
let data = WatchEventData::SetPlaying { ))
playing, }
time: sess.get_time_ms(), });
};
let get_status_route = get_running_session
handle_watch_event_data(uuid, &mut sess, data.clone()); .and(warb::path::end())
tokio::spawn(ws_publish( .map(|requested_session| match requested_session {
uuid, RequestedSession::Session(_, sess) => {
None, warb::reply::with_status(warb::reply::json(&sess.view()), StatusCode::OK)
WatchEvent { }
user: None, RequestedSession::Error(e) => e,
data, });
reflected: false,
}, let ws_subscribe_route = get_running_session
)); .and(warb::path!("subscribe"))
.and(warb::query())
warb::reply::with_status(warb::reply::json(&sess.view()), StatusCode::OK) .and(warb::ws())
} .map(
RequestedSession::Error(e) => e, |requested_session, query: SubscribeQuery, ws: warb::ws::Ws| match requested_session {
}); RequestedSession::Session(uuid, _) => ws
.on_upgrade(move |ws| ws_subscribe(uuid, query.nickname, query.colour, ws))
let set_timestamp_route = get_running_session .into_response(),
.and(warb::path!("current_time")) RequestedSession::Error(error_response) => error_response.into_response(),
.and(warb::put()) },
.and(warb::body::json()) );
.map(
|requested_session, current_time_ms: u64| match requested_session { let routes = start_session_route
RequestedSession::Session(uuid, mut sess) => { .or(get_status_route)
let data = WatchEventData::SetTime(current_time_ms); .or(ws_subscribe_route)
.or(get_emoji_route)
handle_watch_event_data(uuid, &mut sess, data.clone()); .or(warb::path::end().and(warb::fs::file("frontend/index.html")))
tokio::spawn(ws_publish( .or(warb::fs::dir("frontend"));
uuid,
None, let ip = std::env::var("IP")
WatchEvent { .ok()
user: None, .and_then(|s| s.parse::<IpAddr>().ok())
data, .unwrap_or_else(|| [127, 0, 0, 1].into());
reflected: false, let port = std::env::var("PORT")
}, .ok()
)); .and_then(|s| s.parse::<u16>().ok())
.unwrap_or(3000);
warb::reply::with_status(warb::reply::json(&sess.view()), StatusCode::OK)
} println!("Listening at http://{}:{} ...", &ip, &port);
RequestedSession::Error(e) => e, warb::serve(routes).run((ip, port)).await;
}, }
);
let ws_subscribe_route = get_running_session
.and(warb::path!("subscribe"))
.and(warb::query())
.and(warb::ws())
.map(
|requested_session, query: SubscribeQuery, ws: warb::ws::Ws| match requested_session {
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 routes = start_session_route
.or(get_status_route)
.or(set_playing_route)
.or(set_timestamp_route)
.or(ws_subscribe_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;
}

6
src/utils.rs Normal file
View File

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