forked from lavender/watch-party
		
	Compare commits
	
		
			1 commit
		
	
	
		
			main
			...
			experiment
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 4dfe6a544d | 
					 24 changed files with 345 additions and 1498 deletions
				
			
		
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							|  | @ -1,2 +1 @@ | ||||||
| /target | /target | ||||||
| /frontend/emojis/* |  | ||||||
|  |  | ||||||
|  | @ -10,6 +10,6 @@ once_cell = "1.8.0" | ||||||
| serde = { version = "1.0.130", features = ["derive"] } | serde = { version = "1.0.130", features = ["derive"] } | ||||||
| serde_json = "1.0.68" | serde_json = "1.0.68" | ||||||
| tokio = { version = "1.12.0", features = ["full"] } | tokio = { version = "1.12.0", features = ["full"] } | ||||||
| tokio-stream = { version = "0.1.7", features = ["fs"] } | tokio-stream = "0.1.7" | ||||||
| uuid = { version = "0.8.2", features = ["v4"] } | uuid = { version = "0.8.2", features = ["v4"] } | ||||||
| warp = "0.3.1" | warp = "0.3.1" | ||||||
|  |  | ||||||
|  | @ -3,7 +3,7 @@ | ||||||
|   <head> |   <head> | ||||||
|     <meta charset="utf-8" /> |     <meta charset="utf-8" /> | ||||||
|     <title>watch party :D</title> |     <title>watch party :D</title> | ||||||
|     <link rel="stylesheet" href="/styles.css?v=bfdcf2" /> |     <link rel="stylesheet" href="/styles.css?v=5" /> | ||||||
|   </head> |   </head> | ||||||
| 
 | 
 | ||||||
|   <body> |   <body> | ||||||
|  | @ -39,14 +39,14 @@ | ||||||
|           placeholder="English" |           placeholder="English" | ||||||
|         /> |         /> | ||||||
|         <button>Create</button> |         <button>Create</button> | ||||||
| 
 |  | ||||||
|         <p> |  | ||||||
|           Already have a session? |  | ||||||
|           <a href="/">Join your session</a> instead. |  | ||||||
|         </p> |  | ||||||
|       </form> |       </form> | ||||||
|  | 
 | ||||||
|  |       <p> | ||||||
|  |         Already have a session? | ||||||
|  |         <a href="/">Join your session</a> instead. | ||||||
|  |       </p> | ||||||
|     </div> |     </div> | ||||||
| 
 | 
 | ||||||
|     <script type="module" src="/create.mjs?v=bfdcf2"></script> |     <script type="module" src="/create.mjs?v=5"></script> | ||||||
|   </body> |   </body> | ||||||
| </html> | </html> | ||||||
|  |  | ||||||
|  | @ -1,4 +1,4 @@ | ||||||
| import { setupCreateSessionForm } from "./lib/create-session.mjs?v=bfdcf2"; | import { setupCreateSessionForm } from "./lib/create-session.mjs?v=5"; | ||||||
| 
 | 
 | ||||||
| const main = () => { | const main = () => { | ||||||
|   setupCreateSessionForm(); |   setupCreateSessionForm(); | ||||||
|  |  | ||||||
|  | @ -3,7 +3,7 @@ | ||||||
|   <head> |   <head> | ||||||
|     <meta charset="utf-8" /> |     <meta charset="utf-8" /> | ||||||
|     <title>watch party :D</title> |     <title>watch party :D</title> | ||||||
|     <link rel="stylesheet" href="/styles.css?v=bfdcf2" /> |     <link rel="stylesheet" href="/styles.css?v=5" /> | ||||||
|   </head> |   </head> | ||||||
| 
 | 
 | ||||||
|   <body> |   <body> | ||||||
|  | @ -26,15 +26,9 @@ | ||||||
|           type="text" |           type="text" | ||||||
|           id="join-session-nickname" |           id="join-session-nickname" | ||||||
|           placeholder="Nickname" |           placeholder="Nickname" | ||||||
|           maxlength="50" |  | ||||||
|           required |           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> |         <label for="join-session-id">Session ID:</label> | ||||||
|         <input |         <input | ||||||
|           type="text" |           type="text" | ||||||
|  | @ -43,42 +37,21 @@ | ||||||
|           required |           required | ||||||
|         /> |         /> | ||||||
|         <button id="join-session-button">Join</button> |         <button id="join-session-button">Join</button> | ||||||
| 
 |  | ||||||
|         <p> |  | ||||||
|           No session to join? |  | ||||||
|           <a href="/create.html">Create a session</a> instead. |  | ||||||
|         </p> |  | ||||||
|       </form> |       </form> | ||||||
|  | 
 | ||||||
|  |       <p> | ||||||
|  |         No session to join? <a href="/create.html">Create a session</a> instead. | ||||||
|  |       </p> | ||||||
|     </div> |     </div> | ||||||
| 
 | 
 | ||||||
|     <div id="video-container"></div> |     <div id="video-container"></div> | ||||||
|     <div id="chatbox-container"> |     <div id="chatbox-container"> | ||||||
|       <div id="viewer-list"></div> |  | ||||||
|       <div id="chatbox"></div> |       <div id="chatbox"></div> | ||||||
|       <form id="chatbox-send"> |       <form id="chatbox-send"> | ||||||
|         <input |         <input type="text" placeholder="Message..." /> | ||||||
|           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> |       </form> | ||||||
|     </div> |     </div> | ||||||
| 
 | 
 | ||||||
|     <script type="module" src="/main.mjs?v=bfdcf2"></script> |     <script type="module" src="/main.mjs?v=5"></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> |   </body> | ||||||
| </html> | </html> | ||||||
|  |  | ||||||
|  | @ -1,230 +1,17 @@ | ||||||
| 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) => { | const setupChatboxEvents = (socket) => { | ||||||
|   // clear events by just reconstructing the form
 |   // clear events by just reconstructing the form
 | ||||||
|   const oldChatForm = document.querySelector("#chatbox-send"); |   const oldChatForm = document.querySelector("#chatbox-send"); | ||||||
|   const chatForm = oldChatForm.cloneNode(true); |   const chatForm = oldChatForm.cloneNode(true); | ||||||
|   const messageInput = chatForm.querySelector("input"); |  | ||||||
|   const emojiAutocomplete = chatForm.querySelector("#emoji-autocomplete"); |  | ||||||
|   oldChatForm.replaceWith(chatForm); |   oldChatForm.replaceWith(chatForm); | ||||||
| 
 | 
 | ||||||
|   let autocompleting = false, |   chatForm.addEventListener("submit", (e) => { | ||||||
|     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(); |     e.preventDefault(); | ||||||
|     const content = messageInput.value; | 
 | ||||||
|  |     const input = chatForm.querySelector("input"); | ||||||
|  |     const content = input.value; | ||||||
|     if (content.trim().length) { |     if (content.trim().length) { | ||||||
|       messageInput.value = ""; |       input.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( |       socket.send( | ||||||
|         JSON.stringify({ |         JSON.stringify({ | ||||||
|           op: "ChatMessage", |           op: "ChatMessage", | ||||||
|  | @ -235,12 +22,31 @@ const setupChatboxEvents = (socket) => { | ||||||
|   }); |   }); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | const fixChatSize = () => { | ||||||
|  |   const video = document.querySelector("video"); | ||||||
|  |   const chatbox = document.querySelector("#chatbox"); | ||||||
|  |   const chatboxContainer = document.querySelector("#chatbox-container"); | ||||||
|  | 
 | ||||||
|  |   if (video && chatbox && chatboxContainer) { | ||||||
|  |     const delta = chatboxContainer.clientHeight - chatbox.clientHeight; | ||||||
|  | 
 | ||||||
|  |     chatbox.style["height"] = `calc(${ | ||||||
|  |       window.innerHeight - video.clientHeight | ||||||
|  |     }px - ${delta}px - 1em)`;
 | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | 
 | ||||||
| /** | /** | ||||||
|  * @param {WebSocket} socket |  * @param {WebSocket} socket | ||||||
|  */ |  */ | ||||||
| export const setupChat = async (socket) => { | export const setupChat = async (socket) => { | ||||||
|   document.querySelector("#chatbox-container").style["display"] = "flex"; |   document.querySelector("#chatbox-container").style["display"] = "block"; | ||||||
|   setupChatboxEvents(socket); |   setupChatboxEvents(socket); | ||||||
|  | 
 | ||||||
|  |   fixChatSize(); | ||||||
|  |   window.addEventListener("resize", () => { | ||||||
|  |     fixChatSize(); | ||||||
|  |   }); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const addToChat = (node) => { | const addToChat = (node) => { | ||||||
|  | @ -281,41 +87,24 @@ const checkDebounce = (event) => { | ||||||
|   return shouldIgnore; |   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} eventType | ||||||
|  * @param {string?} user |  * @param {string?} user | ||||||
|  * @param {Node?} content |  * @param {Node?} content | ||||||
|  */ |  */ | ||||||
| export const printChatMessage = (eventType, user, colour, content) => { | const printChatMessage = (eventType, user, content) => { | ||||||
|   const chatMessage = document.createElement("div"); |   const chatMessage = document.createElement("div"); | ||||||
|   chatMessage.classList.add("chat-message"); |   chatMessage.classList.add("chat-message"); | ||||||
|   chatMessage.classList.add(eventType); |   chatMessage.classList.add(eventType); | ||||||
|   chatMessage.title = getCurrentTimestamp(); |  | ||||||
| 
 | 
 | ||||||
|   if (user != null) { |   if (user != null) { | ||||||
|     const userName = document.createElement("strong"); |     const userName = document.createElement("strong"); | ||||||
|     userName.style = `--user-color: #${colour}`; |     userName.textContent = user; | ||||||
|     userName.textContent = user + " "; |  | ||||||
|     chatMessage.appendChild(userName); |     chatMessage.appendChild(userName); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   chatMessage.appendChild(document.createTextNode(" ")); | ||||||
|  | 
 | ||||||
|   if (content != null) { |   if (content != null) { | ||||||
|     chatMessage.appendChild(content); |     chatMessage.appendChild(content); | ||||||
|   } |   } | ||||||
|  | @ -334,7 +123,7 @@ const formatTime = (ms) => { | ||||||
|   }:${seconds < 10 ? "0" + seconds : seconds}`;
 |   }:${seconds < 10 ? "0" + seconds : seconds}`;
 | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export const logEventToChat = async (event) => { | export const logEventToChat = (event) => { | ||||||
|   if (checkDebounce(event)) { |   if (checkDebounce(event)) { | ||||||
|     return; |     return; | ||||||
|   } |   } | ||||||
|  | @ -344,7 +133,6 @@ export const logEventToChat = async (event) => { | ||||||
|       printChatMessage( |       printChatMessage( | ||||||
|         "user-join", |         "user-join", | ||||||
|         event.user, |         event.user, | ||||||
|         event.colour, |  | ||||||
|         document.createTextNode("joined") |         document.createTextNode("joined") | ||||||
|       ); |       ); | ||||||
|       break; |       break; | ||||||
|  | @ -353,7 +141,6 @@ export const logEventToChat = async (event) => { | ||||||
|       printChatMessage( |       printChatMessage( | ||||||
|         "user-leave", |         "user-leave", | ||||||
|         event.user, |         event.user, | ||||||
|         event.colour, |  | ||||||
|         document.createTextNode("left") |         document.createTextNode("left") | ||||||
|       ); |       ); | ||||||
|       break; |       break; | ||||||
|  | @ -361,36 +148,19 @@ export const logEventToChat = async (event) => { | ||||||
|     case "ChatMessage": { |     case "ChatMessage": { | ||||||
|       const messageContent = document.createElement("span"); |       const messageContent = document.createElement("span"); | ||||||
|       messageContent.classList.add("message-content"); |       messageContent.classList.add("message-content"); | ||||||
|       messageContent.append(...(await linkify(event.data, emojify))); |       messageContent.textContent = event.data; | ||||||
|       printChatMessage( |       printChatMessage("chat-message", event.user, messageContent); | ||||||
|         "chat-message", |  | ||||||
|         event.user, |  | ||||||
|         event.colour, |  | ||||||
|         messageContent |  | ||||||
|       ); |  | ||||||
|       break; |       break; | ||||||
|     } |     } | ||||||
|     case "SetTime": { |     case "SetTime": { | ||||||
|       const messageContent = document.createElement("span"); |       const messageContent = document.createElement("span"); | ||||||
|       if (event.data.from != undefined) { |       messageContent.appendChild(document.createTextNode("set the time to ")); | ||||||
|         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( |       messageContent.appendChild( | ||||||
|         document.createTextNode(formatTime(event.data.to)) |         document.createTextNode(formatTime(event.data)) | ||||||
|       ); |       ); | ||||||
| 
 | 
 | ||||||
|       printChatMessage("set-time", event.user, event.colour, messageContent); |       printChatMessage("set-time", event.user, messageContent); | ||||||
|       break; |       break; | ||||||
|     } |     } | ||||||
|     case "SetPlaying": { |     case "SetPlaying": { | ||||||
|  | @ -405,55 +175,9 @@ export const logEventToChat = async (event) => { | ||||||
|         document.createTextNode(formatTime(event.data.time)) |         document.createTextNode(formatTime(event.data.time)) | ||||||
|       ); |       ); | ||||||
| 
 | 
 | ||||||
|       printChatMessage("set-playing", event.user, event.colour, messageContent); |       printChatMessage("set-playing", event.user, 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; |       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,4 +1,4 @@ | ||||||
| import { createSession } from "./watch-session.mjs?v=bfdcf2"; | import { createSession } from "./watch-session.mjs?v=5"; | ||||||
| 
 | 
 | ||||||
| export const setupCreateSessionForm = () => { | export const setupCreateSessionForm = () => { | ||||||
|   const form = document.querySelector("#create-session-form"); |   const form = document.querySelector("#create-session-form"); | ||||||
|  |  | ||||||
|  | @ -1,72 +0,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,5 +1,4 @@ | ||||||
| import { joinSession } from "./watch-session.mjs?v=bfdcf2"; | import { joinSession } from "./watch-session.mjs?v=5"; | ||||||
| import { state } from "./state.mjs"; |  | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * @param {HTMLInputElement} field |  * @param {HTMLInputElement} field | ||||||
|  | @ -24,31 +23,6 @@ const saveNickname = (field) => { | ||||||
|   } |   } | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| /** |  | ||||||
|  * @param {HTMLInputElement} field |  | ||||||
|  */ |  | ||||||
| const loadColour = (field) => { |  | ||||||
|   try { |  | ||||||
|     const savedColour = localStorage.getItem("watch-party-colour"); |  | ||||||
|     if (savedColour != null && savedColour != "") { |  | ||||||
|       field.value = savedColour; |  | ||||||
|     } |  | ||||||
|   } catch (_err) { |  | ||||||
|     // Sometimes localStorage is blocked from use
 |  | ||||||
|   } |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * @param {HTMLInputElement} field |  | ||||||
|  */ |  | ||||||
| const saveColour = (field) => { |  | ||||||
|   try { |  | ||||||
|     localStorage.setItem("watch-party-colour", field.value); |  | ||||||
|   } catch (_err) { |  | ||||||
|     // see loadColour
 |  | ||||||
|   } |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const displayPostCreateMessage = () => { | const displayPostCreateMessage = () => { | ||||||
|   const params = new URLSearchParams(window.location.search); |   const params = new URLSearchParams(window.location.search); | ||||||
|   if (params.get("created") == "true") { |   if (params.get("created") == "true") { | ||||||
|  | @ -62,32 +36,21 @@ export const setupJoinSessionForm = () => { | ||||||
| 
 | 
 | ||||||
|   const form = document.querySelector("#join-session-form"); |   const form = document.querySelector("#join-session-form"); | ||||||
|   const nickname = form.querySelector("#join-session-nickname"); |   const nickname = form.querySelector("#join-session-nickname"); | ||||||
|   const colour = form.querySelector("#join-session-colour"); |  | ||||||
|   const sessionId = form.querySelector("#join-session-id"); |   const sessionId = form.querySelector("#join-session-id"); | ||||||
|   const button = form.querySelector("#join-session-button"); |   const button = form.querySelector("#join-session-button"); | ||||||
| 
 | 
 | ||||||
|   loadNickname(nickname); |   loadNickname(nickname); | ||||||
|   loadColour(colour); |  | ||||||
| 
 | 
 | ||||||
|   if (window.location.hash.match(/#[0-9a-f\-]+/)) { |   if (window.location.hash.match(/#[0-9a-f\-]+/)) { | ||||||
|     sessionId.value = window.location.hash.substring(1); |     sessionId.value = window.location.hash.substring(1); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   form.addEventListener("submit", async (event) => { |   form.addEventListener("submit", (event) => { | ||||||
|     event.preventDefault(); |     event.preventDefault(); | ||||||
| 
 | 
 | ||||||
|     button.disabled = true; |     button.disabled = true; | ||||||
| 
 | 
 | ||||||
|     saveNickname(nickname); |     saveNickname(nickname); | ||||||
|     saveColour(colour); |     joinSession(nickname.value, sessionId.value); | ||||||
|     try { |  | ||||||
|       state().nickname = nickname.value; |  | ||||||
|       state().sessionId = sessionId.value; |  | ||||||
|       state().colour = colour.value.replace(/^#/, ""); |  | ||||||
|       await joinSession(); |  | ||||||
|     } catch (e) { |  | ||||||
|       alert(e.message); |  | ||||||
|       button.disabled = false; |  | ||||||
|     } |  | ||||||
|   }); |   }); | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | @ -1,121 +0,0 @@ | ||||||
| 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]]; |  | ||||||
| } |  | ||||||
|  | @ -1,80 +0,0 @@ | ||||||
| export const pling = () => { |  | ||||||
|   const maxGain = 0.3; |  | ||||||
|   const duration = 0.22; |  | ||||||
|   const fadeDuration = 0.1; |  | ||||||
|   const secondBeepOffset = 0.05; |  | ||||||
|   const thirdBeepOffset = 2 * secondBeepOffset; |  | ||||||
| 
 |  | ||||||
|   const ctx = new AudioContext(); |  | ||||||
| 
 |  | ||||||
|   const firstBeepGain = ctx.createGain(); |  | ||||||
|   firstBeepGain.connect(ctx.destination); |  | ||||||
|   firstBeepGain.gain.setValueAtTime(0.01, ctx.currentTime); |  | ||||||
|   firstBeepGain.gain.exponentialRampToValueAtTime( |  | ||||||
|     maxGain, |  | ||||||
|     ctx.currentTime + fadeDuration |  | ||||||
|   ); |  | ||||||
|   firstBeepGain.gain.setValueAtTime( |  | ||||||
|     maxGain, |  | ||||||
|     ctx.currentTime + (duration - fadeDuration) |  | ||||||
|   ); |  | ||||||
|   firstBeepGain.gain.exponentialRampToValueAtTime( |  | ||||||
|     0.01, |  | ||||||
|     ctx.currentTime + duration |  | ||||||
|   ); |  | ||||||
| 
 |  | ||||||
|   const firstBeep = ctx.createOscillator(); |  | ||||||
|   firstBeep.connect(firstBeepGain); |  | ||||||
|   firstBeep.frequency.value = 400; |  | ||||||
|   firstBeep.type = "sine"; |  | ||||||
| 
 |  | ||||||
|   const secondBeepGain = ctx.createGain(); |  | ||||||
|   secondBeepGain.connect(ctx.destination); |  | ||||||
|   secondBeepGain.gain.setValueAtTime(0.01, ctx.currentTime + secondBeepOffset); |  | ||||||
|   secondBeepGain.gain.exponentialRampToValueAtTime( |  | ||||||
|     maxGain, |  | ||||||
|     ctx.currentTime + secondBeepOffset + fadeDuration |  | ||||||
|   ); |  | ||||||
|   secondBeepGain.gain.setValueAtTime( |  | ||||||
|     maxGain, |  | ||||||
|     ctx.currentTime + secondBeepOffset + (duration - fadeDuration) |  | ||||||
|   ); |  | ||||||
|   secondBeepGain.gain.exponentialRampToValueAtTime( |  | ||||||
|     0.01, |  | ||||||
|     ctx.currentTime + secondBeepOffset + duration |  | ||||||
|   ); |  | ||||||
| 
 |  | ||||||
|   const secondBeep = ctx.createOscillator(); |  | ||||||
|   secondBeep.connect(secondBeepGain); |  | ||||||
|   secondBeep.frequency.value = 600; |  | ||||||
|   secondBeep.type = "sine"; |  | ||||||
| 
 |  | ||||||
|   const thirdBeepGain = ctx.createGain(); |  | ||||||
|   thirdBeepGain.connect(ctx.destination); |  | ||||||
|   thirdBeepGain.gain.setValueAtTime(0.01, ctx.currentTime + thirdBeepOffset); |  | ||||||
|   thirdBeepGain.gain.exponentialRampToValueAtTime( |  | ||||||
|     maxGain, |  | ||||||
|     ctx.currentTime + thirdBeepOffset + fadeDuration |  | ||||||
|   ); |  | ||||||
|   thirdBeepGain.gain.setValueAtTime( |  | ||||||
|     maxGain, |  | ||||||
|     ctx.currentTime + thirdBeepOffset + (duration - fadeDuration) |  | ||||||
|   ); |  | ||||||
|   thirdBeepGain.gain.exponentialRampToValueAtTime( |  | ||||||
|     0.01, |  | ||||||
|     ctx.currentTime + thirdBeepOffset + duration |  | ||||||
|   ); |  | ||||||
| 
 |  | ||||||
|   const thirdBeep = ctx.createOscillator(); |  | ||||||
|   thirdBeep.connect(thirdBeepGain); |  | ||||||
|   thirdBeep.frequency.value = 900; |  | ||||||
|   thirdBeep.type = "sine"; |  | ||||||
| 
 |  | ||||||
|   firstBeep.start(ctx.currentTime); |  | ||||||
|   firstBeep.stop(ctx.currentTime + duration); |  | ||||||
|   secondBeep.start(ctx.currentTime + secondBeepOffset); |  | ||||||
|   secondBeep.stop(ctx.currentTime + (secondBeepOffset + duration)); |  | ||||||
|   thirdBeep.start(ctx.currentTime + thirdBeepOffset); |  | ||||||
|   thirdBeep.stop(ctx.currentTime + (thirdBeepOffset + duration)); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
|  | @ -1,71 +0,0 @@ | ||||||
| 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,7 +0,0 @@ | ||||||
| let instance = null; |  | ||||||
| export const state = () => { |  | ||||||
|   if (!instance) { |  | ||||||
|     instance = {}; |  | ||||||
|   } |  | ||||||
|   return instance; |  | ||||||
| }; |  | ||||||
|  | @ -1,104 +1,34 @@ | ||||||
| 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 {string} videoUrl | ||||||
|  * @param {{name: string, url: string}[]} subtitles |  * @param {{name: string, url: string}[]} subtitles | ||||||
|  */ |  */ | ||||||
| const createVideoElement = (videoUrl, subtitles) => { | const createVideoElement = (videoUrl, subtitles) => { | ||||||
|   const oldVideo = document.getElementById("video"); |  | ||||||
|   if (oldVideo) { |  | ||||||
|     oldVideo.remove(); |  | ||||||
|   } |  | ||||||
|   const video = document.createElement("video"); |   const video = document.createElement("video"); | ||||||
|   video.id = "video"; |  | ||||||
|   video.controls = true; |   video.controls = true; | ||||||
|   video.autoplay = false; |   video.autoplay = false; | ||||||
|   video.volume = loadVolume(); |   video.volume = 0.5; | ||||||
|   video.crossOrigin = "anonymous"; |   video.crossOrigin = "anonymous"; | ||||||
| 
 | 
 | ||||||
|   video.addEventListener("volumechange", async () => { |  | ||||||
|     saveVolume(video.volume); |  | ||||||
|   }); |  | ||||||
| 
 |  | ||||||
|   const source = document.createElement("source"); |   const source = document.createElement("source"); | ||||||
|   source.src = videoUrl; |   source.src = videoUrl; | ||||||
| 
 | 
 | ||||||
|   video.appendChild(source); |   video.appendChild(source); | ||||||
| 
 | 
 | ||||||
|   const storedTrack = loadCaptionTrack(); |   let first = true; | ||||||
|   let id = 0; |  | ||||||
|   for (const { name, url } of subtitles) { |   for (const { name, url } of subtitles) { | ||||||
|     const track = document.createElement("track"); |     const track = document.createElement("track"); | ||||||
|     track.label = name; |     track.label = name; | ||||||
|     track.src = url; |     track.src = url; | ||||||
|     track.kind = "captions"; |     track.kind = "captions"; | ||||||
| 
 | 
 | ||||||
|     if (id == storedTrack || storedTrack == -1) { |     if (first) { | ||||||
|       track.default = true; |       track.default = true; | ||||||
|  |       first = false; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     video.appendChild(track); |     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
 |   // 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
 |   // as far as i can tell this is the least hacky solutions to get control visibility change events
 | ||||||
|   const observer = new MutationObserver(async (mutations) => { |   const observer = new MutationObserver(async (mutations) => { | ||||||
|  | @ -114,16 +44,18 @@ const createVideoElement = (videoUrl, subtitles) => { | ||||||
|           navigator.mediaSession.setActionHandler("seekto", null); |           navigator.mediaSession.setActionHandler("seekto", null); | ||||||
|           navigator.mediaSession.setActionHandler("previoustrack", null); |           navigator.mediaSession.setActionHandler("previoustrack", null); | ||||||
|           navigator.mediaSession.setActionHandler("nexttrack", null); |           navigator.mediaSession.setActionHandler("nexttrack", null); | ||||||
|  |           navigator.mediaSession.setActionHandler("skipad", null); | ||||||
|         } else { |         } else { | ||||||
|           // disable media button support by ignoring the events
 |           // disable media button support by ignoring the events
 | ||||||
|           navigator.mediaSession.setActionHandler("play", () => {}); |           navigator.mediaSession.setActionHandler("play", () => { }); | ||||||
|           navigator.mediaSession.setActionHandler("pause", () => {}); |           navigator.mediaSession.setActionHandler("pause", () => { }); | ||||||
|           navigator.mediaSession.setActionHandler("stop", () => {}); |           navigator.mediaSession.setActionHandler("stop", () => { }); | ||||||
|           navigator.mediaSession.setActionHandler("seekbackward", () => {}); |           navigator.mediaSession.setActionHandler("seekbackward", () => { }); | ||||||
|           navigator.mediaSession.setActionHandler("seekforward", () => {}); |           navigator.mediaSession.setActionHandler("seekforward", () => { }); | ||||||
|           navigator.mediaSession.setActionHandler("seekto", () => {}); |           navigator.mediaSession.setActionHandler("seekto", () => { }); | ||||||
|           navigator.mediaSession.setActionHandler("previoustrack", () => {}); |           navigator.mediaSession.setActionHandler("previoustrack", () => { }); | ||||||
|           navigator.mediaSession.setActionHandler("nexttrack", () => {}); |           navigator.mediaSession.setActionHandler("nexttrack", () => { }); | ||||||
|  |           navigator.mediaSession.setActionHandler("skipad", () => { }); | ||||||
|         } |         } | ||||||
|         return; |         return; | ||||||
|       } |       } | ||||||
|  | @ -143,9 +75,7 @@ const createVideoElement = (videoUrl, subtitles) => { | ||||||
| export const setupVideo = async (videoUrl, subtitles, currentTime, playing) => { | export const setupVideo = async (videoUrl, subtitles, currentTime, playing) => { | ||||||
|   document.querySelector("#pre-join-controls").style["display"] = "none"; |   document.querySelector("#pre-join-controls").style["display"] = "none"; | ||||||
|   const video = createVideoElement(videoUrl, subtitles); |   const video = createVideoElement(videoUrl, subtitles); | ||||||
|   const videoContainer = document.querySelector("#video-container"); |   document.querySelector("#video-container").appendChild(video); | ||||||
|   videoContainer.style.display = "block"; |  | ||||||
|   videoContainer.appendChild(video); |  | ||||||
| 
 | 
 | ||||||
|   video.currentTime = currentTime / 1000.0; |   video.currentTime = currentTime / 1000.0; | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,27 +1,19 @@ | ||||||
| import { setupVideo } from "./video.mjs?v=bfdcf2"; | import { setupVideo } from "./video.mjs?v=5"; | ||||||
| import { | import { setupChat, logEventToChat } from "./chat.mjs?v=5"; | ||||||
|   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} sessionId | ||||||
|  * @param {string} nickname |  * @param {string} nickname | ||||||
|  * @returns {ReconnectingWebSocket} |  * @returns {WebSocket} | ||||||
|  */ |  */ | ||||||
| const createWebSocket = () => { | const createWebSocket = (sessionId, nickname) => { | ||||||
|   const wsUrl = new URL( |   const wsUrl = new URL( | ||||||
|     `/sess/${state().sessionId}/subscribe` + |     `/sess/${sessionId}/subscribe` + | ||||||
|       `?nickname=${encodeURIComponent(state().nickname)}` + |       `?nickname=${encodeURIComponent(nickname)}`, | ||||||
|       `&colour=${encodeURIComponent(state().colour)}`, |  | ||||||
|     window.location.href |     window.location.href | ||||||
|   ); |   ); | ||||||
|   wsUrl.protocol = "ws" + window.location.protocol.slice(4); |   wsUrl.protocol = { "http:": "ws:", "https:": "wss:" }[wsUrl.protocol]; | ||||||
|   const socket = new ReconnectingWebSocket(wsUrl); |   const socket = new WebSocket(wsUrl.toString()); | ||||||
| 
 | 
 | ||||||
|   return socket; |   return socket; | ||||||
| }; | }; | ||||||
|  | @ -29,7 +21,7 @@ const createWebSocket = () => { | ||||||
| let outgoingDebounce = false; | let outgoingDebounce = false; | ||||||
| let outgoingDebounceCallbackId = null; | let outgoingDebounceCallbackId = null; | ||||||
| 
 | 
 | ||||||
| export const setDebounce = () => { | const setDebounce = () => { | ||||||
|   outgoingDebounce = true; |   outgoingDebounce = true; | ||||||
| 
 | 
 | ||||||
|   if (outgoingDebounceCallbackId) { |   if (outgoingDebounceCallbackId) { | ||||||
|  | @ -42,37 +34,23 @@ export const setDebounce = () => { | ||||||
|   }, 500); |   }, 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 {HTMLVideoElement} video | ||||||
|  * @param {ReconnectingWebSocket} socket |  * @param {WebSocket} socket | ||||||
|  */ |  */ | ||||||
| const setupIncomingEvents = (video, socket) => { | const setupIncomingEvents = (video, socket) => { | ||||||
|  |   const setVideoTime = (time) => { | ||||||
|  |     const timeSecs = time / 1000.0; | ||||||
|  | 
 | ||||||
|  |     if (Math.abs(video.currentTime - timeSecs) > 0.5) { | ||||||
|  |       video.currentTime = timeSecs; | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|   socket.addEventListener("message", async (messageEvent) => { |   socket.addEventListener("message", async (messageEvent) => { | ||||||
|     try { |     try { | ||||||
|       const event = JSON.parse(messageEvent.data); |       const event = JSON.parse(messageEvent.data); | ||||||
|  | 
 | ||||||
|       if (!event.reflected) { |       if (!event.reflected) { | ||||||
|         switch (event.op) { |         switch (event.op) { | ||||||
|           case "SetPlaying": |           case "SetPlaying": | ||||||
|  | @ -84,14 +62,12 @@ const setupIncomingEvents = (video, socket) => { | ||||||
|               video.pause(); |               video.pause(); | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|             setVideoTime(event.data.time, video); |             setVideoTime(event.data.time); | ||||||
|  | 
 | ||||||
|             break; |             break; | ||||||
|           case "SetTime": |           case "SetTime": | ||||||
|             setDebounce(); |             setDebounce(); | ||||||
|             setVideoTime(event.data, video); |             setVideoTime(event.data); | ||||||
|             break; |  | ||||||
|           case "UpdateViewerList": |  | ||||||
|             updateViewerList(event.data); |  | ||||||
|             break; |             break; | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|  | @ -103,13 +79,14 @@ const setupIncomingEvents = (video, socket) => { | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * @param {HTMLVideoElement} video |  * @param {HTMLVideoElement} video | ||||||
|  * @param {ReconnectingWebSocket} socket |  * @param {WebSocket} socket | ||||||
|  */ |  */ | ||||||
| const setupOutgoingEvents = (video, socket) => { | const setupOutgoingEvents = (video, socket) => { | ||||||
|   const currentVideoTime = () => (video.currentTime * 1000) | 0; |   const currentVideoTime = () => (video.currentTime * 1000) | 0; | ||||||
| 
 | 
 | ||||||
|   video.addEventListener("pause", async (event) => { |   video.addEventListener("pause", async (event) => { | ||||||
|     if (outgoingDebounce || !video.controls) { |     if (outgoingDebounce || !video.controls) { | ||||||
|  |       event.preventDefault(); | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -131,6 +108,7 @@ const setupOutgoingEvents = (video, socket) => { | ||||||
| 
 | 
 | ||||||
|   video.addEventListener("play", (event) => { |   video.addEventListener("play", (event) => { | ||||||
|     if (outgoingDebounce || !video.controls) { |     if (outgoingDebounce || !video.controls) { | ||||||
|  |       event.preventDefault(); | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -154,108 +132,55 @@ const setupOutgoingEvents = (video, socket) => { | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     if (outgoingDebounce || !video.controls) { |     if (outgoingDebounce || !video.controls) { | ||||||
|  |       event.preventDefault(); | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     socket.send( |     socket.send( | ||||||
|       JSON.stringify({ |       JSON.stringify({ | ||||||
|         op: "SetTime", |         op: "SetTime", | ||||||
|         data: { |         data: currentVideoTime(), | ||||||
|           to: currentVideoTime(), |  | ||||||
|         }, |  | ||||||
|       }) |       }) | ||||||
|     ); |     ); | ||||||
|   }); |   }); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export const joinSession = async () => { | /** | ||||||
|   if (state().activeSession) { |  * @param {string} nickname | ||||||
|     if (state().activeSession === state().sessionId) { |  * @param {string} sessionId | ||||||
|       // we are already in this session, dont rejoin
 |  */ | ||||||
|       return; | export const joinSession = async (nickname, sessionId) => { | ||||||
|     } |  | ||||||
|     // 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 { |   try { | ||||||
|     response = await fetch(`/sess/${state().sessionId}`); |     window.location.hash = 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) { |     const { video_url, subtitle_tracks, current_time_ms, is_playing } = | ||||||
|     state().socket.close(); |       await fetch(`/sess/${sessionId}`).then((r) => r.json()); | ||||||
|     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
 |     const socket = createWebSocket(sessionId, nickname); | ||||||
|     let defaultAllowControls = false; |     socket.addEventListener("open", async () => { | ||||||
|     try { |       const video = await setupVideo( | ||||||
|       defaultAllowControls = localStorage.getItem( |         video_url, | ||||||
|         "watch-party-default-allow-controls" |         subtitle_tracks, | ||||||
|  |         current_time_ms, | ||||||
|  |         is_playing | ||||||
|       ); |       ); | ||||||
|     } catch (_err) {} |  | ||||||
| 
 | 
 | ||||||
|     // By default, we should disable video controls if the video is already playing.
 |       // 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
 |       // This solves an issue where Safari users join and seek to 00:00:00 because of
 | ||||||
|     // outgoing events.
 |       // outgoing events.
 | ||||||
|     if (current_time_ms != 0 || !defaultAllowControls) { |       if (current_time_ms != 0) { | ||||||
|       video.controls = false; |         video.controls = false; | ||||||
|     } |       } | ||||||
| 
 | 
 | ||||||
|     setupOutgoingEvents(video, socket); |       setupOutgoingEvents(video, socket); | ||||||
|     setupIncomingEvents(video, socket); |       setupIncomingEvents(video, socket); | ||||||
|     setupChat(socket); |       setupChat(socket); | ||||||
|   }); |     }); | ||||||
|   socket.addEventListener("reconnecting", (e) => { |     // TODO: Close listener ?
 | ||||||
|     console.log("Reconnecting..."); |   } catch (err) { | ||||||
|   }); |     // TODO: Show an error on the screen
 | ||||||
|   socket.addEventListener("reconnected", (e) => { |     console.error(err); | ||||||
|     console.log("Reconnected."); |   } | ||||||
|   }); |  | ||||||
|   //} catch (e) {
 |  | ||||||
|   //  alert(e.message)
 |  | ||||||
|   //}
 |  | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  |  | ||||||
|  | @ -1,4 +1,4 @@ | ||||||
| import { setupJoinSessionForm } from "./lib/join-session.mjs?v=bfdcf2"; | import { setupJoinSessionForm } from "./lib/join-session.mjs?v=5"; | ||||||
| 
 | 
 | ||||||
| const main = () => { | const main = () => { | ||||||
|   setupJoinSessionForm(); |   setupJoinSessionForm(); | ||||||
|  |  | ||||||
|  | @ -1,30 +1,7 @@ | ||||||
| *, |  | ||||||
| *:before, |  | ||||||
| *:after { |  | ||||||
|   box-sizing: border-box; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| :root { | :root { | ||||||
|   --bg-rgb: 28, 23, 36; |   --bg: rgb(28, 23, 36); | ||||||
|   --fg-rgb: 234, 234, 248; |   --fg: rgb(234, 234, 248); | ||||||
|   --accent-rgb: 181, 127, 220; |   --accent: hsl(275, 57%, 68%); | ||||||
|   --fg: rgb(var(--fg-rgb)); |  | ||||||
|   --bg: rgb(var(--bg-rgb)); |  | ||||||
|   --default-user-color: rgb(126, 208, 255); |  | ||||||
|   --accent: rgb(var(--accent-rgb)); |  | ||||||
|   --fg-transparent: rgba(var(--fg-rgb), 0.25); |  | ||||||
|   --bg-transparent: rgba(var(--bg-rgb), 0.25); |  | ||||||
|   --autocomplete-bg: linear-gradient( |  | ||||||
|       var(--fg-transparent), |  | ||||||
|       var(--fg-transparent) |  | ||||||
|     ), |  | ||||||
|     linear-gradient(var(--bg), var(--bg)); |  | ||||||
|   --chip-bg: linear-gradient( |  | ||||||
|       var(--accent-transparent), |  | ||||||
|       var(--accent-transparent) |  | ||||||
|     ), |  | ||||||
|     linear-gradient(var(--bg), var(--bg)); |  | ||||||
|   --accent-transparent: rgba(var(--accent-rgb), 0.25); |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| html { | html { | ||||||
|  | @ -32,85 +9,44 @@ html { | ||||||
|   color: var(--fg); |   color: var(--fg); | ||||||
|   font-size: 1.125rem; |   font-size: 1.125rem; | ||||||
|   font-family: sans-serif; |   font-family: sans-serif; | ||||||
|  | 
 | ||||||
|  |   overflow-y: scroll; | ||||||
|  |   scrollbar-width: none; | ||||||
|  |   -ms-overflow-style: none; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | ::-webkit-scrollbar { | ||||||
|  |   width: 0; | ||||||
|  |   background: transparent; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| html, | html, | ||||||
| body { | body { | ||||||
|   margin: 0; |   margin: 0; | ||||||
|   padding: 0; |  | ||||||
|   overflow: hidden; |  | ||||||
|   overscroll-behavior: none; |  | ||||||
|   width: 100%; |  | ||||||
|   height: 100%; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| body { |  | ||||||
|   display: flex; |  | ||||||
|   flex-direction: column; |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| video { | video { | ||||||
|   display: block; |   display: block; | ||||||
|   width: 100%; |  | ||||||
|   height: 100%; |  | ||||||
|   object-fit: contain; |  | ||||||
| } |  | ||||||
| 
 | 
 | ||||||
| #video-container { |   width: 100vw; | ||||||
|   flex-grow: 0; |   height: auto; | ||||||
|   flex-shrink: 1; | 
 | ||||||
|   display: none; |   max-width: auto; | ||||||
|  |   max-height: 100vh; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| a { | a { | ||||||
|   color: var(--accent); |   color: var(--accent); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .chip { |  | ||||||
|   color: var(--fg); |  | ||||||
|   background: var(--chip-bg); |  | ||||||
|   text-decoration: none; |  | ||||||
|   padding: 0 0.5rem 0 1.45rem; |  | ||||||
|   display: inline-flex; |  | ||||||
|   position: relative; |  | ||||||
|   font-size: 0.9rem; |  | ||||||
|   height: 1.125rem; |  | ||||||
|   align-items: center; |  | ||||||
|   border-radius: 2rem; |  | ||||||
|   overflow: hidden; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| .chip::before { |  | ||||||
|   content: ""; |  | ||||||
|   position: absolute; |  | ||||||
|   left: 0; |  | ||||||
|   top: 0; |  | ||||||
|   width: 1.125rem; |  | ||||||
|   height: 100%; |  | ||||||
|   display: flex; |  | ||||||
|   align-items: center; |  | ||||||
|   justify-content: center; |  | ||||||
|   text-align: center; |  | ||||||
|   background: var(--accent-transparent); |  | ||||||
|   background-repeat: no-repeat; |  | ||||||
|   background-size: 18px; |  | ||||||
|   background-position: center; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| .join-chip::before { |  | ||||||
|   background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI2ZmZiI+PHBhdGggZD0iTTggNXYxNGwxMS03eiIvPjwvc3ZnPg=="); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| .time-chip::before { |  | ||||||
|   background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI2ZmZiI+PHBhdGggZD0iTTExLjk5IDJDNi40NyAyIDIgNi40OCAyIDEyczQuNDcgMTAgOS45OSAxMEMxNy41MiAyMiAyMiAxNy41MiAyMiAxMlMxNy41MiAyIDExLjk5IDJ6TTEyIDIwYy00LjQyIDAtOC0zLjU4LTgtOHMzLjU4LTggOC04IDggMy41OCA4IDgtMy41OCA4LTggOHoiLz48cGF0aCBkPSJNMTIuNSA3SDExdjZsNS4yNSAzLjE1Ljc1LTEuMjMtNC41LTIuNjd6Ii8+PC9zdmc+"); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| label { | label { | ||||||
|   display: block; |   display: block; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| input[type="url"], | input[type="url"], | ||||||
| input[type="text"] { | input[type="text"] { | ||||||
|  |   box-sizing: border-box; | ||||||
|  | 
 | ||||||
|   background: #fff; |   background: #fff; | ||||||
|   background-clip: padding-box; |   background-clip: padding-box; | ||||||
|   border: 1px solid rgba(0, 0, 0, 0.12); |   border: 1px solid rgba(0, 0, 0, 0.12); | ||||||
|  | @ -124,7 +60,8 @@ input[type="text"] { | ||||||
| 
 | 
 | ||||||
|   font-family: sans-serif; |   font-family: sans-serif; | ||||||
|   font-size: 1em; |   font-size: 1em; | ||||||
|   width: 100%; |   width: 500px; | ||||||
|  |   max-width: 100%; | ||||||
| 
 | 
 | ||||||
|   resize: none; |   resize: none; | ||||||
|   overflow-x: wrap; |   overflow-x: wrap; | ||||||
|  | @ -145,19 +82,12 @@ button { | ||||||
| 
 | 
 | ||||||
|   font-family: sans-serif; |   font-family: sans-serif; | ||||||
|   font-size: 1em; |   font-size: 1em; | ||||||
|   width: 100%; |   width: 500px; | ||||||
|  |   max-width: 100%; | ||||||
| 
 | 
 | ||||||
|   user-select: none; |   user-select: none; | ||||||
|   border: 1px solid rgba(0, 0, 0, 0); |   border: 1px solid rgba(0, 0, 0, 0); | ||||||
|   line-height: 1.5; |   line-height: 1.5; | ||||||
|   cursor: pointer; |  | ||||||
|   margin: 0.5em 0; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| button:disabled { |  | ||||||
|   filter: saturate(0.75); |  | ||||||
|   opacity: 0.75; |  | ||||||
|   cursor: default; |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| button.small-button { | button.small-button { | ||||||
|  | @ -178,30 +108,20 @@ button.small-button { | ||||||
| 
 | 
 | ||||||
| #pre-join-controls, | #pre-join-controls, | ||||||
| #create-controls { | #create-controls { | ||||||
|   margin: 0; |   width: 60%; | ||||||
|   flex-grow: 1; |   margin: 0 auto; | ||||||
|   overflow-y: auto; |   margin-top: 4em; | ||||||
|   display: flex; |  | ||||||
|   flex-direction: column; |  | ||||||
|   align-items: center; |  | ||||||
|   justify-content: center; |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #join-session-form, | #join-session-form, | ||||||
| #create-session-form { | #create-session-form { | ||||||
|   width: 500px; |   margin-bottom: 4em; | ||||||
|   max-width: 100%; |  | ||||||
|   padding: 1rem; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #join-session-form > *:first-child, |  | ||||||
| #create-session-form > *:first-child { |  | ||||||
|   margin-top: 0; |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #post-create-message { | #post-create-message { | ||||||
|   display: none; |   display: none; | ||||||
|   width: 100%; |   width: 500px; | ||||||
|  |   max-width: 100%; | ||||||
|   font-size: 0.85em; |   font-size: 0.85em; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -211,76 +131,42 @@ button.small-button { | ||||||
| 
 | 
 | ||||||
| .chat-message { | .chat-message { | ||||||
|   overflow-wrap: break-word; |   overflow-wrap: break-word; | ||||||
|   margin-bottom: 0.125rem; |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .chat-message > strong, | .chat-message > strong { | ||||||
| #viewer-list strong { |   color: rgb(126, 208, 255); | ||||||
|   color: var(--user-color, var(--default-user-color)); |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .chat-message.user-join, | .chat-message.user-join, | ||||||
| .chat-message.user-leave, | .chat-message.user-leave { | ||||||
| .chat-message.ping { |  | ||||||
|   font-style: italic; |   font-style: italic; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .chat-message.set-time, | .chat-message.set-time, | ||||||
| .chat-message.set-playing, | .chat-message.set-playing { | ||||||
| .chat-message.join-session { |  | ||||||
|   font-style: italic; |   font-style: italic; | ||||||
|   text-align: right; |   text-align: right; | ||||||
|   font-size: 0.85em; |   font-size: 0.85em; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .chat-message.command-message { |  | ||||||
|   font-size: 0.85em; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| .chat-message.set-time > strong, | .chat-message.set-time > strong, | ||||||
| .chat-message.set-playing > strong, | .chat-message.set-playing > strong { | ||||||
| .chat-message.join-session > strong { |   color: unset; | ||||||
|   color: unset !important; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| .emoji { |  | ||||||
|   width: 2ch; |  | ||||||
|   height: 2ch; |  | ||||||
|   object-fit: contain; |  | ||||||
|   margin-bottom: -0.35ch; |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #chatbox { | #chatbox { | ||||||
|   padding: 0.5em 1em; |   padding: 0.5em 2em; | ||||||
|  |   min-height: 8em; | ||||||
|   overflow-y: scroll; |   overflow-y: scroll; | ||||||
|   flex-shrink: 1; |  | ||||||
|   flex-grow: 1; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #viewer-list { |  | ||||||
|   padding: 0.5em 1em; |  | ||||||
|   /* TODO: turn this into max-height instead of fixed height without breaking the chatbox height */ |  | ||||||
|   overflow-y: scroll; |  | ||||||
|   border-bottom: var(--fg-transparent); |  | ||||||
|   border-bottom-style: solid; |  | ||||||
|   max-height: 4rem; |  | ||||||
|   flex-shrink: 0; |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #chatbox-container { | #chatbox-container { | ||||||
|   background-color: var(--bg); |   background-color: #222; | ||||||
|   flex-direction: column; |  | ||||||
|   flex-grow: 1; |  | ||||||
|   flex-shrink: 1; |  | ||||||
|   flex-basis: 36ch; |  | ||||||
|   min-width: 36ch; |  | ||||||
|   overflow: hidden; |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #chatbox-send { | #chatbox-send { | ||||||
|   padding: 0 1em; |   padding: 0 2em; | ||||||
|   padding-bottom: 0.5em; |   padding-bottom: 0.5em; | ||||||
|   position: relative; |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #chatbox-send > input { | #chatbox-send > input { | ||||||
|  | @ -288,110 +174,27 @@ button.small-button { | ||||||
|   width: 100%; |   width: 100%; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #emoji-autocomplete { |  | ||||||
|   position: absolute; |  | ||||||
|   bottom: 3.25rem; |  | ||||||
|   background-image: var(--autocomplete-bg); |  | ||||||
|   border-radius: 6px; |  | ||||||
|   width: calc(100% - 2rem); |  | ||||||
|   max-height: 8.5rem; |  | ||||||
|   overflow-y: auto; |  | ||||||
|   clip-path: inset(0 0 0 0 round 8px); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #emoji-autocomplete:empty { |  | ||||||
|   display: none; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| .emoji-option { |  | ||||||
|   background: transparent; |  | ||||||
|   font-size: 0.75rem; |  | ||||||
|   text-align: left; |  | ||||||
|   margin: 0 0.25rem; |  | ||||||
|   border-radius: 4px; |  | ||||||
|   width: calc(100% - 0.5rem); |  | ||||||
|   display: flex; |  | ||||||
|   align-items: center; |  | ||||||
|   padding: 0.25rem 0.5rem; |  | ||||||
|   scroll-margin: 0.25rem; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| .emoji-option:first-child { |  | ||||||
|   margin-top: 0.25rem; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| .emoji-option:last-child { |  | ||||||
|   margin-bottom: 0.25rem; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| .emoji-option .emoji { |  | ||||||
|   width: 1.25rem; |  | ||||||
|   height: 1.25rem; |  | ||||||
|   margin: 0 0.5rem 0 0; |  | ||||||
|   font-size: 2.25ch; |  | ||||||
|   display: flex; |  | ||||||
|   align-items: center; |  | ||||||
|   justify-content: center; |  | ||||||
|   overflow: hidden; |  | ||||||
|   flex-shrink: 0; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| .emoji-name { |  | ||||||
|   overflow: hidden; |  | ||||||
|   text-overflow: ellipsis; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| .emoji-option.selected { |  | ||||||
|   background: var(--fg-transparent); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #join-session-colour { |  | ||||||
|   -moz-appearance: none; |  | ||||||
|   -webkit-appearance: none; |  | ||||||
|   appearance: none; |  | ||||||
|   border: none; |  | ||||||
|   padding: 0; |  | ||||||
|   border-radius: 6px; |  | ||||||
|   overflow: hidden; |  | ||||||
|   margin: 0.5em 0; |  | ||||||
|   height: 2rem; |  | ||||||
|   width: 2.5rem; |  | ||||||
|   cursor: pointer; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| input[type="color"]::-moz-color-swatch { |  | ||||||
|   border: none; |  | ||||||
|   margin: 0; |  | ||||||
|   padding: 0; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| input[type="color"]::-webkit-color-swatch { |  | ||||||
|   border: none; |  | ||||||
|   margin: 0; |  | ||||||
|   padding: 0; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| input[type="color"]::-webkit-color-swatch-wrapper { |  | ||||||
|   border: none; |  | ||||||
|   margin: 0; |  | ||||||
|   padding: 0; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| @media (min-aspect-ratio: 4/3) { | @media (min-aspect-ratio: 4/3) { | ||||||
|   body { |   #video-container video { | ||||||
|     flex-direction: row; |     width: calc(100vw - 400px); | ||||||
|   } |     position: absolute; | ||||||
| 
 |     height: 100vh; | ||||||
|   #chatbox-container { |     background-color: black; | ||||||
|     height: 100vh !important; |  | ||||||
|     flex-grow: 0; |  | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   #video-container { |   #video-container { | ||||||
|     flex-grow: 1; |     float: left; | ||||||
|  |     height: 100vh; | ||||||
|  |     position: relative; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   #chatbox-container { | ||||||
|  |     float: right; | ||||||
|  |     width: 400px; | ||||||
|  |     height: 100vh !important; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   #chatbox { |   #chatbox { | ||||||
|     height: calc(100vh - 5em - 4em) !important; |     height: calc(100vh - 5em) !important; | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,23 +0,0 @@ | ||||||
| #!/bin/sh |  | ||||||
| set -eu |  | ||||||
| 
 |  | ||||||
| # Get guild ID |  | ||||||
| if [ ! "$1" ];then |  | ||||||
|    echo "You need to provide a Discord Guild ID." |  | ||||||
|    exit 1 |  | ||||||
| else |  | ||||||
|    guild="$1" |  | ||||||
| fi |  | ||||||
| 
 |  | ||||||
| # Get emoji folder |  | ||||||
| emojiFolder="$(readlink -f "$(dirname $0)/../frontend/emojis/")" |  | ||||||
| 
 |  | ||||||
| # Get Discord token |  | ||||||
| printf "Token: " 1>&2 |  | ||||||
| trap 'stty echo' INT EXIT |  | ||||||
| stty -echo |  | ||||||
| read token |  | ||||||
| printf "\n" 1>&2 |  | ||||||
| stty echo |  | ||||||
| 
 |  | ||||||
| curl "https://discord.com/api/v9/guilds/${guild}/emojis" -H "Authorization: $token"  |  jq  --raw-output 'map((if .animated then ".gif" else ".png" end) as $ext | "curl '"'"'https://cdn.discordapp.com/emojis/" + .id + $ext + "?size=48&quality=lossless'"'"' -o '"'${emojiFolder}/"'" + .name + $ext + "'"'"'") | join("\n")' | sh |  | ||||||
|  | @ -1,6 +0,0 @@ | ||||||
| #!/bin/sh |  | ||||||
| 
 |  | ||||||
| # Get emoji folder |  | ||||||
| emojiFolder="$(readlink -f "$(dirname $0)/../frontend/emojis/")" |  | ||||||
| 
 |  | ||||||
| curl 'https://raw.githubusercontent.com/iamcal/emoji-data/master/emoji.json' | jq '. | map(. as $emoji | .short_names | map([., ($emoji.unified | split("-") | map(. | split("") | map(. as $nibble | (["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F"] | index($nibble))) | reduce .[] as $item (0; (. * 16) + $item)) | map(if (. < 65536) then (.) else [55296 - 64 + (. / 1024 | floor), 56320 + (((. / 1024) - (. / 1024 | floor)) * 1024)] end) | flatten(1) | map(("\\u" + ("0000" + ({"str": "", "num": .} | until(.num < 1; {"str": (["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F"][((.num / 16) - (.num / 16 | floor)) * 16] + .str), "num": (.num / 16) | floor})).str)[-4:])) | join("") | "\"" + . + "\"" | fromjson)])) | flatten(1)' --raw-output >"$emojiFolder/unicode.json" |  | ||||||
|  | @ -1,39 +1,20 @@ | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
| 
 | 
 | ||||||
| #[derive(Clone, Serialize, Deserialize)] |  | ||||||
| pub struct Viewer { |  | ||||||
|     #[serde(default, skip_serializing_if = "Option::is_none")] |  | ||||||
|     pub nickname: Option<String>, |  | ||||||
|     #[serde(default, skip_serializing_if = "Option::is_none")] |  | ||||||
|     pub colour: Option<String>, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| #[derive(Clone, Serialize, Deserialize)] | #[derive(Clone, Serialize, Deserialize)] | ||||||
| #[serde(tag = "op", content = "data")] | #[serde(tag = "op", content = "data")] | ||||||
| pub enum WatchEventData { | pub enum WatchEventData { | ||||||
|     SetPlaying { |     SetPlaying { playing: bool, time: u64 }, | ||||||
|         playing: bool, |     SetTime(u64), | ||||||
|         time: u64, |  | ||||||
|     }, |  | ||||||
|     SetTime { |  | ||||||
|         #[serde(default, skip_serializing_if = "Option::is_none")] |  | ||||||
|         from: Option<u64>, |  | ||||||
|         to: u64, |  | ||||||
|     }, |  | ||||||
| 
 | 
 | ||||||
|     UserJoin, |     UserJoin, | ||||||
|     UserLeave, |     UserLeave, | ||||||
|     ChatMessage(String), |     ChatMessage(String), | ||||||
|     Ping(String), |  | ||||||
|     UpdateViewerList(Vec<Viewer>), |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Clone, Serialize, Deserialize)] | #[derive(Clone, Serialize, Deserialize)] | ||||||
| pub struct WatchEvent { | pub struct WatchEvent { | ||||||
|     #[serde(default, skip_serializing_if = "Option::is_none")] |     #[serde(default, skip_serializing_if = "Option::is_none")] | ||||||
|     pub user: Option<String>, |     pub user: Option<String>, | ||||||
|     #[serde(default, skip_serializing_if = "Option::is_none")] |  | ||||||
|     pub colour: Option<String>, |  | ||||||
|     #[serde(flatten)] |     #[serde(flatten)] | ||||||
|     pub data: WatchEventData, |     pub data: WatchEventData, | ||||||
|     #[serde(default)] |     #[serde(default)] | ||||||
|  | @ -41,10 +22,9 @@ pub struct WatchEvent { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl WatchEvent { | impl WatchEvent { | ||||||
|     pub fn new(user: String, colour: String, data: WatchEventData) -> Self { |     pub fn new(user: String, data: WatchEventData) -> Self { | ||||||
|         WatchEvent { |         WatchEvent { | ||||||
|             user: Some(user), |             user: Some(user), | ||||||
|             colour: Some(colour), |  | ||||||
|             data, |             data, | ||||||
|             reflected: false, |             reflected: false, | ||||||
|         } |         } | ||||||
|  |  | ||||||
							
								
								
									
										23
									
								
								src/main.rs
									
									
									
									
									
								
							
							
						
						
									
										23
									
								
								src/main.rs
									
									
									
									
									
								
							|  | @ -6,7 +6,6 @@ use warb::{hyper::StatusCode, Filter, Reply}; | ||||||
| use warp as warb; // i think it's funny
 | use warp as warb; // i think it's funny
 | ||||||
| 
 | 
 | ||||||
| mod events; | mod events; | ||||||
| mod utils; |  | ||||||
| mod viewer_connection; | mod viewer_connection; | ||||||
| mod watch_session; | mod watch_session; | ||||||
| 
 | 
 | ||||||
|  | @ -27,23 +26,6 @@ struct StartSessionBody { | ||||||
| #[derive(Deserialize)] | #[derive(Deserialize)] | ||||||
| struct SubscribeQuery { | struct SubscribeQuery { | ||||||
|     nickname: String, |     nickname: String, | ||||||
|     colour: String, |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| async fn get_emoji_list() -> Result<impl warb::Reply, warb::Rejection> { |  | ||||||
|     use tokio_stream::{wrappers::ReadDirStream, StreamExt}; |  | ||||||
| 
 |  | ||||||
|     let dir = tokio::fs::read_dir("frontend/emojis") |  | ||||||
|         .await |  | ||||||
|         .expect("Couldn't read emojis directory!"); |  | ||||||
| 
 |  | ||||||
|     let files = ReadDirStream::new(dir) |  | ||||||
|         .filter_map(|r| r.ok()) |  | ||||||
|         .map(|e| e.file_name().to_string_lossy().to_string()) |  | ||||||
|         .collect::<Vec<_>>() |  | ||||||
|         .await; |  | ||||||
| 
 |  | ||||||
|     Ok(warb::reply::json(&files)) |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[tokio::main] | #[tokio::main] | ||||||
|  | @ -62,8 +44,6 @@ async fn main() { | ||||||
|             warb::reply::json(&json!({ "id": session_uuid.to_string(), "session": session_view })) |             warb::reply::json(&json!({ "id": session_uuid.to_string(), "session": session_view })) | ||||||
|         }); |         }); | ||||||
| 
 | 
 | ||||||
|     let get_emoji_route = warb::path!("emojos").and_then(get_emoji_list); |  | ||||||
| 
 |  | ||||||
|     enum RequestedSession { |     enum RequestedSession { | ||||||
|         Session(Uuid, WatchSession), |         Session(Uuid, WatchSession), | ||||||
|         Error(warb::reply::WithStatus<warb::reply::Json>), |         Error(warb::reply::WithStatus<warb::reply::Json>), | ||||||
|  | @ -105,7 +85,7 @@ async fn main() { | ||||||
|         .map( |         .map( | ||||||
|             |requested_session, query: SubscribeQuery, ws: warb::ws::Ws| match requested_session { |             |requested_session, query: SubscribeQuery, ws: warb::ws::Ws| match requested_session { | ||||||
|                 RequestedSession::Session(uuid, _) => ws |                 RequestedSession::Session(uuid, _) => ws | ||||||
|                     .on_upgrade(move |ws| ws_subscribe(uuid, query.nickname, query.colour, ws)) |                     .on_upgrade(move |ws| ws_subscribe(uuid, query.nickname, ws)) | ||||||
|                     .into_response(), |                     .into_response(), | ||||||
|                 RequestedSession::Error(error_response) => error_response.into_response(), |                 RequestedSession::Error(error_response) => error_response.into_response(), | ||||||
|             }, |             }, | ||||||
|  | @ -114,7 +94,6 @@ async fn main() { | ||||||
|     let routes = start_session_route |     let routes = start_session_route | ||||||
|         .or(get_status_route) |         .or(get_status_route) | ||||||
|         .or(ws_subscribe_route) |         .or(ws_subscribe_route) | ||||||
|         .or(get_emoji_route) |  | ||||||
|         .or(warb::path::end().and(warb::fs::file("frontend/index.html"))) |         .or(warb::path::end().and(warb::fs::file("frontend/index.html"))) | ||||||
|         .or(warb::fs::dir("frontend")); |         .or(warb::fs::dir("frontend")); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,6 +0,0 @@ | ||||||
| pub fn truncate_str(s: &str, max_chars: usize) -> &str { |  | ||||||
|     match s.char_indices().nth(max_chars) { |  | ||||||
|         None => s, |  | ||||||
|         Some((idx, _)) => &s[..idx], |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  | @ -15,8 +15,7 @@ use uuid::Uuid; | ||||||
| use warp::ws::{Message, WebSocket}; | use warp::ws::{Message, WebSocket}; | ||||||
| 
 | 
 | ||||||
| use crate::{ | use crate::{ | ||||||
|     events::{Viewer, WatchEvent, WatchEventData}, |     events::{WatchEvent, WatchEventData}, | ||||||
|     utils::truncate_str, |  | ||||||
|     watch_session::{get_session, handle_watch_event_data}, |     watch_session::{get_session, handle_watch_event_data}, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | @ -29,10 +28,9 @@ pub struct ConnectedViewer { | ||||||
|     pub viewer_id: usize, |     pub viewer_id: usize, | ||||||
|     pub tx: UnboundedSender<WatchEvent>, |     pub tx: UnboundedSender<WatchEvent>, | ||||||
|     pub nickname: Option<String>, |     pub nickname: Option<String>, | ||||||
|     pub colour: Option<String>, |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| pub async fn ws_subscribe(session_uuid: Uuid, nickname: String, colour: String, ws: WebSocket) { | pub async fn ws_subscribe(session_uuid: Uuid, nickname: String, ws: WebSocket) { | ||||||
|     let viewer_id = NEXT_VIEWER_ID.fetch_add(1, Ordering::Relaxed); |     let viewer_id = NEXT_VIEWER_ID.fetch_add(1, Ordering::Relaxed); | ||||||
|     let (mut viewer_ws_tx, mut viewer_ws_rx) = ws.split(); |     let (mut viewer_ws_tx, mut viewer_ws_rx) = ws.split(); | ||||||
| 
 | 
 | ||||||
|  | @ -50,12 +48,6 @@ pub async fn ws_subscribe(session_uuid: Uuid, nickname: String, colour: String, | ||||||
|         } |         } | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     let mut colour = colour; |  | ||||||
|     if colour.len() != 6 || !colour.chars().all(|x| x.is_ascii_hexdigit()) { |  | ||||||
|         colour = String::from("7ed0ff"); |  | ||||||
|     } |  | ||||||
|     let nickname = truncate_str(&nickname, 50).to_string(); |  | ||||||
| 
 |  | ||||||
|     CONNECTED_VIEWERS.write().await.insert( |     CONNECTED_VIEWERS.write().await.insert( | ||||||
|         viewer_id, |         viewer_id, | ||||||
|         ConnectedViewer { |         ConnectedViewer { | ||||||
|  | @ -63,19 +55,16 @@ pub async fn ws_subscribe(session_uuid: Uuid, nickname: String, colour: String, | ||||||
|             session: session_uuid, |             session: session_uuid, | ||||||
|             tx, |             tx, | ||||||
|             nickname: Some(nickname.clone()), |             nickname: Some(nickname.clone()), | ||||||
|             colour: Some(colour.clone()), |  | ||||||
|         }, |         }, | ||||||
|     ); |     ); | ||||||
| 
 | 
 | ||||||
|     ws_publish( |     ws_publish( | ||||||
|         session_uuid, |         session_uuid, | ||||||
|         None, |         None, | ||||||
|         WatchEvent::new(nickname.clone(), colour.clone(), WatchEventData::UserJoin), |         WatchEvent::new(nickname.clone(), WatchEventData::UserJoin), | ||||||
|     ) |     ) | ||||||
|     .await; |     .await; | ||||||
| 
 | 
 | ||||||
|     update_viewer_list(session_uuid).await; |  | ||||||
| 
 |  | ||||||
|     while let Some(Ok(message)) = viewer_ws_rx.next().await { |     while let Some(Ok(message)) = viewer_ws_rx.next().await { | ||||||
|         let event: WatchEventData = match message |         let event: WatchEventData = match message | ||||||
|             .to_str() |             .to_str() | ||||||
|  | @ -86,23 +75,16 @@ pub async fn ws_subscribe(session_uuid: Uuid, nickname: String, colour: String, | ||||||
|             None => continue, |             None => continue, | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         let session = &mut get_session(session_uuid).unwrap(); |         handle_watch_event_data( | ||||||
| 
 |             session_uuid, | ||||||
|         // server side event modification where neccessary
 |             &mut get_session(session_uuid).unwrap(), | ||||||
|         let event: WatchEventData = match event { |             event.clone(), | ||||||
|             WatchEventData::SetTime { from: _, to } => WatchEventData::SetTime { |         ); | ||||||
|                 from: Some(session.get_time_ms()), |  | ||||||
|                 to, |  | ||||||
|             }, |  | ||||||
|             _ => event, |  | ||||||
|         }; |  | ||||||
| 
 |  | ||||||
|         handle_watch_event_data(session_uuid, session, event.clone()); |  | ||||||
| 
 | 
 | ||||||
|         ws_publish( |         ws_publish( | ||||||
|             session_uuid, |             session_uuid, | ||||||
|             Some(viewer_id), |             Some(viewer_id), | ||||||
|             WatchEvent::new(nickname.clone(), colour.clone(), event), |             WatchEvent::new(nickname.clone(), event), | ||||||
|         ) |         ) | ||||||
|         .await; |         .await; | ||||||
|     } |     } | ||||||
|  | @ -110,12 +92,11 @@ pub async fn ws_subscribe(session_uuid: Uuid, nickname: String, colour: String, | ||||||
|     ws_publish( |     ws_publish( | ||||||
|         session_uuid, |         session_uuid, | ||||||
|         None, |         None, | ||||||
|         WatchEvent::new(nickname.clone(), colour.clone(), WatchEventData::UserLeave), |         WatchEvent::new(nickname.clone(), WatchEventData::UserLeave), | ||||||
|     ) |     ) | ||||||
|     .await; |     .await; | ||||||
| 
 | 
 | ||||||
|     CONNECTED_VIEWERS.write().await.remove(&viewer_id); |     CONNECTED_VIEWERS.write().await.remove(&viewer_id); | ||||||
|     update_viewer_list(session_uuid).await; |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| pub async fn ws_publish(session_uuid: Uuid, skip_viewer_id: Option<usize>, event: WatchEvent) { | pub async fn ws_publish(session_uuid: Uuid, skip_viewer_id: Option<usize>, event: WatchEvent) { | ||||||
|  | @ -130,27 +111,3 @@ pub async fn ws_publish(session_uuid: Uuid, skip_viewer_id: Option<usize>, event | ||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 |  | ||||||
| async fn update_viewer_list(session_uuid: Uuid) { |  | ||||||
|     let mut viewers = Vec::new(); |  | ||||||
| 
 |  | ||||||
|     for viewer in CONNECTED_VIEWERS.read().await.values() { |  | ||||||
|         if viewer.session == session_uuid { |  | ||||||
|             viewers.push(Viewer { |  | ||||||
|                 nickname: viewer.nickname.clone(), |  | ||||||
|                 colour: viewer.colour.clone(), |  | ||||||
|             }) |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     ws_publish( |  | ||||||
|         session_uuid, |  | ||||||
|         None, |  | ||||||
|         WatchEvent::new( |  | ||||||
|             String::from("server"), |  | ||||||
|             String::from(""), |  | ||||||
|             WatchEventData::UpdateViewerList(viewers), |  | ||||||
|         ), |  | ||||||
|     ) |  | ||||||
|     .await; |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  | @ -85,8 +85,8 @@ pub fn handle_watch_event_data( | ||||||
|             watch_session.set_playing(playing, time); |             watch_session.set_playing(playing, time); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         WatchEventData::SetTime { from: _, to } => { |         WatchEventData::SetTime(time) => { | ||||||
|             watch_session.set_time_ms(to); |             watch_session.set_time_ms(time); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         _ => {} |         _ => {} | ||||||
|  |  | ||||||
		Loading…
	
		Reference in a new issue