From 8da286fad9579659ee058b085f2e7418e4e7d94f Mon Sep 17 00:00:00 2001 From: videogame hacker Date: Tue, 9 Nov 2021 13:21:14 +0000 Subject: [PATCH] Refactor frontend to use ES modules --- frontend/index.html | 2 +- frontend/lib/chat.mjs | 102 ++++++++++ frontend/lib/join-session.mjs | 45 +++++ frontend/lib/video.mjs | 58 ++++++ frontend/lib/watch-session.mjs | 171 +++++++++++++++++ frontend/main.js | 332 --------------------------------- frontend/main.mjs | 11 ++ 7 files changed, 388 insertions(+), 333 deletions(-) create mode 100644 frontend/lib/chat.mjs create mode 100644 frontend/lib/join-session.mjs create mode 100644 frontend/lib/video.mjs create mode 100644 frontend/lib/watch-session.mjs delete mode 100644 frontend/main.js create mode 100644 frontend/main.mjs diff --git a/frontend/index.html b/frontend/index.html index 2427707..e1c2e6b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -47,6 +47,6 @@ - + diff --git a/frontend/lib/chat.mjs b/frontend/lib/chat.mjs new file mode 100644 index 0000000..1a6ee1d --- /dev/null +++ b/frontend/lib/chat.mjs @@ -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; + } + } +}; diff --git a/frontend/lib/join-session.mjs b/frontend/lib/join-session.mjs new file mode 100644 index 0000000..9174167 --- /dev/null +++ b/frontend/lib/join-session.mjs @@ -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); + }); +}; diff --git a/frontend/lib/video.mjs b/frontend/lib/video.mjs new file mode 100644 index 0000000..e4c5d4a --- /dev/null +++ b/frontend/lib/video.mjs @@ -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; +}; diff --git a/frontend/lib/watch-session.mjs b/frontend/lib/watch-session.mjs new file mode 100644 index 0000000..614b43e --- /dev/null +++ b/frontend/lib/watch-session.mjs @@ -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); + } +}; diff --git a/frontend/main.js b/frontend/main.js deleted file mode 100644 index cdde8de..0000000 --- a/frontend/main.js +++ /dev/null @@ -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); -} diff --git a/frontend/main.mjs b/frontend/main.mjs new file mode 100644 index 0000000..ec9d888 --- /dev/null +++ b/frontend/main.mjs @@ -0,0 +1,11 @@ +import { setupJoinSessionForm } from "./lib/join-session.mjs"; + +const main = () => { + setupJoinSessionForm(); +}; + +if (document.readyState === "complete") { + main(); +} else { + document.addEventListener("DOMContentLoaded", main); +}