forked from lavender/watch-party
		
	use plyr for video controls
This commit is contained in:
		
							parent
							
								
									e43184ab49
								
							
						
					
					
						commit
						1bd7071cec
					
				
					 16 changed files with 1416 additions and 1437 deletions
				
			
		|  | @ -1,52 +1,52 @@ | |||
| <!DOCTYPE html> | ||||
| <html> | ||||
|   <head> | ||||
|     <meta charset="utf-8" /> | ||||
|     <title>watch party :D</title> | ||||
|     <link rel="stylesheet" href="/styles.css?v=bfdcf2" /> | ||||
|   </head> | ||||
| 
 | ||||
|   <body> | ||||
|     <noscript> | ||||
|       This site will <em>not</em> work without JavaScript, and there's not | ||||
|       really any way around that :( | ||||
|     </noscript> | ||||
| 
 | ||||
|     <div id="create-controls"> | ||||
|       <form id="create-session-form"> | ||||
|         <h2>Create a session</h2> | ||||
| 
 | ||||
|         <label for="create-session-video">Video:</label> | ||||
|         <input | ||||
|           type="text" | ||||
|           id="create-session-video" | ||||
|           placeholder="https://video.example.com/example.mp4" | ||||
|           required | ||||
|         /> | ||||
| 
 | ||||
|         <!-- TODO: Ability to add multiple subtitles for different languages --> | ||||
|         <label for="create-session-subs">Subtitles:</label> | ||||
|         <input | ||||
|           type="text" | ||||
|           id="create-session-subs" | ||||
|           placeholder="https://video.example.com/example.vtt" | ||||
|         /> | ||||
| 
 | ||||
|         <label for="create-session-subs-name">Subtitle track name:</label> | ||||
|         <input | ||||
|           type="text" | ||||
|           id="create-session-subs-name" | ||||
|           placeholder="English" | ||||
|         /> | ||||
|         <button>Create</button> | ||||
| 
 | ||||
|         <p> | ||||
|           Already have a session? | ||||
|           <a href="/">Join your session</a> instead. | ||||
|         </p> | ||||
|       </form> | ||||
|     </div> | ||||
| 
 | ||||
|     <script type="module" src="/create.mjs?v=bfdcf2"></script> | ||||
|   </body> | ||||
| </html> | ||||
| <!DOCTYPE html> | ||||
| <html> | ||||
|   <head> | ||||
|     <meta charset="utf-8" /> | ||||
|     <title>watch party :D</title> | ||||
|     <link rel="stylesheet" href="/styles.css?v=bfdcf2" /> | ||||
|   </head> | ||||
| 
 | ||||
|   <body> | ||||
|     <noscript> | ||||
|       This site will <em>not</em> work without JavaScript, and there's not | ||||
|       really any way around that :( | ||||
|     </noscript> | ||||
| 
 | ||||
|     <div id="create-controls"> | ||||
|       <form id="create-session-form"> | ||||
|         <h2>Create a session</h2> | ||||
| 
 | ||||
|         <label for="create-session-video">Video:</label> | ||||
|         <input | ||||
|           type="text" | ||||
|           id="create-session-video" | ||||
|           placeholder="https://video.example.com/example.mp4" | ||||
|           required | ||||
|         /> | ||||
| 
 | ||||
|         <!-- TODO: Ability to add multiple subtitles for different languages --> | ||||
|         <label for="create-session-subs">Subtitles:</label> | ||||
|         <input | ||||
|           type="text" | ||||
|           id="create-session-subs" | ||||
|           placeholder="https://video.example.com/example.vtt" | ||||
|         /> | ||||
| 
 | ||||
|         <label for="create-session-subs-name">Subtitle track name:</label> | ||||
|         <input | ||||
|           type="text" | ||||
|           id="create-session-subs-name" | ||||
|           placeholder="English" | ||||
|         /> | ||||
|         <button>Create</button> | ||||
| 
 | ||||
|         <p> | ||||
|           Already have a session? | ||||
|           <a href="/">Join your session</a> instead. | ||||
|         </p> | ||||
|       </form> | ||||
|     </div> | ||||
| 
 | ||||
|     <script type="module" src="/create.mjs?v=bfdcf2"></script> | ||||
|   </body> | ||||
| </html> | ||||
|  |  | |||
|  | @ -1,11 +1,11 @@ | |||
| import { setupCreateSessionForm } from "./lib/create-session.mjs?v=bfdcf2"; | ||||
| 
 | ||||
| const main = () => { | ||||
|   setupCreateSessionForm(); | ||||
| }; | ||||
| 
 | ||||
| if (document.readyState === "complete") { | ||||
|   main(); | ||||
| } else { | ||||
|   document.addEventListener("DOMContentLoaded", main); | ||||
| } | ||||
| import { setupCreateSessionForm } from "./lib/create-session.mjs?v=bfdcf2"; | ||||
| 
 | ||||
| const main = () => { | ||||
|   setupCreateSessionForm(); | ||||
| }; | ||||
| 
 | ||||
| if (document.readyState === "complete") { | ||||
|   main(); | ||||
| } else { | ||||
|   document.addEventListener("DOMContentLoaded", main); | ||||
| } | ||||
|  |  | |||
|  | @ -1,84 +1,85 @@ | |||
| <!DOCTYPE html> | ||||
| <html> | ||||
|   <head> | ||||
|     <meta charset="utf-8" /> | ||||
|     <title>watch party :D</title> | ||||
|     <link rel="stylesheet" href="/styles.css?v=bfdcf2" /> | ||||
|   </head> | ||||
| 
 | ||||
|   <body> | ||||
|     <noscript> | ||||
|       This site will <em>not</em> work without JavaScript, and there's not | ||||
|       really any way around that :( | ||||
|     </noscript> | ||||
| 
 | ||||
|     <div id="pre-join-controls"> | ||||
|       <form id="join-session-form"> | ||||
|         <h2>Join a session</h2> | ||||
| 
 | ||||
|         <p id="post-create-message"> | ||||
|           Your session has been created successfully. Copy the current url or | ||||
|           the Session ID below and share it with your friends. :) | ||||
|         </p> | ||||
| 
 | ||||
|         <label for="join-session-nickname">Nickname:</label> | ||||
|         <input | ||||
|           type="text" | ||||
|           id="join-session-nickname" | ||||
|           placeholder="Nickname" | ||||
|           maxlength="50" | ||||
|           required | ||||
|         /> | ||||
| 
 | ||||
|         <label id="join-session-colour-label" for="join-session-colour"> | ||||
|           Personal Colour: | ||||
|         </label> | ||||
|         <input type="color" id="join-session-colour" value="#ffffff" required /> | ||||
| 
 | ||||
|         <label for="join-session-id">Session ID:</label> | ||||
|         <input | ||||
|           type="text" | ||||
|           id="join-session-id" | ||||
|           placeholder="123e4567-e89b-12d3-a456-426614174000" | ||||
|           required | ||||
|         /> | ||||
|         <button id="join-session-button">Join</button> | ||||
| 
 | ||||
|         <p> | ||||
|           No session to join? | ||||
|           <a href="/create.html">Create a session</a> instead. | ||||
|         </p> | ||||
|       </form> | ||||
|     </div> | ||||
| 
 | ||||
|     <div id="video-container"></div> | ||||
|     <div id="chatbox-container"> | ||||
|       <div id="viewer-list"></div> | ||||
|       <div id="chatbox"></div> | ||||
|       <form id="chatbox-send"> | ||||
|         <input | ||||
|           type="text" | ||||
|           placeholder="Message... (/help for commands)" | ||||
|           list="emoji-autocomplete" | ||||
|         /> | ||||
|         <div id="emoji-autocomplete"></div> | ||||
|         <!-- DO NOT ADD SPACING INSIDE THE TAG IT WILL BREAK THE CSS kthxbye --> | ||||
|       </form> | ||||
|     </div> | ||||
| 
 | ||||
|     <script type="module" src="/main.mjs?v=bfdcf2"></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> | ||||
| <!DOCTYPE html> | ||||
| <html> | ||||
|   <head> | ||||
|     <meta charset="utf-8" /> | ||||
|     <title>watch party :D</title> | ||||
|     <link rel="stylesheet" href="/lib/plyr-3.7.3.css" /> | ||||
|     <link rel="stylesheet" href="/styles.css?v=bfdcf2" /> | ||||
|   </head> | ||||
| 
 | ||||
|   <body> | ||||
|     <noscript> | ||||
|       This site will <em>not</em> work without JavaScript, and there's not | ||||
|       really any way around that :( | ||||
|     </noscript> | ||||
| 
 | ||||
|     <div id="pre-join-controls"> | ||||
|       <form id="join-session-form"> | ||||
|         <h2>Join a session</h2> | ||||
| 
 | ||||
|         <p id="post-create-message"> | ||||
|           Your session has been created successfully. Copy the current url or | ||||
|           the Session ID below and share it with your friends. :) | ||||
|         </p> | ||||
| 
 | ||||
|         <label for="join-session-nickname">Nickname:</label> | ||||
|         <input | ||||
|           type="text" | ||||
|           id="join-session-nickname" | ||||
|           placeholder="Nickname" | ||||
|           maxlength="50" | ||||
|           required | ||||
|         /> | ||||
| 
 | ||||
|         <label id="join-session-colour-label" for="join-session-colour"> | ||||
|           Personal Colour: | ||||
|         </label> | ||||
|         <input type="color" id="join-session-colour" value="#ffffff" required /> | ||||
| 
 | ||||
|         <label for="join-session-id">Session ID:</label> | ||||
|         <input | ||||
|           type="text" | ||||
|           id="join-session-id" | ||||
|           placeholder="123e4567-e89b-12d3-a456-426614174000" | ||||
|           required | ||||
|         /> | ||||
|         <button id="join-session-button">Join</button> | ||||
| 
 | ||||
|         <p> | ||||
|           No session to join? | ||||
|           <a href="/create.html">Create a session</a> instead. | ||||
|         </p> | ||||
|       </form> | ||||
|     </div> | ||||
| 
 | ||||
|     <div id="video-container"></div> | ||||
|     <div id="chatbox-container"> | ||||
|       <div id="viewer-list"></div> | ||||
|       <div id="chatbox"></div> | ||||
|       <form id="chatbox-send"> | ||||
|         <input | ||||
|           type="text" | ||||
|           placeholder="Message... (/help for commands)" | ||||
|           list="emoji-autocomplete" | ||||
|         /> | ||||
|         <div id="emoji-autocomplete"></div> | ||||
|         <!-- DO NOT ADD SPACING INSIDE THE TAG IT WILL BREAK THE CSS kthxbye --> | ||||
|       </form> | ||||
|     </div> | ||||
| 
 | ||||
|     <script type="module" src="/main.mjs?v=bfdcf2"></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> | ||||
|  |  | |||
|  | @ -1,459 +1,453 @@ | |||
| import { | ||||
|   setDebounce, | ||||
|   setVideoTime, | ||||
|   setPlaying, | ||||
| } from "./watch-session.mjs?v=bfdcf2"; | ||||
| import { emojify, findEmojis } from "./emojis.mjs?v=bfdcf2"; | ||||
| import { linkify } from "./links.mjs?v=bfdcf2"; | ||||
| import { joinSession } from "./watch-session.mjs?v=bfdcf2"; | ||||
| import { pling } from "./pling.mjs?v=bfdcf2"; | ||||
| import { state } from "./state.mjs"; | ||||
| 
 | ||||
| function setCaretPosition(elem, caretPos) { | ||||
|   if (elem.createTextRange) { | ||||
|     var range = elem.createTextRange(); | ||||
|     range.move("character", caretPos); | ||||
|     range.select(); | ||||
|   } else { | ||||
|     if (elem.selectionStart) { | ||||
|       elem.focus(); | ||||
|       elem.setSelectionRange(caretPos, caretPos); | ||||
|     } else elem.focus(); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| const setupChatboxEvents = (socket) => { | ||||
|   // clear events by just reconstructing the form
 | ||||
|   const oldChatForm = document.querySelector("#chatbox-send"); | ||||
|   const chatForm = oldChatForm.cloneNode(true); | ||||
|   const messageInput = chatForm.querySelector("input"); | ||||
|   const emojiAutocomplete = chatForm.querySelector("#emoji-autocomplete"); | ||||
|   oldChatForm.replaceWith(chatForm); | ||||
| 
 | ||||
|   let autocompleting = false, | ||||
|     showListTimer; | ||||
| 
 | ||||
|   const replaceMessage = (message) => () => { | ||||
|     messageInput.value = message; | ||||
|     autocomplete(); | ||||
|   }; | ||||
|   async function autocomplete(fromListTimeout) { | ||||
|     if (autocompleting) return; | ||||
|     try { | ||||
|       clearInterval(showListTimer); | ||||
|       emojiAutocomplete.textContent = ""; | ||||
|       autocompleting = true; | ||||
|       let text = messageInput.value.slice(0, messageInput.selectionStart); | ||||
|       const match = text.match(/(:[^\s:]+)?:([^\s:]{2,})$/); | ||||
|       if (!match || match[1]) return (autocompleting = false); // We don't need to autocomplete.
 | ||||
|       const prefix = text.slice(0, match.index); | ||||
|       const search = text.slice(match.index + 1); | ||||
|       if (search.length < 1 && !fromListTimeout) { | ||||
|         autocompleting = false; | ||||
|         showListTimer = setTimeout(() => autocomplete(true), 500); | ||||
|         return; | ||||
|       } | ||||
|       const suffix = messageInput.value.slice(messageInput.selectionStart); | ||||
|       let selected; | ||||
|       const select = (button) => { | ||||
|         if (selected) selected.classList.remove("selected"); | ||||
|         selected = button; | ||||
|         button.classList.add("selected"); | ||||
|       }; | ||||
|       let results = await findEmojis(search); | ||||
|       let yieldAt = performance.now() + 13; | ||||
|       for (let i = 0; i < results.length; i += 100) { | ||||
|         emojiAutocomplete.append.apply( | ||||
|           emojiAutocomplete, | ||||
|           results.slice(i, i + 100).map(([name, replaceWith, ext], i) => { | ||||
|             const button = Object.assign(document.createElement("button"), { | ||||
|               className: "emoji-option", | ||||
|               onmousedown: (e) => e.preventDefault(), | ||||
|               onclick: () => { | ||||
|                 messageInput.value = prefix + replaceWith + " " + suffix; | ||||
|                 setCaretPosition( | ||||
|                   messageInput, | ||||
|                   (prefix + " " + replaceWith).length | ||||
|                 ); | ||||
|               }, | ||||
|               onmouseover: () => select(button), | ||||
|               onfocus: () => select(button), | ||||
|               type: "button", | ||||
|               title: name, | ||||
|             }); | ||||
|             button.append( | ||||
|               replaceWith[0] !== ":" | ||||
|                 ? Object.assign(document.createElement("span"), { | ||||
|                     textContent: replaceWith, | ||||
|                     className: "emoji", | ||||
|                   }) | ||||
|                 : Object.assign(new Image(), { | ||||
|                     loading: "lazy", | ||||
|                     src: `/emojis/${name}${ext}`, | ||||
|                     className: "emoji", | ||||
|                   }), | ||||
|               Object.assign(document.createElement("span"), { | ||||
|                 textContent: name, | ||||
|                 className: "emoji-name", | ||||
|               }) | ||||
|             ); | ||||
|             return button; | ||||
|           }) | ||||
|         ); | ||||
|         if (i == 0 && emojiAutocomplete.children[0]) { | ||||
|           emojiAutocomplete.children[0].scrollIntoView(); | ||||
|           select(emojiAutocomplete.children[0]); | ||||
|         } | ||||
|         const now = performance.now(); | ||||
|         if (now > yieldAt) { | ||||
|           yieldAt = now + 13; | ||||
|           await new Promise((cb) => setTimeout(cb, 0)); | ||||
|         } | ||||
|       } | ||||
|       autocompleting = false; | ||||
|     } catch (e) { | ||||
|       autocompleting = false; | ||||
|     } | ||||
|   } | ||||
|   messageInput.addEventListener("input", () => autocomplete()); | ||||
|   messageInput.addEventListener("selectionchange", () => autocomplete()); | ||||
|   messageInput.addEventListener("keydown", (event) => { | ||||
|     if (event.key == "ArrowUp" || event.key == "ArrowDown") { | ||||
|       let selected = document.querySelector(".emoji-option.selected"); | ||||
|       if (!selected) return; | ||||
|       event.preventDefault(); | ||||
|       selected.classList.remove("selected"); | ||||
|       selected = | ||||
|         event.key == "ArrowDown" | ||||
|           ? selected.nextElementSibling || selected.parentElement.children[0] | ||||
|           : selected.previousElementSibling || | ||||
|             selected.parentElement.children[ | ||||
|               selected.parentElement.children.length - 1 | ||||
|             ]; | ||||
|       selected.classList.add("selected"); | ||||
|       selected.scrollIntoView({ scrollMode: "if-needed", block: "nearest" }); | ||||
|     } | ||||
|     if (event.key == "Tab" || event.key == "Enter") { | ||||
|       let selected = document.querySelector(".emoji-option.selected"); | ||||
|       if (!selected) return; | ||||
|       event.preventDefault(); | ||||
|       selected.onclick(); | ||||
|     } | ||||
|   }); | ||||
| 
 | ||||
|   chatForm.addEventListener("submit", async (e) => { | ||||
|     e.preventDefault(); | ||||
|     const content = messageInput.value; | ||||
|     if (content.trim().length) { | ||||
|       messageInput.value = ""; | ||||
| 
 | ||||
|       // handle commands
 | ||||
|       if (content.startsWith("/")) { | ||||
|         const command = content.toLowerCase().match(/^\/\S+/)[0]; | ||||
|         const args = content.slice(command.length).trim(); | ||||
| 
 | ||||
|         let handled = false; | ||||
|         switch (command) { | ||||
|           case "/ping": | ||||
|             socket.send( | ||||
|               JSON.stringify({ | ||||
|                 op: "Ping", | ||||
|                 data: args, | ||||
|               }) | ||||
|             ); | ||||
|             handled = true; | ||||
|             break; | ||||
|           case "/sync": | ||||
|             const sessionId = window.location.hash.slice(1); | ||||
|             const { current_time_ms, is_playing } = await fetch( | ||||
|               `/sess/${sessionId}` | ||||
|             ).then((r) => r.json()); | ||||
| 
 | ||||
|             setDebounce(); | ||||
|             setPlaying(is_playing); | ||||
|             setVideoTime(current_time_ms); | ||||
| 
 | ||||
|             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>" + | ||||
|               " <code>/help</code> - display this help message<br>" + | ||||
|               " <code>/ping [message]</code> - ping all viewers<br>" + | ||||
|               " <code>/sync</code> - resyncs you with other viewers<br>" + | ||||
|               " <code>/shrug</code> - appends ¯\\_(ツ)_/¯ to your message<br>" + | ||||
|               " <code>/join [session id]</code> - joins another session"; | ||||
| 
 | ||||
|             printChatMessage( | ||||
|               "command-message", | ||||
|               "/help", | ||||
|               "b57fdc", | ||||
|               helpMessageContent | ||||
|             ); | ||||
|             handled = true; | ||||
|             break; | ||||
|           default: | ||||
|             break; | ||||
|         } | ||||
| 
 | ||||
|         if (handled) { | ||||
|           return; | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       // handle regular chat messages
 | ||||
|       socket.send( | ||||
|         JSON.stringify({ | ||||
|           op: "ChatMessage", | ||||
|           data: content, | ||||
|         }) | ||||
|       ); | ||||
|     } | ||||
|   }); | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * @param {WebSocket} socket | ||||
|  */ | ||||
| export const setupChat = async (socket) => { | ||||
|   document.querySelector("#chatbox-container").style["display"] = "flex"; | ||||
|   setupChatboxEvents(socket); | ||||
| }; | ||||
| 
 | ||||
| const addToChat = (node) => { | ||||
|   const chatbox = document.querySelector("#chatbox"); | ||||
|   chatbox.appendChild(node); | ||||
|   chatbox.scrollTop = chatbox.scrollHeight; | ||||
| }; | ||||
| 
 | ||||
| let lastTimeMs = null; | ||||
| let lastPlaying = false; | ||||
| 
 | ||||
| const checkDebounce = (event) => { | ||||
|   let timeMs = null; | ||||
|   let playing = null; | ||||
|   if (event.op == "SetTime") { | ||||
|     timeMs = event.data; | ||||
|   } else if (event.op == "SetPlaying") { | ||||
|     timeMs = event.data.time; | ||||
|     playing = event.data.playing; | ||||
|   } | ||||
| 
 | ||||
|   let shouldIgnore = false; | ||||
| 
 | ||||
|   if (timeMs != null) { | ||||
|     if (lastTimeMs && Math.abs(lastTimeMs - timeMs) < 500) { | ||||
|       shouldIgnore = true; | ||||
|     } | ||||
|     lastTimeMs = timeMs; | ||||
|   } | ||||
| 
 | ||||
|   if (playing != null) { | ||||
|     if (lastPlaying != playing) { | ||||
|       shouldIgnore = false; | ||||
|     } | ||||
|     lastPlaying = playing; | ||||
|   } | ||||
| 
 | ||||
|   return shouldIgnore; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * @returns {string} | ||||
|  */ | ||||
| const getCurrentTimestamp = () => { | ||||
|   const t = new Date(); | ||||
|   return `${matpad(t.getHours())}:${matpad(t.getMinutes())}:${matpad( | ||||
|     t.getSeconds() | ||||
|   )}`;
 | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * https://media.discordapp.net/attachments/834541919568527361/931678814751301632/66d2c68c48daa414c96951381665ec2e.png
 | ||||
|  */ | ||||
| const matpad = (n) => { | ||||
|   return ("00" + n).slice(-2); | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * @param {string} eventType | ||||
|  * @param {string?} user | ||||
|  * @param {Node?} content | ||||
|  */ | ||||
| export const printChatMessage = (eventType, user, colour, content) => { | ||||
|   const chatMessage = document.createElement("div"); | ||||
|   chatMessage.classList.add("chat-message"); | ||||
|   chatMessage.classList.add(eventType); | ||||
|   chatMessage.title = getCurrentTimestamp(); | ||||
| 
 | ||||
|   if (user != null) { | ||||
|     const userName = document.createElement("strong"); | ||||
|     userName.style = `--user-color: #${colour}`; | ||||
|     userName.textContent = user + " "; | ||||
|     chatMessage.appendChild(userName); | ||||
|   } | ||||
| 
 | ||||
|   if (content != null) { | ||||
|     chatMessage.appendChild(content); | ||||
|   } | ||||
| 
 | ||||
|   addToChat(chatMessage); | ||||
| 
 | ||||
|   return chatMessage; | ||||
| }; | ||||
| 
 | ||||
| const formatTime = (ms) => { | ||||
|   const seconds = Math.floor((ms / 1000) % 60); | ||||
|   const minutes = Math.floor((ms / (60 * 1000)) % 60); | ||||
|   const hours = Math.floor((ms / (3600 * 1000)) % 3600); | ||||
|   return `${hours < 10 ? "0" + hours : hours}:${ | ||||
|     minutes < 10 ? "0" + minutes : minutes | ||||
|   }:${seconds < 10 ? "0" + seconds : seconds}`;
 | ||||
| }; | ||||
| 
 | ||||
| export const logEventToChat = async (event) => { | ||||
|   if (checkDebounce(event)) { | ||||
|     return; | ||||
|   } | ||||
| 
 | ||||
|   switch (event.op) { | ||||
|     case "UserJoin": { | ||||
|       printChatMessage( | ||||
|         "user-join", | ||||
|         event.user, | ||||
|         event.colour, | ||||
|         document.createTextNode("joined") | ||||
|       ); | ||||
|       break; | ||||
|     } | ||||
|     case "UserLeave": { | ||||
|       printChatMessage( | ||||
|         "user-leave", | ||||
|         event.user, | ||||
|         event.colour, | ||||
|         document.createTextNode("left") | ||||
|       ); | ||||
|       break; | ||||
|     } | ||||
|     case "ChatMessage": { | ||||
|       const messageContent = document.createElement("span"); | ||||
|       messageContent.classList.add("message-content"); | ||||
|       messageContent.append(...(await linkify(event.data, emojify))); | ||||
|       printChatMessage( | ||||
|         "chat-message", | ||||
|         event.user, | ||||
|         event.colour, | ||||
|         messageContent | ||||
|       ); | ||||
|       break; | ||||
|     } | ||||
|     case "SetTime": { | ||||
|       const messageContent = document.createElement("span"); | ||||
|       if (event.data.from != undefined) { | ||||
|         messageContent.appendChild( | ||||
|           document.createTextNode("set the time from ") | ||||
|         ); | ||||
| 
 | ||||
|         messageContent.appendChild( | ||||
|           document.createTextNode(formatTime(event.data.from)) | ||||
|         ); | ||||
| 
 | ||||
|         messageContent.appendChild(document.createTextNode(" to ")); | ||||
|       } else { | ||||
|         messageContent.appendChild(document.createTextNode("set the time to ")); | ||||
|       } | ||||
| 
 | ||||
|       messageContent.appendChild( | ||||
|         document.createTextNode(formatTime(event.data.to)) | ||||
|       ); | ||||
| 
 | ||||
|       printChatMessage("set-time", event.user, event.colour, messageContent); | ||||
|       break; | ||||
|     } | ||||
|     case "SetPlaying": { | ||||
|       const messageContent = document.createElement("span"); | ||||
|       messageContent.appendChild( | ||||
|         document.createTextNode( | ||||
|           event.data.playing ? "started playing" : "paused" | ||||
|         ) | ||||
|       ); | ||||
|       messageContent.appendChild(document.createTextNode(" at ")); | ||||
|       messageContent.appendChild( | ||||
|         document.createTextNode(formatTime(event.data.time)) | ||||
|       ); | ||||
| 
 | ||||
|       printChatMessage("set-playing", event.user, event.colour, messageContent); | ||||
|       break; | ||||
|     } | ||||
|     case "Ping": { | ||||
|       const messageContent = document.createElement("span"); | ||||
|       if (event.data) { | ||||
|         messageContent.appendChild(document.createTextNode("pinged saying: ")); | ||||
|         messageContent.appendChild(document.createTextNode(event.data)); | ||||
|       } else { | ||||
|         messageContent.appendChild(document.createTextNode("pinged")); | ||||
|       } | ||||
| 
 | ||||
|       printChatMessage("ping", event.user, event.colour, messageContent); | ||||
|       pling(); | ||||
|       if ("Notification" in window) { | ||||
|         const title = "watch party :)"; | ||||
|         const options = { | ||||
|           body: event.data | ||||
|             ? `${event.user} pinged saying: ${event.data}` | ||||
|             : `${event.user} pinged`, | ||||
|         }; | ||||
|         if (Notification.permission === "granted") { | ||||
|           new Notification(title, options); | ||||
|         } else if (Notification.permission !== "denied") { | ||||
|           Notification.requestPermission().then(function (permission) { | ||||
|             if (permission === "granted") { | ||||
|               new Notification(title, options); | ||||
|             } | ||||
|           }); | ||||
|         } | ||||
|       } | ||||
|       break; | ||||
|     } | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| export const updateViewerList = (viewers) => { | ||||
|   const listContainer = document.querySelector("#viewer-list"); | ||||
| 
 | ||||
|   // empty out the current list
 | ||||
|   listContainer.innerHTML = ""; | ||||
| 
 | ||||
|   // display the updated list
 | ||||
|   for (const viewer of viewers) { | ||||
|     const viewerElem = document.createElement("div"); | ||||
|     const content = document.createElement("strong"); | ||||
|     content.textContent = viewer.nickname; | ||||
|     content.style = `--user-color: #${viewer.colour}`; | ||||
|     viewerElem.appendChild(content); | ||||
|     listContainer.appendChild(viewerElem); | ||||
|   } | ||||
| }; | ||||
| import { | ||||
|   setDebounce, | ||||
|   setVideoTime, | ||||
|   setPlaying, | ||||
|   sync, | ||||
| } from "./watch-session.mjs?v=bfdcf2"; | ||||
| import { emojify, findEmojis } from "./emojis.mjs?v=bfdcf2"; | ||||
| import { linkify } from "./links.mjs?v=bfdcf2"; | ||||
| import { joinSession } from "./watch-session.mjs?v=bfdcf2"; | ||||
| import { pling } from "./pling.mjs?v=bfdcf2"; | ||||
| import { state } from "./state.mjs"; | ||||
| 
 | ||||
| function setCaretPosition(elem, caretPos) { | ||||
|   if (elem.createTextRange) { | ||||
|     var range = elem.createTextRange(); | ||||
|     range.move("character", caretPos); | ||||
|     range.select(); | ||||
|   } else { | ||||
|     if (elem.selectionStart) { | ||||
|       elem.focus(); | ||||
|       elem.setSelectionRange(caretPos, caretPos); | ||||
|     } else elem.focus(); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| const setupChatboxEvents = (socket) => { | ||||
|   // clear events by just reconstructing the form
 | ||||
|   const oldChatForm = document.querySelector("#chatbox-send"); | ||||
|   const chatForm = oldChatForm.cloneNode(true); | ||||
|   const messageInput = chatForm.querySelector("input"); | ||||
|   const emojiAutocomplete = chatForm.querySelector("#emoji-autocomplete"); | ||||
|   oldChatForm.replaceWith(chatForm); | ||||
| 
 | ||||
|   let autocompleting = false, | ||||
|     showListTimer; | ||||
| 
 | ||||
|   const replaceMessage = (message) => () => { | ||||
|     messageInput.value = message; | ||||
|     autocomplete(); | ||||
|   }; | ||||
|   async function autocomplete(fromListTimeout) { | ||||
|     if (autocompleting) return; | ||||
|     try { | ||||
|       clearInterval(showListTimer); | ||||
|       emojiAutocomplete.textContent = ""; | ||||
|       autocompleting = true; | ||||
|       let text = messageInput.value.slice(0, messageInput.selectionStart); | ||||
|       const match = text.match(/(:[^\s:]+)?:([^\s:]{2,})$/); | ||||
|       if (!match || match[1]) return (autocompleting = false); // We don't need to autocomplete.
 | ||||
|       const prefix = text.slice(0, match.index); | ||||
|       const search = text.slice(match.index + 1); | ||||
|       if (search.length < 1 && !fromListTimeout) { | ||||
|         autocompleting = false; | ||||
|         showListTimer = setTimeout(() => autocomplete(true), 500); | ||||
|         return; | ||||
|       } | ||||
|       const suffix = messageInput.value.slice(messageInput.selectionStart); | ||||
|       let selected; | ||||
|       const select = (button) => { | ||||
|         if (selected) selected.classList.remove("selected"); | ||||
|         selected = button; | ||||
|         button.classList.add("selected"); | ||||
|       }; | ||||
|       let results = await findEmojis(search); | ||||
|       let yieldAt = performance.now() + 13; | ||||
|       for (let i = 0; i < results.length; i += 100) { | ||||
|         emojiAutocomplete.append.apply( | ||||
|           emojiAutocomplete, | ||||
|           results.slice(i, i + 100).map(([name, replaceWith, ext], i) => { | ||||
|             const button = Object.assign(document.createElement("button"), { | ||||
|               className: "emoji-option", | ||||
|               onmousedown: (e) => e.preventDefault(), | ||||
|               onclick: () => { | ||||
|                 messageInput.value = prefix + replaceWith + " " + suffix; | ||||
|                 setCaretPosition( | ||||
|                   messageInput, | ||||
|                   (prefix + " " + replaceWith).length | ||||
|                 ); | ||||
|               }, | ||||
|               onmouseover: () => select(button), | ||||
|               onfocus: () => select(button), | ||||
|               type: "button", | ||||
|               title: name, | ||||
|             }); | ||||
|             button.append( | ||||
|               replaceWith[0] !== ":" | ||||
|                 ? Object.assign(document.createElement("span"), { | ||||
|                     textContent: replaceWith, | ||||
|                     className: "emoji", | ||||
|                   }) | ||||
|                 : Object.assign(new Image(), { | ||||
|                     loading: "lazy", | ||||
|                     src: `/emojis/${name}${ext}`, | ||||
|                     className: "emoji", | ||||
|                   }), | ||||
|               Object.assign(document.createElement("span"), { | ||||
|                 textContent: name, | ||||
|                 className: "emoji-name", | ||||
|               }) | ||||
|             ); | ||||
|             return button; | ||||
|           }) | ||||
|         ); | ||||
|         if (i == 0 && emojiAutocomplete.children[0]) { | ||||
|           emojiAutocomplete.children[0].scrollIntoView(); | ||||
|           select(emojiAutocomplete.children[0]); | ||||
|         } | ||||
|         const now = performance.now(); | ||||
|         if (now > yieldAt) { | ||||
|           yieldAt = now + 13; | ||||
|           await new Promise((cb) => setTimeout(cb, 0)); | ||||
|         } | ||||
|       } | ||||
|       autocompleting = false; | ||||
|     } catch (e) { | ||||
|       autocompleting = false; | ||||
|     } | ||||
|   } | ||||
|   messageInput.addEventListener("input", () => autocomplete()); | ||||
|   messageInput.addEventListener("selectionchange", () => autocomplete()); | ||||
|   messageInput.addEventListener("keydown", (event) => { | ||||
|     if (event.key == "ArrowUp" || event.key == "ArrowDown") { | ||||
|       let selected = document.querySelector(".emoji-option.selected"); | ||||
|       if (!selected) return; | ||||
|       event.preventDefault(); | ||||
|       selected.classList.remove("selected"); | ||||
|       selected = | ||||
|         event.key == "ArrowDown" | ||||
|           ? selected.nextElementSibling || selected.parentElement.children[0] | ||||
|           : selected.previousElementSibling || | ||||
|             selected.parentElement.children[ | ||||
|               selected.parentElement.children.length - 1 | ||||
|             ]; | ||||
|       selected.classList.add("selected"); | ||||
|       selected.scrollIntoView({ scrollMode: "if-needed", block: "nearest" }); | ||||
|     } | ||||
|     if (event.key == "Tab" || event.key == "Enter") { | ||||
|       let selected = document.querySelector(".emoji-option.selected"); | ||||
|       if (!selected) return; | ||||
|       event.preventDefault(); | ||||
|       selected.onclick(); | ||||
|     } | ||||
|   }); | ||||
| 
 | ||||
|   chatForm.addEventListener("submit", async (e) => { | ||||
|     e.preventDefault(); | ||||
|     const content = messageInput.value; | ||||
|     if (content.trim().length) { | ||||
|       messageInput.value = ""; | ||||
| 
 | ||||
|       // handle commands
 | ||||
|       if (content.startsWith("/")) { | ||||
|         const command = content.toLowerCase().match(/^\/\S+/)[0]; | ||||
|         const args = content.slice(command.length).trim(); | ||||
| 
 | ||||
|         let handled = false; | ||||
|         switch (command) { | ||||
|           case "/ping": | ||||
|             socket.send( | ||||
|               JSON.stringify({ | ||||
|                 op: "Ping", | ||||
|                 data: args, | ||||
|               }) | ||||
|             ); | ||||
|             handled = true; | ||||
|             break; | ||||
|           case "/sync": | ||||
|             await sync(); | ||||
| 
 | ||||
|             const syncMessageContent = document.createElement("span"); | ||||
|             syncMessageContent.appendChild( | ||||
|               document.createTextNode("resynced you to ") | ||||
|             ); | ||||
|             syncMessageContent.appendChild( | ||||
|               document.createTextNode(formatTime(current_time_ms)) | ||||
|             ); | ||||
|             printChatMessage("set-time", "/sync", "b57fdc", syncMessageContent); | ||||
|             handled = true; | ||||
|             break; | ||||
|           case "/shrug": | ||||
|             socket.send( | ||||
|               JSON.stringify({ | ||||
|                 op: "ChatMessage", | ||||
|                 data: `${args} ¯\\_(ツ)_/¯`.trim(), | ||||
|               }) | ||||
|             ); | ||||
|             handled = true; | ||||
|             break; | ||||
|           case "/join": | ||||
|             state().sessionId = args; | ||||
|             joinSession(); | ||||
|             handled = true; | ||||
|             break; | ||||
|           case "/help": | ||||
|             const helpMessageContent = document.createElement("span"); | ||||
|             helpMessageContent.innerHTML = | ||||
|               "Available commands:<br>" + | ||||
|               " <code>/help</code> - display this help message<br>" + | ||||
|               " <code>/ping [message]</code> - ping all viewers<br>" + | ||||
|               " <code>/sync</code> - resyncs you with other viewers<br>" + | ||||
|               " <code>/shrug</code> - appends ¯\\_(ツ)_/¯ to your message<br>" + | ||||
|               " <code>/join [session id]</code> - joins another session"; | ||||
| 
 | ||||
|             printChatMessage( | ||||
|               "command-message", | ||||
|               "/help", | ||||
|               "b57fdc", | ||||
|               helpMessageContent | ||||
|             ); | ||||
|             handled = true; | ||||
|             break; | ||||
|           default: | ||||
|             break; | ||||
|         } | ||||
| 
 | ||||
|         if (handled) { | ||||
|           return; | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       // handle regular chat messages
 | ||||
|       socket.send( | ||||
|         JSON.stringify({ | ||||
|           op: "ChatMessage", | ||||
|           data: content, | ||||
|         }) | ||||
|       ); | ||||
|     } | ||||
|   }); | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * @param {WebSocket} socket | ||||
|  */ | ||||
| export const setupChat = async (socket) => { | ||||
|   document.querySelector("#chatbox-container").style["display"] = "flex"; | ||||
|   setupChatboxEvents(socket); | ||||
| }; | ||||
| 
 | ||||
| const addToChat = (node) => { | ||||
|   const chatbox = document.querySelector("#chatbox"); | ||||
|   chatbox.appendChild(node); | ||||
|   chatbox.scrollTop = chatbox.scrollHeight; | ||||
| }; | ||||
| 
 | ||||
| let lastTimeMs = null; | ||||
| let lastPlaying = false; | ||||
| 
 | ||||
| const checkDebounce = (event) => { | ||||
|   let timeMs = null; | ||||
|   let playing = null; | ||||
|   if (event.op == "SetTime") { | ||||
|     timeMs = event.data; | ||||
|   } else if (event.op == "SetPlaying") { | ||||
|     timeMs = event.data.time; | ||||
|     playing = event.data.playing; | ||||
|   } | ||||
| 
 | ||||
|   let shouldIgnore = false; | ||||
| 
 | ||||
|   if (timeMs != null) { | ||||
|     if (lastTimeMs && Math.abs(lastTimeMs - timeMs) < 500) { | ||||
|       shouldIgnore = true; | ||||
|     } | ||||
|     lastTimeMs = timeMs; | ||||
|   } | ||||
| 
 | ||||
|   if (playing != null) { | ||||
|     if (lastPlaying != playing) { | ||||
|       shouldIgnore = false; | ||||
|     } | ||||
|     lastPlaying = playing; | ||||
|   } | ||||
| 
 | ||||
|   return shouldIgnore; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * @returns {string} | ||||
|  */ | ||||
| const getCurrentTimestamp = () => { | ||||
|   const t = new Date(); | ||||
|   return `${matpad(t.getHours())}:${matpad(t.getMinutes())}:${matpad( | ||||
|     t.getSeconds() | ||||
|   )}`;
 | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * https://media.discordapp.net/attachments/834541919568527361/931678814751301632/66d2c68c48daa414c96951381665ec2e.png
 | ||||
|  */ | ||||
| const matpad = (n) => { | ||||
|   return ("00" + n).slice(-2); | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * @param {string} eventType | ||||
|  * @param {string?} user | ||||
|  * @param {Node?} content | ||||
|  */ | ||||
| export const printChatMessage = (eventType, user, colour, content) => { | ||||
|   const chatMessage = document.createElement("div"); | ||||
|   chatMessage.classList.add("chat-message"); | ||||
|   chatMessage.classList.add(eventType); | ||||
|   chatMessage.title = getCurrentTimestamp(); | ||||
| 
 | ||||
|   if (user != null) { | ||||
|     const userName = document.createElement("strong"); | ||||
|     userName.style = `--user-color: #${colour}`; | ||||
|     userName.textContent = user + " "; | ||||
|     chatMessage.appendChild(userName); | ||||
|   } | ||||
| 
 | ||||
|   if (content != null) { | ||||
|     chatMessage.appendChild(content); | ||||
|   } | ||||
| 
 | ||||
|   addToChat(chatMessage); | ||||
| 
 | ||||
|   return chatMessage; | ||||
| }; | ||||
| 
 | ||||
| const formatTime = (ms) => { | ||||
|   const seconds = Math.floor((ms / 1000) % 60); | ||||
|   const minutes = Math.floor((ms / (60 * 1000)) % 60); | ||||
|   const hours = Math.floor((ms / (3600 * 1000)) % 3600); | ||||
|   return `${hours < 10 ? "0" + hours : hours}:${ | ||||
|     minutes < 10 ? "0" + minutes : minutes | ||||
|   }:${seconds < 10 ? "0" + seconds : seconds}`;
 | ||||
| }; | ||||
| 
 | ||||
| export const logEventToChat = async (event) => { | ||||
|   if (checkDebounce(event)) { | ||||
|     return; | ||||
|   } | ||||
| 
 | ||||
|   switch (event.op) { | ||||
|     case "UserJoin": { | ||||
|       printChatMessage( | ||||
|         "user-join", | ||||
|         event.user, | ||||
|         event.colour, | ||||
|         document.createTextNode("joined") | ||||
|       ); | ||||
|       break; | ||||
|     } | ||||
|     case "UserLeave": { | ||||
|       printChatMessage( | ||||
|         "user-leave", | ||||
|         event.user, | ||||
|         event.colour, | ||||
|         document.createTextNode("left") | ||||
|       ); | ||||
|       break; | ||||
|     } | ||||
|     case "ChatMessage": { | ||||
|       const messageContent = document.createElement("span"); | ||||
|       messageContent.classList.add("message-content"); | ||||
|       messageContent.append(...(await linkify(event.data, emojify))); | ||||
|       printChatMessage( | ||||
|         "chat-message", | ||||
|         event.user, | ||||
|         event.colour, | ||||
|         messageContent | ||||
|       ); | ||||
|       break; | ||||
|     } | ||||
|     case "SetTime": { | ||||
|       const messageContent = document.createElement("span"); | ||||
|       if (event.data.from != undefined) { | ||||
|         messageContent.appendChild( | ||||
|           document.createTextNode("set the time from ") | ||||
|         ); | ||||
| 
 | ||||
|         messageContent.appendChild( | ||||
|           document.createTextNode(formatTime(event.data.from)) | ||||
|         ); | ||||
| 
 | ||||
|         messageContent.appendChild(document.createTextNode(" to ")); | ||||
|       } else { | ||||
|         messageContent.appendChild(document.createTextNode("set the time to ")); | ||||
|       } | ||||
| 
 | ||||
|       messageContent.appendChild( | ||||
|         document.createTextNode(formatTime(event.data.to)) | ||||
|       ); | ||||
| 
 | ||||
|       printChatMessage("set-time", event.user, event.colour, messageContent); | ||||
|       break; | ||||
|     } | ||||
|     case "SetPlaying": { | ||||
|       const messageContent = document.createElement("span"); | ||||
|       messageContent.appendChild( | ||||
|         document.createTextNode( | ||||
|           event.data.playing ? "started playing" : "paused" | ||||
|         ) | ||||
|       ); | ||||
|       messageContent.appendChild(document.createTextNode(" at ")); | ||||
|       messageContent.appendChild( | ||||
|         document.createTextNode(formatTime(event.data.time)) | ||||
|       ); | ||||
| 
 | ||||
|       printChatMessage("set-playing", event.user, event.colour, messageContent); | ||||
|       break; | ||||
|     } | ||||
|     case "Ping": { | ||||
|       const messageContent = document.createElement("span"); | ||||
|       if (event.data) { | ||||
|         messageContent.appendChild(document.createTextNode("pinged saying: ")); | ||||
|         messageContent.appendChild(document.createTextNode(event.data)); | ||||
|       } else { | ||||
|         messageContent.appendChild(document.createTextNode("pinged")); | ||||
|       } | ||||
| 
 | ||||
|       printChatMessage("ping", event.user, event.colour, messageContent); | ||||
|       pling(); | ||||
|       if ("Notification" in window) { | ||||
|         const title = "watch party :)"; | ||||
|         const options = { | ||||
|           body: event.data | ||||
|             ? `${event.user} pinged saying: ${event.data}` | ||||
|             : `${event.user} pinged`, | ||||
|         }; | ||||
|         if (Notification.permission === "granted") { | ||||
|           new Notification(title, options); | ||||
|         } else if (Notification.permission !== "denied") { | ||||
|           Notification.requestPermission().then(function (permission) { | ||||
|             if (permission === "granted") { | ||||
|               new Notification(title, options); | ||||
|             } | ||||
|           }); | ||||
|         } | ||||
|       } | ||||
|       break; | ||||
|     } | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| export const updateViewerList = (viewers) => { | ||||
|   const listContainer = document.querySelector("#viewer-list"); | ||||
| 
 | ||||
|   // empty out the current list
 | ||||
|   listContainer.innerHTML = ""; | ||||
| 
 | ||||
|   // display the updated list
 | ||||
|   for (const viewer of viewers) { | ||||
|     const viewerElem = document.createElement("div"); | ||||
|     const content = document.createElement("strong"); | ||||
|     content.textContent = viewer.nickname; | ||||
|     content.style = `--user-color: #${viewer.colour}`; | ||||
|     viewerElem.appendChild(content); | ||||
|     listContainer.appendChild(viewerElem); | ||||
|   } | ||||
| }; | ||||
|  |  | |||
|  | @ -1,18 +1,18 @@ | |||
| import { createSession } from "./watch-session.mjs?v=bfdcf2"; | ||||
| 
 | ||||
| export const setupCreateSessionForm = () => { | ||||
|   const form = document.querySelector("#create-session-form"); | ||||
|   const videoUrl = form.querySelector("#create-session-video"); | ||||
|   const subsUrl = form.querySelector("#create-session-subs"); | ||||
|   const subsName = form.querySelector("#create-session-subs-name"); | ||||
| 
 | ||||
|   form.addEventListener("submit", (event) => { | ||||
|     event.preventDefault(); | ||||
| 
 | ||||
|     let subs = []; | ||||
|     if (subsUrl.value) { | ||||
|       subs.push({ url: subsUrl.value, name: subsName.value || "default" }); | ||||
|     } | ||||
|     createSession(videoUrl.value, subs); | ||||
|   }); | ||||
| }; | ||||
| import { createSession } from "./watch-session.mjs?v=bfdcf2"; | ||||
| 
 | ||||
| export const setupCreateSessionForm = () => { | ||||
|   const form = document.querySelector("#create-session-form"); | ||||
|   const videoUrl = form.querySelector("#create-session-video"); | ||||
|   const subsUrl = form.querySelector("#create-session-subs"); | ||||
|   const subsName = form.querySelector("#create-session-subs-name"); | ||||
| 
 | ||||
|   form.addEventListener("submit", (event) => { | ||||
|     event.preventDefault(); | ||||
| 
 | ||||
|     let subs = []; | ||||
|     if (subsUrl.value) { | ||||
|       subs.push({ url: subsUrl.value, name: subsName.value || "default" }); | ||||
|     } | ||||
|     createSession(videoUrl.value, subs); | ||||
|   }); | ||||
| }; | ||||
|  |  | |||
|  | @ -1,72 +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]]; | ||||
| } | ||||
| 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]]; | ||||
| } | ||||
|  |  | |||
|  | @ -1,93 +1,95 @@ | |||
| import { joinSession } from "./watch-session.mjs?v=bfdcf2"; | ||||
| import { state } from "./state.mjs"; | ||||
| 
 | ||||
| /** | ||||
|  * @param {HTMLInputElement} field | ||||
|  */ | ||||
| const loadNickname = (field) => { | ||||
|   try { | ||||
|     const savedNickname = localStorage.getItem("watch-party-nickname"); | ||||
|     field.value = savedNickname; | ||||
|   } catch (_err) { | ||||
|     // Sometimes localStorage is blocked from use
 | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * @param {HTMLInputElement} field | ||||
|  */ | ||||
| const saveNickname = (field) => { | ||||
|   try { | ||||
|     localStorage.setItem("watch-party-nickname", field.value); | ||||
|   } catch (_err) { | ||||
|     // see loadNickname
 | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * @param {HTMLInputElement} field | ||||
|  */ | ||||
| const loadColour = (field) => { | ||||
|   try { | ||||
|     const savedColour = localStorage.getItem("watch-party-colour"); | ||||
|     if (savedColour != null && savedColour != "") { | ||||
|       field.value = savedColour; | ||||
|     } | ||||
|   } catch (_err) { | ||||
|     // Sometimes localStorage is blocked from use
 | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * @param {HTMLInputElement} field | ||||
|  */ | ||||
| const saveColour = (field) => { | ||||
|   try { | ||||
|     localStorage.setItem("watch-party-colour", field.value); | ||||
|   } catch (_err) { | ||||
|     // see loadColour
 | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| const displayPostCreateMessage = () => { | ||||
|   const params = new URLSearchParams(window.location.search); | ||||
|   if (params.get("created") == "true") { | ||||
|     document.querySelector("#post-create-message").style["display"] = "block"; | ||||
|     window.history.replaceState({}, document.title, `/${window.location.hash}`); | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| export const setupJoinSessionForm = () => { | ||||
|   displayPostCreateMessage(); | ||||
| 
 | ||||
|   const form = document.querySelector("#join-session-form"); | ||||
|   const nickname = form.querySelector("#join-session-nickname"); | ||||
|   const colour = form.querySelector("#join-session-colour"); | ||||
|   const sessionId = form.querySelector("#join-session-id"); | ||||
|   const button = form.querySelector("#join-session-button"); | ||||
| 
 | ||||
|   loadNickname(nickname); | ||||
|   loadColour(colour); | ||||
| 
 | ||||
|   if (window.location.hash.match(/#[0-9a-f\-]+/)) { | ||||
|     sessionId.value = window.location.hash.substring(1); | ||||
|   } | ||||
| 
 | ||||
|   form.addEventListener("submit", async (event) => { | ||||
|     event.preventDefault(); | ||||
| 
 | ||||
|     button.disabled = true; | ||||
| 
 | ||||
|     saveNickname(nickname); | ||||
|     saveColour(colour); | ||||
|     try { | ||||
|       state().nickname = nickname.value; | ||||
|       state().sessionId = sessionId.value; | ||||
|       state().colour = colour.value.replace(/^#/, ""); | ||||
|       await joinSession(); | ||||
|     } catch (e) { | ||||
|       alert(e.message); | ||||
|       button.disabled = false; | ||||
|     } | ||||
|   }); | ||||
| }; | ||||
| import { joinSession } from "./watch-session.mjs?v=bfdcf2"; | ||||
| import { state } from "./state.mjs"; | ||||
| 
 | ||||
| /** | ||||
|  * @param {HTMLInputElement} field | ||||
|  */ | ||||
| const loadNickname = (field) => { | ||||
|   try { | ||||
|     const savedNickname = localStorage.getItem("watch-party-nickname"); | ||||
|     field.value = savedNickname; | ||||
|   } catch (_err) { | ||||
|     // Sometimes localStorage is blocked from use
 | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * @param {HTMLInputElement} field | ||||
|  */ | ||||
| const saveNickname = (field) => { | ||||
|   try { | ||||
|     localStorage.setItem("watch-party-nickname", field.value); | ||||
|   } catch (_err) { | ||||
|     // see loadNickname
 | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * @param {HTMLInputElement} field | ||||
|  */ | ||||
| const loadColour = (field) => { | ||||
|   try { | ||||
|     const savedColour = localStorage.getItem("watch-party-colour"); | ||||
|     if (savedColour != null && savedColour != "") { | ||||
|       field.value = savedColour; | ||||
|     } | ||||
|   } catch (_err) { | ||||
|     // Sometimes localStorage is blocked from use
 | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * @param {HTMLInputElement} field | ||||
|  */ | ||||
| const saveColour = (field) => { | ||||
|   try { | ||||
|     localStorage.setItem("watch-party-colour", field.value); | ||||
|   } catch (_err) { | ||||
|     // see loadColour
 | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| const displayPostCreateMessage = () => { | ||||
|   const params = new URLSearchParams(window.location.search); | ||||
|   if (params.get("created") == "true") { | ||||
|     document.querySelector("#post-create-message").style["display"] = "block"; | ||||
|     window.history.replaceState({}, document.title, `/${window.location.hash}`); | ||||
|     return true; | ||||
|   } | ||||
|   return false; | ||||
| }; | ||||
| 
 | ||||
| export const setupJoinSessionForm = () => { | ||||
|   const created = displayPostCreateMessage(); | ||||
| 
 | ||||
|   const form = document.querySelector("#join-session-form"); | ||||
|   const nickname = form.querySelector("#join-session-nickname"); | ||||
|   const colour = form.querySelector("#join-session-colour"); | ||||
|   const sessionId = form.querySelector("#join-session-id"); | ||||
|   const button = form.querySelector("#join-session-button"); | ||||
| 
 | ||||
|   loadNickname(nickname); | ||||
|   loadColour(colour); | ||||
| 
 | ||||
|   if (window.location.hash.match(/#[0-9a-f\-]+/)) { | ||||
|     sessionId.value = window.location.hash.substring(1); | ||||
|   } | ||||
| 
 | ||||
|   form.addEventListener("submit", async (event) => { | ||||
|     event.preventDefault(); | ||||
| 
 | ||||
|     button.disabled = true; | ||||
| 
 | ||||
|     saveNickname(nickname); | ||||
|     saveColour(colour); | ||||
|     try { | ||||
|       state().nickname = nickname.value; | ||||
|       state().sessionId = sessionId.value; | ||||
|       state().colour = colour.value.replace(/^#/, ""); | ||||
|       await joinSession(created); | ||||
|     } catch (e) { | ||||
|       alert(e.message); | ||||
|       button.disabled = false; | ||||
|     } | ||||
|   }); | ||||
| }; | ||||
|  |  | |||
|  | @ -1,121 +1,121 @@ | |||
| import { joinSession } from "./watch-session.mjs?v=bfdcf2"; | ||||
| 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]]; | ||||
| } | ||||
| import { joinSession } from "./watch-session.mjs?v=bfdcf2"; | ||||
| 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]]; | ||||
| } | ||||
|  |  | |||
|  | @ -77,4 +77,3 @@ export const pling = () => { | |||
|   thirdBeep.start(ctx.currentTime + thirdBeepOffset); | ||||
|   thirdBeep.stop(ctx.currentTime + (thirdBeepOffset + duration)); | ||||
| }; | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										1
									
								
								frontend/lib/plyr-3.7.3.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								frontend/lib/plyr-3.7.3.css
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										1
									
								
								frontend/lib/plyr-3.7.3.min.esm.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								frontend/lib/plyr-3.7.3.min.esm.js
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							|  | @ -1,71 +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); | ||||
|   } | ||||
| } | ||||
| 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); | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -1,163 +1,112 @@ | |||
| const loadVolume = () => { | ||||
|   try { | ||||
|     const savedVolume = localStorage.getItem("watch-party-volume"); | ||||
|     if (savedVolume != null && savedVolume != "") { | ||||
|       return +savedVolume; | ||||
|     } | ||||
|   } catch (_err) { | ||||
|     // Sometimes localStorage is blocked from use
 | ||||
|   } | ||||
|   // default
 | ||||
|   return 0.5; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * @param {number} volume | ||||
|  */ | ||||
| const saveVolume = (volume) => { | ||||
|   try { | ||||
|     localStorage.setItem("watch-party-volume", volume); | ||||
|   } catch (_err) { | ||||
|     // see loadVolume
 | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| const loadCaptionTrack = () => { | ||||
|   try { | ||||
|     const savedTrack = localStorage.getItem("watch-party-captions"); | ||||
|     if (savedTrack != null && savedTrack != "") { | ||||
|       return +savedTrack; | ||||
|     } | ||||
|   } catch (_err) { | ||||
|     // Sometimes localStorage is blocked from use
 | ||||
|   } | ||||
|   // default
 | ||||
|   return -1; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * @param {number} track | ||||
|  */ | ||||
| const saveCaptionsTrack = (track) => { | ||||
|   try { | ||||
|     localStorage.setItem("watch-party-captions", track); | ||||
|   } catch (_err) { | ||||
|     // see loadCaptionsTrack
 | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * @param {string} videoUrl | ||||
|  * @param {{name: string, url: string}[]} subtitles | ||||
|  */ | ||||
| const createVideoElement = (videoUrl, subtitles) => { | ||||
|   const oldVideo = document.getElementById("video"); | ||||
|   if (oldVideo) { | ||||
|     oldVideo.remove(); | ||||
|   } | ||||
|   const video = document.createElement("video"); | ||||
|   video.id = "video"; | ||||
|   video.controls = true; | ||||
|   video.autoplay = false; | ||||
|   video.volume = loadVolume(); | ||||
|   video.crossOrigin = "anonymous"; | ||||
| 
 | ||||
|   video.addEventListener("volumechange", async () => { | ||||
|     saveVolume(video.volume); | ||||
|   }); | ||||
| 
 | ||||
|   const source = document.createElement("source"); | ||||
|   source.src = videoUrl; | ||||
| 
 | ||||
|   video.appendChild(source); | ||||
| 
 | ||||
|   const storedTrack = loadCaptionTrack(); | ||||
|   let id = 0; | ||||
|   for (const { name, url } of subtitles) { | ||||
|     const track = document.createElement("track"); | ||||
|     track.label = name; | ||||
|     track.src = url; | ||||
|     track.kind = "captions"; | ||||
| 
 | ||||
|     if (id == storedTrack || storedTrack == -1) { | ||||
|       track.default = true; | ||||
|     } | ||||
| 
 | ||||
|     video.appendChild(track); | ||||
|     id++; | ||||
|   } | ||||
| 
 | ||||
|   video.textTracks.addEventListener("change", async () => { | ||||
|     let id = 0; | ||||
|     for (const track of video.textTracks) { | ||||
|       if (track.mode != "disabled") { | ||||
|         saveCaptionsTrack(id); | ||||
|         return; | ||||
|       } | ||||
|       id++; | ||||
|     } | ||||
|     saveCaptionsTrack(-1); | ||||
|   }); | ||||
| 
 | ||||
|   // watch for attribute changes on the video object to detect hiding/showing of controls
 | ||||
|   // as far as i can tell this is the least hacky solutions to get control visibility change events
 | ||||
|   const observer = new MutationObserver(async (mutations) => { | ||||
|     for (const mutation of mutations) { | ||||
|       if (mutation.attributeName == "controls") { | ||||
|         if (video.controls) { | ||||
|           // 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", () => {}); | ||||
|         } | ||||
|         return; | ||||
|       } | ||||
|     } | ||||
|   }); | ||||
|   observer.observe(video, { attributes: true }); | ||||
| 
 | ||||
|   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); | ||||
|   const videoContainer = document.querySelector("#video-container"); | ||||
|   videoContainer.style.display = "block"; | ||||
|   videoContainer.appendChild(video); | ||||
| 
 | ||||
|   video.currentTime = currentTime / 1000.0; | ||||
| 
 | ||||
|   try { | ||||
|     if (playing) { | ||||
|       await video.play(); | ||||
|     } else { | ||||
|       video.pause(); | ||||
|     } | ||||
|   } catch (err) { | ||||
|     // Auto-play is probably disabled, we should uhhhhhhh do something about it
 | ||||
|   } | ||||
| 
 | ||||
|   return video; | ||||
| }; | ||||
| import Plyr from "./plyr-3.7.3.min.esm.js"; | ||||
| 
 | ||||
| /** | ||||
|  * @param {string} videoUrl | ||||
|  * @param {{name: string, url: string}[]} subtitles | ||||
|  */ | ||||
| const createVideoElement = (videoUrl, subtitles, created) => { | ||||
|   const oldVideo = document.getElementById(".plyr"); | ||||
|   if (oldVideo) { | ||||
|     oldVideo.remove(); | ||||
|   } | ||||
|   const video = document.createElement("video"); | ||||
|   video.id = "video"; | ||||
|   video.crossOrigin = "anonymous"; | ||||
| 
 | ||||
|   const source = document.createElement("source"); | ||||
|   source.src = videoUrl; | ||||
| 
 | ||||
|   video.appendChild(source); | ||||
| 
 | ||||
|   for (const { name, url } of subtitles) { | ||||
|     const track = document.createElement("track"); | ||||
|     track.label = name; | ||||
|     track.srclang = "xx-" + name.toLowerCase(); | ||||
|     track.src = url; | ||||
|     track.kind = "captions"; | ||||
|     video.appendChild(track); | ||||
|   } | ||||
| 
 | ||||
|   const videoContainer = document.querySelector("#video-container"); | ||||
|   videoContainer.style.display = "block"; | ||||
|   videoContainer.appendChild(video); | ||||
| 
 | ||||
|   const player = new Plyr(video, { | ||||
|     clickToPlay: false, | ||||
|     settings: ["captions", "quality"], | ||||
|     autopause: false, | ||||
|   }); | ||||
|   player.elements.controls.insertAdjacentHTML( | ||||
|     "afterbegin", | ||||
|     `<button type="button" aria-pressed="false" class="plyr__controls__item plyr__control lock-controls"><svg aria-hidden="true" focusable="false" viewBox="0 0 24 24"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zM9 8V6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9z"></path></svg><span class="label--pressed plyr__sr-only">Unlock controls</span><span class="label--not-pressed plyr__sr-only">Lock controls</span></button>` | ||||
|   ); | ||||
|   const lockButton = player.elements.controls.children[0]; | ||||
|   let controlsEnabled = created; | ||||
|   const setControlsEnabled = (enabled) => { | ||||
|     controlsEnabled = enabled; | ||||
|     lockButton.setAttribute("aria-pressed", enabled); | ||||
|     lockButton.classList.toggle("plyr__control--pressed", enabled); | ||||
|     player.elements.buttons.play[0].disabled = | ||||
|       player.elements.buttons.play[1].disabled = | ||||
|       player.elements.inputs.seek.disabled = | ||||
|         !enabled; | ||||
|     if (!enabled) { | ||||
|       // enable media button support
 | ||||
|       navigator.mediaSession.setActionHandler("play", null); | ||||
|       navigator.mediaSession.setActionHandler("pause", null); | ||||
|       navigator.mediaSession.setActionHandler("stop", null); | ||||
|       navigator.mediaSession.setActionHandler("seekbackward", null); | ||||
|       navigator.mediaSession.setActionHandler("seekforward", null); | ||||
|       navigator.mediaSession.setActionHandler("seekto", null); | ||||
|       navigator.mediaSession.setActionHandler("previoustrack", null); | ||||
|       navigator.mediaSession.setActionHandler("nexttrack", null); | ||||
|     } else { | ||||
|       // disable media button support by ignoring the events
 | ||||
|       navigator.mediaSession.setActionHandler("play", () => {}); | ||||
|       navigator.mediaSession.setActionHandler("pause", () => {}); | ||||
|       navigator.mediaSession.setActionHandler("stop", () => {}); | ||||
|       navigator.mediaSession.setActionHandler("seekbackward", () => {}); | ||||
|       navigator.mediaSession.setActionHandler("seekforward", () => {}); | ||||
|       navigator.mediaSession.setActionHandler("seekto", () => {}); | ||||
|       navigator.mediaSession.setActionHandler("previoustrack", () => {}); | ||||
|       navigator.mediaSession.setActionHandler("nexttrack", () => {}); | ||||
|     } | ||||
|   }; | ||||
|   setControlsEnabled(controlsEnabled); | ||||
|   lockButton.addEventListener("click", () => | ||||
|     setControlsEnabled(!controlsEnabled) | ||||
|   ); | ||||
|   window.__plyr = player; | ||||
| 
 | ||||
|   return player; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * @param {string} videoUrl | ||||
|  * @param {{name: string, url: string}[]} subtitles | ||||
|  * @param {number} currentTime | ||||
|  * @param {boolean} playing | ||||
|  */ | ||||
| export const setupVideo = async ( | ||||
|   videoUrl, | ||||
|   subtitles, | ||||
|   currentTime, | ||||
|   playing, | ||||
|   created | ||||
| ) => { | ||||
|   document.querySelector("#pre-join-controls").style["display"] = "none"; | ||||
|   const player = createVideoElement(videoUrl, subtitles, created); | ||||
|   player.currentTime = currentTime / 1000.0; | ||||
| 
 | ||||
|   try { | ||||
|     if (playing) { | ||||
|       player.play(); | ||||
|     } else { | ||||
|       player.pause(); | ||||
|     } | ||||
|   } catch (err) { | ||||
|     // Auto-play is probably disabled, we should uhhhhhhh do something about it
 | ||||
|   } | ||||
| 
 | ||||
|   return player; | ||||
| }; | ||||
|  |  | |||
|  | @ -1,276 +1,270 @@ | |||
| import { setupVideo } from "./video.mjs?v=bfdcf2"; | ||||
| import { | ||||
|   setupChat, | ||||
|   logEventToChat, | ||||
|   updateViewerList, | ||||
|   printChatMessage, | ||||
| } from "./chat.mjs?v=bfdcf2"; | ||||
| import ReconnectingWebSocket from "./reconnecting-web-socket.mjs"; | ||||
| import { state } from "./state.mjs"; | ||||
| 
 | ||||
| /** | ||||
|  * @param {string} sessionId | ||||
|  * @param {string} nickname | ||||
|  * @returns {ReconnectingWebSocket} | ||||
|  */ | ||||
| const createWebSocket = () => { | ||||
|   const wsUrl = new URL( | ||||
|     `/sess/${state().sessionId}/subscribe` + | ||||
|       `?nickname=${encodeURIComponent(state().nickname)}` + | ||||
|       `&colour=${encodeURIComponent(state().colour)}`, | ||||
|     window.location.href | ||||
|   ); | ||||
|   wsUrl.protocol = "ws" + window.location.protocol.slice(4); | ||||
|   const socket = new ReconnectingWebSocket(wsUrl); | ||||
| 
 | ||||
|   return socket; | ||||
| }; | ||||
| 
 | ||||
| let outgoingDebounce = false; | ||||
| let outgoingDebounceCallbackId = null; | ||||
| 
 | ||||
| export const setDebounce = () => { | ||||
|   outgoingDebounce = true; | ||||
| 
 | ||||
|   if (outgoingDebounceCallbackId) { | ||||
|     cancelIdleCallback(outgoingDebounceCallbackId); | ||||
|     outgoingDebounceCallbackId = null; | ||||
|   } | ||||
| 
 | ||||
|   outgoingDebounceCallbackId = setTimeout(() => { | ||||
|     outgoingDebounce = false; | ||||
|   }, 500); | ||||
| }; | ||||
| 
 | ||||
| export const setVideoTime = (time, video = null) => { | ||||
|   if (video == null) { | ||||
|     video = document.querySelector("video"); | ||||
|   } | ||||
| 
 | ||||
|   const timeSecs = time / 1000.0; | ||||
|   if (Math.abs(video.currentTime - timeSecs) > 0.5) { | ||||
|     video.currentTime = timeSecs; | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| export const setPlaying = async (playing, video = null) => { | ||||
|   if (video == null) { | ||||
|     video = document.querySelector("video"); | ||||
|   } | ||||
| 
 | ||||
|   if (playing) { | ||||
|     await video.play(); | ||||
|   } else { | ||||
|     video.pause(); | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * @param {HTMLVideoElement} video | ||||
|  * @param {ReconnectingWebSocket} socket | ||||
|  */ | ||||
| const setupIncomingEvents = (video, 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(); | ||||
|             } else { | ||||
|               video.pause(); | ||||
|             } | ||||
| 
 | ||||
|             setVideoTime(event.data.time, video); | ||||
|             break; | ||||
|           case "SetTime": | ||||
|             setDebounce(); | ||||
|             setVideoTime(event.data, video); | ||||
|             break; | ||||
|           case "UpdateViewerList": | ||||
|             updateViewerList(event.data); | ||||
|             break; | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       logEventToChat(event); | ||||
|     } catch (_err) {} | ||||
|   }); | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * @param {HTMLVideoElement} video | ||||
|  * @param {ReconnectingWebSocket} socket | ||||
|  */ | ||||
| const setupOutgoingEvents = (video, socket) => { | ||||
|   const currentVideoTime = () => (video.currentTime * 1000) | 0; | ||||
| 
 | ||||
|   video.addEventListener("pause", async (event) => { | ||||
|     if (outgoingDebounce || !video.controls) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     // don't send a pause event for the video ending
 | ||||
|     if (video.currentTime == video.duration) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     socket.send( | ||||
|       JSON.stringify({ | ||||
|         op: "SetPlaying", | ||||
|         data: { | ||||
|           playing: false, | ||||
|           time: currentVideoTime(), | ||||
|         }, | ||||
|       }) | ||||
|     ); | ||||
|   }); | ||||
| 
 | ||||
|   video.addEventListener("play", (event) => { | ||||
|     if (outgoingDebounce || !video.controls) { | ||||
|       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 || !video.controls) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     socket.send( | ||||
|       JSON.stringify({ | ||||
|         op: "SetTime", | ||||
|         data: { | ||||
|           to: currentVideoTime(), | ||||
|         }, | ||||
|       }) | ||||
|     ); | ||||
|   }); | ||||
| }; | ||||
| 
 | ||||
| export const joinSession = async () => { | ||||
|   if (state().activeSession) { | ||||
|     if (state().activeSession === state().sessionId) { | ||||
|       // we are already in this session, dont rejoin
 | ||||
|       return; | ||||
|     } | ||||
|     // we are joining a new session from an existing session
 | ||||
|     const messageContent = document.createElement("span"); | ||||
|     messageContent.appendChild(document.createTextNode("joining new session ")); | ||||
|     messageContent.appendChild(document.createTextNode(state().sessionId)); | ||||
| 
 | ||||
|     printChatMessage("join-session", "watch-party", "#fffff", messageContent); | ||||
|   } | ||||
|   state().activeSession = state().sessionId; | ||||
| 
 | ||||
|   // try { // we are handling errors in the join form.
 | ||||
|   const genericConnectionError = new Error( | ||||
|     "There was an issue getting the session information." | ||||
|   ); | ||||
|   window.location.hash = state().sessionId; | ||||
|   let response, video_url, subtitle_tracks, current_time_ms, is_playing; | ||||
|   try { | ||||
|     response = await fetch(`/sess/${state().sessionId}`); | ||||
|   } catch (e) { | ||||
|     console.error(e); | ||||
|     throw genericConnectionError; | ||||
|   } | ||||
|   if (!response.ok) { | ||||
|     let error; | ||||
|     try { | ||||
|       ({ error } = await response.json()); | ||||
|       if (!error) throw new Error(); | ||||
|     } catch (e) { | ||||
|       console.error(e); | ||||
|       throw genericConnectionError; | ||||
|     } | ||||
|     throw new Error(error); | ||||
|   } | ||||
|   try { | ||||
|     ({ video_url, subtitle_tracks, current_time_ms, is_playing } = | ||||
|       await response.json()); | ||||
|   } catch (e) { | ||||
|     console.error(e); | ||||
|     throw genericConnectionError; | ||||
|   } | ||||
| 
 | ||||
|   if (state().socket) { | ||||
|     state().socket.close(); | ||||
|     state().socket = null; | ||||
|   } | ||||
|   const socket = createWebSocket(); | ||||
|   state().socket = socket; | ||||
|   socket.addEventListener("open", async () => { | ||||
|     const video = await setupVideo( | ||||
|       video_url, | ||||
|       subtitle_tracks, | ||||
|       current_time_ms, | ||||
|       is_playing | ||||
|     ); | ||||
| 
 | ||||
|     // TODO: Allow the user to set this somewhere
 | ||||
|     let defaultAllowControls = false; | ||||
|     try { | ||||
|       defaultAllowControls = localStorage.getItem( | ||||
|         "watch-party-default-allow-controls" | ||||
|       ); | ||||
|     } catch (_err) {} | ||||
| 
 | ||||
|     // By default, we should disable video controls if the video is already playing.
 | ||||
|     // This solves an issue where Safari users join and seek to 00:00:00 because of
 | ||||
|     // outgoing events.
 | ||||
|     if (current_time_ms != 0 || !defaultAllowControls) { | ||||
|       video.controls = false; | ||||
|     } | ||||
| 
 | ||||
|     setupOutgoingEvents(video, socket); | ||||
|     setupIncomingEvents(video, socket); | ||||
|     setupChat(socket); | ||||
|   }); | ||||
|   socket.addEventListener("reconnecting", (e) => { | ||||
|     console.log("Reconnecting..."); | ||||
|   }); | ||||
|   socket.addEventListener("reconnected", (e) => { | ||||
|     console.log("Reconnected."); | ||||
|   }); | ||||
|   //} catch (e) {
 | ||||
|   //  alert(e.message)
 | ||||
|   //}
 | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * @param {string} videoUrl | ||||
|  * @param {Array} subtitleTracks | ||||
|  */ | ||||
| export const createSession = async (videoUrl, subtitleTracks) => { | ||||
|   const { id } = await fetch("/start_session", { | ||||
|     method: "POST", | ||||
|     headers: { "Content-Type": "application/json" }, | ||||
|     body: JSON.stringify({ | ||||
|       video_url: videoUrl, | ||||
|       subtitle_tracks: subtitleTracks, | ||||
|     }), | ||||
|   }).then((r) => r.json()); | ||||
| 
 | ||||
|   window.location = `/?created=true#${id}`; | ||||
| }; | ||||
| import { setupVideo } from "./video.mjs?v=bfdcf2"; | ||||
| import { | ||||
|   setupChat, | ||||
|   logEventToChat, | ||||
|   updateViewerList, | ||||
|   printChatMessage, | ||||
| } from "./chat.mjs?v=bfdcf2"; | ||||
| import ReconnectingWebSocket from "./reconnecting-web-socket.mjs"; | ||||
| import { state } from "./state.mjs"; | ||||
| let player; | ||||
| /** | ||||
|  * @param {string} sessionId | ||||
|  * @param {string} nickname | ||||
|  * @returns {ReconnectingWebSocket} | ||||
|  */ | ||||
| const createWebSocket = () => { | ||||
|   const wsUrl = new URL( | ||||
|     `/sess/${state().sessionId}/subscribe` + | ||||
|       `?nickname=${encodeURIComponent(state().nickname)}` + | ||||
|       `&colour=${encodeURIComponent(state().colour)}`, | ||||
|     window.location.href | ||||
|   ); | ||||
|   wsUrl.protocol = "ws" + window.location.protocol.slice(4); | ||||
|   const socket = new ReconnectingWebSocket(wsUrl); | ||||
| 
 | ||||
|   return socket; | ||||
| }; | ||||
| 
 | ||||
| let outgoingDebounce = false; | ||||
| let outgoingDebounceCallbackId = null; | ||||
| 
 | ||||
| export const setDebounce = () => { | ||||
|   outgoingDebounce = true; | ||||
| 
 | ||||
|   if (outgoingDebounceCallbackId) { | ||||
|     cancelIdleCallback(outgoingDebounceCallbackId); | ||||
|     outgoingDebounceCallbackId = null; | ||||
|   } | ||||
| 
 | ||||
|   outgoingDebounceCallbackId = setTimeout(() => { | ||||
|     outgoingDebounce = false; | ||||
|   }, 500); | ||||
| }; | ||||
| 
 | ||||
| export const setVideoTime = (time) => { | ||||
|   const timeSecs = time / 1000.0; | ||||
|   if (Math.abs(player.currentTime - timeSecs) > 0.5) { | ||||
|     player.currentTime = timeSecs; | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| export const setPlaying = async (playing) => { | ||||
|   if (playing) { | ||||
|     await player.play(); | ||||
|   } else { | ||||
|     player.pause(); | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * @param {HTMLVideoElement} video | ||||
|  * @param {ReconnectingWebSocket} socket | ||||
|  */ | ||||
| const setupIncomingEvents = (player, socket) => { | ||||
|   socket.addEventListener("message", async (messageEvent) => { | ||||
|     try { | ||||
|       const event = JSON.parse(messageEvent.data); | ||||
|       if (!event.reflected) { | ||||
|         switch (event.op) { | ||||
|           case "SetPlaying": | ||||
|             setDebounce(); | ||||
| 
 | ||||
|             if (event.data.playing) { | ||||
|               await player.play(); | ||||
|             } else { | ||||
|               player.pause(); | ||||
|             } | ||||
| 
 | ||||
|             setVideoTime(event.data.time); | ||||
|             break; | ||||
|           case "SetTime": | ||||
|             setDebounce(); | ||||
|             setVideoTime(event.data); | ||||
|             break; | ||||
|           case "UpdateViewerList": | ||||
|             updateViewerList(event.data); | ||||
|             break; | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       logEventToChat(event); | ||||
|     } catch (_err) {} | ||||
|   }); | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * @param {Plyr} player | ||||
|  * @param {ReconnectingWebSocket} socket | ||||
|  */ | ||||
| const setupOutgoingEvents = (player, socket) => { | ||||
|   const currentVideoTime = () => (player.currentTime * 1000) | 0; | ||||
| 
 | ||||
|   player.on("pause", async () => { | ||||
|     if (outgoingDebounce || player.elements.inputs.seek.disabled) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     // don't send a pause event for the video ending
 | ||||
|     if (player.currentTime == player.duration) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     socket.send( | ||||
|       JSON.stringify({ | ||||
|         op: "SetPlaying", | ||||
|         data: { | ||||
|           playing: false, | ||||
|           time: currentVideoTime(), | ||||
|         }, | ||||
|       }) | ||||
|     ); | ||||
|   }); | ||||
| 
 | ||||
|   player.on("play", () => { | ||||
|     if (outgoingDebounce || player.elements.inputs.seek.disabled) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     socket.send( | ||||
|       JSON.stringify({ | ||||
|         op: "SetPlaying", | ||||
|         data: { | ||||
|           playing: true, | ||||
|           time: currentVideoTime(), | ||||
|         }, | ||||
|       }) | ||||
|     ); | ||||
|   }); | ||||
| 
 | ||||
|   let firstSeekComplete = false; | ||||
|   player.on("seeked", async (event) => { | ||||
|     if (!firstSeekComplete) { | ||||
|       // The first seeked event is performed by the browser when the video is loading
 | ||||
|       firstSeekComplete = true; | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     if (outgoingDebounce || player.elements.inputs.seek.disabled) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     socket.send( | ||||
|       JSON.stringify({ | ||||
|         op: "SetTime", | ||||
|         data: { | ||||
|           to: currentVideoTime(), | ||||
|         }, | ||||
|       }) | ||||
|     ); | ||||
|   }); | ||||
| }; | ||||
| 
 | ||||
| export const joinSession = async (created) => { | ||||
|   if (state().activeSession) { | ||||
|     if (state().activeSession === state().sessionId) { | ||||
|       // we are already in this session, dont rejoin
 | ||||
|       return; | ||||
|     } | ||||
|     // we are joining a new session from an existing session
 | ||||
|     const messageContent = document.createElement("span"); | ||||
|     messageContent.appendChild(document.createTextNode("joining new session ")); | ||||
|     messageContent.appendChild(document.createTextNode(state().sessionId)); | ||||
| 
 | ||||
|     printChatMessage("join-session", "watch-party", "#fffff", messageContent); | ||||
|   } | ||||
|   state().activeSession = state().sessionId; | ||||
| 
 | ||||
|   // try { // we are handling errors in the join form.
 | ||||
|   const genericConnectionError = new Error( | ||||
|     "There was an issue getting the session information." | ||||
|   ); | ||||
|   window.location.hash = state().sessionId; | ||||
|   let response, video_url, subtitle_tracks, current_time_ms, is_playing; | ||||
|   try { | ||||
|     response = await fetch(`/sess/${state().sessionId}`); | ||||
|   } catch (e) { | ||||
|     console.error(e); | ||||
|     throw genericConnectionError; | ||||
|   } | ||||
|   if (!response.ok) { | ||||
|     let error; | ||||
|     try { | ||||
|       ({ error } = await response.json()); | ||||
|       if (!error) throw new Error(); | ||||
|     } catch (e) { | ||||
|       console.error(e); | ||||
|       throw genericConnectionError; | ||||
|     } | ||||
|     throw new Error(error); | ||||
|   } | ||||
|   try { | ||||
|     ({ video_url, subtitle_tracks, current_time_ms, is_playing } = | ||||
|       await response.json()); | ||||
|   } catch (e) { | ||||
|     console.error(e); | ||||
|     throw genericConnectionError; | ||||
|   } | ||||
| 
 | ||||
|   if (state().socket) { | ||||
|     state().socket.close(); | ||||
|     state().socket = null; | ||||
|   } | ||||
|   const socket = createWebSocket(); | ||||
|   state().socket = socket; | ||||
|   socket.addEventListener("open", async () => { | ||||
|     player = await setupVideo( | ||||
|       video_url, | ||||
|       subtitle_tracks, | ||||
|       current_time_ms, | ||||
|       is_playing, | ||||
|       created | ||||
|     ); | ||||
| 
 | ||||
|     player.on("canplay", () => { | ||||
|       sync(); | ||||
|     }); | ||||
| 
 | ||||
|     setupOutgoingEvents(player, socket); | ||||
|     setupIncomingEvents(player, socket); | ||||
|     setupChat(socket); | ||||
|   }); | ||||
|   socket.addEventListener("reconnecting", (e) => { | ||||
|     console.log("Reconnecting..."); | ||||
|   }); | ||||
|   socket.addEventListener("reconnected", (e) => { | ||||
|     console.log("Reconnected."); | ||||
|   }); | ||||
|   //} catch (e) {
 | ||||
|   //  alert(e.message)
 | ||||
|   //}
 | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * @param {string} videoUrl | ||||
|  * @param {Array} subtitleTracks | ||||
|  */ | ||||
| export const createSession = async (videoUrl, subtitleTracks) => { | ||||
|   const { id } = await fetch("/start_session", { | ||||
|     method: "POST", | ||||
|     headers: { "Content-Type": "application/json" }, | ||||
|     body: JSON.stringify({ | ||||
|       video_url: videoUrl, | ||||
|       subtitle_tracks: subtitleTracks, | ||||
|     }), | ||||
|   }).then((r) => r.json()); | ||||
| 
 | ||||
|   window.location = `/?created=true#${id}`; | ||||
| }; | ||||
| 
 | ||||
| export const sync = async () => { | ||||
|   setDebounce(); | ||||
|   await setPlaying(false); | ||||
|   const { current_time_ms, is_playing } = await fetch( | ||||
|     `/sess/${state().sessionId}` | ||||
|   ).then((r) => r.json()); | ||||
| 
 | ||||
|   setDebounce(); | ||||
|   setVideoTime(current_time_ms); | ||||
|   if (is_playing) await setPlaying(is_playing); | ||||
| }; | ||||
|  |  | |||
|  | @ -1,11 +1,11 @@ | |||
| import { setupJoinSessionForm } from "./lib/join-session.mjs?v=bfdcf2"; | ||||
| 
 | ||||
| const main = () => { | ||||
|   setupJoinSessionForm(); | ||||
| }; | ||||
| 
 | ||||
| if (document.readyState === "complete") { | ||||
|   main(); | ||||
| } else { | ||||
|   document.addEventListener("DOMContentLoaded", main); | ||||
| } | ||||
| import { setupJoinSessionForm } from "./lib/join-session.mjs?v=bfdcf2"; | ||||
| 
 | ||||
| const main = () => { | ||||
|   setupJoinSessionForm(); | ||||
| }; | ||||
| 
 | ||||
| if (document.readyState === "complete") { | ||||
|   main(); | ||||
| } else { | ||||
|   document.addEventListener("DOMContentLoaded", main); | ||||
| } | ||||
|  |  | |||
|  | @ -25,6 +25,14 @@ | |||
|     ), | ||||
|     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 { | ||||
|  | @ -49,11 +57,41 @@ body { | |||
|   flex-direction: column; | ||||
| } | ||||
| 
 | ||||
| video { | ||||
|   display: block; | ||||
| .lock-controls.plyr__control--pressed svg { | ||||
|   opacity: 0.5; | ||||
| } | ||||
| 
 | ||||
| .plyr { | ||||
|   width: 100%; | ||||
|   height: 100%; | ||||
|   object-fit: contain; | ||||
| } | ||||
| 
 | ||||
| .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 { | ||||
|  | @ -131,7 +169,7 @@ input[type="text"] { | |||
|   overflow-y: scroll; | ||||
| } | ||||
| 
 | ||||
| button { | ||||
| button:not(.plyr button) { | ||||
|   background-color: var(--accent); | ||||
|   border: var(--accent); | ||||
|   border-radius: 6px; | ||||
|  | @ -303,7 +341,7 @@ button.small-button { | |||
|   display: none; | ||||
| } | ||||
| 
 | ||||
| .emoji-option { | ||||
| .emoji-option:not(:root) { | ||||
|   background: transparent; | ||||
|   font-size: 0.75rem; | ||||
|   text-align: left; | ||||
|  |  | |||
		Loading…
	
		Reference in a new issue