Compare commits

..

84 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
maia arson crimew 051516fef6 Dont send events if video controls hidden 2021-11-20 02:12:53 +01:00
Charlotte Som 903fd535ce Merge pull request from nyancrimew/watch-party:create-form into main
thank u maia <3
2021-11-12 20:23:07 +00:00
maia arson crimew 10d821659d Basic creation page 2021-11-11 18:26:30 +01:00
maia arson crimew bd4a333d67 Fix label IDs on join form 2021-11-11 16:47:43 +01:00
Charlotte Som be5a05e0fd Big changes: All events are reported to chat, new layout options 2021-11-10 14:29:52 +00:00
28 changed files with 2368 additions and 1069 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,22 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>watch party chat <3</title>
<link rel="stylesheet" href="/styles.css?v=3" />
</head>
<body>
<noscript>
This site will <em>not</em> work without JavaScript, and there's not
really any way around that :(
</noscript>
<div id="chatbox-container" class="popped-out">
<div id="chatbox"></div>
<form id="chatbox-send">
<input type="text" placeholder="Message..." />
</form>
</div>
</body>
</html>

52
frontend/create.html Normal file
View File

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

11
frontend/create.mjs Normal file
View File

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

View File

@ -1,53 +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>
<label for="nickname">Nickname:</label>
<input <p id="post-create-message">
type="text" Your session has been created successfully. Copy the current url or
id="join-session-nickname" the Session ID below and share it with your friends. :)
placeholder="Nickname" </p>
required
/> <label for="join-session-nickname">Nickname:</label>
<input
<label for="session-id">Session ID:</label> type="text"
<input id="join-session-nickname"
type="text" placeholder="Nickname"
id="join-session-id" maxlength="50"
placeholder="123e4567-e89b-12d3-a456-426614174000" required
required />
/>
<button>Join</button> <label id="join-session-colour-label" for="join-session-colour">
</form> Personal Colour:
</label>
<p> <input type="color" id="join-session-colour" value="#ffffff" required />
No session to join? <a href="/create.html">Create a session</a> instead.
</p> <label for="join-session-id">Session ID:</label>
</div> <input
type="text"
<div id="video-container"></div> id="join-session-id"
<div id="chatbox-container"> placeholder="123e4567-e89b-12d3-a456-426614174000"
<div id="chatbox"></div> required
<form id="chatbox-send"> />
<input type="text" id="message-box" placeholder="Message..." /> <button id="join-session-button">Join</button>
<input type="button" id="pop-chat" value="Pop out chat" />
</form> <p>
</div> No session to join?
<a href="/create.html">Create a session</a> instead.
<script type="module" src="/main.mjs?v=2"></script> </p>
</body> </form>
</html> </div>
<div id="video-container"></div>
<div id="chatbox-container">
<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,164 +1,453 @@
var win = window; import {
var poppedOut = false; setDebounce,
setVideoTime,
const setupChatboxEvents = (socket) => { setPlaying,
// clear events by just reconstructing the form sync,
const oldChatForm = win.document.querySelector("#chatbox-send"); } from "./watch-session.mjs?v=4b61c4";
const chatForm = oldChatForm.cloneNode(true); import { emojify, findEmojis } from "./emojis.mjs?v=4b61c4";
oldChatForm.replaceWith(chatForm); import { linkify } from "./links.mjs?v=4b61c4";
if (!poppedOut) { import { joinSession } from "./watch-session.mjs?v=4b61c4";
setupPopoutEvent(socket); import { pling } from "./pling.mjs?v=4b61c4";
} import { state } from "./state.mjs";
chatForm.addEventListener("submit", (e) => { function setCaretPosition(elem, caretPos) {
e.preventDefault(); if (elem.createTextRange) {
var range = elem.createTextRange();
const input = chatForm.querySelector("input"); range.move("character", caretPos);
const content = input.value; range.select();
if (content.trim().length) { } else {
input.value = ""; if (elem.selectionStart) {
elem.focus();
socket.send( elem.setSelectionRange(caretPos, caretPos);
JSON.stringify({ } else elem.focus();
op: "ChatMessage", }
data: { }
message: content,
}, const setupChatboxEvents = (socket) => {
}) // clear events by just reconstructing the form
); const oldChatForm = document.querySelector("#chatbox-send");
} const chatForm = oldChatForm.cloneNode(true);
}); const messageInput = chatForm.querySelector("input");
}; const emojiAutocomplete = chatForm.querySelector("#emoji-autocomplete");
oldChatForm.replaceWith(chatForm);
const setupPopoutEvent = (socket) => {
const button = document.querySelector("#pop-chat"); let autocompleting = false,
button.addEventListener("click", () => { showListTimer;
openPopout(socket);
}); const replaceMessage = (message) => () => {
}; messageInput.value = message;
autocomplete();
const fixChatSize = () => { };
const video = document.querySelector("video"); async function autocomplete(fromListTimeout) {
const chatbox = document.querySelector("#chatbox"); if (autocompleting) return;
const chatboxContainer = document.querySelector("#chatbox-container"); try {
clearInterval(showListTimer);
if (video && chatbox && chatboxContainer) { emojiAutocomplete.textContent = "";
const delta = chatboxContainer.clientHeight - chatbox.clientHeight; autocompleting = true;
let text = messageInput.value.slice(0, messageInput.selectionStart);
chatbox.style["height"] = `calc(${ const match = text.match(/(:[^\s:]+)?:([^\s:]{2,})$/);
window.innerHeight - video.clientHeight if (!match || match[1]) return (autocompleting = false); // We don't need to autocomplete.
}px - ${delta}px - 1em)`; const prefix = text.slice(0, match.index);
} const search = text.slice(match.index + 1);
}; if (search.length < 1 && !fromListTimeout) {
autocompleting = false;
// TODO: transfer chat history to popout and back? showListTimer = setTimeout(() => autocomplete(true), 500);
const openPopout = async (socket) => { return;
win.document.querySelector("#chatbox-container").style["display"] = "none"; }
const popout = window.open("/chat.html", "popout", "width=800,height=1000"); const suffix = messageInput.value.slice(messageInput.selectionStart);
win = popout; let selected;
poppedOut = true; const select = (button) => {
if (selected) selected.classList.remove("selected");
// I am aware of the fact that this is incredibly cursed, selected = button;
// but apparently this is the only way to wait for a child window to load.... button.classList.add("selected");
// I love browsers :) };
function defer(callback) { let results = await findEmojis(search);
var channel = new MessageChannel(); let yieldAt = performance.now() + 13;
channel.port1.onmessage = function (e) { for (let i = 0; i < results.length; i += 100) {
callback(); emojiAutocomplete.append.apply(
}; emojiAutocomplete,
channel.port2.postMessage(null); results.slice(i, i + 100).map(([name, replaceWith, ext], i) => {
} const button = Object.assign(document.createElement("button"), {
className: "emoji-option",
win.addEventListener("unload", () => { onmousedown: (e) => e.preventDefault(),
defer(() => { onclick: () => {
if (popout.document.readyState === "complete") { messageInput.value = prefix + replaceWith + " " + suffix;
setupChat(socket); setCaretPosition(
// pop the chat back into the parent window on close messageInput,
win.addEventListener("unload", () => { (prefix + " " + replaceWith).length
popBackChat(socket); );
}); },
} else { onmouseover: () => select(button),
win.document.addEventListener("DOMContentLoaded", () => { onfocus: () => select(button),
setupChat(socket); type: "button",
// pop the chat back into the parent window on close title: name,
win.addEventListener("unload", () => { });
popBackChat(socket); button.append(
}); replaceWith[0] !== ":"
}); ? Object.assign(document.createElement("span"), {
} textContent: replaceWith,
}); className: "emoji",
}); })
}; : Object.assign(new Image(), {
loading: "lazy",
const popBackChat = async (socket) => { src: `/emojis/${name}${ext}`,
win = window; className: "emoji",
poppedOut = false; }),
setupChat(socket); Object.assign(document.createElement("span"), {
}; textContent: name,
className: "emoji-name",
const resizeCallback = () => { })
fixChatSize(); );
}; return button;
})
/** );
* @param {WebSocket} socket if (i == 0 && emojiAutocomplete.children[0]) {
*/ emojiAutocomplete.children[0].scrollIntoView();
export const setupChat = async (socket) => { select(emojiAutocomplete.children[0]);
win.document.querySelector("#chatbox-container").style["display"] = "block"; }
setupChatboxEvents(socket); const now = performance.now();
if (now > yieldAt) {
if (!poppedOut) { yieldAt = now + 13;
fixChatSize(); await new Promise((cb) => setTimeout(cb, 0));
window.addEventListener("resize", resizeCallback); }
} else { }
window.removeEventListener("resize", resizeCallback); autocompleting = false;
} } catch (e) {
}; autocompleting = false;
}
const printToChat = (elem) => { }
const chatbox = win.document.querySelector("#chatbox"); messageInput.addEventListener("input", () => autocomplete());
chatbox.appendChild(elem); messageInput.addEventListener("selectionchange", () => autocomplete());
chatbox.scrollTop = chatbox.scrollHeight; messageInput.addEventListener("keydown", (event) => {
}; if (event.key == "ArrowUp" || event.key == "ArrowDown") {
let selected = document.querySelector(".emoji-option.selected");
export const handleChatEvent = (event) => { if (!selected) return;
switch (event.op) { event.preventDefault();
case "UserJoin": { selected.classList.remove("selected");
// print something to the chat selected =
const chatMessage = win.document.createElement("div"); event.key == "ArrowDown"
chatMessage.classList.add("chat-message"); ? selected.nextElementSibling || selected.parentElement.children[0]
chatMessage.classList.add("user-join"); : selected.previousElementSibling ||
const userName = win.document.createElement("strong"); selected.parentElement.children[
userName.textContent = event.data; selected.parentElement.children.length - 1
chatMessage.appendChild(userName); ];
chatMessage.appendChild(win.document.createTextNode(" joined")); selected.classList.add("selected");
printToChat(chatMessage); selected.scrollIntoView({ scrollMode: "if-needed", block: "nearest" });
break; }
} if (event.key == "Tab" || event.key == "Enter") {
case "UserLeave": { let selected = document.querySelector(".emoji-option.selected");
const chatMessage = win.document.createElement("div"); if (!selected) return;
chatMessage.classList.add("chat-message"); event.preventDefault();
chatMessage.classList.add("user-leave"); selected.onclick();
const userName = win.document.createElement("strong"); }
userName.textContent = event.data; });
chatMessage.appendChild(userName);
chatMessage.appendChild(win.document.createTextNode(" left")); chatForm.addEventListener("submit", async (e) => {
printToChat(chatMessage); e.preventDefault();
break; const content = messageInput.value;
} if (content.trim().length) {
case "ChatMessage": { messageInput.value = "";
const chatMessage = win.document.createElement("div");
chatMessage.classList.add("chat-message"); // handle commands
const userName = win.document.createElement("strong"); if (content.startsWith("/")) {
userName.innerText = event.data.user; const command = content.toLowerCase().match(/^\/\S+/)[0];
chatMessage.appendChild(userName); const args = content.slice(command.length).trim();
chatMessage.appendChild(win.document.createTextNode(" "));
const messageContent = win.document.createElement("span"); let handled = false;
messageContent.classList.add("message-content"); switch (command) {
messageContent.textContent = event.data.message; case "/ping":
chatMessage.appendChild(messageContent); socket.send(
printToChat(chatMessage); JSON.stringify({
break; op: "Ping",
} data: args,
} })
}; );
handled = true;
break;
case "/sync":
await sync();
const syncMessageContent = document.createElement("span");
syncMessageContent.appendChild(
document.createTextNode("resynced you to ")
);
syncMessageContent.appendChild(
document.createTextNode(formatTime(current_time_ms))
);
printChatMessage("set-time", "/sync", "b57fdc", syncMessageContent);
handled = true;
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

@ -0,0 +1,18 @@
import { createSession } from "./watch-session.mjs?v=4b61c4";
export const setupCreateSessionForm = () => {
const form = document.querySelector("#create-session-form");
const videoUrl = form.querySelector("#create-session-video");
const subsUrl = form.querySelector("#create-session-subs");
const subsName = form.querySelector("#create-session-subs-name");
form.addEventListener("submit", (event) => {
event.preventDefault();
let subs = [];
if (subsUrl.value) {
subs.push({ url: subsUrl.value, name: subsName.value || "default" });
}
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,45 +1,95 @@
import { joinSession } from "./watch-session.mjs"; 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
}; }
};
export const setupJoinSessionForm = () => {
const form = document.querySelector("#join-session-form"); /**
const nickname = form.querySelector("#join-session-nickname"); * @param {HTMLInputElement} field
const sessionId = form.querySelector("#join-session-id"); */
const loadColour = (field) => {
loadNickname(nickname); try {
const savedColour = localStorage.getItem("watch-party-colour");
if (window.location.hash.match(/#[0-9a-f\-]+/)) { if (savedColour != null && savedColour != "") {
sessionId.value = window.location.hash.substring(1); field.value = savedColour;
} }
} catch (_err) {
document // Sometimes localStorage is blocked from use
.querySelector("#join-session-form") }
.addEventListener("submit", (event) => { };
event.preventDefault();
/**
saveNickname(nickname); * @param {HTMLInputElement} field
joinSession(nickname.value, sessionId.value); */
}); const saveColour = (field) => {
}; try {
localStorage.setItem("watch-party-colour", field.value);
} catch (_err) {
// see loadColour
}
};
const displayPostCreateMessage = () => {
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,171 +1,270 @@
import { setupVideo } from "./video.mjs"; import { setupVideo } from "./video.mjs?v=4b61c4";
import { setupChat, handleChatEvent } from "./chat.mjs"; 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) => {
// console.log(event); if (playing) {
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();
case "UserJoin":
case "UserLeave": if (event.data.playing) {
case "ChatMessage": await player.play();
handleChatEvent(event); } else {
break; player.pause();
} }
} catch (_err) {}
}); setVideoTime(event.data.time);
}; break;
case "SetTime":
/** setDebounce();
* @param {HTMLVideoElement} video setVideoTime(event.data);
* @param {WebSocket} socket break;
*/ case "UpdateViewerList":
const setupOutgoingEvents = (video, socket) => { updateViewerList(event.data);
const currentVideoTime = () => (video.currentTime * 1000) | 0; break;
}
video.addEventListener("pause", async (event) => { }
if (outgoingDebounce) {
return; logEventToChat(event);
} } catch (_err) {}
});
socket.send( };
JSON.stringify({
op: "SetPlaying", /**
data: { * @param {Plyr} player
playing: false, * @param {ReconnectingWebSocket} socket
time: currentVideoTime(), */
}, const setupOutgoingEvents = (player, socket) => {
}) const currentVideoTime = () => (player.currentTime * 1000) | 0;
);
}); player.on("pause", async () => {
if (outgoingDebounce || player.elements.inputs.seek.disabled) {
video.addEventListener("play", (event) => { return;
if (outgoingDebounce) { }
return;
} // don't send a pause event for the video ending
if (player.currentTime == player.duration) {
socket.send( return;
JSON.stringify({ }
op: "SetPlaying",
data: { socket.send(
playing: true, JSON.stringify({
time: currentVideoTime(), 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 player.on("play", () => {
firstSeekComplete = true; if (outgoingDebounce || player.elements.inputs.seek.disabled) {
return; return;
} }
if (outgoingDebounce) { socket.send(
return; JSON.stringify({
} op: "SetPlaying",
data: {
socket.send( playing: true,
JSON.stringify({ time: currentVideoTime(),
op: "SetTime", },
data: currentVideoTime(), })
}) );
); });
});
}; let firstSeekComplete = false;
player.on("seeked", async (event) => {
/** if (!firstSeekComplete) {
* @param {string} nickname // The first seeked event is performed by the browser when the video is loading
* @param {string} sessionId firstSeekComplete = true;
*/ return;
export const joinSession = async (nickname, sessionId) => { }
try {
window.location.hash = sessionId; if (outgoingDebounce || player.elements.inputs.seek.disabled) {
return;
const { video_url, subtitle_tracks, current_time_ms, is_playing } = }
await fetch(`/sess/${sessionId}`).then((r) => r.json());
socket.send(
const socket = createWebSocket(sessionId, nickname); JSON.stringify({
socket.addEventListener("open", async () => { op: "SetTime",
const video = await setupVideo( data: {
video_url, to: currentVideoTime(),
subtitle_tracks, },
current_time_ms, })
is_playing );
); });
};
setupOutgoingEvents(video, socket);
setupIncomingEvents(video, socket); export const joinSession = async (created) => {
setupChat(socket); if (state().activeSession) {
}); if (state().activeSession === state().sessionId) {
// TODO: Close listener ? // we are already in this session, dont rejoin
} catch (err) { return;
// TODO: Show an error on the screen }
console.error(err); // we are joining a new session from an existing session
} const messageContent = document.createElement("span");
}; messageContent.appendChild(document.createTextNode("joining new session "));
messageContent.appendChild(document.createTextNode(state().sessionId));
printChatMessage("join-session", "watch-party", "#fffff", messageContent);
}
state().activeSession = state().sessionId;
// try { // we are handling errors in the join form.
const genericConnectionError = new Error(
"There was an issue getting the session information."
);
window.location.hash = state().sessionId;
let response, video_url, subtitle_tracks, current_time_ms, is_playing;
try {
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"; 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,167 +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; ),
height: 100%; 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 {
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);
input[type="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
.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);
width: 60%; text-decoration: none;
margin: 0 auto; padding: 0 0.5rem 0 1.45rem;
margin-top: 4em; display: inline-flex;
} position: relative;
font-size: 0.9rem;
#join-session-form { height: 1.125rem;
margin-bottom: 4em; align-items: center;
} border-radius: 2rem;
overflow: hidden;
#chatbox-container { }
display: none;
} .chip::before {
content: "";
#chatbox-container.popped-out { position: absolute;
width: 100%; left: 0;
height: 100%; top: 0;
} width: 1.125rem;
height: 100%;
#chatbox-container.popped-out > #chatbox { display: flex;
height: calc(100vh - 5em); align-items: center;
} justify-content: center;
text-align: center;
.user-join, background: var(--accent-transparent);
.user-leave { background-repeat: no-repeat;
font-style: italic; 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==");
}
#chatbox {
padding: 0.5em 2em; .time-chip::before {
min-height: 8em; background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI2ZmZiI+PHBhdGggZD0iTTExLjk5IDJDNi40NyAyIDIgNi40OCAyIDEyczQuNDcgMTAgOS45OSAxMEMxNy41MiAyMiAyMiAxNy41MiAyMiAxMlMxNy41MiAyIDExLjk5IDJ6TTEyIDIwYy00LjQyIDAtOC0zLjU4LTgtOHMzLjU4LTggOC04IDggMy41OCA4IDgtMy41OCA4LTggOHoiLz48cGF0aCBkPSJNMTIuNSA3SDExdjZsNS4yNSAzLjE1Ljc1LTEuMjMtNC41LTIuNjd6Ii8+PC9zdmc+");
overflow-y: scroll; }
}
label {
#chatbox-container { display: block;
background-color: #222; }
}
input[type="url"],
#chatbox-send { input[type="text"] {
padding: 0 2em; background: #fff;
padding-bottom: 0.5em; background-clip: padding-box;
} border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 6px;
#chatbox-send > input { color: rgba(0, 0, 0, 0.8);
font-size: 0.75em; display: block;
}
margin: 0.5em 0;
#message-box { padding: 0.5em 1em;
display: inline-block; line-height: 1.5;
}
font-family: sans-serif;
#pop-chat { font-size: 1em;
width: auto; width: 100%;
float: right;
} resize: none;
overflow-x: wrap;
overflow-y: scroll;
}
button:not(.plyr button) {
background-color: var(--accent);
border: var(--accent);
border-radius: 6px;
color: #fff;
padding: 0.5em 1em;
display: inline-block;
font-weight: 400;
text-align: center;
white-space: nowrap;
vertical-align: middle;
font-family: sans-serif;
font-size: 1em;
width: 100%;
user-select: none;
border: 1px solid rgba(0, 0, 0, 0);
line-height: 1.5;
cursor: pointer;
margin: 0.5em 0;
}
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,19 +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 WatchEvent { #[serde(default, skip_serializing_if = "Option::is_none")]
SetPlaying { pub nickname: Option<String>,
playing: bool, #[serde(default, skip_serializing_if = "Option::is_none")]
time: u64, pub colour: Option<String>,
}, }
SetTime(u64),
#[derive(Clone, Serialize, Deserialize)]
UserJoin(String), #[serde(tag = "op", content = "data")]
UserLeave(String), pub enum WatchEventData {
ChatMessage { SetPlaying {
#[serde(default = "String::new")] playing: bool,
user: String, time: u64,
message: String, },
}, SetTime {
} #[serde(default, skip_serializing_if = "Option::is_none")]
from: Option<u64>,
to: u64,
},
UserJoin,
UserLeave,
ChatMessage(String),
Ping(String),
UpdateViewerList(Vec<Viewer>),
}
#[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,151 +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, use crate::{
viewer_connection::{ws_publish, ws_subscribe}, viewer_connection::ws_subscribe,
watch_session::{get_session, handle_watch_event, 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 event = WatchEvent::SetPlaying { ))
playing, }
time: sess.get_time_ms(), });
};
let get_status_route = get_running_session
handle_watch_event(uuid, &mut sess, event.clone()); .and(warb::path::end())
tokio::spawn(ws_publish(uuid, None, event)); .map(|requested_session| match requested_session {
RequestedSession::Session(_, sess) => {
warb::reply::with_status(warb::reply::json(&sess.view()), StatusCode::OK) warb::reply::with_status(warb::reply::json(&sess.view()), StatusCode::OK)
} }
RequestedSession::Error(e) => e, RequestedSession::Error(e) => e,
}); });
let set_timestamp_route = get_running_session let ws_subscribe_route = get_running_session
.and(warb::path!("current_time")) .and(warb::path!("subscribe"))
.and(warb::put()) .and(warb::query())
.and(warb::body::json()) .and(warb::ws())
.map( .map(
|requested_session, current_time_ms: u64| match requested_session { |requested_session, query: SubscribeQuery, ws: warb::ws::Ws| match requested_session {
RequestedSession::Session(uuid, mut sess) => { RequestedSession::Session(uuid, _) => ws
let event = WatchEvent::SetTime(current_time_ms); .on_upgrade(move |ws| ws_subscribe(uuid, query.nickname, query.colour, ws))
.into_response(),
handle_watch_event(uuid, &mut sess, event.clone()); RequestedSession::Error(error_response) => error_response.into_response(),
tokio::spawn(ws_publish(uuid, None, event)); },
);
warb::reply::with_status(warb::reply::json(&sess.view()), StatusCode::OK)
} let routes = start_session_route
RequestedSession::Error(e) => e, .or(get_status_route)
}, .or(ws_subscribe_route)
); .or(get_emoji_route)
.or(warb::path::end().and(warb::fs::file("frontend/index.html")))
let ws_subscribe_route = get_running_session .or(warb::fs::dir("frontend"));
.and(warb::path!("subscribe"))
.and(warb::query()) let ip = std::env::var("IP")
.and(warb::ws()) .ok()
.map( .and_then(|s| s.parse::<IpAddr>().ok())
|requested_session, query: SubscribeQuery, ws: warb::ws::Ws| match requested_session { .unwrap_or_else(|| [127, 0, 0, 1].into());
RequestedSession::Session(uuid, _) => ws let port = std::env::var("PORT")
.on_upgrade(move |ws| ws_subscribe(uuid, query.nickname, ws)) .ok()
.into_response(), .and_then(|s| s.parse::<u16>().ok())
RequestedSession::Error(error_response) => error_response.into_response(), .unwrap_or(3000);
},
); println!("Listening at http://{}:{} ...", &ip, &port);
warb::serve(routes).run((ip, port)).await;
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,114 +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, events::{Viewer, WatchEvent, WatchEventData},
watch_session::{get_session, handle_watch_event}, 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 tx: UnboundedSender<WatchEvent>,
pub nickname: Option<String>,
pub async fn ws_subscribe(session_uuid: Uuid, nickname: String, ws: WebSocket) { pub colour: Option<String>,
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 (tx, rx) = mpsc::unbounded_channel::<WatchEvent>(); let viewer_id = NEXT_VIEWER_ID.fetch_add(1, Ordering::Relaxed);
let mut rx = UnboundedReceiverStream::new(rx); let (mut viewer_ws_tx, mut viewer_ws_rx) = ws.split();
tokio::task::spawn(async move { let (tx, rx) = mpsc::unbounded_channel::<WatchEvent>();
while let Some(event) = rx.next().await { let mut rx = UnboundedReceiverStream::new(rx);
viewer_ws_tx
.send(Message::text( tokio::task::spawn(async move {
serde_json::to_string(&event).expect("couldn't convert WatchEvent into JSON"), while let Some(event) = rx.next().await {
)) viewer_ws_tx
.unwrap_or_else(|e| eprintln!("ws send error: {}", e)) .send(Message::text(
.await; serde_json::to_string(&event).expect("couldn't convert WatchEvent into JSON"),
} ))
}); .unwrap_or_else(|e| eprintln!("ws send error: {}", e))
.await;
CONNECTED_VIEWERS.write().await.insert( }
viewer_id, });
ConnectedViewer {
viewer_id, let mut colour = colour;
session: session_uuid, if colour.len() != 6 || !colour.chars().all(|x| x.is_ascii_hexdigit()) {
tx, colour = String::from("7ed0ff");
}, }
); let nickname = truncate_str(&nickname, 50).to_string();
ws_publish(session_uuid, None, WatchEvent::UserJoin(nickname.clone())).await; CONNECTED_VIEWERS.write().await.insert(
viewer_id,
while let Some(Ok(message)) = viewer_ws_rx.next().await { ConnectedViewer {
let mut event: WatchEvent = match message viewer_id,
.to_str() session: session_uuid,
.ok() tx,
.and_then(|s| serde_json::from_str(s).ok()) nickname: Some(nickname.clone()),
{ colour: Some(colour.clone()),
Some(e) => e, },
None => continue, );
};
ws_publish(
// Make sure people don't spoof their nicknames to pretend to be others session_uuid,
// If a nickname change is required, I guess reconnect idk None,
if let WatchEvent::ChatMessage { user: _, message } = event { WatchEvent::new(nickname.clone(), colour.clone(), WatchEventData::UserJoin),
event = WatchEvent::ChatMessage { )
user: nickname.clone(), .await;
message,
}; update_viewer_list(session_uuid).await;
// Don't pass through the viewer_id because we want the chat message while let Some(Ok(message)) = viewer_ws_rx.next().await {
// to be reflected to the user. let event: WatchEventData = match message
ws_publish(session_uuid, None, event).await; .to_str()
.ok()
// We don't need to handle() chat messages, .and_then(|s| serde_json::from_str(s).ok())
// and we are already publishing them ourselves. {
continue; Some(e) => e,
} None => continue,
};
handle_watch_event(
session_uuid, let session = &mut get_session(session_uuid).unwrap();
&mut get_session(session_uuid).unwrap(),
event.clone(), // server side event modification where neccessary
); let event: WatchEventData = match event {
WatchEventData::SetTime { from: _, to } => WatchEventData::SetTime {
ws_publish(session_uuid, Some(viewer_id), event).await; from: Some(session.get_time_ms()),
} to,
},
ws_publish(session_uuid, None, WatchEvent::UserLeave(nickname.clone())).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, viewer_id: Option<usize>, event: WatchEvent) { ws_publish(
for viewer in CONNECTED_VIEWERS.read().await.values() { session_uuid,
if viewer_id == Some(viewer.viewer_id) { Some(viewer_id),
continue; WatchEvent::new(nickname.clone(), colour.clone(), event),
} )
.await;
if viewer.session != session_uuid { }
continue;
} ws_publish(
session_uuid,
let _ = viewer.tx.send(event.clone()); 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,92 +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::WatchEvent; 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(uuid: Uuid, watch_session: &mut WatchSession, event: WatchEvent) { pub fn handle_watch_event_data(
match event { uuid: Uuid,
WatchEvent::SetPlaying { playing, time } => { watch_session: &mut WatchSession,
watch_session.set_playing(playing, time); event: WatchEventData,
} ) {
match event {
WatchEvent::SetTime(time) => { WatchEventData::SetPlaying { playing, time } => {
watch_session.set_time_ms(time); watch_session.set_playing(playing, time);
} }
_ => {} WatchEventData::SetTime { from: _, to } => {
}; watch_session.set_time_ms(to);
}
let _ = SESSIONS.lock().unwrap().insert(uuid, watch_session.clone());
} _ => {}
};
let _ = SESSIONS.lock().unwrap().insert(uuid, watch_session.clone());
}