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

View File

@ -3,7 +3,7 @@ root = true
[*]
indent_style = space
indent_size = 2
end_of_line = crlf
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = true

1
.gitignore vendored
View File

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

View File

@ -10,6 +10,6 @@ once_cell = "1.8.0"
serde = { version = "1.0.130", features = ["derive"] }
serde_json = "1.0.68"
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"] }
warp = "0.3.1"

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,52 +1,85 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>watch party :D</title>
<link rel="stylesheet" href="/styles.css?v=2" />
</head>
<body>
<noscript>
This site will <em>not</em> work without JavaScript, and there's not
really any way around that :(
</noscript>
<div id="pre-join-controls">
<form id="join-session-form">
<h2>Join a session</h2>
<label for="nickname">Nickname:</label>
<input
type="text"
id="join-session-nickname"
placeholder="Nickname"
required
/>
<label for="session-id">Session ID:</label>
<input
type="text"
id="join-session-id"
placeholder="123e4567-e89b-12d3-a456-426614174000"
required
/>
<button>Join</button>
</form>
<p>
No session to join? <a href="/create.html">Create a session</a> instead.
</p>
</div>
<div id="video-container"></div>
<div id="chatbox-container">
<div id="chatbox"></div>
<form id="chatbox-send">
<input type="text" placeholder="Message..." />
</form>
</div>
<script type="module" src="/main.mjs?v=1"></script>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>watch party :D</title>
<link rel="stylesheet" href="/lib/plyr-3.7.3.css" />
<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="pre-join-controls">
<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
the Session ID below and share it with your friends. :)
</p>
<label for="join-session-nickname">Nickname:</label>
<input
type="text"
id="join-session-nickname"
placeholder="Nickname"
maxlength="50"
required
/>
<label id="join-session-colour-label" for="join-session-colour">
Personal Colour:
</label>
<input type="color" id="join-session-colour" value="#ffffff" required />
<label for="join-session-id">Session ID:</label>
<input
type="text"
id="join-session-id"
placeholder="123e4567-e89b-12d3-a456-426614174000"
required
/>
<button id="join-session-button">Join</button>
<p>
No session to join?
<a href="/create.html">Create a session</a> instead.
</p>
</form>
</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,102 +1,453 @@
const setupChatboxEvents = (socket) => {
// clear events by just reconstructing the form
const oldChatForm = document.querySelector("#chatbox-send");
const chatForm = oldChatForm.cloneNode(true);
oldChatForm.replaceWith(chatForm);
chatForm.addEventListener("submit", (e) => {
e.preventDefault();
const input = chatForm.querySelector("input");
const content = input.value;
if (content.trim().length) {
input.value = "";
socket.send(
JSON.stringify({
op: "ChatMessage",
data: {
message: content,
},
})
);
}
});
};
const fixChatSize = () => {
const video = document.querySelector("video");
const chatbox = document.querySelector("#chatbox");
const chatboxContainer = document.querySelector("#chatbox-container");
if (video && chatbox && chatboxContainer) {
const delta = chatboxContainer.clientHeight - chatbox.clientHeight;
chatbox.style["height"] = `calc(${
window.innerHeight - video.clientHeight
}px - ${delta}px - 1em)`;
}
};
/**
* @param {WebSocket} socket
*/
export const setupChat = async (socket) => {
document.querySelector("#chatbox-container").style["display"] = "block";
setupChatboxEvents(socket);
fixChatSize();
window.addEventListener("resize", () => {
fixChatSize();
});
};
const printToChat = (elem) => {
const chatbox = document.querySelector("#chatbox");
chatbox.appendChild(elem);
chatbox.scrollTop = chatbox.scrollHeight;
};
export const handleChatEvent = (event) => {
switch (event.op) {
case "UserJoin": {
// print something to the chat
const chatMessage = document.createElement("div");
chatMessage.classList.add("chat-message");
chatMessage.classList.add("user-join");
const userName = document.createElement("strong");
userName.textContent = event.data;
chatMessage.appendChild(userName);
chatMessage.appendChild(document.createTextNode(" joined"));
printToChat(chatMessage);
break;
}
case "UserLeave": {
const chatMessage = document.createElement("div");
chatMessage.classList.add("chat-message");
chatMessage.classList.add("user-leave");
const userName = document.createElement("strong");
userName.textContent = event.data;
chatMessage.appendChild(userName);
chatMessage.appendChild(document.createTextNode(" left"));
printToChat(chatMessage);
break;
}
case "ChatMessage": {
const chatMessage = document.createElement("div");
chatMessage.classList.add("chat-message");
const userName = document.createElement("strong");
userName.innerText = event.data.user;
chatMessage.appendChild(userName);
chatMessage.appendChild(document.createTextNode(" "));
const messageContent = document.createElement("span");
messageContent.classList.add("message-content");
messageContent.textContent = event.data.message;
chatMessage.appendChild(messageContent);
printToChat(chatMessage);
break;
}
}
};
import {
setDebounce,
setVideoTime,
setPlaying,
sync,
} from "./watch-session.mjs?v=4b61c4";
import { emojify, findEmojis } from "./emojis.mjs?v=4b61c4";
import { linkify } from "./links.mjs?v=4b61c4";
import { joinSession } from "./watch-session.mjs?v=4b61c4";
import { pling } from "./pling.mjs?v=4b61c4";
import { state } from "./state.mjs";
function setCaretPosition(elem, caretPos) {
if (elem.createTextRange) {
var range = elem.createTextRange();
range.move("character", caretPos);
range.select();
} else {
if (elem.selectionStart) {
elem.focus();
elem.setSelectionRange(caretPos, caretPos);
} else elem.focus();
}
}
const setupChatboxEvents = (socket) => {
// clear events by just reconstructing the form
const oldChatForm = document.querySelector("#chatbox-send");
const chatForm = oldChatForm.cloneNode(true);
const messageInput = chatForm.querySelector("input");
const emojiAutocomplete = chatForm.querySelector("#emoji-autocomplete");
oldChatForm.replaceWith(chatForm);
let autocompleting = false,
showListTimer;
const replaceMessage = (message) => () => {
messageInput.value = message;
autocomplete();
};
async function autocomplete(fromListTimeout) {
if (autocompleting) return;
try {
clearInterval(showListTimer);
emojiAutocomplete.textContent = "";
autocompleting = true;
let text = messageInput.value.slice(0, messageInput.selectionStart);
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);
if (search.length < 1 && !fromListTimeout) {
autocompleting = false;
showListTimer = setTimeout(() => autocomplete(true), 500);
return;
}
const suffix = messageInput.value.slice(messageInput.selectionStart);
let selected;
const select = (button) => {
if (selected) selected.classList.remove("selected");
selected = button;
button.classList.add("selected");
};
let results = await findEmojis(search);
let yieldAt = performance.now() + 13;
for (let i = 0; i < results.length; i += 100) {
emojiAutocomplete.append.apply(
emojiAutocomplete,
results.slice(i, i + 100).map(([name, replaceWith, ext], i) => {
const button = Object.assign(document.createElement("button"), {
className: "emoji-option",
onmousedown: (e) => e.preventDefault(),
onclick: () => {
messageInput.value = prefix + replaceWith + " " + suffix;
setCaretPosition(
messageInput,
(prefix + " " + replaceWith).length
);
},
onmouseover: () => select(button),
onfocus: () => select(button),
type: "button",
title: name,
});
button.append(
replaceWith[0] !== ":"
? Object.assign(document.createElement("span"), {
textContent: replaceWith,
className: "emoji",
})
: Object.assign(new Image(), {
loading: "lazy",
src: `/emojis/${name}${ext}`,
className: "emoji",
}),
Object.assign(document.createElement("span"), {
textContent: name,
className: "emoji-name",
})
);
return button;
})
);
if (i == 0 && emojiAutocomplete.children[0]) {
emojiAutocomplete.children[0].scrollIntoView();
select(emojiAutocomplete.children[0]);
}
const now = performance.now();
if (now > yieldAt) {
yieldAt = now + 13;
await new Promise((cb) => setTimeout(cb, 0));
}
}
autocompleting = false;
} catch (e) {
autocompleting = false;
}
}
messageInput.addEventListener("input", () => autocomplete());
messageInput.addEventListener("selectionchange", () => autocomplete());
messageInput.addEventListener("keydown", (event) => {
if (event.key == "ArrowUp" || event.key == "ArrowDown") {
let selected = document.querySelector(".emoji-option.selected");
if (!selected) return;
event.preventDefault();
selected.classList.remove("selected");
selected =
event.key == "ArrowDown"
? selected.nextElementSibling || selected.parentElement.children[0]
: selected.previousElementSibling ||
selected.parentElement.children[
selected.parentElement.children.length - 1
];
selected.classList.add("selected");
selected.scrollIntoView({ scrollMode: "if-needed", block: "nearest" });
}
if (event.key == "Tab" || event.key == "Enter") {
let selected = document.querySelector(".emoji-option.selected");
if (!selected) return;
event.preventDefault();
selected.onclick();
}
});
chatForm.addEventListener("submit", async (e) => {
e.preventDefault();
const content = messageInput.value;
if (content.trim().length) {
messageInput.value = "";
// handle commands
if (content.startsWith("/")) {
const command = content.toLowerCase().match(/^\/\S+/)[0];
const args = content.slice(command.length).trim();
let handled = false;
switch (command) {
case "/ping":
socket.send(
JSON.stringify({
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";
/**
* @param {HTMLInputElement} field
*/
const loadNickname = (field) => {
try {
const savedNickname = localStorage.getItem("watch-party-nickname");
field.value = savedNickname;
} catch (_err) {
// Sometimes localStorage is blocked from use
}
};
/**
* @param {HTMLInputElement} field
*/
const saveNickname = (field) => {
try {
localStorage.setItem("watch-party-nickname", field.value);
} catch (_err) {
// see loadNickname
}
};
export const setupJoinSessionForm = () => {
const form = document.querySelector("#join-session-form");
const nickname = form.querySelector("#join-session-nickname");
const sessionId = form.querySelector("#join-session-id");
loadNickname(nickname);
if (window.location.hash.match(/#[0-9a-f\-]+/)) {
sessionId.value = window.location.hash.substring(1);
}
document
.querySelector("#join-session-form")
.addEventListener("submit", (event) => {
event.preventDefault();
saveNickname(nickname);
joinSession(nickname.value, sessionId.value);
});
};
import { joinSession } from "./watch-session.mjs?v=4b61c4";
import { state } from "./state.mjs";
/**
* @param {HTMLInputElement} field
*/
const loadNickname = (field) => {
try {
const savedNickname = localStorage.getItem("watch-party-nickname");
field.value = savedNickname;
} catch (_err) {
// Sometimes localStorage is blocked from use
}
};
/**
* @param {HTMLInputElement} field
*/
const saveNickname = (field) => {
try {
localStorage.setItem("watch-party-nickname", field.value);
} catch (_err) {
// see loadNickname
}
};
/**
* @param {HTMLInputElement} field
*/
const loadColour = (field) => {
try {
const savedColour = localStorage.getItem("watch-party-colour");
if (savedColour != null && savedColour != "") {
field.value = savedColour;
}
} catch (_err) {
// Sometimes localStorage is blocked from use
}
};
/**
* @param {HTMLInputElement} field
*/
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 @@
/**
* @param {string} videoUrl
* @param {{name: string, url: string}[]} subtitles
*/
const createVideoElement = (videoUrl, subtitles) => {
const video = document.createElement("video");
video.controls = true;
video.autoplay = false;
video.crossOrigin = "anonymous";
const source = document.createElement("source");
source.src = videoUrl;
video.appendChild(source);
let first = true;
for (const { name, url } of subtitles) {
const track = document.createElement("track");
track.label = name;
track.src = url;
track.kind = "captions";
if (first) {
track.default = true;
first = false;
}
video.appendChild(track);
}
return video;
};
/**
* @param {string} videoUrl
* @param {{name: string, url: string}[]} subtitles
* @param {number} currentTime
* @param {boolean} playing
*/
export const setupVideo = async (videoUrl, subtitles, currentTime, playing) => {
document.querySelector("#pre-join-controls").style["display"] = "none";
const video = createVideoElement(videoUrl, subtitles);
document.querySelector("#video-container").appendChild(video);
video.currentTime = currentTime / 1000.0;
try {
if (playing) {
await video.play();
} else {
video.pause();
}
} catch (err) {
// Auto-play is probably disabled, we should uhhhhhhh do something about it
}
return video;
};
import Plyr from "./plyr-3.7.3.min.esm.js";
/**
* @param {string} videoUrl
* @param {{name: string, url: string}[]} subtitles
*/
const createVideoElement = (videoUrl, subtitles, created) => {
const oldVideo = document.getElementById(".plyr");
if (oldVideo) {
oldVideo.remove();
}
const video = document.createElement("video");
video.id = "video";
video.crossOrigin = "anonymous";
const source = document.createElement("source");
source.src = videoUrl;
video.appendChild(source);
for (const { name, url } of subtitles) {
const track = document.createElement("track");
track.label = name;
track.srclang = "xx-" + name.toLowerCase();
track.src = url;
track.kind = "captions";
video.appendChild(track);
}
const videoContainer = document.querySelector("#video-container");
videoContainer.style.display = "block";
videoContainer.appendChild(video);
const player = new Plyr(video, {
clickToPlay: false,
settings: ["captions", "quality"],
autopause: false,
});
player.elements.controls.insertAdjacentHTML(
"afterbegin",
`<button type="button" aria-pressed="false" class="plyr__controls__item plyr__control lock-controls"><svg aria-hidden="true" focusable="false" viewBox="0 0 24 24"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zM9 8V6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9z"></path></svg><span class="label--pressed plyr__sr-only">Unlock controls</span><span class="label--not-pressed plyr__sr-only">Lock controls</span></button>`
);
const lockButton = player.elements.controls.children[0];
let controlsEnabled = created;
const setControlsEnabled = (enabled) => {
controlsEnabled = enabled;
lockButton.setAttribute("aria-pressed", enabled);
lockButton.classList.toggle("plyr__control--pressed", enabled);
player.elements.buttons.play[0].disabled =
player.elements.buttons.play[1].disabled =
player.elements.inputs.seek.disabled =
!enabled;
if (!enabled) {
// enable media button support
navigator.mediaSession.setActionHandler("play", null);
navigator.mediaSession.setActionHandler("pause", null);
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 { setupChat, handleChatEvent } from "./chat.mjs";
/**
* @param {string} sessionId
* @param {string} nickname
* @returns {WebSocket}
*/
const createWebSocket = (sessionId, nickname) => {
const wsUrl = new URL(
`/sess/${sessionId}/subscribe` +
`?nickname=${encodeURIComponent(nickname)}`,
window.location.href
);
wsUrl.protocol = { "http:": "ws:", "https:": "wss:" }[wsUrl.protocol];
const socket = new WebSocket(wsUrl.toString());
return socket;
};
let outgoingDebounce = false;
let outgoingDebounceCallbackId = null;
const setDebounce = () => {
outgoingDebounce = true;
if (outgoingDebounceCallbackId) {
cancelIdleCallback(outgoingDebounceCallbackId);
outgoingDebounceCallbackId = null;
}
outgoingDebounceCallbackId = setTimeout(() => {
outgoingDebounce = false;
}, 500);
};
/**
* @param {HTMLVideoElement} video
* @param {WebSocket} socket
*/
const setupIncomingEvents = (video, socket) => {
const setVideoTime = (time) => {
const timeSecs = time / 1000.0;
if (Math.abs(video.currentTime - timeSecs) > 0.5) {
video.currentTime = timeSecs;
}
};
socket.addEventListener("message", async (messageEvent) => {
try {
const event = JSON.parse(messageEvent.data);
// console.log(event);
switch (event.op) {
case "SetPlaying":
setDebounce();
if (event.data.playing) {
await video.play();
} else {
video.pause();
}
setVideoTime(event.data.time);
break;
case "SetTime":
setDebounce();
setVideoTime(event.data);
break;
case "UserJoin":
case "UserLeave":
case "ChatMessage":
handleChatEvent(event);
break;
}
} catch (_err) {}
});
};
/**
* @param {HTMLVideoElement} video
* @param {WebSocket} socket
*/
const setupOutgoingEvents = (video, socket) => {
const currentVideoTime = () => (video.currentTime * 1000) | 0;
video.addEventListener("pause", async (event) => {
if (outgoingDebounce) {
return;
}
socket.send(
JSON.stringify({
op: "SetPlaying",
data: {
playing: false,
time: currentVideoTime(),
},
})
);
});
video.addEventListener("play", (event) => {
if (outgoingDebounce) {
return;
}
socket.send(
JSON.stringify({
op: "SetPlaying",
data: {
playing: true,
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;
}
if (outgoingDebounce) {
return;
}
socket.send(
JSON.stringify({
op: "SetTime",
data: currentVideoTime(),
})
);
});
};
/**
* @param {string} nickname
* @param {string} sessionId
*/
export const joinSession = async (nickname, sessionId) => {
try {
window.location.hash = sessionId;
const { video_url, subtitle_tracks, current_time_ms, is_playing } =
await fetch(`/sess/${sessionId}`).then((r) => r.json());
const socket = createWebSocket(sessionId, nickname);
socket.addEventListener("open", async () => {
const video = await setupVideo(
video_url,
subtitle_tracks,
current_time_ms,
is_playing
);
setupOutgoingEvents(video, socket);
setupIncomingEvents(video, socket);
setupChat(socket);
});
// TODO: Close listener ?
} catch (err) {
// TODO: Show an error on the screen
console.error(err);
}
};
import { setupVideo } from "./video.mjs?v=4b61c4";
import {
setupChat,
logEventToChat,
updateViewerList,
printChatMessage,
} from "./chat.mjs?v=4b61c4";
import ReconnectingWebSocket from "./reconnecting-web-socket.mjs";
import { state } from "./state.mjs";
let player;
/**
* @param {string} sessionId
* @param {string} nickname
* @returns {ReconnectingWebSocket}
*/
const createWebSocket = () => {
const wsUrl = new URL(
`/sess/${state().sessionId}/subscribe` +
`?nickname=${encodeURIComponent(state().nickname)}` +
`&colour=${encodeURIComponent(state().colour)}`,
window.location.href
);
wsUrl.protocol = "ws" + window.location.protocol.slice(4);
const socket = new ReconnectingWebSocket(wsUrl);
return socket;
};
let outgoingDebounce = false;
let outgoingDebounceCallbackId = null;
export const setDebounce = () => {
outgoingDebounce = true;
if (outgoingDebounceCallbackId) {
cancelIdleCallback(outgoingDebounceCallbackId);
outgoingDebounceCallbackId = null;
}
outgoingDebounceCallbackId = setTimeout(() => {
outgoingDebounce = false;
}, 500);
};
export const setVideoTime = (time) => {
const timeSecs = time / 1000.0;
if (Math.abs(player.currentTime - timeSecs) > 0.5) {
player.currentTime = timeSecs;
}
};
export const setPlaying = async (playing) => {
if (playing) {
await player.play();
} else {
player.pause();
}
};
/**
* @param {HTMLVideoElement} video
* @param {ReconnectingWebSocket} socket
*/
const setupIncomingEvents = (player, socket) => {
socket.addEventListener("message", async (messageEvent) => {
try {
const event = JSON.parse(messageEvent.data);
if (!event.reflected) {
switch (event.op) {
case "SetPlaying":
setDebounce();
if (event.data.playing) {
await player.play();
} else {
player.pause();
}
setVideoTime(event.data.time);
break;
case "SetTime":
setDebounce();
setVideoTime(event.data);
break;
case "UpdateViewerList":
updateViewerList(event.data);
break;
}
}
logEventToChat(event);
} catch (_err) {}
});
};
/**
* @param {Plyr} player
* @param {ReconnectingWebSocket} socket
*/
const setupOutgoingEvents = (player, socket) => {
const currentVideoTime = () => (player.currentTime * 1000) | 0;
player.on("pause", async () => {
if (outgoingDebounce || player.elements.inputs.seek.disabled) {
return;
}
// don't send a pause event for the video ending
if (player.currentTime == player.duration) {
return;
}
socket.send(
JSON.stringify({
op: "SetPlaying",
data: {
playing: false,
time: currentVideoTime(),
},
})
);
});
player.on("play", () => {
if (outgoingDebounce || player.elements.inputs.seek.disabled) {
return;
}
socket.send(
JSON.stringify({
op: "SetPlaying",
data: {
playing: true,
time: currentVideoTime(),
},
})
);
});
let firstSeekComplete = false;
player.on("seeked", async (event) => {
if (!firstSeekComplete) {
// The first seeked event is performed by the browser when the video is loading
firstSeekComplete = true;
return;
}
if (outgoingDebounce || player.elements.inputs.seek.disabled) {
return;
}
socket.send(
JSON.stringify({
op: "SetTime",
data: {
to: currentVideoTime(),
},
})
);
});
};
export const joinSession = async (created) => {
if (state().activeSession) {
if (state().activeSession === state().sessionId) {
// we are already in this session, dont rejoin
return;
}
// we are joining a new session from an existing session
const messageContent = document.createElement("span");
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";
const main = () => {
setupJoinSessionForm();
};
if (document.readyState === "complete") {
main();
} else {
document.addEventListener("DOMContentLoaded", main);
}
import { setupJoinSessionForm } from "./lib/join-session.mjs?v=4b61c4";
const main = () => {
setupJoinSessionForm();
};
if (document.readyState === "complete") {
main();
} else {
document.addEventListener("DOMContentLoaded", main);
}

View File

@ -1,147 +1,435 @@
:root {
--bg: rgb(28, 23, 36);
--fg: rgb(234, 234, 248);
--accent: hsl(275, 57%, 68%);
}
html {
background-color: var(--bg);
color: var(--fg);
font-size: 1.125rem;
font-family: sans-serif;
overflow-y: scroll;
scrollbar-width: none;
-ms-overflow-style: none;
}
::-webkit-scrollbar {
width: 0;
background: transparent;
}
html,
body {
margin: 0;
}
video {
display: block;
width: 100vw;
height: auto;
max-width: auto;
max-height: 100vh;
}
a {
color: var(--accent);
}
label {
display: block;
}
input[type="url"],
input[type="text"] {
box-sizing: border-box;
background: #fff;
background-clip: padding-box;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 6px;
color: rgba(0, 0, 0, 0.8);
display: block;
margin: 0.5em 0;
padding: 0.5em 1em;
line-height: 1.5;
font-family: sans-serif;
font-size: 1em;
width: 500px;
resize: none;
overflow-x: wrap;
overflow-y: scroll;
}
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: 500px;
user-select: none;
border: 1px solid rgba(0, 0, 0, 0);
line-height: 1.5;
}
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 {
width: 60%;
margin: 0 auto;
margin-top: 4em;
}
#join-session-form {
margin-bottom: 4em;
}
#chatbox-container {
display: none;
}
.user-join,
.user-leave {
font-style: italic;
}
.chat-message > strong {
color: rgb(126, 208, 255);
}
#chatbox {
padding: 0.5em 2em;
min-height: 8em;
overflow-y: scroll;
}
#chatbox-container {
background-color: #222;
}
#chatbox-send {
padding: 0 2em;
padding-bottom: 0.5em;
}
#chatbox-send > input {
font-size: 0.75em;
}
*,
*:before,
*:after {
box-sizing: border-box;
}
:root {
--bg-rgb: 28, 23, 36;
--fg-rgb: 234, 234, 248;
--accent-rgb: 181, 127, 220;
--fg: rgb(var(--fg-rgb));
--bg: rgb(var(--bg-rgb));
--default-user-color: rgb(126, 208, 255);
--accent: rgb(var(--accent-rgb));
--fg-transparent: rgba(var(--fg-rgb), 0.25);
--bg-transparent: rgba(var(--bg-rgb), 0.25);
--autocomplete-bg: linear-gradient(
var(--fg-transparent),
var(--fg-transparent)
),
linear-gradient(var(--bg), var(--bg));
--chip-bg: linear-gradient(
var(--accent-transparent),
var(--accent-transparent)
),
linear-gradient(var(--bg), var(--bg));
--accent-transparent: rgba(var(--accent-rgb), 0.25);
--plyr-color-main: var(--accent);
--plyr-control-radius: 6px;
--plyr-menu-radius: 6px;
--plyr-menu-background: var(--autocomplete-bg);
--plyr-menu-color: var(--fg);
--plyr-menu-arrow-color: var(--fg);
--plyr-menu-back-border-color: var(--fg-transparent);
--plyr-menu-back-border-shadow-color: transparent;
}
html {
background-color: var(--bg);
color: var(--fg);
font-size: 1.125rem;
font-family: sans-serif;
}
html,
body {
margin: 0;
padding: 0;
overflow: hidden;
overscroll-behavior: none;
width: 100%;
height: 100%;
}
body {
display: flex;
flex-direction: column;
}
.lock-controls.plyr__control--pressed svg {
opacity: 0.5;
}
.plyr {
width: 100%;
height: 100%;
}
.plyr__menu__container {
--plyr-video-control-background-hover: var(--fg-transparent);
--plyr-video-control-color-hover: var(--fg);
--plyr-control-radius: 4px;
--plyr-control-spacing: calc(0.25rem / 0.7);
--plyr-font-size-menu: 0.75rem;
--plyr-menu-arrow-size: 0;
margin-bottom: 0.48rem;
max-height: 27vmin;
clip-path: inset(0 0 0 0 round 4px);
scrollbar-width: thin;
}
.plyr__menu__container .plyr__control[role="menuitemradio"]::after {
left: 10px;
}
.plyr__menu__container
.plyr__control[role="menuitemradio"][aria-checked="true"].plyr__tab-focus::before,
.plyr__menu__container
.plyr__control[role="menuitemradio"][aria-checked="true"]:hover::before {
background: var(--accent);
}
[data-plyr="language"] .plyr__menu__value {
display: none;
}
#video-container {
flex-grow: 0;
flex-shrink: 1;
display: none;
}
a {
color: var(--accent);
}
.chip {
color: var(--fg);
background: var(--chip-bg);
text-decoration: none;
padding: 0 0.5rem 0 1.45rem;
display: inline-flex;
position: relative;
font-size: 0.9rem;
height: 1.125rem;
align-items: center;
border-radius: 2rem;
overflow: hidden;
}
.chip::before {
content: "";
position: absolute;
left: 0;
top: 0;
width: 1.125rem;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
background: var(--accent-transparent);
background-repeat: no-repeat;
background-size: 18px;
background-position: center;
}
.join-chip::before {
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI2ZmZiI+PHBhdGggZD0iTTggNXYxNGwxMS03eiIvPjwvc3ZnPg==");
}
.time-chip::before {
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI2ZmZiI+PHBhdGggZD0iTTExLjk5IDJDNi40NyAyIDIgNi40OCAyIDEyczQuNDcgMTAgOS45OSAxMEMxNy41MiAyMiAyMiAxNy41MiAyMiAxMlMxNy41MiAyIDExLjk5IDJ6TTEyIDIwYy00LjQyIDAtOC0zLjU4LTgtOHMzLjU4LTggOC04IDggMy41OCA4IDgtMy41OCA4LTggOHoiLz48cGF0aCBkPSJNMTIuNSA3SDExdjZsNS4yNSAzLjE1Ljc1LTEuMjMtNC41LTIuNjd6Ii8+PC9zdmc+");
}
label {
display: block;
}
input[type="url"],
input[type="text"] {
background: #fff;
background-clip: padding-box;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 6px;
color: rgba(0, 0, 0, 0.8);
display: block;
margin: 0.5em 0;
padding: 0.5em 1em;
line-height: 1.5;
font-family: sans-serif;
font-size: 1em;
width: 100%;
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};
#[derive(Clone, Serialize, Deserialize)]
#[serde(tag = "op", content = "data")]
pub enum WatchEvent {
SetPlaying {
playing: bool,
time: u64,
},
SetTime(u64),
UserJoin(String),
UserLeave(String),
ChatMessage {
#[serde(default = "String::new")]
user: String,
message: String,
},
}
use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize, Deserialize)]
pub struct Viewer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nickname: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub colour: Option<String>,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(tag = "op", content = "data")]
pub enum WatchEventData {
SetPlaying {
playing: bool,
time: u64,
},
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 std::net::IpAddr;
use uuid::Uuid;
use warb::{hyper::StatusCode, Filter, Reply};
use warp as warb; // i think it's funny
mod events;
mod viewer_connection;
mod watch_session;
use serde::Deserialize;
use crate::{
events::WatchEvent,
viewer_connection::{ws_publish, ws_subscribe},
watch_session::{get_session, handle_watch_event, SubtitleTrack, WatchSession, SESSIONS},
};
#[derive(Deserialize)]
struct StartSessionBody {
video_url: String,
#[serde(default = "Vec::new")]
subtitle_tracks: Vec<SubtitleTrack>,
}
#[derive(Deserialize)]
struct SubscribeQuery {
nickname: String,
}
#[tokio::main]
async fn main() {
let start_session_route = warb::path!("start_session")
.and(warb::path::end())
.and(warb::post())
.and(warb::body::json())
.map(|body: StartSessionBody| {
let mut sessions = SESSIONS.lock().unwrap();
let session_uuid = Uuid::new_v4();
let session = WatchSession::new(body.video_url, body.subtitle_tracks);
let session_view = session.view();
sessions.insert(session_uuid, session);
warb::reply::json(&json!({ "id": session_uuid.to_string(), "session": session_view }))
});
enum RequestedSession {
Session(Uuid, WatchSession),
Error(warb::reply::WithStatus<warb::reply::Json>),
}
let get_running_session = warb::path::path("sess")
.and(warb::path::param::<String>())
.map(|session_id: String| {
if let Ok(uuid) = Uuid::parse_str(&session_id) {
get_session(uuid)
.map(|sess| RequestedSession::Session(uuid, sess))
.unwrap_or_else(|| {
RequestedSession::Error(warb::reply::with_status(
warb::reply::json(&json!({ "error": "session does not exist" })),
StatusCode::NOT_FOUND,
))
})
} else {
RequestedSession::Error(warb::reply::with_status(
warb::reply::json(&json!({ "error": "invalid session UUID" })),
StatusCode::BAD_REQUEST,
))
}
});
let get_status_route = get_running_session
.and(warb::path::end())
.map(|requested_session| match requested_session {
RequestedSession::Session(_, sess) => {
warb::reply::with_status(warb::reply::json(&sess.view()), StatusCode::OK)
}
RequestedSession::Error(e) => e,
});
let set_playing_route = get_running_session
.and(warb::path!("playing"))
.and(warb::put())
.and(warb::body::json())
.map(|requested_session, playing: bool| match requested_session {
RequestedSession::Session(uuid, mut sess) => {
let event = WatchEvent::SetPlaying {
playing,
time: sess.get_time_ms(),
};
handle_watch_event(uuid, &mut sess, event.clone());
tokio::spawn(ws_publish(uuid, None, event));
warb::reply::with_status(warb::reply::json(&sess.view()), StatusCode::OK)
}
RequestedSession::Error(e) => e,
});
let set_timestamp_route = get_running_session
.and(warb::path!("current_time"))
.and(warb::put())
.and(warb::body::json())
.map(
|requested_session, current_time_ms: u64| match requested_session {
RequestedSession::Session(uuid, mut sess) => {
let event = WatchEvent::SetTime(current_time_ms);
handle_watch_event(uuid, &mut sess, event.clone());
tokio::spawn(ws_publish(uuid, None, event));
warb::reply::with_status(warb::reply::json(&sess.view()), StatusCode::OK)
}
RequestedSession::Error(e) => e,
},
);
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;
}
use serde_json::json;
use std::net::IpAddr;
use uuid::Uuid;
use warb::{hyper::StatusCode, Filter, Reply};
use warp as warb; // i think it's funny
mod events;
mod utils;
mod viewer_connection;
mod watch_session;
use serde::Deserialize;
use crate::{
viewer_connection::ws_subscribe,
watch_session::{get_session, SubtitleTrack, WatchSession, SESSIONS},
};
#[derive(Deserialize)]
struct StartSessionBody {
video_url: String,
#[serde(default = "Vec::new")]
subtitle_tracks: Vec<SubtitleTrack>,
}
#[derive(Deserialize)]
struct SubscribeQuery {
nickname: String,
colour: String,
}
async fn get_emoji_list() -> Result<impl warb::Reply, warb::Rejection> {
use tokio_stream::{wrappers::ReadDirStream, StreamExt};
let dir = tokio::fs::read_dir("frontend/emojis")
.await
.expect("Couldn't read emojis directory!");
let files = ReadDirStream::new(dir)
.filter_map(|r| r.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.collect::<Vec<_>>()
.await;
Ok(warb::reply::json(&files))
}
#[tokio::main]
async fn main() {
let start_session_route = warb::path!("start_session")
.and(warb::path::end())
.and(warb::post())
.and(warb::body::json())
.map(|body: StartSessionBody| {
let mut sessions = SESSIONS.lock().unwrap();
let session_uuid = Uuid::new_v4();
let session = WatchSession::new(body.video_url, body.subtitle_tracks);
let session_view = session.view();
sessions.insert(session_uuid, session);
warb::reply::json(&json!({ "id": session_uuid.to_string(), "session": session_view }))
});
let get_emoji_route = warb::path!("emojos").and_then(get_emoji_list);
enum RequestedSession {
Session(Uuid, WatchSession),
Error(warb::reply::WithStatus<warb::reply::Json>),
}
let get_running_session = warb::path::path("sess")
.and(warb::path::param::<String>())
.map(|session_id: String| {
if let Ok(uuid) = Uuid::parse_str(&session_id) {
get_session(uuid)
.map(|sess| RequestedSession::Session(uuid, sess))
.unwrap_or_else(|| {
RequestedSession::Error(warb::reply::with_status(
warb::reply::json(&json!({ "error": "session does not exist" })),
StatusCode::NOT_FOUND,
))
})
} else {
RequestedSession::Error(warb::reply::with_status(
warb::reply::json(&json!({ "error": "invalid session UUID" })),
StatusCode::BAD_REQUEST,
))
}
});
let get_status_route = get_running_session
.and(warb::path::end())
.map(|requested_session| match requested_session {
RequestedSession::Session(_, sess) => {
warb::reply::with_status(warb::reply::json(&sess.view()), StatusCode::OK)
}
RequestedSession::Error(e) => e,
});
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, query.colour, ws))
.into_response(),
RequestedSession::Error(error_response) => error_response.into_response(),
},
);
let routes = start_session_route
.or(get_status_route)
.or(ws_subscribe_route)
.or(get_emoji_route)
.or(warb::path::end().and(warb::fs::file("frontend/index.html")))
.or(warb::fs::dir("frontend"));
let ip = std::env::var("IP")
.ok()
.and_then(|s| s.parse::<IpAddr>().ok())
.unwrap_or_else(|| [127, 0, 0, 1].into());
let port = std::env::var("PORT")
.ok()
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(3000);
println!("Listening at http://{}:{} ...", &ip, &port);
warb::serve(routes).run((ip, port)).await;
}

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 std::{
collections::HashMap,
sync::atomic::{AtomicUsize, Ordering},
};
use futures::{SinkExt, StreamExt, TryFutureExt};
use tokio::sync::{
mpsc::{self, UnboundedSender},
RwLock,
};
use tokio_stream::wrappers::UnboundedReceiverStream;
use uuid::Uuid;
use warp::ws::{Message, WebSocket};
use crate::{
events::WatchEvent,
watch_session::{get_session, handle_watch_event},
};
static CONNECTED_VIEWERS: Lazy<RwLock<HashMap<usize, ConnectedViewer>>> =
Lazy::new(|| RwLock::new(HashMap::new()));
static NEXT_VIEWER_ID: AtomicUsize = AtomicUsize::new(1);
pub struct ConnectedViewer {
pub session: Uuid,
pub viewer_id: usize,
pub tx: UnboundedSender<WatchEvent>,
}
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();
let (tx, rx) = mpsc::unbounded_channel::<WatchEvent>();
let mut rx = UnboundedReceiverStream::new(rx);
tokio::task::spawn(async move {
while let Some(event) = rx.next().await {
viewer_ws_tx
.send(Message::text(
serde_json::to_string(&event).expect("couldn't convert WatchEvent into JSON"),
))
.unwrap_or_else(|e| eprintln!("ws send error: {}", e))
.await;
}
});
CONNECTED_VIEWERS.write().await.insert(
viewer_id,
ConnectedViewer {
viewer_id,
session: session_uuid,
tx,
},
);
ws_publish(session_uuid, None, WatchEvent::UserJoin(nickname.clone())).await;
while let Some(Ok(message)) = viewer_ws_rx.next().await {
let mut event: WatchEvent = match message
.to_str()
.ok()
.and_then(|s| serde_json::from_str(s).ok())
{
Some(e) => e,
None => continue,
};
// Make sure people don't spoof their nicknames to pretend to be others
// If a nickname change is required, I guess reconnect idk
if let WatchEvent::ChatMessage { user: _, message } = event {
event = WatchEvent::ChatMessage {
user: nickname.clone(),
message,
};
// Don't pass through the viewer_id because we want the chat message
// to be reflected to the user.
ws_publish(session_uuid, None, event).await;
// We don't need to handle() chat messages,
// and we are already publishing them ourselves.
continue;
}
handle_watch_event(
session_uuid,
&mut get_session(session_uuid).unwrap(),
event.clone(),
);
ws_publish(session_uuid, Some(viewer_id), event).await;
}
ws_publish(session_uuid, None, WatchEvent::UserLeave(nickname.clone())).await;
CONNECTED_VIEWERS.write().await.remove(&viewer_id);
}
pub async fn ws_publish(session_uuid: Uuid, viewer_id: Option<usize>, event: WatchEvent) {
for viewer in CONNECTED_VIEWERS.read().await.values() {
if viewer_id == Some(viewer.viewer_id) {
continue;
}
if viewer.session != session_uuid {
continue;
}
let _ = viewer.tx.send(event.clone());
}
}
use once_cell::sync::Lazy;
use std::{
collections::HashMap,
sync::atomic::{AtomicUsize, Ordering},
};
use futures::{SinkExt, StreamExt, TryFutureExt};
use tokio::sync::{
mpsc::{self, UnboundedSender},
RwLock,
};
use tokio_stream::wrappers::UnboundedReceiverStream;
use uuid::Uuid;
use warp::ws::{Message, WebSocket};
use crate::{
events::{Viewer, WatchEvent, WatchEventData},
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 NEXT_VIEWER_ID: AtomicUsize = AtomicUsize::new(1);
pub struct ConnectedViewer {
pub session: Uuid,
pub viewer_id: usize,
pub tx: UnboundedSender<WatchEvent>,
pub nickname: Option<String>,
pub colour: Option<String>,
}
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 (mut viewer_ws_tx, mut viewer_ws_rx) = ws.split();
let (tx, rx) = mpsc::unbounded_channel::<WatchEvent>();
let mut rx = UnboundedReceiverStream::new(rx);
tokio::task::spawn(async move {
while let Some(event) = rx.next().await {
viewer_ws_tx
.send(Message::text(
serde_json::to_string(&event).expect("couldn't convert WatchEvent into JSON"),
))
.unwrap_or_else(|e| eprintln!("ws send error: {}", e))
.await;
}
});
let mut colour = colour;
if colour.len() != 6 || !colour.chars().all(|x| x.is_ascii_hexdigit()) {
colour = String::from("7ed0ff");
}
let nickname = truncate_str(&nickname, 50).to_string();
CONNECTED_VIEWERS.write().await.insert(
viewer_id,
ConnectedViewer {
viewer_id,
session: session_uuid,
tx,
nickname: Some(nickname.clone()),
colour: Some(colour.clone()),
},
);
ws_publish(
session_uuid,
None,
WatchEvent::new(nickname.clone(), colour.clone(), WatchEventData::UserJoin),
)
.await;
update_viewer_list(session_uuid).await;
while let Some(Ok(message)) = viewer_ws_rx.next().await {
let event: WatchEventData = match message
.to_str()
.ok()
.and_then(|s| serde_json::from_str(s).ok())
{
Some(e) => e,
None => continue,
};
let session = &mut get_session(session_uuid).unwrap();
// server side event modification where neccessary
let event: WatchEventData = match event {
WatchEventData::SetTime { from: _, to } => WatchEventData::SetTime {
from: Some(session.get_time_ms()),
to,
},
_ => event,
};
handle_watch_event_data(session_uuid, session, event.clone());
ws_publish(
session_uuid,
Some(viewer_id),
WatchEvent::new(nickname.clone(), colour.clone(), event),
)
.await;
}
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,92 +1,96 @@
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, sync::Mutex, time::Instant};
use uuid::Uuid;
use crate::events::WatchEvent;
#[derive(Serialize, Deserialize, Clone)]
pub struct SubtitleTrack {
pub url: String,
pub name: String,
}
#[derive(Clone)]
pub struct WatchSession {
pub video_url: String,
pub subtitle_tracks: Vec<SubtitleTrack>,
is_playing: bool,
playing_from_timestamp: u64,
playing_from_instant: Instant,
}
#[derive(Serialize)]
pub struct WatchSessionView {
pub video_url: String,
pub subtitle_tracks: Vec<SubtitleTrack>,
pub current_time_ms: u64,
pub is_playing: bool,
}
impl WatchSession {
pub fn new(video_url: String, subtitle_tracks: Vec<SubtitleTrack>) -> Self {
WatchSession {
video_url,
subtitle_tracks,
is_playing: false,
playing_from_timestamp: 0,
playing_from_instant: Instant::now(),
}
}
pub fn view(&self) -> WatchSessionView {
WatchSessionView {
video_url: self.video_url.clone(),
subtitle_tracks: self.subtitle_tracks.clone(),
current_time_ms: self.get_time_ms() as u64,
is_playing: self.is_playing,
}
}
pub fn get_time_ms(&self) -> u64 {
if !self.is_playing {
return self.playing_from_timestamp;
}
self.playing_from_timestamp + self.playing_from_instant.elapsed().as_millis() as u64
}
pub fn set_time_ms(&mut self, time_ms: u64) {
self.playing_from_timestamp = time_ms;
self.playing_from_instant = Instant::now();
}
pub fn set_playing(&mut self, playing: bool, time_ms: u64) {
self.set_time_ms(time_ms);
self.is_playing = playing;
}
}
pub static SESSIONS: Lazy<Mutex<HashMap<Uuid, WatchSession>>> =
Lazy::new(|| Mutex::new(HashMap::new()));
pub fn get_session(uuid: Uuid) -> Option<WatchSession> {
SESSIONS.lock().unwrap().get(&uuid).cloned()
}
pub fn handle_watch_event(uuid: Uuid, watch_session: &mut WatchSession, event: WatchEvent) {
match event {
WatchEvent::SetPlaying { playing, time } => {
watch_session.set_playing(playing, time);
}
WatchEvent::SetTime(time) => {
watch_session.set_time_ms(time);
}
_ => {}
};
let _ = SESSIONS.lock().unwrap().insert(uuid, watch_session.clone());
}
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, sync::Mutex, time::Instant};
use uuid::Uuid;
use crate::events::WatchEventData;
#[derive(Serialize, Deserialize, Clone)]
pub struct SubtitleTrack {
pub url: String,
pub name: String,
}
#[derive(Clone)]
pub struct WatchSession {
pub video_url: String,
pub subtitle_tracks: Vec<SubtitleTrack>,
is_playing: bool,
playing_from_timestamp: u64,
playing_from_instant: Instant,
}
#[derive(Serialize)]
pub struct WatchSessionView {
pub video_url: String,
pub subtitle_tracks: Vec<SubtitleTrack>,
pub current_time_ms: u64,
pub is_playing: bool,
}
impl WatchSession {
pub fn new(video_url: String, subtitle_tracks: Vec<SubtitleTrack>) -> Self {
WatchSession {
video_url,
subtitle_tracks,
is_playing: false,
playing_from_timestamp: 0,
playing_from_instant: Instant::now(),
}
}
pub fn view(&self) -> WatchSessionView {
WatchSessionView {
video_url: self.video_url.clone(),
subtitle_tracks: self.subtitle_tracks.clone(),
current_time_ms: self.get_time_ms() as u64,
is_playing: self.is_playing,
}
}
pub fn get_time_ms(&self) -> u64 {
if !self.is_playing {
return self.playing_from_timestamp;
}
self.playing_from_timestamp + self.playing_from_instant.elapsed().as_millis() as u64
}
pub fn set_time_ms(&mut self, time_ms: u64) {
self.playing_from_timestamp = time_ms;
self.playing_from_instant = Instant::now();
}
pub fn set_playing(&mut self, playing: bool, time_ms: u64) {
self.set_time_ms(time_ms);
self.is_playing = playing;
}
}
pub static SESSIONS: Lazy<Mutex<HashMap<Uuid, WatchSession>>> =
Lazy::new(|| Mutex::new(HashMap::new()));
pub fn get_session(uuid: Uuid) -> Option<WatchSession> {
SESSIONS.lock().unwrap().get(&uuid).cloned()
}
pub fn handle_watch_event_data(
uuid: Uuid,
watch_session: &mut WatchSession,
event: WatchEventData,
) {
match event {
WatchEventData::SetPlaying { playing, 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());
}