Refactor frontend to use ES modules

This commit is contained in:
videogame hacker 2021-11-09 13:21:14 +00:00
parent caf96d1d04
commit 8da286fad9
7 changed files with 388 additions and 333 deletions

View File

@ -47,6 +47,6 @@
</form> </form>
</div> </div>
<script src="/main.js?v=2"></script> <script type="module" src="/main.mjs?v=1"></script>
</body> </body>
</html> </html>

102
frontend/lib/chat.mjs Normal file
View File

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

View File

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

58
frontend/lib/video.mjs Normal file
View File

@ -0,0 +1,58 @@
/**
* @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;
};

View File

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

View File

@ -1,332 +0,0 @@
/**
* @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;
}
let outgoingDebounce = false;
let outgoingDebounceCallbackId = null;
const setDebounce = () => {
outgoingDebounce = true;
if (outgoingDebounceCallbackId) {
cancelIdleCallback(outgoingDebounceCallbackId);
outgoingDebounceCallbackId = null;
}
outgoingDebounceCallbackId = setTimeout(() => {
outgoingDebounce = false;
}, 500);
}
const clearChat = () => {
document.querySelector("#chatbox").innerHTML = "";
}
const printToChat = (elem) => {
const chatbox = document.querySelector("#chatbox");
chatbox.appendChild(elem);
chatbox.scrollTop = chatbox.scrollHeight;
}
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;
}
}
}
/**
* @param {WebSocket} socket
* @param {HTMLVideoElement} video
*/
const setupSocketEvents = (socket, video) => {
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 {string} sessionId
* @param {HTMLVideoElement} video
* @param {WebSocket} socket
*/
const setupVideoEvents = (sessionId, 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} videoUrl
* @param {{name: string, url: string}[]} subtitles
* @param {number} currentTime
* @param {boolean} playing
* @param {WebSocket} socket
*/
const setupVideo = async (sessionId, videoUrl, subtitles, currentTime, playing, socket) => {
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
}
setupSocketEvents(socket, video);
setupVideoEvents(sessionId, video, 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)`;
}
};
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,
}
}));
}
});
}
/**
* @param {string} sessionId
* @param {WebSocket} socket
*/
const setupChat = async (sessionId, socket) => {
document.querySelector("#chatbox-container").style["display"] = "block";
setupChatboxEvents(socket);
fixChatSize();
}
/**
* @param {string} nickname
* @param {string} sessionId
*/
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 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());
socket.addEventListener("open", () => {
setupVideo(sessionId, video_url, subtitle_tracks, current_time_ms, is_playing, socket);
setupChat(sessionId, socket);
});
} catch (err) {
// TODO: Show an error on the screen
console.error(err);
}
}
const main = () => {
document.querySelector("#join-session-nickname").value = localStorage.getItem("watch-party-nickname");
document.querySelector("#join-session-form").addEventListener("submit", event => {
event.preventDefault();
const nickname = document.querySelector("#join-session-nickname").value;
const sessionId = document.querySelector("#join-session-id").value;
localStorage.setItem("watch-party-nickname", nickname);
joinSession(nickname, sessionId);
});
if (window.location.hash.match(/#[0-9a-f\-]+/)) {
document.querySelector("#join-session-id").value = window.location.hash.substring(1);
}
window.addEventListener("resize", event => {
fixChatSize();
});
};
if (document.readyState === "complete") {
main();
} else {
document.addEventListener("DOMContentLoaded", main);
}

11
frontend/main.mjs Normal file
View File

@ -0,0 +1,11 @@
import { setupJoinSessionForm } from "./lib/join-session.mjs";
const main = () => {
setupJoinSessionForm();
};
if (document.readyState === "complete") {
main();
} else {
document.addEventListener("DOMContentLoaded", main);
}