Compare commits

...

80 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
27 changed files with 2368 additions and 1237 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"

View File

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<title>watch party :D</title>
<link rel="stylesheet" href="/styles.css?v=3" />
<link rel="stylesheet" href="/styles.css?v=4b61c4" />
</head>
<body>
@ -39,14 +39,14 @@
placeholder="English"
/>
<button>Create</button>
</form>
<p>
Already have a session?
<a href="/">Join your session</a> instead.
</p>
<p>
Already have a session?
<a href="/">Join your session</a> instead.
</p>
</form>
</div>
<script type="module" src="/create.mjs?v=1"></script>
<script type="module" src="/create.mjs?v=4b61c4"></script>
</body>
</html>

View File

@ -1,4 +1,4 @@
import { setupCreateSessionForm } from "./lib/create-session.mjs";
import { setupCreateSessionForm } from "./lib/create-session.mjs?v=4b61c4";
const main = () => {
setupCreateSessionForm();

View File

@ -3,7 +3,8 @@
<head>
<meta charset="utf-8" />
<title>watch party :D</title>
<link rel="stylesheet" href="/styles.css?v=3" />
<link rel="stylesheet" href="/lib/plyr-3.7.3.css" />
<link rel="stylesheet" href="/styles.css?v=4b61c4" />
</head>
<body>
@ -26,9 +27,15 @@
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"
@ -36,22 +43,43 @@
placeholder="123e4567-e89b-12d3-a456-426614174000"
required
/>
<button>Join</button>
</form>
<button id="join-session-button">Join</button>
<p>
No session to join? <a href="/create.html">Create a session</a> instead.
</p>
<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..." />
<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=2"></script>
<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,17 +1,224 @@
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);
chatForm.addEventListener("submit", (e) => {
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 input = chatForm.querySelector("input");
const content = input.value;
const content = messageInput.value;
if (content.trim().length) {
input.value = "";
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",
@ -22,31 +229,12 @@ const setupChatboxEvents = (socket) => {
});
};
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";
document.querySelector("#chatbox-container").style["display"] = "flex";
setupChatboxEvents(socket);
fixChatSize();
window.addEventListener("resize", () => {
fixChatSize();
});
};
const addToChat = (node) => {
@ -87,24 +275,41 @@ const checkDebounce = (event) => {
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
*/
const printChatMessage = (eventType, user, 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.textContent = user;
userName.style = `--user-color: #${colour}`;
userName.textContent = user + " ";
chatMessage.appendChild(userName);
}
chatMessage.appendChild(document.createTextNode(" "));
if (content != null) {
chatMessage.appendChild(content);
}
@ -123,7 +328,7 @@ const formatTime = (ms) => {
}:${seconds < 10 ? "0" + seconds : seconds}`;
};
export const logEventToChat = (event) => {
export const logEventToChat = async (event) => {
if (checkDebounce(event)) {
return;
}
@ -133,6 +338,7 @@ export const logEventToChat = (event) => {
printChatMessage(
"user-join",
event.user,
event.colour,
document.createTextNode("joined")
);
break;
@ -141,6 +347,7 @@ export const logEventToChat = (event) => {
printChatMessage(
"user-leave",
event.user,
event.colour,
document.createTextNode("left")
);
break;
@ -148,19 +355,36 @@ export const logEventToChat = (event) => {
case "ChatMessage": {
const messageContent = document.createElement("span");
messageContent.classList.add("message-content");
messageContent.textContent = event.data;
printChatMessage("chat-message", event.user, messageContent);
messageContent.append(...(await linkify(event.data, emojify)));
printChatMessage(
"chat-message",
event.user,
event.colour,
messageContent
);
break;
}
case "SetTime": {
const messageContent = document.createElement("span");
messageContent.appendChild(document.createTextNode("set the time to "));
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))
document.createTextNode(formatTime(event.data.to))
);
printChatMessage("set-time", event.user, messageContent);
printChatMessage("set-time", event.user, event.colour, messageContent);
break;
}
case "SetPlaying": {
@ -175,9 +399,55 @@ export const logEventToChat = (event) => {
document.createTextNode(formatTime(event.data.time))
);
printChatMessage("set-playing", event.user, messageContent);
printChatMessage("set-playing", event.user, event.colour, messageContent);
break;
}
case "Ping": {
const messageContent = document.createElement("span");
if (event.data) {
messageContent.appendChild(document.createTextNode("pinged saying: "));
messageContent.appendChild(document.createTextNode(event.data));
} else {
messageContent.appendChild(document.createTextNode("pinged"));
}
printChatMessage("ping", event.user, event.colour, messageContent);
pling();
if ("Notification" in window) {
const title = "watch party :)";
const options = {
body: event.data
? `${event.user} pinged saying: ${event.data}`
: `${event.user} pinged`,
};
if (Notification.permission === "granted") {
new Notification(title, options);
} else if (Notification.permission !== "denied") {
Notification.requestPermission().then(function (permission) {
if (permission === "granted") {
new Notification(title, options);
}
});
}
}
break;
}
}
};
export const updateViewerList = (viewers) => {
const listContainer = document.querySelector("#viewer-list");
// empty out the current list
listContainer.innerHTML = "";
// display the updated list
for (const viewer of viewers) {
const viewerElem = document.createElement("div");
const content = document.createElement("strong");
content.textContent = viewer.nickname;
content.style = `--user-color: #${viewer.colour}`;
viewerElem.appendChild(content);
listContainer.appendChild(viewerElem);
}
};

View File

@ -1,4 +1,4 @@
import { createSession } from "./watch-session.mjs?v=3";
import { createSession } from "./watch-session.mjs?v=4b61c4";
export const setupCreateSessionForm = () => {
const form = document.querySelector("#create-session-form");

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,4 +1,5 @@
import { joinSession } from "./watch-session.mjs?v=3";
import { joinSession } from "./watch-session.mjs?v=4b61c4";
import { state } from "./state.mjs";
/**
* @param {HTMLInputElement} field
@ -23,33 +24,72 @@ const saveNickname = (field) => {
}
};
/**
* @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 = () => {
displayPostCreateMessage();
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);
}
document
.querySelector("#join-session-form")
.addEventListener("submit", (event) => {
event.preventDefault();
form.addEventListener("submit", async (event) => {
event.preventDefault();
saveNickname(nickname);
joinSession(nickname.value, sessionId.value);
});
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,11 +1,16 @@
import Plyr from "./plyr-3.7.3.min.esm.js";
/**
* @param {string} videoUrl
* @param {{name: string, url: string}[]} subtitles
*/
const createVideoElement = (videoUrl, subtitles) => {
const createVideoElement = (videoUrl, subtitles, created) => {
const oldVideo = document.getElementById(".plyr");
if (oldVideo) {
oldVideo.remove();
}
const video = document.createElement("video");
video.controls = true;
video.autoplay = false;
video.id = "video";
video.crossOrigin = "anonymous";
const source = document.createElement("source");
@ -13,22 +18,67 @@ const createVideoElement = (videoUrl, subtitles) => {
video.appendChild(source);
let first = true;
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";
if (first) {
track.default = true;
first = false;
}
video.appendChild(track);
}
return video;
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;
};
/**
@ -37,22 +87,26 @@ const createVideoElement = (videoUrl, subtitles) => {
* @param {number} currentTime
* @param {boolean} playing
*/
export const setupVideo = async (videoUrl, subtitles, currentTime, playing) => {
export const setupVideo = async (
videoUrl,
subtitles,
currentTime,
playing,
created
) => {
document.querySelector("#pre-join-controls").style["display"] = "none";
const video = createVideoElement(videoUrl, subtitles);
document.querySelector("#video-container").appendChild(video);
video.currentTime = currentTime / 1000.0;
const player = createVideoElement(videoUrl, subtitles, created);
player.currentTime = currentTime / 1000.0;
try {
if (playing) {
await video.play();
player.play();
} else {
video.pause();
player.pause();
}
} catch (err) {
// Auto-play is probably disabled, we should uhhhhhhh do something about it
}
return video;
return player;
};

View File

@ -1,19 +1,27 @@
import { setupVideo } from "./video.mjs?v=2";
import { setupChat, logEventToChat } from "./chat.mjs?v=2";
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 {WebSocket}
* @returns {ReconnectingWebSocket}
*/
const createWebSocket = (sessionId, nickname) => {
const createWebSocket = () => {
const wsUrl = new URL(
`/sess/${sessionId}/subscribe` +
`?nickname=${encodeURIComponent(nickname)}`,
`/sess/${state().sessionId}/subscribe` +
`?nickname=${encodeURIComponent(state().nickname)}` +
`&colour=${encodeURIComponent(state().colour)}`,
window.location.href
);
wsUrl.protocol = { "http:": "ws:", "https:": "wss:" }[wsUrl.protocol];
const socket = new WebSocket(wsUrl.toString());
wsUrl.protocol = "ws" + window.location.protocol.slice(4);
const socket = new ReconnectingWebSocket(wsUrl);
return socket;
};
@ -21,7 +29,7 @@ const createWebSocket = (sessionId, nickname) => {
let outgoingDebounce = false;
let outgoingDebounceCallbackId = null;
const setDebounce = () => {
export const setDebounce = () => {
outgoingDebounce = true;
if (outgoingDebounceCallbackId) {
@ -34,41 +42,49 @@ const setDebounce = () => {
}, 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 {WebSocket} socket
* @param {ReconnectingWebSocket} socket
*/
const setupIncomingEvents = (video, socket) => {
const setVideoTime = (time) => {
const timeSecs = time / 1000.0;
if (Math.abs(video.currentTime - timeSecs) > 0.5) {
video.currentTime = timeSecs;
}
};
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 video.play();
await player.play();
} else {
video.pause();
player.pause();
}
setVideoTime(event.data.time);
break;
case "SetTime":
setDebounce();
setVideoTime(event.data);
break;
case "UpdateViewerList":
updateViewerList(event.data);
break;
}
}
@ -78,14 +94,19 @@ const setupIncomingEvents = (video, socket) => {
};
/**
* @param {HTMLVideoElement} video
* @param {WebSocket} socket
* @param {Plyr} player
* @param {ReconnectingWebSocket} socket
*/
const setupOutgoingEvents = (video, socket) => {
const currentVideoTime = () => (video.currentTime * 1000) | 0;
const setupOutgoingEvents = (player, socket) => {
const currentVideoTime = () => (player.currentTime * 1000) | 0;
video.addEventListener("pause", async (event) => {
if (outgoingDebounce) {
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;
}
@ -100,8 +121,8 @@ const setupOutgoingEvents = (video, socket) => {
);
});
video.addEventListener("play", (event) => {
if (outgoingDebounce) {
player.on("play", () => {
if (outgoingDebounce || player.elements.inputs.seek.disabled) {
return;
}
@ -117,55 +138,106 @@ const setupOutgoingEvents = (video, socket) => {
});
let firstSeekComplete = false;
video.addEventListener("seeked", async (event) => {
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) {
if (outgoingDebounce || player.elements.inputs.seek.disabled) {
return;
}
socket.send(
JSON.stringify({
op: "SetTime",
data: currentVideoTime(),
data: {
to: currentVideoTime(),
},
})
);
});
};
/**
* @param {string} nickname
* @param {string} sessionId
*/
export const joinSession = async (nickname, sessionId) => {
try {
window.location.hash = sessionId;
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));
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);
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)
//}
};
/**
@ -184,3 +256,15 @@ export const createSession = async (videoUrl, subtitleTracks) => {
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,4 +1,4 @@
import { setupJoinSessionForm } from "./lib/join-session.mjs?v=2";
import { setupJoinSessionForm } from "./lib/join-session.mjs?v=4b61c4";
const main = () => {
setupJoinSessionForm();

View File

@ -1,7 +1,38 @@
*,
*:before,
*:after {
box-sizing: border-box;
}
:root {
--bg: rgb(28, 23, 36);
--fg: rgb(234, 234, 248);
--accent: hsl(275, 57%, 68%);
--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 {
@ -9,44 +40,115 @@ html {
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;
padding: 0;
overflow: hidden;
overscroll-behavior: none;
width: 100%;
height: 100%;
}
video {
display: block;
body {
display: flex;
flex-direction: column;
}
width: 100vw;
height: auto;
.lock-controls.plyr__control--pressed svg {
opacity: 0.5;
}
max-width: auto;
max-height: 100vh;
.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"] {
box-sizing: border-box;
background: #fff;
background-clip: padding-box;
border: 1px solid rgba(0, 0, 0, 0.12);
@ -60,15 +162,14 @@ input[type="text"] {
font-family: sans-serif;
font-size: 1em;
width: 500px;
max-width: 100%;
width: 100%;
resize: none;
overflow-x: wrap;
overflow-y: scroll;
}
button {
button:not(.plyr button) {
background-color: var(--accent);
border: var(--accent);
border-radius: 6px;
@ -82,12 +183,19 @@ button {
font-family: sans-serif;
font-size: 1em;
width: 500px;
max-width: 100%;
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 {
@ -108,20 +216,30 @@ button.small-button {
#pre-join-controls,
#create-controls {
width: 60%;
margin: 0 auto;
margin-top: 4em;
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 {
margin-bottom: 4em;
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: 500px;
max-width: 100%;
width: 100%;
font-size: 0.85em;
}
@ -129,40 +247,78 @@ button.small-button {
display: none;
}
.chat-message > strong {
color: rgb(126, 208, 255);
.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.user-leave,
.chat-message.ping {
font-style: italic;
}
.chat-message.set-time,
.chat-message.set-playing {
.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 {
color: unset;
.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 2em;
min-height: 8em;
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: #222;
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 2em;
padding: 0 1em;
padding-bottom: 0.5em;
position: relative;
}
#chatbox-send > input {
@ -170,27 +326,110 @@ button.small-button {
width: 100%;
}
@media (min-aspect-ratio: 4/3) {
#video-container video {
width: calc(100vw - 400px);
position: absolute;
height: 100vh;
background-color: black;
}
#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);
}
#video-container {
float: left;
height: 100vh;
position: relative;
#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 {
float: right;
width: 400px;
height: 100vh !important;
flex-grow: 0;
}
#video-container {
flex-grow: 1;
}
#chatbox {
height: calc(100vh - 5em) !important;
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,20 +1,39 @@
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(u64),
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)]
@ -22,9 +41,10 @@ pub struct WatchEvent {
}
impl WatchEvent {
pub fn new(user: String, data: WatchEventData) -> Self {
pub fn new(user: String, colour: String, data: WatchEventData) -> Self {
WatchEvent {
user: Some(user),
colour: Some(colour),
data,
reflected: false,
}

View File

@ -6,15 +6,15 @@ 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::{
events::{WatchEvent, WatchEventData},
viewer_connection::{ws_publish, ws_subscribe},
watch_session::{get_session, handle_watch_event_data, SubtitleTrack, WatchSession, SESSIONS},
viewer_connection::ws_subscribe,
watch_session::{get_session, SubtitleTrack, WatchSession, SESSIONS},
};
#[derive(Deserialize)]
@ -27,6 +27,23 @@ struct StartSessionBody {
#[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]
@ -45,6 +62,8 @@ async fn main() {
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>),
@ -79,59 +98,6 @@ async fn main() {
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 data = WatchEventData::SetPlaying {
playing,
time: sess.get_time_ms(),
};
handle_watch_event_data(uuid, &mut sess, data.clone());
tokio::spawn(ws_publish(
uuid,
None,
WatchEvent {
user: None,
data,
reflected: false,
},
));
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 data = WatchEventData::SetTime(current_time_ms);
handle_watch_event_data(uuid, &mut sess, data.clone());
tokio::spawn(ws_publish(
uuid,
None,
WatchEvent {
user: None,
data,
reflected: false,
},
));
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())
@ -139,7 +105,7 @@ async fn main() {
.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))
.on_upgrade(move |ws| ws_subscribe(uuid, query.nickname, query.colour, ws))
.into_response(),
RequestedSession::Error(error_response) => error_response.into_response(),
},
@ -147,9 +113,8 @@ async fn main() {
let routes = start_session_route
.or(get_status_route)
.or(set_playing_route)
.or(set_timestamp_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"));

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

@ -15,7 +15,8 @@ use uuid::Uuid;
use warp::ws::{Message, WebSocket};
use crate::{
events::{WatchEvent, WatchEventData},
events::{Viewer, WatchEvent, WatchEventData},
utils::truncate_str,
watch_session::{get_session, handle_watch_event_data},
};
@ -28,9 +29,10 @@ pub struct ConnectedViewer {
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, ws: WebSocket) {
pub async fn ws_subscribe(session_uuid: Uuid, nickname: String, colour: String, ws: WebSocket) {
let viewer_id = NEXT_VIEWER_ID.fetch_add(1, Ordering::Relaxed);
let (mut viewer_ws_tx, mut viewer_ws_rx) = ws.split();
@ -48,6 +50,12 @@ pub async fn ws_subscribe(session_uuid: Uuid, nickname: String, ws: WebSocket) {
}
});
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 {
@ -55,16 +63,19 @@ pub async fn ws_subscribe(session_uuid: Uuid, nickname: String, ws: WebSocket) {
session: session_uuid,
tx,
nickname: Some(nickname.clone()),
colour: Some(colour.clone()),
},
);
ws_publish(
session_uuid,
None,
WatchEvent::new(nickname.clone(), WatchEventData::UserJoin),
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()
@ -75,16 +86,23 @@ pub async fn ws_subscribe(session_uuid: Uuid, nickname: String, ws: WebSocket) {
None => continue,
};
handle_watch_event_data(
session_uuid,
&mut get_session(session_uuid).unwrap(),
event.clone(),
);
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(), event),
WatchEvent::new(nickname.clone(), colour.clone(), event),
)
.await;
}
@ -92,11 +110,12 @@ pub async fn ws_subscribe(session_uuid: Uuid, nickname: String, ws: WebSocket) {
ws_publish(
session_uuid,
None,
WatchEvent::new(nickname.clone(), WatchEventData::UserLeave),
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) {
@ -111,3 +130,27 @@ pub async fn ws_publish(session_uuid: Uuid, skip_viewer_id: Option<usize>, event
});
}
}
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

@ -85,8 +85,8 @@ pub fn handle_watch_event_data(
watch_session.set_playing(playing, time);
}
WatchEventData::SetTime(time) => {
watch_session.set_time_ms(time);
WatchEventData::SetTime { from: _, to } => {
watch_session.set_time_ms(to);
}
_ => {}