forked from lavender/watch-party
		
	Compare commits
	
		
			4 commits
		
	
	
		
			options-pa
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| e6d09b14c5 | |||
| 4b61c44d6a | |||
| f3ee2ecc83 | |||
| 1bd7071cec | 
					 22 changed files with 2324 additions and 2500 deletions
				
			
		|  | @ -3,7 +3,7 @@ root = true | ||||||
| [*] | [*] | ||||||
| indent_style = space | indent_style = space | ||||||
| indent_size = 2 | indent_size = 2 | ||||||
| end_of_line = crlf | end_of_line = lf | ||||||
| charset = utf-8 | charset = utf-8 | ||||||
| trim_trailing_whitespace = false | trim_trailing_whitespace = false | ||||||
| insert_final_newline = true | insert_final_newline = true | ||||||
|  |  | ||||||
|  | @ -1,52 +1,52 @@ | ||||||
| <!DOCTYPE html> | <!DOCTYPE html> | ||||||
| <html> | <html> | ||||||
|   <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=4b61c4" /> | ||||||
|   </head> |   </head> | ||||||
| 
 | 
 | ||||||
|   <body> |   <body> | ||||||
|     <noscript> |     <noscript> | ||||||
|       This site will <em>not</em> work without JavaScript, and there's not |       This site will <em>not</em> work without JavaScript, and there's not | ||||||
|       really any way around that :( |       really any way around that :( | ||||||
|     </noscript> |     </noscript> | ||||||
| 
 | 
 | ||||||
|     <div id="create-controls"> |     <div id="create-controls"> | ||||||
|       <form id="create-session-form"> |       <form id="create-session-form"> | ||||||
|         <h2>Create a session</h2> |         <h2>Create a session</h2> | ||||||
| 
 | 
 | ||||||
|         <label for="create-session-video">Video:</label> |         <label for="create-session-video">Video:</label> | ||||||
|         <input |         <input | ||||||
|           type="text" |           type="text" | ||||||
|           id="create-session-video" |           id="create-session-video" | ||||||
|           placeholder="https://video.example.com/example.mp4" |           placeholder="https://video.example.com/example.mp4" | ||||||
|           required |           required | ||||||
|         /> |         /> | ||||||
| 
 | 
 | ||||||
|         <!-- TODO: Ability to add multiple subtitles for different languages --> |         <!-- TODO: Ability to add multiple subtitles for different languages --> | ||||||
|         <label for="create-session-subs">Subtitles:</label> |         <label for="create-session-subs">Subtitles:</label> | ||||||
|         <input |         <input | ||||||
|           type="text" |           type="text" | ||||||
|           id="create-session-subs" |           id="create-session-subs" | ||||||
|           placeholder="https://video.example.com/example.vtt" |           placeholder="https://video.example.com/example.vtt" | ||||||
|         /> |         /> | ||||||
| 
 | 
 | ||||||
|         <label for="create-session-subs-name">Subtitle track name:</label> |         <label for="create-session-subs-name">Subtitle track name:</label> | ||||||
|         <input |         <input | ||||||
|           type="text" |           type="text" | ||||||
|           id="create-session-subs-name" |           id="create-session-subs-name" | ||||||
|           placeholder="English" |           placeholder="English" | ||||||
|         /> |         /> | ||||||
|         <button>Create</button> |         <button>Create</button> | ||||||
| 
 | 
 | ||||||
|         <p> |         <p> | ||||||
|           Already have a session? |           Already have a session? | ||||||
|           <a href="/">Join your session</a> instead. |           <a href="/">Join your session</a> instead. | ||||||
|         </p> |         </p> | ||||||
|       </form> |       </form> | ||||||
|     </div> |     </div> | ||||||
| 
 | 
 | ||||||
|     <script type="module" src="/create.mjs?v=bfdcf2"></script> |     <script type="module" src="/create.mjs?v=4b61c4"></script> | ||||||
|   </body> |   </body> | ||||||
| </html> | </html> | ||||||
|  |  | ||||||
|  | @ -1,11 +1,11 @@ | ||||||
| import { setupCreateSessionForm } from "./lib/create-session.mjs?v=bfdcf2"; | import { setupCreateSessionForm } from "./lib/create-session.mjs?v=4b61c4"; | ||||||
| 
 | 
 | ||||||
| const main = () => { | const main = () => { | ||||||
|   setupCreateSessionForm(); |   setupCreateSessionForm(); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| if (document.readyState === "complete") { | if (document.readyState === "complete") { | ||||||
|   main(); |   main(); | ||||||
| } else { | } else { | ||||||
|   document.addEventListener("DOMContentLoaded", main); |   document.addEventListener("DOMContentLoaded", main); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,119 +1,85 @@ | ||||||
| <!DOCTYPE html> | <!DOCTYPE html> | ||||||
| <html> | <html> | ||||||
|   <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="/lib/plyr-3.7.3.css" /> | ||||||
|   </head> |     <link rel="stylesheet" href="/styles.css?v=4b61c4" /> | ||||||
| 
 |   </head> | ||||||
|   <body> | 
 | ||||||
|     <noscript> |   <body> | ||||||
|       This site will <em>not</em> work without JavaScript, and there's not |     <noscript> | ||||||
|       really any way around that :( |       This site will <em>not</em> work without JavaScript, and there's not | ||||||
|     </noscript> |       really any way around that :( | ||||||
| 
 |     </noscript> | ||||||
|     <div id="pre-join-controls"> | 
 | ||||||
|       <form id="join-session-form"> |     <div id="pre-join-controls"> | ||||||
|         <h2>Join a session</h2> |       <form id="join-session-form"> | ||||||
| 
 |         <h2>Join a session</h2> | ||||||
|         <p id="post-create-message"> | 
 | ||||||
|           Your session has been created successfully. Copy the current url or |         <p id="post-create-message"> | ||||||
|           the Session ID below and share it with your friends. :) |           Your session has been created successfully. Copy the current url or | ||||||
|         </p> |           the Session ID below and share it with your friends. :) | ||||||
| 
 |         </p> | ||||||
|         <label for="join-session-nickname">Nickname:</label> | 
 | ||||||
|         <input |         <label for="join-session-nickname">Nickname:</label> | ||||||
|           type="text" |         <input | ||||||
|           id="join-session-nickname" |           type="text" | ||||||
|           placeholder="Nickname" |           id="join-session-nickname" | ||||||
|           maxlength="50" |           placeholder="Nickname" | ||||||
|           required |           maxlength="50" | ||||||
|         /> |           required | ||||||
| 
 |         /> | ||||||
|         <label id="join-session-colour-label" for="join-session-colour"> | 
 | ||||||
|           Personal Colour: |         <label id="join-session-colour-label" for="join-session-colour"> | ||||||
|         </label> |           Personal Colour: | ||||||
|         <input type="color" id="join-session-colour" value="#ffffff" required /> |         </label> | ||||||
| 
 |         <input type="color" id="join-session-colour" value="#ffffff" required /> | ||||||
|         <label for="join-session-id">Session ID:</label> | 
 | ||||||
|         <input |         <label for="join-session-id">Session ID:</label> | ||||||
|           type="text" |         <input | ||||||
|           id="join-session-id" |           type="text" | ||||||
|           placeholder="123e4567-e89b-12d3-a456-426614174000" |           id="join-session-id" | ||||||
|           required |           placeholder="123e4567-e89b-12d3-a456-426614174000" | ||||||
|         /> |           required | ||||||
|         <button id="join-session-button">Join</button> |         /> | ||||||
| 
 |         <button id="join-session-button">Join</button> | ||||||
|         <p> | 
 | ||||||
|           No session to join? |         <p> | ||||||
|           <a href="/create.html">Create a session</a> instead. |           No session to join? | ||||||
|         </p> |           <a href="/create.html">Create a session</a> instead. | ||||||
|       </form> |         </p> | ||||||
|     </div> |       </form> | ||||||
| 
 |     </div> | ||||||
|     <div id="video-container"></div> | 
 | ||||||
|     <div id="chatbox-container"> |     <div id="video-container"></div> | ||||||
|       <section id="viewing"> |     <div id="chatbox-container"> | ||||||
|         <div id="viewer-list"></div> |       <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" |           type="text" | ||||||
|             placeholder="Message... (/help for commands)" |           placeholder="Message... (/help for commands)" | ||||||
|             list="emoji-autocomplete" |           list="emoji-autocomplete" | ||||||
|           /> |         /> | ||||||
|           <div id="emoji-autocomplete"></div> |         <div id="emoji-autocomplete"></div> | ||||||
|           <!-- DO NOT ADD SPACING INSIDE THE TAG IT WILL BREAK THE CSS kthxbye --> |         <!-- DO NOT ADD SPACING INSIDE THE TAG IT WILL BREAK THE CSS kthxbye --> | ||||||
|         </form> |       </form> | ||||||
|       </section> |     </div> | ||||||
|       <section id="options"> | 
 | ||||||
|         <h2>settings</h2> |     <script type="module" src="/main.mjs?v=4b61c4"></script> | ||||||
|         <hr /> |     <script> | ||||||
|         <form id="options-form"> |       const updateColourLabel = () => { | ||||||
|           <label for="plingVolume" |         const colour = document.querySelector("#join-session-colour").value; | ||||||
|             ><input |         document.querySelector( | ||||||
|               type="range" |           "#join-session-colour-label" | ||||||
|               min="0" |         ).textContent = `Personal Colour: ${colour}`; | ||||||
|               max="100" |       }; | ||||||
|               value="100" | 
 | ||||||
|               id="plingVolume" |       document | ||||||
|               onchange="handlePlingVolume(this)" |         .querySelector("#join-session-colour") | ||||||
|             /> |         .addEventListener("input", updateColourLabel); | ||||||
|             ping volume</label |       updateColourLabel(); | ||||||
|           > |     </script> | ||||||
|           <label |   </body> | ||||||
|           ><input | </html> | ||||||
|             id="playerControlsShown" |  | ||||||
|             type="checkbox" |  | ||||||
|             onchange="togglePlayerControlsShown(this)" |  | ||||||
|           />hide controls when loading video player</label |  | ||||||
|         > |  | ||||||
|         </form> |  | ||||||
|       </section> |  | ||||||
|       <div id="options-toggle"> |  | ||||||
|         <button |  | ||||||
|           aria-label="settings" |  | ||||||
|           id="options-icon" |  | ||||||
|           onclick="toggleOptionPane(event, this)" |  | ||||||
|         > |  | ||||||
|           ⚙️ |  | ||||||
|         </button> |  | ||||||
|       </div> |  | ||||||
|     </div> |  | ||||||
| 
 |  | ||||||
|     <script type="module" src="/main.mjs?v=bfdcf2"></script> |  | ||||||
|     <script> |  | ||||||
|       const updateColourLabel = () => { |  | ||||||
|         const colour = document.querySelector("#join-session-colour").value; |  | ||||||
|         document.querySelector( |  | ||||||
|           "#join-session-colour-label" |  | ||||||
|         ).textContent = `Personal Colour: ${colour}`; |  | ||||||
|       }; |  | ||||||
| 
 |  | ||||||
|       document |  | ||||||
|         .querySelector("#join-session-colour") |  | ||||||
|         .addEventListener("input", updateColourLabel); |  | ||||||
|       updateColourLabel(); |  | ||||||
|     </script> |  | ||||||
|   </body> |  | ||||||
| </html> |  | ||||||
|  |  | ||||||
|  | @ -1,459 +1,453 @@ | ||||||
| import { | import { | ||||||
|   setDebounce, |   setDebounce, | ||||||
|   setVideoTime, |   setVideoTime, | ||||||
|   setPlaying, |   setPlaying, | ||||||
| } from "./watch-session.mjs?v=bfdcf2"; |   sync, | ||||||
| import { emojify, findEmojis } from "./emojis.mjs?v=bfdcf2"; | } from "./watch-session.mjs?v=4b61c4"; | ||||||
| import { linkify } from "./links.mjs?v=bfdcf2"; | import { emojify, findEmojis } from "./emojis.mjs?v=4b61c4"; | ||||||
| import { joinSession } from "./watch-session.mjs?v=bfdcf2"; | import { linkify } from "./links.mjs?v=4b61c4"; | ||||||
| import { pling } from "./pling.mjs?v=bfdcf2"; | import { joinSession } from "./watch-session.mjs?v=4b61c4"; | ||||||
| import { state } from "./state.mjs"; | import { pling } from "./pling.mjs?v=4b61c4"; | ||||||
| 
 | import { state } from "./state.mjs"; | ||||||
| function setCaretPosition(elem, caretPos) { | 
 | ||||||
|   if (elem.createTextRange) { | function setCaretPosition(elem, caretPos) { | ||||||
|     var range = elem.createTextRange(); |   if (elem.createTextRange) { | ||||||
|     range.move("character", caretPos); |     var range = elem.createTextRange(); | ||||||
|     range.select(); |     range.move("character", caretPos); | ||||||
|   } else { |     range.select(); | ||||||
|     if (elem.selectionStart) { |   } else { | ||||||
|       elem.focus(); |     if (elem.selectionStart) { | ||||||
|       elem.setSelectionRange(caretPos, caretPos); |       elem.focus(); | ||||||
|     } else elem.focus(); |       elem.setSelectionRange(caretPos, caretPos); | ||||||
|   } |     } else elem.focus(); | ||||||
| } |   } | ||||||
| 
 | } | ||||||
| const setupChatboxEvents = (socket) => { | 
 | ||||||
|   // clear events by just reconstructing the form
 | const setupChatboxEvents = (socket) => { | ||||||
|   const oldChatForm = document.querySelector("#chatbox-send"); |   // clear events by just reconstructing the form
 | ||||||
|   const chatForm = oldChatForm.cloneNode(true); |   const oldChatForm = document.querySelector("#chatbox-send"); | ||||||
|   const messageInput = chatForm.querySelector("input"); |   const chatForm = oldChatForm.cloneNode(true); | ||||||
|   const emojiAutocomplete = chatForm.querySelector("#emoji-autocomplete"); |   const messageInput = chatForm.querySelector("input"); | ||||||
|   oldChatForm.replaceWith(chatForm); |   const emojiAutocomplete = chatForm.querySelector("#emoji-autocomplete"); | ||||||
| 
 |   oldChatForm.replaceWith(chatForm); | ||||||
|   let autocompleting = false, | 
 | ||||||
|     showListTimer; |   let autocompleting = false, | ||||||
| 
 |     showListTimer; | ||||||
|   const replaceMessage = (message) => () => { | 
 | ||||||
|     messageInput.value = message; |   const replaceMessage = (message) => () => { | ||||||
|     autocomplete(); |     messageInput.value = message; | ||||||
|   }; |     autocomplete(); | ||||||
|   async function autocomplete(fromListTimeout) { |   }; | ||||||
|     if (autocompleting) return; |   async function autocomplete(fromListTimeout) { | ||||||
|     try { |     if (autocompleting) return; | ||||||
|       clearInterval(showListTimer); |     try { | ||||||
|       emojiAutocomplete.textContent = ""; |       clearInterval(showListTimer); | ||||||
|       autocompleting = true; |       emojiAutocomplete.textContent = ""; | ||||||
|       let text = messageInput.value.slice(0, messageInput.selectionStart); |       autocompleting = true; | ||||||
|       const match = text.match(/(:[^\s:]+)?:([^\s:]{2,})$/); |       let text = messageInput.value.slice(0, messageInput.selectionStart); | ||||||
|       if (!match || match[1]) return (autocompleting = false); // We don't need to autocomplete.
 |       const match = text.match(/(:[^\s:]+)?:([^\s:]{2,})$/); | ||||||
|       const prefix = text.slice(0, match.index); |       if (!match || match[1]) return (autocompleting = false); // We don't need to autocomplete.
 | ||||||
|       const search = text.slice(match.index + 1); |       const prefix = text.slice(0, match.index); | ||||||
|       if (search.length < 1 && !fromListTimeout) { |       const search = text.slice(match.index + 1); | ||||||
|         autocompleting = false; |       if (search.length < 1 && !fromListTimeout) { | ||||||
|         showListTimer = setTimeout(() => autocomplete(true), 500); |         autocompleting = false; | ||||||
|         return; |         showListTimer = setTimeout(() => autocomplete(true), 500); | ||||||
|       } |         return; | ||||||
|       const suffix = messageInput.value.slice(messageInput.selectionStart); |       } | ||||||
|       let selected; |       const suffix = messageInput.value.slice(messageInput.selectionStart); | ||||||
|       const select = (button) => { |       let selected; | ||||||
|         if (selected) selected.classList.remove("selected"); |       const select = (button) => { | ||||||
|         selected = button; |         if (selected) selected.classList.remove("selected"); | ||||||
|         button.classList.add("selected"); |         selected = button; | ||||||
|       }; |         button.classList.add("selected"); | ||||||
|       let results = await findEmojis(search); |       }; | ||||||
|       let yieldAt = performance.now() + 13; |       let results = await findEmojis(search); | ||||||
|       for (let i = 0; i < results.length; i += 100) { |       let yieldAt = performance.now() + 13; | ||||||
|         emojiAutocomplete.append.apply( |       for (let i = 0; i < results.length; i += 100) { | ||||||
|           emojiAutocomplete, |         emojiAutocomplete.append.apply( | ||||||
|           results.slice(i, i + 100).map(([name, replaceWith, ext], i) => { |           emojiAutocomplete, | ||||||
|             const button = Object.assign(document.createElement("button"), { |           results.slice(i, i + 100).map(([name, replaceWith, ext], i) => { | ||||||
|               className: "emoji-option", |             const button = Object.assign(document.createElement("button"), { | ||||||
|               onmousedown: (e) => e.preventDefault(), |               className: "emoji-option", | ||||||
|               onclick: () => { |               onmousedown: (e) => e.preventDefault(), | ||||||
|                 messageInput.value = prefix + replaceWith + " " + suffix; |               onclick: () => { | ||||||
|                 setCaretPosition( |                 messageInput.value = prefix + replaceWith + " " + suffix; | ||||||
|                   messageInput, |                 setCaretPosition( | ||||||
|                   (prefix + " " + replaceWith).length |                   messageInput, | ||||||
|                 ); |                   (prefix + " " + replaceWith).length | ||||||
|               }, |                 ); | ||||||
|               onmouseover: () => select(button), |               }, | ||||||
|               onfocus: () => select(button), |               onmouseover: () => select(button), | ||||||
|               type: "button", |               onfocus: () => select(button), | ||||||
|               title: name, |               type: "button", | ||||||
|             }); |               title: name, | ||||||
|             button.append( |             }); | ||||||
|               replaceWith[0] !== ":" |             button.append( | ||||||
|                 ? Object.assign(document.createElement("span"), { |               replaceWith[0] !== ":" | ||||||
|                     textContent: replaceWith, |                 ? Object.assign(document.createElement("span"), { | ||||||
|                     className: "emoji", |                     textContent: replaceWith, | ||||||
|                   }) |                     className: "emoji", | ||||||
|                 : Object.assign(new Image(), { |                   }) | ||||||
|                     loading: "lazy", |                 : Object.assign(new Image(), { | ||||||
|                     src: `/emojis/${name}${ext}`, |                     loading: "lazy", | ||||||
|                     className: "emoji", |                     src: `/emojis/${name}${ext}`, | ||||||
|                   }), |                     className: "emoji", | ||||||
|               Object.assign(document.createElement("span"), { |                   }), | ||||||
|                 textContent: name, |               Object.assign(document.createElement("span"), { | ||||||
|                 className: "emoji-name", |                 textContent: name, | ||||||
|               }) |                 className: "emoji-name", | ||||||
|             ); |               }) | ||||||
|             return button; |             ); | ||||||
|           }) |             return button; | ||||||
|         ); |           }) | ||||||
|         if (i == 0 && emojiAutocomplete.children[0]) { |         ); | ||||||
|           emojiAutocomplete.children[0].scrollIntoView(); |         if (i == 0 && emojiAutocomplete.children[0]) { | ||||||
|           select(emojiAutocomplete.children[0]); |           emojiAutocomplete.children[0].scrollIntoView(); | ||||||
|         } |           select(emojiAutocomplete.children[0]); | ||||||
|         const now = performance.now(); |         } | ||||||
|         if (now > yieldAt) { |         const now = performance.now(); | ||||||
|           yieldAt = now + 13; |         if (now > yieldAt) { | ||||||
|           await new Promise((cb) => setTimeout(cb, 0)); |           yieldAt = now + 13; | ||||||
|         } |           await new Promise((cb) => setTimeout(cb, 0)); | ||||||
|       } |         } | ||||||
|       autocompleting = false; |       } | ||||||
|     } catch (e) { |       autocompleting = false; | ||||||
|       autocompleting = false; |     } catch (e) { | ||||||
|     } |       autocompleting = false; | ||||||
|   } |     } | ||||||
|   messageInput.addEventListener("input", () => autocomplete()); |   } | ||||||
|   messageInput.addEventListener("selectionchange", () => autocomplete()); |   messageInput.addEventListener("input", () => autocomplete()); | ||||||
|   messageInput.addEventListener("keydown", (event) => { |   messageInput.addEventListener("selectionchange", () => autocomplete()); | ||||||
|     if (event.key == "ArrowUp" || event.key == "ArrowDown") { |   messageInput.addEventListener("keydown", (event) => { | ||||||
|       let selected = document.querySelector(".emoji-option.selected"); |     if (event.key == "ArrowUp" || event.key == "ArrowDown") { | ||||||
|       if (!selected) return; |       let selected = document.querySelector(".emoji-option.selected"); | ||||||
|       event.preventDefault(); |       if (!selected) return; | ||||||
|       selected.classList.remove("selected"); |       event.preventDefault(); | ||||||
|       selected = |       selected.classList.remove("selected"); | ||||||
|         event.key == "ArrowDown" |       selected = | ||||||
|           ? selected.nextElementSibling || selected.parentElement.children[0] |         event.key == "ArrowDown" | ||||||
|           : selected.previousElementSibling || |           ? selected.nextElementSibling || selected.parentElement.children[0] | ||||||
|             selected.parentElement.children[ |           : selected.previousElementSibling || | ||||||
|               selected.parentElement.children.length - 1 |             selected.parentElement.children[ | ||||||
|             ]; |               selected.parentElement.children.length - 1 | ||||||
|       selected.classList.add("selected"); |             ]; | ||||||
|       selected.scrollIntoView({ scrollMode: "if-needed", block: "nearest" }); |       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 (event.key == "Tab" || event.key == "Enter") { | ||||||
|       if (!selected) return; |       let selected = document.querySelector(".emoji-option.selected"); | ||||||
|       event.preventDefault(); |       if (!selected) return; | ||||||
|       selected.onclick(); |       event.preventDefault(); | ||||||
|     } |       selected.onclick(); | ||||||
|   }); |     } | ||||||
| 
 |   }); | ||||||
|   chatForm.addEventListener("submit", async (e) => { | 
 | ||||||
|     e.preventDefault(); |   chatForm.addEventListener("submit", async (e) => { | ||||||
|     const content = messageInput.value; |     e.preventDefault(); | ||||||
|     if (content.trim().length) { |     const content = messageInput.value; | ||||||
|       messageInput.value = ""; |     if (content.trim().length) { | ||||||
| 
 |       messageInput.value = ""; | ||||||
|       // handle commands
 | 
 | ||||||
|       if (content.startsWith("/")) { |       // handle commands
 | ||||||
|         const command = content.toLowerCase().match(/^\/\S+/)[0]; |       if (content.startsWith("/")) { | ||||||
|         const args = content.slice(command.length).trim(); |         const command = content.toLowerCase().match(/^\/\S+/)[0]; | ||||||
| 
 |         const args = content.slice(command.length).trim(); | ||||||
|         let handled = false; | 
 | ||||||
|         switch (command) { |         let handled = false; | ||||||
|           case "/ping": |         switch (command) { | ||||||
|             socket.send( |           case "/ping": | ||||||
|               JSON.stringify({ |             socket.send( | ||||||
|                 op: "Ping", |               JSON.stringify({ | ||||||
|                 data: args, |                 op: "Ping", | ||||||
|               }) |                 data: args, | ||||||
|             ); |               }) | ||||||
|             handled = true; |             ); | ||||||
|             break; |             handled = true; | ||||||
|           case "/sync": |             break; | ||||||
|             const sessionId = window.location.hash.slice(1); |           case "/sync": | ||||||
|             const { current_time_ms, is_playing } = await fetch( |             await sync(); | ||||||
|               `/sess/${sessionId}` | 
 | ||||||
|             ).then((r) => r.json()); |             const syncMessageContent = document.createElement("span"); | ||||||
| 
 |             syncMessageContent.appendChild( | ||||||
|             setDebounce(); |               document.createTextNode("resynced you to ") | ||||||
|             setPlaying(is_playing); |             ); | ||||||
|             setVideoTime(current_time_ms); |             syncMessageContent.appendChild( | ||||||
| 
 |               document.createTextNode(formatTime(current_time_ms)) | ||||||
|             const syncMessageContent = document.createElement("span"); |             ); | ||||||
|             syncMessageContent.appendChild( |             printChatMessage("set-time", "/sync", "b57fdc", syncMessageContent); | ||||||
|               document.createTextNode("resynced you to ") |             handled = true; | ||||||
|             ); |             break; | ||||||
|             syncMessageContent.appendChild( |           case "/shrug": | ||||||
|               document.createTextNode(formatTime(current_time_ms)) |             socket.send( | ||||||
|             ); |               JSON.stringify({ | ||||||
|             printChatMessage("set-time", "/sync", "b57fdc", syncMessageContent); |                 op: "ChatMessage", | ||||||
|             handled = true; |                 data: `${args} ¯\\_(ツ)_/¯`.trim(), | ||||||
|             break; |               }) | ||||||
|           case "/shrug": |             ); | ||||||
|             socket.send( |             handled = true; | ||||||
|               JSON.stringify({ |             break; | ||||||
|                 op: "ChatMessage", |           case "/join": | ||||||
|                 data: `${args} ¯\\_(ツ)_/¯`.trim(), |             state().sessionId = args; | ||||||
|               }) |             joinSession(); | ||||||
|             ); |             handled = true; | ||||||
|             handled = true; |             break; | ||||||
|             break; |           case "/help": | ||||||
|           case "/join": |             const helpMessageContent = document.createElement("span"); | ||||||
|             state().sessionId = args; |             helpMessageContent.innerHTML = | ||||||
|             joinSession(); |               "Available commands:<br>" + | ||||||
|             handled = true; |               " <code>/help</code> - display this help message<br>" + | ||||||
|             break; |               " <code>/ping [message]</code> - ping all viewers<br>" + | ||||||
|           case "/help": |               " <code>/sync</code> - resyncs you with other viewers<br>" + | ||||||
|             const helpMessageContent = document.createElement("span"); |               " <code>/shrug</code> - appends ¯\\_(ツ)_/¯ to your message<br>" + | ||||||
|             helpMessageContent.innerHTML = |               " <code>/join [session id]</code> - joins another session"; | ||||||
|               "Available commands:<br>" + | 
 | ||||||
|               " <code>/help</code> - display this help message<br>" + |             printChatMessage( | ||||||
|               " <code>/ping [message]</code> - ping all viewers<br>" + |               "command-message", | ||||||
|               " <code>/sync</code> - resyncs you with other viewers<br>" + |               "/help", | ||||||
|               " <code>/shrug</code> - appends ¯\\_(ツ)_/¯ to your message<br>" + |               "b57fdc", | ||||||
|               " <code>/join [session id]</code> - joins another session"; |               helpMessageContent | ||||||
| 
 |             ); | ||||||
|             printChatMessage( |             handled = true; | ||||||
|               "command-message", |             break; | ||||||
|               "/help", |           default: | ||||||
|               "b57fdc", |             break; | ||||||
|               helpMessageContent |         } | ||||||
|             ); | 
 | ||||||
|             handled = true; |         if (handled) { | ||||||
|             break; |           return; | ||||||
|           default: |         } | ||||||
|             break; |       } | ||||||
|         } | 
 | ||||||
| 
 |       // handle regular chat messages
 | ||||||
|         if (handled) { |       socket.send( | ||||||
|           return; |         JSON.stringify({ | ||||||
|         } |           op: "ChatMessage", | ||||||
|       } |           data: content, | ||||||
| 
 |         }) | ||||||
|       // handle regular chat messages
 |       ); | ||||||
|       socket.send( |     } | ||||||
|         JSON.stringify({ |   }); | ||||||
|           op: "ChatMessage", | }; | ||||||
|           data: content, | 
 | ||||||
|         }) | /** | ||||||
|       ); |  * @param {WebSocket} socket | ||||||
|     } |  */ | ||||||
|   }); | export const setupChat = async (socket) => { | ||||||
| }; |   document.querySelector("#chatbox-container").style["display"] = "flex"; | ||||||
| 
 |   setupChatboxEvents(socket); | ||||||
| /** | }; | ||||||
|  * @param {WebSocket} socket | 
 | ||||||
|  */ | const addToChat = (node) => { | ||||||
| export const setupChat = async (socket) => { |   const chatbox = document.querySelector("#chatbox"); | ||||||
|   document.querySelector("#chatbox-container").style["display"] = "flex"; |   chatbox.appendChild(node); | ||||||
|   setupChatboxEvents(socket); |   chatbox.scrollTop = chatbox.scrollHeight; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const addToChat = (node) => { | let lastTimeMs = null; | ||||||
|   const chatbox = document.querySelector("#chatbox"); | let lastPlaying = false; | ||||||
|   chatbox.appendChild(node); | 
 | ||||||
|   chatbox.scrollTop = chatbox.scrollHeight; | const checkDebounce = (event) => { | ||||||
| }; |   let timeMs = null; | ||||||
| 
 |   let playing = null; | ||||||
| let lastTimeMs = null; |   if (event.op == "SetTime") { | ||||||
| let lastPlaying = false; |     timeMs = event.data; | ||||||
| 
 |   } else if (event.op == "SetPlaying") { | ||||||
| const checkDebounce = (event) => { |     timeMs = event.data.time; | ||||||
|   let timeMs = null; |     playing = event.data.playing; | ||||||
|   let playing = null; |   } | ||||||
|   if (event.op == "SetTime") { | 
 | ||||||
|     timeMs = event.data; |   let shouldIgnore = false; | ||||||
|   } else if (event.op == "SetPlaying") { | 
 | ||||||
|     timeMs = event.data.time; |   if (timeMs != null) { | ||||||
|     playing = event.data.playing; |     if (lastTimeMs && Math.abs(lastTimeMs - timeMs) < 500) { | ||||||
|   } |       shouldIgnore = true; | ||||||
| 
 |     } | ||||||
|   let shouldIgnore = false; |     lastTimeMs = timeMs; | ||||||
| 
 |   } | ||||||
|   if (timeMs != null) { | 
 | ||||||
|     if (lastTimeMs && Math.abs(lastTimeMs - timeMs) < 500) { |   if (playing != null) { | ||||||
|       shouldIgnore = true; |     if (lastPlaying != playing) { | ||||||
|     } |       shouldIgnore = false; | ||||||
|     lastTimeMs = timeMs; |     } | ||||||
|   } |     lastPlaying = playing; | ||||||
| 
 |   } | ||||||
|   if (playing != null) { | 
 | ||||||
|     if (lastPlaying != playing) { |   return shouldIgnore; | ||||||
|       shouldIgnore = false; | }; | ||||||
|     } | 
 | ||||||
|     lastPlaying = playing; | /** | ||||||
|   } |  * @returns {string} | ||||||
| 
 |  */ | ||||||
|   return shouldIgnore; | const getCurrentTimestamp = () => { | ||||||
| }; |   const t = new Date(); | ||||||
| 
 |   return `${matpad(t.getHours())}:${matpad(t.getMinutes())}:${matpad( | ||||||
| /** |     t.getSeconds() | ||||||
|  * @returns {string} |   )}`;
 | ||||||
|  */ | }; | ||||||
| const getCurrentTimestamp = () => { | 
 | ||||||
|   const t = new Date(); | /** | ||||||
|   return `${matpad(t.getHours())}:${matpad(t.getMinutes())}:${matpad( |  * https://media.discordapp.net/attachments/834541919568527361/931678814751301632/66d2c68c48daa414c96951381665ec2e.png
 | ||||||
|     t.getSeconds() |  */ | ||||||
|   )}`;
 | const matpad = (n) => { | ||||||
| }; |   return ("00" + n).slice(-2); | ||||||
| 
 | }; | ||||||
| /** | 
 | ||||||
|  * https://media.discordapp.net/attachments/834541919568527361/931678814751301632/66d2c68c48daa414c96951381665ec2e.png
 | /** | ||||||
|  */ |  * @param {string} eventType | ||||||
| const matpad = (n) => { |  * @param {string?} user | ||||||
|   return ("00" + n).slice(-2); |  * @param {Node?} content | ||||||
| }; |  */ | ||||||
| 
 | export const printChatMessage = (eventType, user, colour, content) => { | ||||||
| /** |   const chatMessage = document.createElement("div"); | ||||||
|  * @param {string} eventType |   chatMessage.classList.add("chat-message"); | ||||||
|  * @param {string?} user |   chatMessage.classList.add(eventType); | ||||||
|  * @param {Node?} content |   chatMessage.title = getCurrentTimestamp(); | ||||||
|  */ | 
 | ||||||
| export const printChatMessage = (eventType, user, colour, content) => { |   if (user != null) { | ||||||
|   const chatMessage = document.createElement("div"); |     const userName = document.createElement("strong"); | ||||||
|   chatMessage.classList.add("chat-message"); |     userName.style = `--user-color: #${colour}`; | ||||||
|   chatMessage.classList.add(eventType); |     userName.textContent = user + " "; | ||||||
|   chatMessage.title = getCurrentTimestamp(); |     chatMessage.appendChild(userName); | ||||||
| 
 |   } | ||||||
|   if (user != null) { | 
 | ||||||
|     const userName = document.createElement("strong"); |   if (content != null) { | ||||||
|     userName.style = `--user-color: #${colour}`; |     chatMessage.appendChild(content); | ||||||
|     userName.textContent = user + " "; |   } | ||||||
|     chatMessage.appendChild(userName); | 
 | ||||||
|   } |   addToChat(chatMessage); | ||||||
| 
 | 
 | ||||||
|   if (content != null) { |   return chatMessage; | ||||||
|     chatMessage.appendChild(content); | }; | ||||||
|   } | 
 | ||||||
| 
 | const formatTime = (ms) => { | ||||||
|   addToChat(chatMessage); |   const seconds = Math.floor((ms / 1000) % 60); | ||||||
| 
 |   const minutes = Math.floor((ms / (60 * 1000)) % 60); | ||||||
|   return chatMessage; |   const hours = Math.floor((ms / (3600 * 1000)) % 3600); | ||||||
| }; |   return `${hours < 10 ? "0" + hours : hours}:${ | ||||||
| 
 |     minutes < 10 ? "0" + minutes : minutes | ||||||
| const formatTime = (ms) => { |   }:${seconds < 10 ? "0" + seconds : seconds}`;
 | ||||||
|   const seconds = Math.floor((ms / 1000) % 60); | }; | ||||||
|   const minutes = Math.floor((ms / (60 * 1000)) % 60); | 
 | ||||||
|   const hours = Math.floor((ms / (3600 * 1000)) % 3600); | export const logEventToChat = async (event) => { | ||||||
|   return `${hours < 10 ? "0" + hours : hours}:${ |   if (checkDebounce(event)) { | ||||||
|     minutes < 10 ? "0" + minutes : minutes |     return; | ||||||
|   }:${seconds < 10 ? "0" + seconds : seconds}`;
 |   } | ||||||
| }; | 
 | ||||||
| 
 |   switch (event.op) { | ||||||
| export const logEventToChat = async (event) => { |     case "UserJoin": { | ||||||
|   if (checkDebounce(event)) { |       printChatMessage( | ||||||
|     return; |         "user-join", | ||||||
|   } |         event.user, | ||||||
| 
 |         event.colour, | ||||||
|   switch (event.op) { |         document.createTextNode("joined") | ||||||
|     case "UserJoin": { |       ); | ||||||
|       printChatMessage( |       break; | ||||||
|         "user-join", |     } | ||||||
|         event.user, |     case "UserLeave": { | ||||||
|         event.colour, |       printChatMessage( | ||||||
|         document.createTextNode("joined") |         "user-leave", | ||||||
|       ); |         event.user, | ||||||
|       break; |         event.colour, | ||||||
|     } |         document.createTextNode("left") | ||||||
|     case "UserLeave": { |       ); | ||||||
|       printChatMessage( |       break; | ||||||
|         "user-leave", |     } | ||||||
|         event.user, |     case "ChatMessage": { | ||||||
|         event.colour, |       const messageContent = document.createElement("span"); | ||||||
|         document.createTextNode("left") |       messageContent.classList.add("message-content"); | ||||||
|       ); |       messageContent.append(...(await linkify(event.data, emojify))); | ||||||
|       break; |       printChatMessage( | ||||||
|     } |         "chat-message", | ||||||
|     case "ChatMessage": { |         event.user, | ||||||
|       const messageContent = document.createElement("span"); |         event.colour, | ||||||
|       messageContent.classList.add("message-content"); |         messageContent | ||||||
|       messageContent.append(...(await linkify(event.data, emojify))); |       ); | ||||||
|       printChatMessage( |       break; | ||||||
|         "chat-message", |     } | ||||||
|         event.user, |     case "SetTime": { | ||||||
|         event.colour, |       const messageContent = document.createElement("span"); | ||||||
|         messageContent |       if (event.data.from != undefined) { | ||||||
|       ); |         messageContent.appendChild( | ||||||
|       break; |           document.createTextNode("set the time from ") | ||||||
|     } |         ); | ||||||
|     case "SetTime": { | 
 | ||||||
|       const messageContent = document.createElement("span"); |         messageContent.appendChild( | ||||||
|       if (event.data.from != undefined) { |           document.createTextNode(formatTime(event.data.from)) | ||||||
|         messageContent.appendChild( |         ); | ||||||
|           document.createTextNode("set the time from ") | 
 | ||||||
|         ); |         messageContent.appendChild(document.createTextNode(" to ")); | ||||||
| 
 |       } else { | ||||||
|         messageContent.appendChild( |         messageContent.appendChild(document.createTextNode("set the time to ")); | ||||||
|           document.createTextNode(formatTime(event.data.from)) |       } | ||||||
|         ); | 
 | ||||||
| 
 |       messageContent.appendChild( | ||||||
|         messageContent.appendChild(document.createTextNode(" to ")); |         document.createTextNode(formatTime(event.data.to)) | ||||||
|       } else { |       ); | ||||||
|         messageContent.appendChild(document.createTextNode("set the time to ")); | 
 | ||||||
|       } |       printChatMessage("set-time", event.user, event.colour, messageContent); | ||||||
| 
 |       break; | ||||||
|       messageContent.appendChild( |     } | ||||||
|         document.createTextNode(formatTime(event.data.to)) |     case "SetPlaying": { | ||||||
|       ); |       const messageContent = document.createElement("span"); | ||||||
| 
 |       messageContent.appendChild( | ||||||
|       printChatMessage("set-time", event.user, event.colour, messageContent); |         document.createTextNode( | ||||||
|       break; |           event.data.playing ? "started playing" : "paused" | ||||||
|     } |         ) | ||||||
|     case "SetPlaying": { |       ); | ||||||
|       const messageContent = document.createElement("span"); |       messageContent.appendChild(document.createTextNode(" at ")); | ||||||
|       messageContent.appendChild( |       messageContent.appendChild( | ||||||
|         document.createTextNode( |         document.createTextNode(formatTime(event.data.time)) | ||||||
|           event.data.playing ? "started playing" : "paused" |       ); | ||||||
|         ) | 
 | ||||||
|       ); |       printChatMessage("set-playing", event.user, event.colour, messageContent); | ||||||
|       messageContent.appendChild(document.createTextNode(" at ")); |       break; | ||||||
|       messageContent.appendChild( |     } | ||||||
|         document.createTextNode(formatTime(event.data.time)) |     case "Ping": { | ||||||
|       ); |       const messageContent = document.createElement("span"); | ||||||
| 
 |       if (event.data) { | ||||||
|       printChatMessage("set-playing", event.user, event.colour, messageContent); |         messageContent.appendChild(document.createTextNode("pinged saying: ")); | ||||||
|       break; |         messageContent.appendChild(document.createTextNode(event.data)); | ||||||
|     } |       } else { | ||||||
|     case "Ping": { |         messageContent.appendChild(document.createTextNode("pinged")); | ||||||
|       const messageContent = document.createElement("span"); |       } | ||||||
|       if (event.data) { | 
 | ||||||
|         messageContent.appendChild(document.createTextNode("pinged saying: ")); |       printChatMessage("ping", event.user, event.colour, messageContent); | ||||||
|         messageContent.appendChild(document.createTextNode(event.data)); |       pling(); | ||||||
|       } else { |       if ("Notification" in window) { | ||||||
|         messageContent.appendChild(document.createTextNode("pinged")); |         const title = "watch party :)"; | ||||||
|       } |         const options = { | ||||||
| 
 |           body: event.data | ||||||
|       printChatMessage("ping", event.user, event.colour, messageContent); |             ? `${event.user} pinged saying: ${event.data}` | ||||||
|       pling(); |             : `${event.user} pinged`, | ||||||
|       if ("Notification" in window) { |         }; | ||||||
|         const title = "watch party :)"; |         if (Notification.permission === "granted") { | ||||||
|         const options = { |           new Notification(title, options); | ||||||
|           body: event.data |         } else if (Notification.permission !== "denied") { | ||||||
|             ? `${event.user} pinged saying: ${event.data}` |           Notification.requestPermission().then(function (permission) { | ||||||
|             : `${event.user} pinged`, |             if (permission === "granted") { | ||||||
|         }; |               new Notification(title, options); | ||||||
|         if (Notification.permission === "granted") { |             } | ||||||
|           new Notification(title, options); |           }); | ||||||
|         } else if (Notification.permission !== "denied") { |         } | ||||||
|           Notification.requestPermission().then(function (permission) { |       } | ||||||
|             if (permission === "granted") { |       break; | ||||||
|               new Notification(title, options); |     } | ||||||
|             } |   } | ||||||
|           }); | }; | ||||||
|         } | 
 | ||||||
|       } | export const updateViewerList = (viewers) => { | ||||||
|       break; |   const listContainer = document.querySelector("#viewer-list"); | ||||||
|     } | 
 | ||||||
|   } |   // empty out the current list
 | ||||||
| }; |   listContainer.innerHTML = ""; | ||||||
| 
 | 
 | ||||||
| export const updateViewerList = (viewers) => { |   // display the updated list
 | ||||||
|   const listContainer = document.querySelector("#viewer-list"); |   for (const viewer of viewers) { | ||||||
| 
 |     const viewerElem = document.createElement("div"); | ||||||
|   // empty out the current list
 |     const content = document.createElement("strong"); | ||||||
|   listContainer.innerHTML = ""; |     content.textContent = viewer.nickname; | ||||||
| 
 |     content.style = `--user-color: #${viewer.colour}`; | ||||||
|   // display the updated list
 |     viewerElem.appendChild(content); | ||||||
|   for (const viewer of viewers) { |     listContainer.appendChild(viewerElem); | ||||||
|     const viewerElem = document.createElement("div"); |   } | ||||||
|     const content = document.createElement("strong"); | }; | ||||||
|     content.textContent = viewer.nickname; |  | ||||||
|     content.style = `--user-color: #${viewer.colour}`; |  | ||||||
|     viewerElem.appendChild(content); |  | ||||||
|     listContainer.appendChild(viewerElem); |  | ||||||
|   } |  | ||||||
| }; |  | ||||||
|  |  | ||||||
|  | @ -1,18 +1,18 @@ | ||||||
| import { createSession } from "./watch-session.mjs?v=bfdcf2"; | import { createSession } from "./watch-session.mjs?v=4b61c4"; | ||||||
| 
 | 
 | ||||||
| export const setupCreateSessionForm = () => { | export const setupCreateSessionForm = () => { | ||||||
|   const form = document.querySelector("#create-session-form"); |   const form = document.querySelector("#create-session-form"); | ||||||
|   const videoUrl = form.querySelector("#create-session-video"); |   const videoUrl = form.querySelector("#create-session-video"); | ||||||
|   const subsUrl = form.querySelector("#create-session-subs"); |   const subsUrl = form.querySelector("#create-session-subs"); | ||||||
|   const subsName = form.querySelector("#create-session-subs-name"); |   const subsName = form.querySelector("#create-session-subs-name"); | ||||||
| 
 | 
 | ||||||
|   form.addEventListener("submit", (event) => { |   form.addEventListener("submit", (event) => { | ||||||
|     event.preventDefault(); |     event.preventDefault(); | ||||||
| 
 | 
 | ||||||
|     let subs = []; |     let subs = []; | ||||||
|     if (subsUrl.value) { |     if (subsUrl.value) { | ||||||
|       subs.push({ url: subsUrl.value, name: subsName.value || "default" }); |       subs.push({ url: subsUrl.value, name: subsName.value || "default" }); | ||||||
|     } |     } | ||||||
|     createSession(videoUrl.value, subs); |     createSession(videoUrl.value, subs); | ||||||
|   }); |   }); | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | @ -1,72 +1,72 @@ | ||||||
| export async function emojify(text) { | export async function emojify(text) { | ||||||
|   await emojisLoaded; |   await emojisLoaded; | ||||||
|   let last = 0; |   let last = 0; | ||||||
|   let nodes = []; |   let nodes = []; | ||||||
|   text.replace(/:([^\s:]+):/g, (match, name, index) => { |   text.replace(/:([^\s:]+):/g, (match, name, index) => { | ||||||
|     if (last <= index) |     if (last <= index) | ||||||
|       nodes.push(document.createTextNode(text.slice(last, index))); |       nodes.push(document.createTextNode(text.slice(last, index))); | ||||||
|     let emoji; |     let emoji; | ||||||
|     try { |     try { | ||||||
|       emoji = emojis[name.toLowerCase()[0]].find((e) => e[0] == name); |       emoji = emojis[name.toLowerCase()[0]].find((e) => e[0] == name); | ||||||
|     } catch (e) {} |     } catch (e) {} | ||||||
|     if (!emoji) { |     if (!emoji) { | ||||||
|       nodes.push(document.createTextNode(match)); |       nodes.push(document.createTextNode(match)); | ||||||
|     } else { |     } else { | ||||||
|       if (emoji[1][0] !== ":") { |       if (emoji[1][0] !== ":") { | ||||||
|         nodes.push(document.createTextNode(emoji[1])); |         nodes.push(document.createTextNode(emoji[1])); | ||||||
|       } else { |       } else { | ||||||
|         nodes.push( |         nodes.push( | ||||||
|           Object.assign(new Image(), { |           Object.assign(new Image(), { | ||||||
|             src: `/emojis/${name}${emoji[2]}`, |             src: `/emojis/${name}${emoji[2]}`, | ||||||
|             className: "emoji", |             className: "emoji", | ||||||
|             alt: name, |             alt: name, | ||||||
|           }) |           }) | ||||||
|         ); |         ); | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     last = index + match.length; |     last = index + match.length; | ||||||
|   }); |   }); | ||||||
|   if (last < text.length) nodes.push(document.createTextNode(text.slice(last))); |   if (last < text.length) nodes.push(document.createTextNode(text.slice(last))); | ||||||
|   return nodes; |   return nodes; | ||||||
| } | } | ||||||
| const emojis = {}; | const emojis = {}; | ||||||
| 
 | 
 | ||||||
| export const emojisLoaded = Promise.all([ | export const emojisLoaded = Promise.all([ | ||||||
|   fetch("/emojis/unicode.json") |   fetch("/emojis/unicode.json") | ||||||
|     .then((e) => e.json()) |     .then((e) => e.json()) | ||||||
|     .then((a) => { |     .then((a) => { | ||||||
|       for (let e of a) { |       for (let e of a) { | ||||||
|         emojis[e[0][0]] = emojis[e[0][0]] || []; |         emojis[e[0][0]] = emojis[e[0][0]] || []; | ||||||
|         emojis[e[0][0]].push([e[0], e[1], null, e[0]]); |         emojis[e[0][0]].push([e[0], e[1], null, e[0]]); | ||||||
|       } |       } | ||||||
|     }), |     }), | ||||||
|   fetch("/emojos") |   fetch("/emojos") | ||||||
|     .then((e) => e.json()) |     .then((e) => e.json()) | ||||||
|     .then((a) => { |     .then((a) => { | ||||||
|       for (let e of a) { |       for (let e of a) { | ||||||
|         const name = e.slice(0, -4), |         const name = e.slice(0, -4), | ||||||
|           lower = name.toLowerCase(); |           lower = name.toLowerCase(); | ||||||
|         emojis[lower[0]] = emojis[lower[0]] || []; |         emojis[lower[0]] = emojis[lower[0]] || []; | ||||||
|         emojis[lower[0]].push([name, ":" + name + ":", e.slice(-4), lower]); |         emojis[lower[0]].push([name, ":" + name + ":", e.slice(-4), lower]); | ||||||
|       } |       } | ||||||
|     }), |     }), | ||||||
| ]); | ]); | ||||||
| 
 | 
 | ||||||
| export async function findEmojis(search) { | export async function findEmojis(search) { | ||||||
|   await emojisLoaded; |   await emojisLoaded; | ||||||
|   let groups = [[], []]; |   let groups = [[], []]; | ||||||
|   if (search.length < 1) { |   if (search.length < 1) { | ||||||
|     for (let letter of Object.keys(emojis).sort()) |     for (let letter of Object.keys(emojis).sort()) | ||||||
|       for (let emoji of emojis[letter]) { |       for (let emoji of emojis[letter]) { | ||||||
|         (emoji[1][0] === ":" ? groups[0] : groups[1]).push(emoji); |         (emoji[1][0] === ":" ? groups[0] : groups[1]).push(emoji); | ||||||
|       } |       } | ||||||
|   } else { |   } else { | ||||||
|     search = search.toLowerCase(); |     search = search.toLowerCase(); | ||||||
|     for (let emoji of emojis[search[0]]) { |     for (let emoji of emojis[search[0]]) { | ||||||
|       if (search.length == 1 || emoji[3].startsWith(search)) { |       if (search.length == 1 || emoji[3].startsWith(search)) { | ||||||
|         (emoji[1][0] === ":" ? groups[0] : groups[1]).push(emoji); |         (emoji[1][0] === ":" ? groups[0] : groups[1]).push(emoji); | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|   return [...groups[1], ...groups[0]]; |   return [...groups[1], ...groups[0]]; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,93 +1,95 @@ | ||||||
| import { joinSession } from "./watch-session.mjs?v=bfdcf2"; | import { joinSession } from "./watch-session.mjs?v=4b61c4"; | ||||||
| import { state } from "./state.mjs"; | import { state } from "./state.mjs"; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * @param {HTMLInputElement} field |  * @param {HTMLInputElement} field | ||||||
|  */ |  */ | ||||||
| const loadNickname = (field) => { | const loadNickname = (field) => { | ||||||
|   try { |   try { | ||||||
|     const savedNickname = localStorage.getItem("watch-party-nickname"); |     const savedNickname = localStorage.getItem("watch-party-nickname"); | ||||||
|     field.value = savedNickname; |     field.value = savedNickname; | ||||||
|   } catch (_err) { |   } catch (_err) { | ||||||
|     // Sometimes localStorage is blocked from use
 |     // Sometimes localStorage is blocked from use
 | ||||||
|   } |   } | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * @param {HTMLInputElement} field |  * @param {HTMLInputElement} field | ||||||
|  */ |  */ | ||||||
| const saveNickname = (field) => { | const saveNickname = (field) => { | ||||||
|   try { |   try { | ||||||
|     localStorage.setItem("watch-party-nickname", field.value); |     localStorage.setItem("watch-party-nickname", field.value); | ||||||
|   } catch (_err) { |   } catch (_err) { | ||||||
|     // see loadNickname
 |     // see loadNickname
 | ||||||
|   } |   } | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * @param {HTMLInputElement} field |  * @param {HTMLInputElement} field | ||||||
|  */ |  */ | ||||||
| const loadColour = (field) => { | const loadColour = (field) => { | ||||||
|   try { |   try { | ||||||
|     const savedColour = localStorage.getItem("watch-party-colour"); |     const savedColour = localStorage.getItem("watch-party-colour"); | ||||||
|     if (savedColour != null && savedColour != "") { |     if (savedColour != null && savedColour != "") { | ||||||
|       field.value = savedColour; |       field.value = savedColour; | ||||||
|     } |     } | ||||||
|   } catch (_err) { |   } catch (_err) { | ||||||
|     // Sometimes localStorage is blocked from use
 |     // Sometimes localStorage is blocked from use
 | ||||||
|   } |   } | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * @param {HTMLInputElement} field |  * @param {HTMLInputElement} field | ||||||
|  */ |  */ | ||||||
| const saveColour = (field) => { | const saveColour = (field) => { | ||||||
|   try { |   try { | ||||||
|     localStorage.setItem("watch-party-colour", field.value); |     localStorage.setItem("watch-party-colour", field.value); | ||||||
|   } catch (_err) { |   } catch (_err) { | ||||||
|     // see loadColour
 |     // 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") { | ||||||
|     document.querySelector("#post-create-message").style["display"] = "block"; |     document.querySelector("#post-create-message").style["display"] = "block"; | ||||||
|     window.history.replaceState({}, document.title, `/${window.location.hash}`); |     window.history.replaceState({}, document.title, `/${window.location.hash}`); | ||||||
|   } |     return true; | ||||||
| }; |   } | ||||||
| 
 |   return false; | ||||||
| export const setupJoinSessionForm = () => { | }; | ||||||
|   displayPostCreateMessage(); | 
 | ||||||
| 
 | export const setupJoinSessionForm = () => { | ||||||
|   const form = document.querySelector("#join-session-form"); |   const created = displayPostCreateMessage(); | ||||||
|   const nickname = form.querySelector("#join-session-nickname"); | 
 | ||||||
|   const colour = form.querySelector("#join-session-colour"); |   const form = document.querySelector("#join-session-form"); | ||||||
|   const sessionId = form.querySelector("#join-session-id"); |   const nickname = form.querySelector("#join-session-nickname"); | ||||||
|   const button = form.querySelector("#join-session-button"); |   const colour = form.querySelector("#join-session-colour"); | ||||||
| 
 |   const sessionId = form.querySelector("#join-session-id"); | ||||||
|   loadNickname(nickname); |   const button = form.querySelector("#join-session-button"); | ||||||
|   loadColour(colour); | 
 | ||||||
| 
 |   loadNickname(nickname); | ||||||
|   if (window.location.hash.match(/#[0-9a-f\-]+/)) { |   loadColour(colour); | ||||||
|     sessionId.value = window.location.hash.substring(1); | 
 | ||||||
|   } |   if (window.location.hash.match(/#[0-9a-f\-]+/)) { | ||||||
| 
 |     sessionId.value = window.location.hash.substring(1); | ||||||
|   form.addEventListener("submit", async (event) => { |   } | ||||||
|     event.preventDefault(); | 
 | ||||||
| 
 |   form.addEventListener("submit", async (event) => { | ||||||
|     button.disabled = true; |     event.preventDefault(); | ||||||
| 
 | 
 | ||||||
|     saveNickname(nickname); |     button.disabled = true; | ||||||
|     saveColour(colour); | 
 | ||||||
|     try { |     saveNickname(nickname); | ||||||
|       state().nickname = nickname.value; |     saveColour(colour); | ||||||
|       state().sessionId = sessionId.value; |     try { | ||||||
|       state().colour = colour.value.replace(/^#/, ""); |       state().nickname = nickname.value; | ||||||
|       await joinSession(); |       state().sessionId = sessionId.value; | ||||||
|     } catch (e) { |       state().colour = colour.value.replace(/^#/, ""); | ||||||
|       alert(e.message); |       await joinSession(created); | ||||||
|       button.disabled = false; |     } catch (e) { | ||||||
|     } |       alert(e.message); | ||||||
|   }); |       button.disabled = false; | ||||||
| }; |     } | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | @ -1,121 +1,121 @@ | ||||||
| import { joinSession } from "./watch-session.mjs?v=bfdcf2"; | import { joinSession } from "./watch-session.mjs?v=4b61c4"; | ||||||
| import { state } from "./state.mjs"; | import { state } from "./state.mjs"; | ||||||
| 
 | 
 | ||||||
| export async function linkify( | export async function linkify( | ||||||
|   text, |   text, | ||||||
|   next = async (t) => [document.createTextNode(t)] |   next = async (t) => [document.createTextNode(t)] | ||||||
| ) { | ) { | ||||||
|   let last = 0; |   let last = 0; | ||||||
|   let nodes = []; |   let nodes = []; | ||||||
|   let promise = Promise.resolve(); |   let promise = Promise.resolve(); | ||||||
|   // matching non-urls isn't a problem, we use the browser's url parser to filter them out
 |   // matching non-urls isn't a problem, we use the browser's url parser to filter them out
 | ||||||
|   text.replace( |   text.replace( | ||||||
|     /[^:/?#\s]+:\/\/\S+/g, |     /[^:/?#\s]+:\/\/\S+/g, | ||||||
|     (match, index) => |     (match, index) => | ||||||
|       (promise = promise.then(async () => { |       (promise = promise.then(async () => { | ||||||
|         if (last <= index) nodes.push(...(await next(text.slice(last, index)))); |         if (last <= index) nodes.push(...(await next(text.slice(last, index)))); | ||||||
|         let url; |         let url; | ||||||
|         try { |         try { | ||||||
|           url = new URL(match); |           url = new URL(match); | ||||||
|           if (url.protocol === "javascript:") throw new Error(); |           if (url.protocol === "javascript:") throw new Error(); | ||||||
|         } catch (e) { |         } catch (e) { | ||||||
|           url = null; |           url = null; | ||||||
|         } |         } | ||||||
|         if (!url) { |         if (!url) { | ||||||
|           nodes.push(...(await next(match))); |           nodes.push(...(await next(match))); | ||||||
|         } else { |         } else { | ||||||
|           let s; |           let s; | ||||||
|           if ( |           if ( | ||||||
|             url.origin == location.origin && |             url.origin == location.origin && | ||||||
|             url.pathname == "/" && |             url.pathname == "/" && | ||||||
|             url.hash.length > 1 |             url.hash.length > 1 | ||||||
|           ) { |           ) { | ||||||
|             nodes.push( |             nodes.push( | ||||||
|               Object.assign(document.createElement("a"), { |               Object.assign(document.createElement("a"), { | ||||||
|                 textContent: "Join Session", |                 textContent: "Join Session", | ||||||
|                 className: "chip join-chip", |                 className: "chip join-chip", | ||||||
|                 onclick: () => { |                 onclick: () => { | ||||||
|                   state().sessionId = url.hash.substring(1); |                   state().sessionId = url.hash.substring(1); | ||||||
|                   joinSession(); |                   joinSession(); | ||||||
|                 }, |                 }, | ||||||
|               }) |               }) | ||||||
|             ); |             ); | ||||||
|           } else if ( |           } else if ( | ||||||
|             url.hostname == "xiv.st" && |             url.hostname == "xiv.st" && | ||||||
|             (s = url.pathname.match(/(\d?\d).?(\d\d)/)) |             (s = url.pathname.match(/(\d?\d).?(\d\d)/)) | ||||||
|           ) { |           ) { | ||||||
|             if (s) { |             if (s) { | ||||||
|               const date = new Date(); |               const date = new Date(); | ||||||
|               date.setUTCSeconds(0); |               date.setUTCSeconds(0); | ||||||
|               date.setUTCMilliseconds(0); |               date.setUTCMilliseconds(0); | ||||||
|               date.setUTCHours(s[1]), date.setUTCMinutes(s[2]); |               date.setUTCHours(s[1]), date.setUTCMinutes(s[2]); | ||||||
|               nodes.push( |               nodes.push( | ||||||
|                 Object.assign(document.createElement("a"), { |                 Object.assign(document.createElement("a"), { | ||||||
|                   href: url.href, |                   href: url.href, | ||||||
|                   textContent: date.toLocaleString([], { |                   textContent: date.toLocaleString([], { | ||||||
|                     hour: "2-digit", |                     hour: "2-digit", | ||||||
|                     minute: "2-digit", |                     minute: "2-digit", | ||||||
|                   }), |                   }), | ||||||
|                   className: "chip time-chip", |                   className: "chip time-chip", | ||||||
|                   target: "_blank", |                   target: "_blank", | ||||||
|                 }) |                 }) | ||||||
|               ); |               ); | ||||||
|             } |             } | ||||||
|           } else { |           } else { | ||||||
|             nodes.push( |             nodes.push( | ||||||
|               Object.assign(document.createElement("a"), { |               Object.assign(document.createElement("a"), { | ||||||
|                 href: url.href, |                 href: url.href, | ||||||
|                 textContent: url.href, |                 textContent: url.href, | ||||||
|                 target: "_blank", |                 target: "_blank", | ||||||
|               }) |               }) | ||||||
|             ); |             ); | ||||||
|           } |           } | ||||||
|         } |         } | ||||||
|         last = index + match.length; |         last = index + match.length; | ||||||
|       })) |       })) | ||||||
|   ); |   ); | ||||||
|   await promise; |   await promise; | ||||||
|   if (last < text.length) nodes.push(...(await next(text.slice(last)))); |   if (last < text.length) nodes.push(...(await next(text.slice(last)))); | ||||||
|   return nodes; |   return nodes; | ||||||
| } | } | ||||||
| const emojis = {}; | const emojis = {}; | ||||||
| 
 | 
 | ||||||
| export const emojisLoaded = Promise.all([ | export const emojisLoaded = Promise.all([ | ||||||
|   fetch("/emojis") |   fetch("/emojis") | ||||||
|     .then((e) => e.json()) |     .then((e) => e.json()) | ||||||
|     .then((a) => { |     .then((a) => { | ||||||
|       for (let e of a) { |       for (let e of a) { | ||||||
|         const name = e.slice(0, -4), |         const name = e.slice(0, -4), | ||||||
|           lower = name.toLowerCase(); |           lower = name.toLowerCase(); | ||||||
|         emojis[lower[0]] = emojis[lower[0]] || []; |         emojis[lower[0]] = emojis[lower[0]] || []; | ||||||
|         emojis[lower[0]].push([name, ":" + name + ":", e.slice(-4), lower]); |         emojis[lower[0]].push([name, ":" + name + ":", e.slice(-4), lower]); | ||||||
|       } |       } | ||||||
|     }), |     }), | ||||||
|   fetch("/emojis/unicode.json") |   fetch("/emojis/unicode.json") | ||||||
|     .then((e) => e.json()) |     .then((e) => e.json()) | ||||||
|     .then((a) => { |     .then((a) => { | ||||||
|       for (let e of a) { |       for (let e of a) { | ||||||
|         emojis[e[0][0]] = emojis[e[0][0]] || []; |         emojis[e[0][0]] = emojis[e[0][0]] || []; | ||||||
|         emojis[e[0][0]].push([e[0], e[1], null, e[0]]); |         emojis[e[0][0]].push([e[0], e[1], null, e[0]]); | ||||||
|       } |       } | ||||||
|     }), |     }), | ||||||
| ]); | ]); | ||||||
| 
 | 
 | ||||||
| export async function findEmojis(search) { | export async function findEmojis(search) { | ||||||
|   await emojisLoaded; |   await emojisLoaded; | ||||||
|   let groups = [[], []]; |   let groups = [[], []]; | ||||||
|   if (search.length < 1) { |   if (search.length < 1) { | ||||||
|     for (let letter of Object.keys(emojis).sort()) |     for (let letter of Object.keys(emojis).sort()) | ||||||
|       for (let emoji of emojis[letter]) { |       for (let emoji of emojis[letter]) { | ||||||
|         (emoji[1][0] === ":" ? groups[0] : groups[1]).push(emoji); |         (emoji[1][0] === ":" ? groups[0] : groups[1]).push(emoji); | ||||||
|       } |       } | ||||||
|   } else { |   } else { | ||||||
|     search = search.toLowerCase(); |     search = search.toLowerCase(); | ||||||
|     for (let emoji of emojis[search[0]]) { |     for (let emoji of emojis[search[0]]) { | ||||||
|       if (search.length == 1 || emoji[3].startsWith(search)) { |       if (search.length == 1 || emoji[3].startsWith(search)) { | ||||||
|         (emoji[1][0] === ":" ? groups[0] : groups[1]).push(emoji); |         (emoji[1][0] === ":" ? groups[0] : groups[1]).push(emoji); | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|   return [...groups[0], ...groups[1]]; |   return [...groups[0], ...groups[1]]; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,43 +0,0 @@ | ||||||
| export const toggleOptionPane = (event, element) => { |  | ||||||
|   event.preventDefault(); |  | ||||||
|   // show options
 |  | ||||||
|   if ( |  | ||||||
|     !document.querySelector("#options").style.display || |  | ||||||
|     document.querySelector("#options").style.display === "none" |  | ||||||
|   ) { |  | ||||||
|     // using this to do any potential init logic for the fields too
 |  | ||||||
|     loadPlayerControlsShown(document.querySelector("#playerControlsShown")) |  | ||||||
|     loadPlingVolume(document.querySelector("#plingVolume")) |  | ||||||
| 
 |  | ||||||
|     element.innerText = "❌"; |  | ||||||
|     document.querySelector("#options").style.display = "block"; |  | ||||||
|     return (document.querySelector("#viewing").style.display = "none"); |  | ||||||
|   } |  | ||||||
|   // hide options
 |  | ||||||
|   element.innerText = "⚙️"; |  | ||||||
|   document.querySelector("#options").style.display = "none"; |  | ||||||
|   document.querySelector("#viewing").style.display = "block"; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const getPlayerControlsShown = () =>  localStorage.getItem("watch-party-default-allow-controls") || false |  | ||||||
| // delete from storage on false to prevent weird js boolean parsing (Boolean('false') === True)
 |  | ||||||
| const setPlayerControlShown = (boolean) => !boolean  |  | ||||||
| ? localStorage.removeItem("watch-party-default-allow-controls") |  | ||||||
| : localStorage.setItem("watch-party-default-allow-controls", boolean) |  | ||||||
| export const togglePlayerControlsShown = (element) => { |  | ||||||
|   const isShown = element.checked |  | ||||||
|   setPlayerControlShown(!isShown) |  | ||||||
| } |  | ||||||
| const loadPlayerControlsShown = (element) => { |  | ||||||
|   const isShown = getPlayerControlsShown() |  | ||||||
|   element.checked = !isShown |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| const getPlingVolume = () =>  localStorage.getItem("watch-party-pling-volume") || 100 |  | ||||||
| const setPlingVolume = (value) => localStorage.setItem("watch-party-pling-volume", value) |  | ||||||
| export const handlePlingVolume = (element) => { |  | ||||||
|   setPlingVolume(element.value) |  | ||||||
| } |  | ||||||
| const loadPlingVolume = (element) => { |  | ||||||
|   element.value = getPlingVolume() |  | ||||||
| } |  | ||||||
|  | @ -1,82 +1,79 @@ | ||||||
| export const pling = () => { | export const pling = () => { | ||||||
|   // technically volume 0 breaks it but its the wanted outcome i guess?
 |   const maxGain = 0.3; | ||||||
|   const maxGain = |   const duration = 0.22; | ||||||
|     (Number(localStorage.getItem("watch-party-pling-volume")) / 100 ?? 1) * 0.3; |   const fadeDuration = 0.1; | ||||||
| 
 |   const secondBeepOffset = 0.05; | ||||||
|   const duration = 0.22; |   const thirdBeepOffset = 2 * secondBeepOffset; | ||||||
|   const fadeDuration = 0.1; | 
 | ||||||
|   const secondBeepOffset = 0.05; |   const ctx = new AudioContext(); | ||||||
|   const thirdBeepOffset = 2 * secondBeepOffset; | 
 | ||||||
| 
 |   const firstBeepGain = ctx.createGain(); | ||||||
|   const ctx = new AudioContext(); |   firstBeepGain.connect(ctx.destination); | ||||||
| 
 |   firstBeepGain.gain.setValueAtTime(0.01, ctx.currentTime); | ||||||
|   const firstBeepGain = ctx.createGain(); |   firstBeepGain.gain.exponentialRampToValueAtTime( | ||||||
|   firstBeepGain.connect(ctx.destination); |     maxGain, | ||||||
|   firstBeepGain.gain.setValueAtTime(0.01, ctx.currentTime); |     ctx.currentTime + fadeDuration | ||||||
|   firstBeepGain.gain.exponentialRampToValueAtTime( |   ); | ||||||
|     maxGain, |   firstBeepGain.gain.setValueAtTime( | ||||||
|     ctx.currentTime + fadeDuration |     maxGain, | ||||||
|   ); |     ctx.currentTime + (duration - fadeDuration) | ||||||
|   firstBeepGain.gain.setValueAtTime( |   ); | ||||||
|     maxGain, |   firstBeepGain.gain.exponentialRampToValueAtTime( | ||||||
|     ctx.currentTime + (duration - fadeDuration) |     0.01, | ||||||
|   ); |     ctx.currentTime + duration | ||||||
|   firstBeepGain.gain.exponentialRampToValueAtTime( |   ); | ||||||
|     0.01, | 
 | ||||||
|     ctx.currentTime + duration |   const firstBeep = ctx.createOscillator(); | ||||||
|   ); |   firstBeep.connect(firstBeepGain); | ||||||
| 
 |   firstBeep.frequency.value = 400; | ||||||
|   const firstBeep = ctx.createOscillator(); |   firstBeep.type = "sine"; | ||||||
|   firstBeep.connect(firstBeepGain); | 
 | ||||||
|   firstBeep.frequency.value = 400; |   const secondBeepGain = ctx.createGain(); | ||||||
|   firstBeep.type = "sine"; |   secondBeepGain.connect(ctx.destination); | ||||||
| 
 |   secondBeepGain.gain.setValueAtTime(0.01, ctx.currentTime + secondBeepOffset); | ||||||
|   const secondBeepGain = ctx.createGain(); |   secondBeepGain.gain.exponentialRampToValueAtTime( | ||||||
|   secondBeepGain.connect(ctx.destination); |     maxGain, | ||||||
|   secondBeepGain.gain.setValueAtTime(0.01, ctx.currentTime + secondBeepOffset); |     ctx.currentTime + secondBeepOffset + fadeDuration | ||||||
|   secondBeepGain.gain.exponentialRampToValueAtTime( |   ); | ||||||
|     maxGain, |   secondBeepGain.gain.setValueAtTime( | ||||||
|     ctx.currentTime + secondBeepOffset + fadeDuration |     maxGain, | ||||||
|   ); |     ctx.currentTime + secondBeepOffset + (duration - fadeDuration) | ||||||
|   secondBeepGain.gain.setValueAtTime( |   ); | ||||||
|     maxGain, |   secondBeepGain.gain.exponentialRampToValueAtTime( | ||||||
|     ctx.currentTime + secondBeepOffset + (duration - fadeDuration) |     0.01, | ||||||
|   ); |     ctx.currentTime + secondBeepOffset + duration | ||||||
|   secondBeepGain.gain.exponentialRampToValueAtTime( |   ); | ||||||
|     0.01, | 
 | ||||||
|     ctx.currentTime + secondBeepOffset + duration |   const secondBeep = ctx.createOscillator(); | ||||||
|   ); |   secondBeep.connect(secondBeepGain); | ||||||
| 
 |   secondBeep.frequency.value = 600; | ||||||
|   const secondBeep = ctx.createOscillator(); |   secondBeep.type = "sine"; | ||||||
|   secondBeep.connect(secondBeepGain); | 
 | ||||||
|   secondBeep.frequency.value = 600; |   const thirdBeepGain = ctx.createGain(); | ||||||
|   secondBeep.type = "sine"; |   thirdBeepGain.connect(ctx.destination); | ||||||
| 
 |   thirdBeepGain.gain.setValueAtTime(0.01, ctx.currentTime + thirdBeepOffset); | ||||||
|   const thirdBeepGain = ctx.createGain(); |   thirdBeepGain.gain.exponentialRampToValueAtTime( | ||||||
|   thirdBeepGain.connect(ctx.destination); |     maxGain, | ||||||
|   thirdBeepGain.gain.setValueAtTime(0.01, ctx.currentTime + thirdBeepOffset); |     ctx.currentTime + thirdBeepOffset + fadeDuration | ||||||
|   thirdBeepGain.gain.exponentialRampToValueAtTime( |   ); | ||||||
|     maxGain, |   thirdBeepGain.gain.setValueAtTime( | ||||||
|     ctx.currentTime + thirdBeepOffset + fadeDuration |     maxGain, | ||||||
|   ); |     ctx.currentTime + thirdBeepOffset + (duration - fadeDuration) | ||||||
|   thirdBeepGain.gain.setValueAtTime( |   ); | ||||||
|     maxGain, |   thirdBeepGain.gain.exponentialRampToValueAtTime( | ||||||
|     ctx.currentTime + thirdBeepOffset + (duration - fadeDuration) |     0.01, | ||||||
|   ); |     ctx.currentTime + thirdBeepOffset + duration | ||||||
|   thirdBeepGain.gain.exponentialRampToValueAtTime( |   ); | ||||||
|     0.01, | 
 | ||||||
|     ctx.currentTime + thirdBeepOffset + duration |   const thirdBeep = ctx.createOscillator(); | ||||||
|   ); |   thirdBeep.connect(thirdBeepGain); | ||||||
| 
 |   thirdBeep.frequency.value = 900; | ||||||
|   const thirdBeep = ctx.createOscillator(); |   thirdBeep.type = "sine"; | ||||||
|   thirdBeep.connect(thirdBeepGain); | 
 | ||||||
|   thirdBeep.frequency.value = 900; |   firstBeep.start(ctx.currentTime); | ||||||
|   thirdBeep.type = "sine"; |   firstBeep.stop(ctx.currentTime + duration); | ||||||
| 
 |   secondBeep.start(ctx.currentTime + secondBeepOffset); | ||||||
|   firstBeep.start(ctx.currentTime); |   secondBeep.stop(ctx.currentTime + (secondBeepOffset + duration)); | ||||||
|   firstBeep.stop(ctx.currentTime + duration); |   thirdBeep.start(ctx.currentTime + thirdBeepOffset); | ||||||
|   secondBeep.start(ctx.currentTime + secondBeepOffset); |   thirdBeep.stop(ctx.currentTime + (thirdBeepOffset + duration)); | ||||||
|   secondBeep.stop(ctx.currentTime + (secondBeepOffset + duration)); | }; | ||||||
|   thirdBeep.start(ctx.currentTime + thirdBeepOffset); |  | ||||||
|   thirdBeep.stop(ctx.currentTime + (thirdBeepOffset + duration)); |  | ||||||
| }; |  | ||||||
|  |  | ||||||
							
								
								
									
										1
									
								
								frontend/lib/plyr-3.7.3.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								frontend/lib/plyr-3.7.3.css
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										1
									
								
								frontend/lib/plyr-3.7.3.min.esm.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								frontend/lib/plyr-3.7.3.min.esm.js
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							|  | @ -1,71 +1,71 @@ | ||||||
| export default class ReconnectingWebSocket { | export default class ReconnectingWebSocket { | ||||||
|   constructor(url) { |   constructor(url) { | ||||||
|     if (url instanceof URL) { |     if (url instanceof URL) { | ||||||
|       this.url = url; |       this.url = url; | ||||||
|     } else { |     } else { | ||||||
|       this.url = new URL(url); |       this.url = new URL(url); | ||||||
|     } |     } | ||||||
|     this.connected = false; |     this.connected = false; | ||||||
|     this._eventTarget = new EventTarget(); |     this._eventTarget = new EventTarget(); | ||||||
|     this._backoff = 250; // milliseconds, doubled before use
 |     this._backoff = 250; // milliseconds, doubled before use
 | ||||||
|     this._lastConnect = 0; |     this._lastConnect = 0; | ||||||
|     this._socket = null; |     this._socket = null; | ||||||
|     this._unsent = []; |     this._unsent = []; | ||||||
|     this._closing = false; |     this._closing = false; | ||||||
|     this._connect(true); |     this._connect(true); | ||||||
|   } |   } | ||||||
|   _connect(first) { |   _connect(first) { | ||||||
|     if (this._socket) |     if (this._socket) | ||||||
|       try { |       try { | ||||||
|         this._socket.close(); |         this._socket.close(); | ||||||
|       } catch (e) {} |       } catch (e) {} | ||||||
|     try { |     try { | ||||||
|       this._socket = new WebSocket(this.url.href); |       this._socket = new WebSocket(this.url.href); | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
|       this._reconnecting = false; |       this._reconnecting = false; | ||||||
|       return this._reconnect(); |       return this._reconnect(); | ||||||
|     } |     } | ||||||
|     this._socket.addEventListener("close", () => this._reconnect()); |     this._socket.addEventListener("close", () => this._reconnect()); | ||||||
|     this._socket.addEventListener("error", () => this._reconnect()); |     this._socket.addEventListener("error", () => this._reconnect()); | ||||||
|     this._socket.addEventListener("message", ({ data }) => { |     this._socket.addEventListener("message", ({ data }) => { | ||||||
|       this._eventTarget.dispatchEvent(new MessageEvent("message", { data })); |       this._eventTarget.dispatchEvent(new MessageEvent("message", { data })); | ||||||
|     }); |     }); | ||||||
|     this._socket.addEventListener("open", (e) => { |     this._socket.addEventListener("open", (e) => { | ||||||
|       if (first) this._eventTarget.dispatchEvent(new Event("open")); |       if (first) this._eventTarget.dispatchEvent(new Event("open")); | ||||||
|       if (this._reconnecting) |       if (this._reconnecting) | ||||||
|         this._eventTarget.dispatchEvent(new Event("reconnected")); |         this._eventTarget.dispatchEvent(new Event("reconnected")); | ||||||
|       this._reconnecting = false; |       this._reconnecting = false; | ||||||
|       this._backoff = 250; |       this._backoff = 250; | ||||||
|       this.connected = true; |       this.connected = true; | ||||||
|       while (this._unsent.length > 0) this._socket.send(this._unsent.shift()); |       while (this._unsent.length > 0) this._socket.send(this._unsent.shift()); | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|   _reconnect() { |   _reconnect() { | ||||||
|     if (this._closing) return; |     if (this._closing) return; | ||||||
|     if (this._reconnecting) return; |     if (this._reconnecting) return; | ||||||
|     this._eventTarget.dispatchEvent(new Event("reconnecting")); |     this._eventTarget.dispatchEvent(new Event("reconnecting")); | ||||||
|     this._reconnecting = true; |     this._reconnecting = true; | ||||||
|     this.connected = false; |     this.connected = false; | ||||||
|     this._backoff *= 2; // exponential backoff
 |     this._backoff *= 2; // exponential backoff
 | ||||||
|     setTimeout(() => { |     setTimeout(() => { | ||||||
|       this._connect(); |       this._connect(); | ||||||
|     }, Math.floor(this._backoff + Math.random() * this._backoff * 0.25 - this._backoff * 0.125)); |     }, Math.floor(this._backoff + Math.random() * this._backoff * 0.25 - this._backoff * 0.125)); | ||||||
|   } |   } | ||||||
|   send(message) { |   send(message) { | ||||||
|     if (this.connected) { |     if (this.connected) { | ||||||
|       this._socket.send(message); |       this._socket.send(message); | ||||||
|     } else { |     } else { | ||||||
|       this._unsent.push(message); |       this._unsent.push(message); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|   close() { |   close() { | ||||||
|     this._closing = true; |     this._closing = true; | ||||||
|     this._socket.close(); |     this._socket.close(); | ||||||
|   } |   } | ||||||
|   addEventListener(...a) { |   addEventListener(...a) { | ||||||
|     return this._eventTarget.addEventListener(...a); |     return this._eventTarget.addEventListener(...a); | ||||||
|   } |   } | ||||||
|   removeEventListener(...a) { |   removeEventListener(...a) { | ||||||
|     return this._eventTarget.removeEventListener(...a); |     return this._eventTarget.removeEventListener(...a); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,163 +1,112 @@ | ||||||
| const loadVolume = () => { | import Plyr from "./plyr-3.7.3.min.esm.js"; | ||||||
|   try { | 
 | ||||||
|     const savedVolume = localStorage.getItem("watch-party-volume"); | /** | ||||||
|     if (savedVolume != null && savedVolume != "") { |  * @param {string} videoUrl | ||||||
|       return +savedVolume; |  * @param {{name: string, url: string}[]} subtitles | ||||||
|     } |  */ | ||||||
|   } catch (_err) { | const createVideoElement = (videoUrl, subtitles, created) => { | ||||||
|     // Sometimes localStorage is blocked from use
 |   const oldVideo = document.getElementById(".plyr"); | ||||||
|   } |   if (oldVideo) { | ||||||
|   // default
 |     oldVideo.remove(); | ||||||
|   return 0.5; |   } | ||||||
| }; |   const video = document.createElement("video"); | ||||||
| 
 |   video.id = "video"; | ||||||
| /** |   video.crossOrigin = "anonymous"; | ||||||
|  * @param {number} volume | 
 | ||||||
|  */ |   const source = document.createElement("source"); | ||||||
| const saveVolume = (volume) => { |   source.src = videoUrl; | ||||||
|   try { | 
 | ||||||
|     localStorage.setItem("watch-party-volume", volume); |   video.appendChild(source); | ||||||
|   } catch (_err) { | 
 | ||||||
|     // see loadVolume
 |   for (const { name, url } of subtitles) { | ||||||
|   } |     const track = document.createElement("track"); | ||||||
| }; |     track.label = name; | ||||||
| 
 |     track.srclang = "xx-" + name.toLowerCase(); | ||||||
| const loadCaptionTrack = () => { |     track.src = url; | ||||||
|   try { |     track.kind = "captions"; | ||||||
|     const savedTrack = localStorage.getItem("watch-party-captions"); |     video.appendChild(track); | ||||||
|     if (savedTrack != null && savedTrack != "") { |   } | ||||||
|       return +savedTrack; | 
 | ||||||
|     } |   const videoContainer = document.querySelector("#video-container"); | ||||||
|   } catch (_err) { |   videoContainer.style.display = "block"; | ||||||
|     // Sometimes localStorage is blocked from use
 |   videoContainer.appendChild(video); | ||||||
|   } | 
 | ||||||
|   // default
 |   const player = new Plyr(video, { | ||||||
|   return -1; |     clickToPlay: false, | ||||||
| }; |     settings: ["captions", "quality"], | ||||||
| 
 |     autopause: false, | ||||||
| /** |   }); | ||||||
|  * @param {number} track |   player.elements.controls.insertAdjacentHTML( | ||||||
|  */ |     "afterbegin", | ||||||
| const saveCaptionsTrack = (track) => { |     `<button type="button" aria-pressed="false" class="plyr__controls__item plyr__control lock-controls"><svg aria-hidden="true" focusable="false" viewBox="0 0 24 24"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zM9 8V6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9z"></path></svg><span class="label--pressed plyr__sr-only">Unlock controls</span><span class="label--not-pressed plyr__sr-only">Lock controls</span></button>` | ||||||
|   try { |   ); | ||||||
|     localStorage.setItem("watch-party-captions", track); |   const lockButton = player.elements.controls.children[0]; | ||||||
|   } catch (_err) { |   let controlsEnabled = created; | ||||||
|     // see loadCaptionsTrack
 |   const setControlsEnabled = (enabled) => { | ||||||
|   } |     controlsEnabled = enabled; | ||||||
| }; |     lockButton.setAttribute("aria-pressed", enabled); | ||||||
| 
 |     lockButton.classList.toggle("plyr__control--pressed", enabled); | ||||||
| /** |     player.elements.buttons.play[0].disabled = | ||||||
|  * @param {string} videoUrl |       player.elements.buttons.play[1].disabled = | ||||||
|  * @param {{name: string, url: string}[]} subtitles |       player.elements.inputs.seek.disabled = | ||||||
|  */ |         !enabled; | ||||||
| const createVideoElement = (videoUrl, subtitles) => { |     if (!enabled) { | ||||||
|   const oldVideo = document.getElementById("video"); |       // enable media button support
 | ||||||
|   if (oldVideo) { |       navigator.mediaSession.setActionHandler("play", null); | ||||||
|     oldVideo.remove(); |       navigator.mediaSession.setActionHandler("pause", null); | ||||||
|   } |       navigator.mediaSession.setActionHandler("stop", null); | ||||||
|   const video = document.createElement("video"); |       navigator.mediaSession.setActionHandler("seekbackward", null); | ||||||
|   video.id = "video"; |       navigator.mediaSession.setActionHandler("seekforward", null); | ||||||
|   video.controls = true; |       navigator.mediaSession.setActionHandler("seekto", null); | ||||||
|   video.autoplay = false; |       navigator.mediaSession.setActionHandler("previoustrack", null); | ||||||
|   video.volume = loadVolume(); |       navigator.mediaSession.setActionHandler("nexttrack", null); | ||||||
|   video.crossOrigin = "anonymous"; |     } else { | ||||||
| 
 |       // disable media button support by ignoring the events
 | ||||||
|   video.addEventListener("volumechange", async () => { |       navigator.mediaSession.setActionHandler("play", () => {}); | ||||||
|     saveVolume(video.volume); |       navigator.mediaSession.setActionHandler("pause", () => {}); | ||||||
|   }); |       navigator.mediaSession.setActionHandler("stop", () => {}); | ||||||
| 
 |       navigator.mediaSession.setActionHandler("seekbackward", () => {}); | ||||||
|   const source = document.createElement("source"); |       navigator.mediaSession.setActionHandler("seekforward", () => {}); | ||||||
|   source.src = videoUrl; |       navigator.mediaSession.setActionHandler("seekto", () => {}); | ||||||
| 
 |       navigator.mediaSession.setActionHandler("previoustrack", () => {}); | ||||||
|   video.appendChild(source); |       navigator.mediaSession.setActionHandler("nexttrack", () => {}); | ||||||
| 
 |     } | ||||||
|   const storedTrack = loadCaptionTrack(); |   }; | ||||||
|   let id = 0; |   setControlsEnabled(controlsEnabled); | ||||||
|   for (const { name, url } of subtitles) { |   lockButton.addEventListener("click", () => | ||||||
|     const track = document.createElement("track"); |     setControlsEnabled(!controlsEnabled) | ||||||
|     track.label = name; |   ); | ||||||
|     track.src = url; |   window.__plyr = player; | ||||||
|     track.kind = "captions"; | 
 | ||||||
| 
 |   return player; | ||||||
|     if (id == storedTrack || storedTrack == -1) { | }; | ||||||
|       track.default = true; | 
 | ||||||
|     } | /** | ||||||
| 
 |  * @param {string} videoUrl | ||||||
|     video.appendChild(track); |  * @param {{name: string, url: string}[]} subtitles | ||||||
|     id++; |  * @param {number} currentTime | ||||||
|   } |  * @param {boolean} playing | ||||||
| 
 |  */ | ||||||
|   video.textTracks.addEventListener("change", async () => { | export const setupVideo = async ( | ||||||
|     let id = 0; |   videoUrl, | ||||||
|     for (const track of video.textTracks) { |   subtitles, | ||||||
|       if (track.mode != "disabled") { |   currentTime, | ||||||
|         saveCaptionsTrack(id); |   playing, | ||||||
|         return; |   created | ||||||
|       } | ) => { | ||||||
|       id++; |   document.querySelector("#pre-join-controls").style["display"] = "none"; | ||||||
|     } |   const player = createVideoElement(videoUrl, subtitles, created); | ||||||
|     saveCaptionsTrack(-1); |   player.currentTime = currentTime / 1000.0; | ||||||
|   }); | 
 | ||||||
| 
 |   try { | ||||||
|   // watch for attribute changes on the video object to detect hiding/showing of controls
 |     if (playing) { | ||||||
|   // as far as i can tell this is the least hacky solutions to get control visibility change events
 |       player.play(); | ||||||
|   const observer = new MutationObserver(async (mutations) => { |     } else { | ||||||
|     for (const mutation of mutations) { |       player.pause(); | ||||||
|       if (mutation.attributeName == "controls") { |     } | ||||||
|         if (video.controls) { |   } catch (err) { | ||||||
|           // enable media button support
 |     // Auto-play is probably disabled, we should uhhhhhhh do something about it
 | ||||||
|           navigator.mediaSession.setActionHandler("play", null); |   } | ||||||
|           navigator.mediaSession.setActionHandler("pause", null); | 
 | ||||||
|           navigator.mediaSession.setActionHandler("stop", null); |   return player; | ||||||
|           navigator.mediaSession.setActionHandler("seekbackward", null); | }; | ||||||
|           navigator.mediaSession.setActionHandler("seekforward", null); |  | ||||||
|           navigator.mediaSession.setActionHandler("seekto", null); |  | ||||||
|           navigator.mediaSession.setActionHandler("previoustrack", null); |  | ||||||
|           navigator.mediaSession.setActionHandler("nexttrack", null); |  | ||||||
|         } else { |  | ||||||
|           // disable media button support by ignoring the events
 |  | ||||||
|           navigator.mediaSession.setActionHandler("play", () => {}); |  | ||||||
|           navigator.mediaSession.setActionHandler("pause", () => {}); |  | ||||||
|           navigator.mediaSession.setActionHandler("stop", () => {}); |  | ||||||
|           navigator.mediaSession.setActionHandler("seekbackward", () => {}); |  | ||||||
|           navigator.mediaSession.setActionHandler("seekforward", () => {}); |  | ||||||
|           navigator.mediaSession.setActionHandler("seekto", () => {}); |  | ||||||
|           navigator.mediaSession.setActionHandler("previoustrack", () => {}); |  | ||||||
|           navigator.mediaSession.setActionHandler("nexttrack", () => {}); |  | ||||||
|         } |  | ||||||
|         return; |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|   }); |  | ||||||
|   observer.observe(video, { attributes: true }); |  | ||||||
| 
 |  | ||||||
|   return video; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * @param {string} videoUrl |  | ||||||
|  * @param {{name: string, url: string}[]} subtitles |  | ||||||
|  * @param {number} currentTime |  | ||||||
|  * @param {boolean} playing |  | ||||||
|  */ |  | ||||||
| export const setupVideo = async (videoUrl, subtitles, currentTime, playing) => { |  | ||||||
|   document.querySelector("#pre-join-controls").style["display"] = "none"; |  | ||||||
|   const video = createVideoElement(videoUrl, subtitles); |  | ||||||
|   const videoContainer = document.querySelector("#video-container"); |  | ||||||
|   videoContainer.style.display = "block"; |  | ||||||
|   videoContainer.appendChild(video); |  | ||||||
| 
 |  | ||||||
|   video.currentTime = currentTime / 1000.0; |  | ||||||
| 
 |  | ||||||
|   try { |  | ||||||
|     if (playing) { |  | ||||||
|       await video.play(); |  | ||||||
|     } else { |  | ||||||
|       video.pause(); |  | ||||||
|     } |  | ||||||
|   } catch (err) { |  | ||||||
|     // Auto-play is probably disabled, we should uhhhhhhh do something about it
 |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   return video; |  | ||||||
| }; |  | ||||||
|  |  | ||||||
|  | @ -1,275 +1,270 @@ | ||||||
| import { setupVideo } from "./video.mjs?v=bfdcf2"; | import { setupVideo } from "./video.mjs?v=4b61c4"; | ||||||
| import { | import { | ||||||
|   setupChat, |   setupChat, | ||||||
|   logEventToChat, |   logEventToChat, | ||||||
|   updateViewerList, |   updateViewerList, | ||||||
|   printChatMessage, |   printChatMessage, | ||||||
| } from "./chat.mjs?v=bfdcf2"; | } from "./chat.mjs?v=4b61c4"; | ||||||
| import ReconnectingWebSocket from "./reconnecting-web-socket.mjs"; | import ReconnectingWebSocket from "./reconnecting-web-socket.mjs"; | ||||||
| import { state } from "./state.mjs"; | import { state } from "./state.mjs"; | ||||||
| 
 | let player; | ||||||
| /** | /** | ||||||
|  * @param {string} sessionId |  * @param {string} sessionId | ||||||
|  * @param {string} nickname |  * @param {string} nickname | ||||||
|  * @returns {ReconnectingWebSocket} |  * @returns {ReconnectingWebSocket} | ||||||
|  */ |  */ | ||||||
| const createWebSocket = () => { | const createWebSocket = () => { | ||||||
|   const wsUrl = new URL( |   const wsUrl = new URL( | ||||||
|     `/sess/${state().sessionId}/subscribe` + |     `/sess/${state().sessionId}/subscribe` + | ||||||
|       `?nickname=${encodeURIComponent(state().nickname)}` + |       `?nickname=${encodeURIComponent(state().nickname)}` + | ||||||
|       `&colour=${encodeURIComponent(state().colour)}`, |       `&colour=${encodeURIComponent(state().colour)}`, | ||||||
|     window.location.href |     window.location.href | ||||||
|   ); |   ); | ||||||
|   wsUrl.protocol = "ws" + window.location.protocol.slice(4); |   wsUrl.protocol = "ws" + window.location.protocol.slice(4); | ||||||
|   const socket = new ReconnectingWebSocket(wsUrl); |   const socket = new ReconnectingWebSocket(wsUrl); | ||||||
| 
 | 
 | ||||||
|   return socket; |   return socket; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| let outgoingDebounce = false; | let outgoingDebounce = false; | ||||||
| let outgoingDebounceCallbackId = null; | let outgoingDebounceCallbackId = null; | ||||||
| 
 | 
 | ||||||
| export const setDebounce = () => { | export const setDebounce = () => { | ||||||
|   outgoingDebounce = true; |   outgoingDebounce = true; | ||||||
| 
 | 
 | ||||||
|   if (outgoingDebounceCallbackId) { |   if (outgoingDebounceCallbackId) { | ||||||
|     cancelIdleCallback(outgoingDebounceCallbackId); |     cancelIdleCallback(outgoingDebounceCallbackId); | ||||||
|     outgoingDebounceCallbackId = null; |     outgoingDebounceCallbackId = null; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   outgoingDebounceCallbackId = setTimeout(() => { |   outgoingDebounceCallbackId = setTimeout(() => { | ||||||
|     outgoingDebounce = false; |     outgoingDebounce = false; | ||||||
|   }, 500); |   }, 500); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export const setVideoTime = (time, video = null) => { | export const setVideoTime = (time) => { | ||||||
|   if (video == null) { |   const timeSecs = time / 1000.0; | ||||||
|     video = document.querySelector("video"); |   if (Math.abs(player.currentTime - timeSecs) > 0.5) { | ||||||
|   } |     player.currentTime = timeSecs; | ||||||
| 
 |   } | ||||||
|   const timeSecs = time / 1000.0; | }; | ||||||
|   if (Math.abs(video.currentTime - timeSecs) > 0.5) { | 
 | ||||||
|     video.currentTime = timeSecs; | export const setPlaying = async (playing) => { | ||||||
|   } |   if (playing) { | ||||||
| }; |     await player.play(); | ||||||
| 
 |   } else { | ||||||
| export const setPlaying = async (playing, video = null) => { |     player.pause(); | ||||||
|   if (video == null) { |   } | ||||||
|     video = document.querySelector("video"); | }; | ||||||
|   } | 
 | ||||||
| 
 | /** | ||||||
|   if (playing) { |  * @param {HTMLVideoElement} video | ||||||
|     await video.play(); |  * @param {ReconnectingWebSocket} socket | ||||||
|   } else { |  */ | ||||||
|     video.pause(); | const setupIncomingEvents = (player, socket) => { | ||||||
|   } |   socket.addEventListener("message", async (messageEvent) => { | ||||||
| }; |     try { | ||||||
| 
 |       const event = JSON.parse(messageEvent.data); | ||||||
| /** |       if (!event.reflected) { | ||||||
|  * @param {HTMLVideoElement} video |         switch (event.op) { | ||||||
|  * @param {ReconnectingWebSocket} socket |           case "SetPlaying": | ||||||
|  */ |             setDebounce(); | ||||||
| const setupIncomingEvents = (video, socket) => { | 
 | ||||||
|   socket.addEventListener("message", async (messageEvent) => { |             if (event.data.playing) { | ||||||
|     try { |               await player.play(); | ||||||
|       const event = JSON.parse(messageEvent.data); |             } else { | ||||||
|       if (!event.reflected) { |               player.pause(); | ||||||
|         switch (event.op) { |             } | ||||||
|           case "SetPlaying": | 
 | ||||||
|             setDebounce(); |             setVideoTime(event.data.time); | ||||||
| 
 |             break; | ||||||
|             if (event.data.playing) { |           case "SetTime": | ||||||
|               await video.play(); |             setDebounce(); | ||||||
|             } else { |             setVideoTime(event.data); | ||||||
|               video.pause(); |             break; | ||||||
|             } |           case "UpdateViewerList": | ||||||
| 
 |             updateViewerList(event.data); | ||||||
|             setVideoTime(event.data.time, video); |             break; | ||||||
|             break; |         } | ||||||
|           case "SetTime": |       } | ||||||
|             setDebounce(); | 
 | ||||||
|             setVideoTime(event.data, video); |       logEventToChat(event); | ||||||
|             break; |     } catch (_err) {} | ||||||
|           case "UpdateViewerList": |   }); | ||||||
|             updateViewerList(event.data); | }; | ||||||
|             break; | 
 | ||||||
|         } | /** | ||||||
|       } |  * @param {Plyr} player | ||||||
| 
 |  * @param {ReconnectingWebSocket} socket | ||||||
|       logEventToChat(event); |  */ | ||||||
|     } catch (_err) {} | const setupOutgoingEvents = (player, socket) => { | ||||||
|   }); |   const currentVideoTime = () => (player.currentTime * 1000) | 0; | ||||||
| }; | 
 | ||||||
| 
 |   player.on("pause", async () => { | ||||||
| /** |     if (outgoingDebounce || player.elements.inputs.seek.disabled) { | ||||||
|  * @param {HTMLVideoElement} video |       return; | ||||||
|  * @param {ReconnectingWebSocket} socket |     } | ||||||
|  */ | 
 | ||||||
| const setupOutgoingEvents = (video, socket) => { |     // don't send a pause event for the video ending
 | ||||||
|   const currentVideoTime = () => (video.currentTime * 1000) | 0; |     if (player.currentTime == player.duration) { | ||||||
| 
 |       return; | ||||||
|   video.addEventListener("pause", async (event) => { |     } | ||||||
|     if (outgoingDebounce || !video.controls) { | 
 | ||||||
|       return; |     socket.send( | ||||||
|     } |       JSON.stringify({ | ||||||
| 
 |         op: "SetPlaying", | ||||||
|     // don't send a pause event for the video ending
 |         data: { | ||||||
|     if (video.currentTime == video.duration) { |           playing: false, | ||||||
|       return; |           time: currentVideoTime(), | ||||||
|     } |         }, | ||||||
| 
 |       }) | ||||||
|     socket.send( |     ); | ||||||
|       JSON.stringify({ |   }); | ||||||
|         op: "SetPlaying", | 
 | ||||||
|         data: { |   player.on("play", () => { | ||||||
|           playing: false, |     if (outgoingDebounce || player.elements.inputs.seek.disabled) { | ||||||
|           time: currentVideoTime(), |       return; | ||||||
|         }, |     } | ||||||
|       }) | 
 | ||||||
|     ); |     socket.send( | ||||||
|   }); |       JSON.stringify({ | ||||||
| 
 |         op: "SetPlaying", | ||||||
|   video.addEventListener("play", (event) => { |         data: { | ||||||
|     if (outgoingDebounce || !video.controls) { |           playing: true, | ||||||
|       return; |           time: currentVideoTime(), | ||||||
|     } |         }, | ||||||
| 
 |       }) | ||||||
|     socket.send( |     ); | ||||||
|       JSON.stringify({ |   }); | ||||||
|         op: "SetPlaying", | 
 | ||||||
|         data: { |   let firstSeekComplete = false; | ||||||
|           playing: true, |   player.on("seeked", async (event) => { | ||||||
|           time: currentVideoTime(), |     if (!firstSeekComplete) { | ||||||
|         }, |       // The first seeked event is performed by the browser when the video is loading
 | ||||||
|       }) |       firstSeekComplete = true; | ||||||
|     ); |       return; | ||||||
|   }); |     } | ||||||
| 
 | 
 | ||||||
|   let firstSeekComplete = false; |     if (outgoingDebounce || player.elements.inputs.seek.disabled) { | ||||||
|   video.addEventListener("seeked", async (event) => { |       return; | ||||||
|     if (!firstSeekComplete) { |     } | ||||||
|       // The first seeked event is performed by the browser when the video is loading
 | 
 | ||||||
|       firstSeekComplete = true; |     socket.send( | ||||||
|       return; |       JSON.stringify({ | ||||||
|     } |         op: "SetTime", | ||||||
| 
 |         data: { | ||||||
|     if (outgoingDebounce || !video.controls) { |           to: currentVideoTime(), | ||||||
|       return; |         }, | ||||||
|     } |       }) | ||||||
| 
 |     ); | ||||||
|     socket.send( |   }); | ||||||
|       JSON.stringify({ | }; | ||||||
|         op: "SetTime", | 
 | ||||||
|         data: { | export const joinSession = async (created) => { | ||||||
|           to: currentVideoTime(), |   if (state().activeSession) { | ||||||
|         }, |     if (state().activeSession === state().sessionId) { | ||||||
|       }) |       // we are already in this session, dont rejoin
 | ||||||
|     ); |       return; | ||||||
|   }); |     } | ||||||
| }; |     // we are joining a new session from an existing session
 | ||||||
| 
 |     const messageContent = document.createElement("span"); | ||||||
| export const joinSession = async () => { |     messageContent.appendChild(document.createTextNode("joining new session ")); | ||||||
|   if (state().activeSession) { |     messageContent.appendChild(document.createTextNode(state().sessionId)); | ||||||
|     if (state().activeSession === state().sessionId) { | 
 | ||||||
|       // we are already in this session, dont rejoin
 |     printChatMessage("join-session", "watch-party", "#fffff", messageContent); | ||||||
|       return; |   } | ||||||
|     } |   state().activeSession = state().sessionId; | ||||||
|     // we are joining a new session from an existing session
 | 
 | ||||||
|     const messageContent = document.createElement("span"); |   // try { // we are handling errors in the join form.
 | ||||||
|     messageContent.appendChild(document.createTextNode("joining new session ")); |   const genericConnectionError = new Error( | ||||||
|     messageContent.appendChild(document.createTextNode(state().sessionId)); |     "There was an issue getting the session information." | ||||||
| 
 |   ); | ||||||
|     printChatMessage("join-session", "watch-party", "#fffff", messageContent); |   window.location.hash = state().sessionId; | ||||||
|   } |   let response, video_url, subtitle_tracks, current_time_ms, is_playing; | ||||||
|   state().activeSession = state().sessionId; |   try { | ||||||
| 
 |     response = await fetch(`/sess/${state().sessionId}`); | ||||||
|   // try { // we are handling errors in the join form.
 |   } catch (e) { | ||||||
|   const genericConnectionError = new Error( |     console.error(e); | ||||||
|     "There was an issue getting the session information." |     throw genericConnectionError; | ||||||
|   ); |   } | ||||||
|   window.location.hash = state().sessionId; |   if (!response.ok) { | ||||||
|   let response, video_url, subtitle_tracks, current_time_ms, is_playing; |     let error; | ||||||
|   try { |     try { | ||||||
|     response = await fetch(`/sess/${state().sessionId}`); |       ({ error } = await response.json()); | ||||||
|   } catch (e) { |       if (!error) throw new Error(); | ||||||
|     console.error(e); |     } catch (e) { | ||||||
|     throw genericConnectionError; |       console.error(e); | ||||||
|   } |       throw genericConnectionError; | ||||||
|   if (!response.ok) { |     } | ||||||
|     let error; |     throw new Error(error); | ||||||
|     try { |   } | ||||||
|       ({ error } = await response.json()); |   try { | ||||||
|       if (!error) throw new Error(); |     ({ video_url, subtitle_tracks, current_time_ms, is_playing } = | ||||||
|     } catch (e) { |       await response.json()); | ||||||
|       console.error(e); |   } catch (e) { | ||||||
|       throw genericConnectionError; |     console.error(e); | ||||||
|     } |     throw genericConnectionError; | ||||||
|     throw new Error(error); |   } | ||||||
|   } | 
 | ||||||
|   try { |   if (state().socket) { | ||||||
|     ({ video_url, subtitle_tracks, current_time_ms, is_playing } = |     state().socket.close(); | ||||||
|       await response.json()); |     state().socket = null; | ||||||
|   } catch (e) { |   } | ||||||
|     console.error(e); |   const socket = createWebSocket(); | ||||||
|     throw genericConnectionError; |   state().socket = socket; | ||||||
|   } |   socket.addEventListener("open", async () => { | ||||||
| 
 |     player = await setupVideo( | ||||||
|   if (state().socket) { |       video_url, | ||||||
|     state().socket.close(); |       subtitle_tracks, | ||||||
|     state().socket = null; |       current_time_ms, | ||||||
|   } |       is_playing, | ||||||
|   const socket = createWebSocket(); |       created | ||||||
|   state().socket = socket; |     ); | ||||||
|   socket.addEventListener("open", async () => { | 
 | ||||||
|     const video = await setupVideo( |     player.on("canplay", () => { | ||||||
|       video_url, |       sync(); | ||||||
|       subtitle_tracks, |     }); | ||||||
|       current_time_ms, | 
 | ||||||
|       is_playing |     setupOutgoingEvents(player, socket); | ||||||
|     ); |     setupIncomingEvents(player, socket); | ||||||
| 
 |     setupChat(socket); | ||||||
|     let defaultAllowControls = false; |   }); | ||||||
|     try { |   socket.addEventListener("reconnecting", (e) => { | ||||||
|       defaultAllowControls = localStorage.getItem( |     console.log("Reconnecting..."); | ||||||
|         "watch-party-default-allow-controls" |   }); | ||||||
|       ); |   socket.addEventListener("reconnected", (e) => { | ||||||
|     } catch (_err) {} |     console.log("Reconnected."); | ||||||
| 
 |   }); | ||||||
|     // By default, we should disable video controls if the video is already playing.
 |   //} catch (e) {
 | ||||||
|     // This solves an issue where Safari users join and seek to 00:00:00 because of
 |   //  alert(e.message)
 | ||||||
|     // outgoing events.
 |   //}
 | ||||||
|     if (current_time_ms != 0 || !defaultAllowControls) { | }; | ||||||
|       video.controls = false; | 
 | ||||||
|     } | /** | ||||||
| 
 |  * @param {string} videoUrl | ||||||
|     setupOutgoingEvents(video, socket); |  * @param {Array} subtitleTracks | ||||||
|     setupIncomingEvents(video, socket); |  */ | ||||||
|     setupChat(socket); | export const createSession = async (videoUrl, subtitleTracks) => { | ||||||
|   }); |   const { id } = await fetch("/start_session", { | ||||||
|   socket.addEventListener("reconnecting", (e) => { |     method: "POST", | ||||||
|     console.log("Reconnecting..."); |     headers: { "Content-Type": "application/json" }, | ||||||
|   }); |     body: JSON.stringify({ | ||||||
|   socket.addEventListener("reconnected", (e) => { |       video_url: videoUrl, | ||||||
|     console.log("Reconnected."); |       subtitle_tracks: subtitleTracks, | ||||||
|   }); |     }), | ||||||
|   //} catch (e) {
 |   }).then((r) => r.json()); | ||||||
|   //  alert(e.message)
 | 
 | ||||||
|   //}
 |   window.location = `/?created=true#${id}`; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| /** | export const sync = async () => { | ||||||
|  * @param {string} videoUrl |   setDebounce(); | ||||||
|  * @param {Array} subtitleTracks |   await setPlaying(false); | ||||||
|  */ |   const { current_time_ms, is_playing } = await fetch( | ||||||
| export const createSession = async (videoUrl, subtitleTracks) => { |     `/sess/${state().sessionId}` | ||||||
|   const { id } = await fetch("/start_session", { |   ).then((r) => r.json()); | ||||||
|     method: "POST", | 
 | ||||||
|     headers: { "Content-Type": "application/json" }, |   setDebounce(); | ||||||
|     body: JSON.stringify({ |   setVideoTime(current_time_ms); | ||||||
|       video_url: videoUrl, |   if (is_playing) await setPlaying(is_playing); | ||||||
|       subtitle_tracks: subtitleTracks, | }; | ||||||
|     }), |  | ||||||
|   }).then((r) => r.json()); |  | ||||||
| 
 |  | ||||||
|   window.location = `/?created=true#${id}`; |  | ||||||
| }; |  | ||||||
|  |  | ||||||
|  | @ -1,19 +1,11 @@ | ||||||
| import { setupJoinSessionForm } from "./lib/join-session.mjs?v=bfdcf2"; | import { setupJoinSessionForm } from "./lib/join-session.mjs?v=4b61c4"; | ||||||
| import { | 
 | ||||||
|   toggleOptionPane, | const main = () => { | ||||||
|   togglePlayerControlsShown, |   setupJoinSessionForm(); | ||||||
|   handlePlingVolume | }; | ||||||
| } from "./lib/options-pane.mjs?v=bfdcf2"; | 
 | ||||||
| 
 | if (document.readyState === "complete") { | ||||||
| const main = () => { |   main(); | ||||||
|   setupJoinSessionForm(); | } else { | ||||||
|   window.toggleOptionPane = toggleOptionPane; |   document.addEventListener("DOMContentLoaded", main); | ||||||
|   window.togglePlayerControlsShown = togglePlayerControlsShown; | } | ||||||
|   window.handlePlingVolume = handlePlingVolume |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| if (document.readyState === "complete") { |  | ||||||
|   main(); |  | ||||||
| } else { |  | ||||||
|   document.addEventListener("DOMContentLoaded", main); |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  | @ -1,465 +1,435 @@ | ||||||
| *, | *, | ||||||
| *:before, | *:before, | ||||||
| *:after { | *:after { | ||||||
|   box-sizing: border-box; |   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-rgb: 181, 127, 220; | ||||||
|   --accent-darker: rgb(95, 37, 136); /*50% darker*/ |   --fg: rgb(var(--fg-rgb)); | ||||||
|   --accent-darkest: rgb(47, 19, 68); /*75% darker*/ |   --bg: rgb(var(--bg-rgb)); | ||||||
|   --fg: rgb(var(--fg-rgb)); |   --default-user-color: rgb(126, 208, 255); | ||||||
|   --bg: rgb(var(--bg-rgb)); |   --accent: rgb(var(--accent-rgb)); | ||||||
|   --default-user-color: rgb(126, 208, 255); |   --fg-transparent: rgba(var(--fg-rgb), 0.25); | ||||||
|   --accent: rgb(var(--accent-rgb)); |   --bg-transparent: rgba(var(--bg-rgb), 0.25); | ||||||
|   --fg-transparent: rgba(var(--fg-rgb), 0.25); |   --autocomplete-bg: linear-gradient( | ||||||
|   --bg-transparent: rgba(var(--bg-rgb), 0.25); |       var(--fg-transparent), | ||||||
|   --autocomplete-bg: linear-gradient( |       var(--fg-transparent) | ||||||
|       var(--fg-transparent), |     ), | ||||||
|       var(--fg-transparent) |     linear-gradient(var(--bg), var(--bg)); | ||||||
|     ), |   --chip-bg: linear-gradient( | ||||||
|     linear-gradient(var(--bg), var(--bg)); |       var(--accent-transparent), | ||||||
|   --chip-bg: linear-gradient( |       var(--accent-transparent) | ||||||
|       var(--accent-transparent), |     ), | ||||||
|       var(--accent-transparent) |     linear-gradient(var(--bg), var(--bg)); | ||||||
|     ), |   --accent-transparent: rgba(var(--accent-rgb), 0.25); | ||||||
|     linear-gradient(var(--bg), var(--bg)); |   --plyr-color-main: var(--accent); | ||||||
|   --accent-transparent: rgba(var(--accent-rgb), 0.25); |   --plyr-control-radius: 6px; | ||||||
| } |   --plyr-menu-radius: 6px; | ||||||
| 
 |   --plyr-menu-background: var(--autocomplete-bg); | ||||||
| html { |   --plyr-menu-color: var(--fg); | ||||||
|   background-color: var(--bg); |   --plyr-menu-arrow-color: var(--fg); | ||||||
|   color: var(--fg); |   --plyr-menu-back-border-color: var(--fg-transparent); | ||||||
|   font-size: 1.125rem; |   --plyr-menu-back-border-shadow-color: transparent; | ||||||
|   font-family: sans-serif; | } | ||||||
| } | 
 | ||||||
| 
 | html { | ||||||
| html, |   background-color: var(--bg); | ||||||
| body { |   color: var(--fg); | ||||||
|   margin: 0; |   font-size: 1.125rem; | ||||||
|   padding: 0; |   font-family: sans-serif; | ||||||
|   overflow: hidden; | } | ||||||
|   overscroll-behavior: none; | 
 | ||||||
|   width: 100%; | html, | ||||||
|   height: 100%; | body { | ||||||
| } |   margin: 0; | ||||||
| 
 |   padding: 0; | ||||||
| body { |   overflow: hidden; | ||||||
|   display: flex; |   overscroll-behavior: none; | ||||||
|   flex-direction: column; |   width: 100%; | ||||||
| } |   height: 100%; | ||||||
| 
 | } | ||||||
| video { | 
 | ||||||
|   display: block; | body { | ||||||
|   width: 100%; |   display: flex; | ||||||
|   height: 100%; |   flex-direction: column; | ||||||
|   object-fit: contain; | } | ||||||
| } | 
 | ||||||
| 
 | .lock-controls.plyr__control--pressed svg { | ||||||
| #video-container { |   opacity: 0.5; | ||||||
|   flex-grow: 0; | } | ||||||
|   flex-shrink: 1; | 
 | ||||||
|   display: none; | .plyr { | ||||||
| } |   width: 100%; | ||||||
| 
 |   height: 100%; | ||||||
| a { | } | ||||||
|   color: var(--accent); | 
 | ||||||
| } | .plyr__menu__container { | ||||||
| 
 |   --plyr-video-control-background-hover: var(--fg-transparent); | ||||||
| .chip { |   --plyr-video-control-color-hover: var(--fg); | ||||||
|   color: var(--fg); |   --plyr-control-radius: 4px; | ||||||
|   background: var(--chip-bg); |   --plyr-control-spacing: calc(0.25rem / 0.7); | ||||||
|   text-decoration: none; |   --plyr-font-size-menu: 0.75rem; | ||||||
|   padding: 0 0.5rem 0 1.45rem; |   --plyr-menu-arrow-size: 0; | ||||||
|   display: inline-flex; |   margin-bottom: 0.48rem; | ||||||
|   position: relative; |   max-height: 27vmin; | ||||||
|   font-size: 0.9rem; |   clip-path: inset(0 0 0 0 round 4px); | ||||||
|   height: 1.125rem; |   scrollbar-width: thin; | ||||||
|   align-items: center; | } | ||||||
|   border-radius: 2rem; | 
 | ||||||
|   overflow: hidden; | .plyr__menu__container .plyr__control[role="menuitemradio"]::after { | ||||||
| } |   left: 10px; | ||||||
| 
 | } | ||||||
| .chip::before { | 
 | ||||||
|   content: ""; | .plyr__menu__container | ||||||
|   position: absolute; |   .plyr__control[role="menuitemradio"][aria-checked="true"].plyr__tab-focus::before, | ||||||
|   left: 0; | .plyr__menu__container | ||||||
|   top: 0; |   .plyr__control[role="menuitemradio"][aria-checked="true"]:hover::before { | ||||||
|   width: 1.125rem; |   background: var(--accent); | ||||||
|   height: 100%; | } | ||||||
|   display: flex; | 
 | ||||||
|   align-items: center; | [data-plyr="language"] .plyr__menu__value { | ||||||
|   justify-content: center; |   display: none; | ||||||
|   text-align: center; | } | ||||||
|   background: var(--accent-transparent); | 
 | ||||||
|   background-repeat: no-repeat; | #video-container { | ||||||
|   background-size: 18px; |   flex-grow: 0; | ||||||
|   background-position: center; |   flex-shrink: 1; | ||||||
| } |   display: none; | ||||||
| 
 | } | ||||||
| .join-chip::before { | 
 | ||||||
|   background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI2ZmZiI+PHBhdGggZD0iTTggNXYxNGwxMS03eiIvPjwvc3ZnPg=="); | a { | ||||||
| } |   color: var(--accent); | ||||||
| 
 | } | ||||||
| .time-chip::before { | 
 | ||||||
|   background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI2ZmZiI+PHBhdGggZD0iTTExLjk5IDJDNi40NyAyIDIgNi40OCAyIDEyczQuNDcgMTAgOS45OSAxMEMxNy41MiAyMiAyMiAxNy41MiAyMiAxMlMxNy41MiAyIDExLjk5IDJ6TTEyIDIwYy00LjQyIDAtOC0zLjU4LTgtOHMzLjU4LTggOC04IDggMy41OCA4IDgtMy41OCA4LTggOHoiLz48cGF0aCBkPSJNMTIuNSA3SDExdjZsNS4yNSAzLjE1Ljc1LTEuMjMtNC41LTIuNjd6Ii8+PC9zdmc+"); | .chip { | ||||||
| } |   color: var(--fg); | ||||||
| 
 |   background: var(--chip-bg); | ||||||
| label { |   text-decoration: none; | ||||||
|   display: block; |   padding: 0 0.5rem 0 1.45rem; | ||||||
| } |   display: inline-flex; | ||||||
| 
 |   position: relative; | ||||||
| input[type="url"], |   font-size: 0.9rem; | ||||||
| input[type="text"] { |   height: 1.125rem; | ||||||
|   background: #fff; |   align-items: center; | ||||||
|   background-clip: padding-box; |   border-radius: 2rem; | ||||||
|   border: 1px solid rgba(0, 0, 0, 0.12); |   overflow: hidden; | ||||||
|   border-radius: 6px; | } | ||||||
|   color: rgba(0, 0, 0, 0.8); | 
 | ||||||
|   display: block; | .chip::before { | ||||||
| 
 |   content: ""; | ||||||
|   margin: 0.5em 0; |   position: absolute; | ||||||
|   padding: 0.5em 1em; |   left: 0; | ||||||
|   line-height: 1.5; |   top: 0; | ||||||
| 
 |   width: 1.125rem; | ||||||
|   font-family: sans-serif; |   height: 100%; | ||||||
|   font-size: 1em; |   display: flex; | ||||||
|   width: 100%; |   align-items: center; | ||||||
| 
 |   justify-content: center; | ||||||
|   resize: none; |   text-align: center; | ||||||
|   overflow-x: wrap; |   background: var(--accent-transparent); | ||||||
|   overflow-y: scroll; |   background-repeat: no-repeat; | ||||||
| } |   background-size: 18px; | ||||||
| 
 |   background-position: center; | ||||||
| button { | } | ||||||
|   background-color: var(--accent); | 
 | ||||||
|   border: var(--accent); | .join-chip::before { | ||||||
|   border-radius: 6px; |   background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI2ZmZiI+PHBhdGggZD0iTTggNXYxNGwxMS03eiIvPjwvc3ZnPg=="); | ||||||
|   color: #fff; | } | ||||||
|   padding: 0.5em 1em; | 
 | ||||||
|   display: inline-block; | .time-chip::before { | ||||||
|   font-weight: 400; |   background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI2ZmZiI+PHBhdGggZD0iTTExLjk5IDJDNi40NyAyIDIgNi40OCAyIDEyczQuNDcgMTAgOS45OSAxMEMxNy41MiAyMiAyMiAxNy41MiAyMiAxMlMxNy41MiAyIDExLjk5IDJ6TTEyIDIwYy00LjQyIDAtOC0zLjU4LTgtOHMzLjU4LTggOC04IDggMy41OCA4IDgtMy41OCA4LTggOHoiLz48cGF0aCBkPSJNMTIuNSA3SDExdjZsNS4yNSAzLjE1Ljc1LTEuMjMtNC41LTIuNjd6Ii8+PC9zdmc+"); | ||||||
|   text-align: center; | } | ||||||
|   white-space: nowrap; | 
 | ||||||
|   vertical-align: middle; | label { | ||||||
| 
 |   display: block; | ||||||
|   font-family: sans-serif; | } | ||||||
|   font-size: 1em; | 
 | ||||||
|   width: 100%; | input[type="url"], | ||||||
| 
 | input[type="text"] { | ||||||
|   user-select: none; |   background: #fff; | ||||||
|   border: 1px solid rgba(0, 0, 0, 0); |   background-clip: padding-box; | ||||||
|   line-height: 1.5; |   border: 1px solid rgba(0, 0, 0, 0.12); | ||||||
|   cursor: pointer; |   border-radius: 6px; | ||||||
|   margin: 0.5em 0; |   color: rgba(0, 0, 0, 0.8); | ||||||
| } |   display: block; | ||||||
| 
 | 
 | ||||||
| button:disabled { |   margin: 0.5em 0; | ||||||
|   filter: saturate(0.75); |   padding: 0.5em 1em; | ||||||
|   opacity: 0.75; |   line-height: 1.5; | ||||||
|   cursor: default; | 
 | ||||||
| } |   font-family: sans-serif; | ||||||
| 
 |   font-size: 1em; | ||||||
| button.small-button { |   width: 100%; | ||||||
|   font-size: 0.75em; | 
 | ||||||
|   padding-top: 0; |   resize: none; | ||||||
|   padding-bottom: 0; |   overflow-x: wrap; | ||||||
| } |   overflow-y: scroll; | ||||||
| 
 | } | ||||||
| .subtitle-track-group { | 
 | ||||||
|   display: flex; | button:not(.plyr button) { | ||||||
| } |   background-color: var(--accent); | ||||||
| 
 |   border: var(--accent); | ||||||
| .subtitle-track-group > * { |   border-radius: 6px; | ||||||
|   margin-top: 0 !important; |   color: #fff; | ||||||
|   margin-bottom: 0 !important; |   padding: 0.5em 1em; | ||||||
|   margin-right: 1ch !important; |   display: inline-block; | ||||||
| } |   font-weight: 400; | ||||||
| 
 |   text-align: center; | ||||||
| #pre-join-controls, |   white-space: nowrap; | ||||||
| #create-controls { |   vertical-align: middle; | ||||||
|   margin: 0; | 
 | ||||||
|   flex-grow: 1; |   font-family: sans-serif; | ||||||
|   overflow-y: auto; |   font-size: 1em; | ||||||
|   display: flex; |   width: 100%; | ||||||
|   flex-direction: column; | 
 | ||||||
|   align-items: center; |   user-select: none; | ||||||
|   justify-content: center; |   border: 1px solid rgba(0, 0, 0, 0); | ||||||
| } |   line-height: 1.5; | ||||||
| 
 |   cursor: pointer; | ||||||
| #join-session-form, |   margin: 0.5em 0; | ||||||
| #create-session-form { | } | ||||||
|   width: 500px; | 
 | ||||||
|   max-width: 100%; | button:disabled { | ||||||
|   padding: 1rem; |   filter: saturate(0.75); | ||||||
| } |   opacity: 0.75; | ||||||
| 
 |   cursor: default; | ||||||
| #join-session-form > *:first-child, | } | ||||||
| #create-session-form > *:first-child { | 
 | ||||||
|   margin-top: 0; | button.small-button { | ||||||
| } |   font-size: 0.75em; | ||||||
| 
 |   padding-top: 0; | ||||||
| #post-create-message { |   padding-bottom: 0; | ||||||
|   display: none; | } | ||||||
|   width: 100%; | 
 | ||||||
|   font-size: 0.85em; | .subtitle-track-group { | ||||||
| } |   display: flex; | ||||||
| 
 | } | ||||||
| #chatbox-container { | 
 | ||||||
|   display: none; | .subtitle-track-group > * { | ||||||
| } |   margin-top: 0 !important; | ||||||
| 
 |   margin-bottom: 0 !important; | ||||||
| .chat-message { |   margin-right: 1ch !important; | ||||||
|   overflow-wrap: break-word; | } | ||||||
|   margin-bottom: 0.125rem; | 
 | ||||||
| } | #pre-join-controls, | ||||||
| 
 | #create-controls { | ||||||
| .chat-message > strong, |   margin: 0; | ||||||
| #viewer-list strong { |   flex-grow: 1; | ||||||
|   color: var(--user-color, var(--default-user-color)); |   overflow-y: auto; | ||||||
| } |   display: flex; | ||||||
| 
 |   flex-direction: column; | ||||||
| .chat-message.user-join, |   align-items: center; | ||||||
| .chat-message.user-leave, |   justify-content: center; | ||||||
| .chat-message.ping { | } | ||||||
|   font-style: italic; | 
 | ||||||
| } | #join-session-form, | ||||||
| 
 | #create-session-form { | ||||||
| .chat-message.set-time, |   width: 500px; | ||||||
| .chat-message.set-playing, |   max-width: 100%; | ||||||
| .chat-message.join-session { |   padding: 1rem; | ||||||
|   font-style: italic; | } | ||||||
|   text-align: right; | 
 | ||||||
|   font-size: 0.85em; | #join-session-form > *:first-child, | ||||||
| } | #create-session-form > *:first-child { | ||||||
| 
 |   margin-top: 0; | ||||||
| .chat-message.command-message { | } | ||||||
|   font-size: 0.85em; | 
 | ||||||
| } | #post-create-message { | ||||||
| 
 |   display: none; | ||||||
| .chat-message.set-time > strong, |   width: 100%; | ||||||
| .chat-message.set-playing > strong, |   font-size: 0.85em; | ||||||
| .chat-message.join-session > strong { | } | ||||||
|   color: unset !important; | 
 | ||||||
| } | #chatbox-container { | ||||||
| 
 |   display: none; | ||||||
| .emoji { | } | ||||||
|   width: 2ch; | 
 | ||||||
|   height: 2ch; | .chat-message { | ||||||
|   object-fit: contain; |   overflow-wrap: break-word; | ||||||
|   margin-bottom: -0.35ch; |   margin-bottom: 0.125rem; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #chatbox { | .chat-message > strong, | ||||||
|   padding: 0.5em 1em; | #viewer-list strong { | ||||||
|   overflow-y: scroll; |   color: var(--user-color, var(--default-user-color)); | ||||||
|   flex-shrink: 1; | } | ||||||
|   flex-grow: 1; | 
 | ||||||
| } | .chat-message.user-join, | ||||||
| 
 | .chat-message.user-leave, | ||||||
| #viewer-list { | .chat-message.ping { | ||||||
|   padding: 0.5em 1em; |   font-style: italic; | ||||||
|   /* TODO: turn this into max-height instead of fixed height without breaking the chatbox height */ | } | ||||||
|   overflow-y: scroll; | 
 | ||||||
|   border-bottom: var(--fg-transparent); | .chat-message.set-time, | ||||||
|   border-bottom-style: solid; | .chat-message.set-playing, | ||||||
|   max-height: 4rem; | .chat-message.join-session { | ||||||
|   flex-shrink: 0; |   font-style: italic; | ||||||
| } |   text-align: right; | ||||||
| 
 |   font-size: 0.85em; | ||||||
| #chatbox-container { | } | ||||||
|   background-color: var(--bg); | 
 | ||||||
|   flex-direction: column; | .chat-message.command-message { | ||||||
|   flex-grow: 1; |   font-size: 0.85em; | ||||||
|   flex-shrink: 1; | } | ||||||
|   flex-basis: 36ch; | 
 | ||||||
|   min-width: 36ch; | .chat-message.set-time > strong, | ||||||
|   overflow: hidden; | .chat-message.set-playing > strong, | ||||||
| } | .chat-message.join-session > strong { | ||||||
| 
 |   color: unset !important; | ||||||
| #chatbox-send { | } | ||||||
|   padding: 0 1em; | 
 | ||||||
|   position: relative; | .emoji { | ||||||
| } |   width: 2ch; | ||||||
| 
 |   height: 2ch; | ||||||
| #chatbox-send > input { |   object-fit: contain; | ||||||
|   font-size: 0.75em; |   margin-bottom: -0.35ch; | ||||||
|   width: 100%; | } | ||||||
| } | 
 | ||||||
| 
 | #chatbox { | ||||||
| #emoji-autocomplete { |   padding: 0.5em 1em; | ||||||
|   position: absolute; |   overflow-y: scroll; | ||||||
|   bottom: 3.25rem; |   flex-shrink: 1; | ||||||
|   background-image: var(--autocomplete-bg); |   flex-grow: 1; | ||||||
|   border-radius: 6px; | } | ||||||
|   width: calc(100% - 2rem); | 
 | ||||||
|   max-height: 8.5rem; | #viewer-list { | ||||||
|   overflow-y: auto; |   padding: 0.5em 1em; | ||||||
|   clip-path: inset(0 0 0 0 round 8px); |   /* TODO: turn this into max-height instead of fixed height without breaking the chatbox height */ | ||||||
| } |   overflow-y: scroll; | ||||||
| 
 |   border-bottom: var(--fg-transparent); | ||||||
| #emoji-autocomplete:empty { |   border-bottom-style: solid; | ||||||
|   display: none; |   max-height: 4rem; | ||||||
| } |   flex-shrink: 0; | ||||||
| 
 | } | ||||||
| .emoji-option { | 
 | ||||||
|   background: transparent; | #chatbox-container { | ||||||
|   font-size: 0.75rem; |   background-color: var(--bg); | ||||||
|   text-align: left; |   flex-direction: column; | ||||||
|   margin: 0 0.25rem; |   flex-grow: 1; | ||||||
|   border-radius: 4px; |   flex-shrink: 1; | ||||||
|   width: calc(100% - 0.5rem); |   flex-basis: 36ch; | ||||||
|   display: flex; |   min-width: 36ch; | ||||||
|   align-items: center; |   overflow: hidden; | ||||||
|   padding: 0.25rem 0.5rem; | } | ||||||
|   scroll-margin: 0.25rem; | 
 | ||||||
| } | #chatbox-send { | ||||||
| 
 |   padding: 0 1em; | ||||||
| .emoji-option:first-child { |   padding-bottom: 0.5em; | ||||||
|   margin-top: 0.25rem; |   position: relative; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .emoji-option:last-child { | #chatbox-send > input { | ||||||
|   margin-bottom: 0.25rem; |   font-size: 0.75em; | ||||||
| } |   width: 100%; | ||||||
| 
 | } | ||||||
| .emoji-option .emoji { | 
 | ||||||
|   width: 1.25rem; | #emoji-autocomplete { | ||||||
|   height: 1.25rem; |   position: absolute; | ||||||
|   margin: 0 0.5rem 0 0; |   bottom: 3.25rem; | ||||||
|   font-size: 2.25ch; |   background-image: var(--autocomplete-bg); | ||||||
|   display: flex; |   border-radius: 6px; | ||||||
|   align-items: center; |   width: calc(100% - 2rem); | ||||||
|   justify-content: center; |   max-height: 8.5rem; | ||||||
|   overflow: hidden; |   overflow-y: auto; | ||||||
|   flex-shrink: 0; |   clip-path: inset(0 0 0 0 round 8px); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .emoji-name { | #emoji-autocomplete:empty { | ||||||
|   overflow: hidden; |   display: none; | ||||||
|   text-overflow: ellipsis; | } | ||||||
| } | 
 | ||||||
| 
 | .emoji-option:not(:root) { | ||||||
| .emoji-option.selected { |   background: transparent; | ||||||
|   background: var(--fg-transparent); |   font-size: 0.75rem; | ||||||
| } |   text-align: left; | ||||||
| 
 |   margin: 0 0.25rem; | ||||||
| #join-session-colour { |   border-radius: 4px; | ||||||
|   -moz-appearance: none; |   width: calc(100% - 0.5rem); | ||||||
|   -webkit-appearance: none; |   display: flex; | ||||||
|   appearance: none; |   align-items: center; | ||||||
|   border: none; |   padding: 0.25rem 0.5rem; | ||||||
|   padding: 0; |   scroll-margin: 0.25rem; | ||||||
|   border-radius: 6px; | } | ||||||
|   overflow: hidden; | 
 | ||||||
|   margin: 0.5em 0; | .emoji-option:first-child { | ||||||
|   height: 2rem; |   margin-top: 0.25rem; | ||||||
|   width: 2.5rem; | } | ||||||
|   cursor: pointer; | 
 | ||||||
| } | .emoji-option:last-child { | ||||||
| 
 |   margin-bottom: 0.25rem; | ||||||
| #options-toggle { | } | ||||||
|   padding: 0 1em 1em; | 
 | ||||||
|   position: relative; | .emoji-option .emoji { | ||||||
|   text-align: right; |   width: 1.25rem; | ||||||
|   margin-top: auto; |   height: 1.25rem; | ||||||
| } |   margin: 0 0.5rem 0 0; | ||||||
| 
 |   font-size: 2.25ch; | ||||||
| #options-toggle #options-icon { |   display: flex; | ||||||
|   padding: 3px 10px; |   align-items: center; | ||||||
|   font-size: 1em; |   justify-content: center; | ||||||
|   max-width: 3em; |   overflow: hidden; | ||||||
| 
 |   flex-shrink: 0; | ||||||
|   color: transparent; | } | ||||||
|   text-shadow: 0 0 0 white; | 
 | ||||||
|   border: none; | .emoji-name { | ||||||
|   box-shadow:0px 0px 0px 2px var(--accent-darkest) inset; |   overflow: hidden; | ||||||
|    |   text-overflow: ellipsis; | ||||||
|   transform-style: preserve-3d; | } | ||||||
|   transition: cubic-bezier(0, 0, 0.58, 1), cubic-bezier(0, 0, 0.58, 1); | 
 | ||||||
|   transition-duration: 150ms; | .emoji-option.selected { | ||||||
| } |   background: var(--fg-transparent); | ||||||
| 
 | } | ||||||
| #options-toggle #options-icon::before { | 
 | ||||||
|   content: ""; | #join-session-colour { | ||||||
| 
 |   -moz-appearance: none; | ||||||
|   position: absolute; |   -webkit-appearance: none; | ||||||
|    |   appearance: none; | ||||||
|   width: 100%; |   border: none; | ||||||
|   height: 100%; |   padding: 0; | ||||||
|   top: 0; |   border-radius: 6px; | ||||||
|   left: 0; |   overflow: hidden; | ||||||
|   right: 0; |   margin: 0.5em 0; | ||||||
|   bottom: 0; |   height: 2rem; | ||||||
| 
 |   width: 2.5rem; | ||||||
|   background-color: var(--accent-darker); |   cursor: pointer; | ||||||
|   border-radius: inherit; | } | ||||||
|   border: none; | 
 | ||||||
|   box-shadow:0px 0px 0px 2px var(--accent-darkest) inset; | input[type="color"]::-moz-color-swatch { | ||||||
| 
 |   border: none; | ||||||
|   transform: translate3d(0, 0.5em, -1em); |   margin: 0; | ||||||
|   transition: cubic-bezier(0, 0, 0.58, 1), cubic-bezier(0, 0, 0.58, 1); |   padding: 0; | ||||||
|   transition-duration: 150ms; | } | ||||||
| } | 
 | ||||||
| 
 | input[type="color"]::-webkit-color-swatch { | ||||||
| #options-toggle #options-icon:hover { |   border: none; | ||||||
|   transform: translate(0, 0.15em); |   margin: 0; | ||||||
|   background-color: rgb(173, 113, 216); /*5% darker accent*/ |   padding: 0; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #options-toggle #options-icon:hover::before { | input[type="color"]::-webkit-color-swatch-wrapper { | ||||||
|   transform: translate3d(0, 0.35em, -1em); |   border: none; | ||||||
| } |   margin: 0; | ||||||
| 
 |   padding: 0; | ||||||
| #options-toggle #options-icon:active { | } | ||||||
|   transform: translate(0em, 0.5em); | 
 | ||||||
|   background-color: rgb(165, 100, 213); /*10% darker accent*/ | @media (min-aspect-ratio: 4/3) { | ||||||
| } |   body { | ||||||
| 
 |     flex-direction: row; | ||||||
| #options-toggle #options-icon:active::before { |   } | ||||||
|   transform: translate3d(0, 0, -1em); | 
 | ||||||
| } |   #chatbox-container { | ||||||
| 
 |     height: 100vh !important; | ||||||
| #options { |     flex-grow: 0; | ||||||
|   display: none; /* default for sections is block */ |   } | ||||||
|   padding: 01em; | 
 | ||||||
| } |   #video-container { | ||||||
| 
 |     flex-grow: 1; | ||||||
| input[type="color"]::-moz-color-swatch { |   } | ||||||
|   border: none; | 
 | ||||||
|   margin: 0; |   #chatbox { | ||||||
|   padding: 0; |     height: calc(100vh - 5em - 4em) !important; | ||||||
| } |   } | ||||||
| 
 | } | ||||||
| input[type="color"]::-webkit-color-swatch { |  | ||||||
|   border: none; |  | ||||||
|   margin: 0; |  | ||||||
|   padding: 0; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| input[type="color"]::-webkit-color-swatch-wrapper { |  | ||||||
|   border: none; |  | ||||||
|   margin: 0; |  | ||||||
|   padding: 0; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| @media (min-aspect-ratio: 4/3) { |  | ||||||
|   body { |  | ||||||
|     flex-direction: row; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   #chatbox-container { |  | ||||||
|     height: 100vh !important; |  | ||||||
|     flex-grow: 0; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   #video-container { |  | ||||||
|     flex-grow: 1; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   #chatbox { |  | ||||||
|     height: calc(100vh - 5em - 4em) !important; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
							
								
								
									
										104
									
								
								src/events.rs
									
									
									
									
									
								
							
							
						
						
									
										104
									
								
								src/events.rs
									
									
									
									
									
								
							|  | @ -1,52 +1,52 @@ | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
| 
 | 
 | ||||||
| #[derive(Clone, Serialize, Deserialize)] | #[derive(Clone, Serialize, Deserialize)] | ||||||
| pub struct Viewer { | pub struct Viewer { | ||||||
|     #[serde(default, skip_serializing_if = "Option::is_none")] |     #[serde(default, skip_serializing_if = "Option::is_none")] | ||||||
|     pub nickname: Option<String>, |     pub nickname: Option<String>, | ||||||
|     #[serde(default, skip_serializing_if = "Option::is_none")] |     #[serde(default, skip_serializing_if = "Option::is_none")] | ||||||
|     pub colour: Option<String>, |     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, |         playing: bool, | ||||||
|         time: u64, |         time: u64, | ||||||
|     }, |     }, | ||||||
|     SetTime { |     SetTime { | ||||||
|         #[serde(default, skip_serializing_if = "Option::is_none")] |         #[serde(default, skip_serializing_if = "Option::is_none")] | ||||||
|         from: Option<u64>, |         from: Option<u64>, | ||||||
|         to: u64, |         to: u64, | ||||||
|     }, |     }, | ||||||
| 
 | 
 | ||||||
|     UserJoin, |     UserJoin, | ||||||
|     UserLeave, |     UserLeave, | ||||||
|     ChatMessage(String), |     ChatMessage(String), | ||||||
|     Ping(String), |     Ping(String), | ||||||
|     UpdateViewerList(Vec<Viewer>), |     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")] |     #[serde(default, skip_serializing_if = "Option::is_none")] | ||||||
|     pub colour: Option<String>, |     pub colour: Option<String>, | ||||||
|     #[serde(flatten)] |     #[serde(flatten)] | ||||||
|     pub data: WatchEventData, |     pub data: WatchEventData, | ||||||
|     #[serde(default)] |     #[serde(default)] | ||||||
|     pub reflected: bool, |     pub reflected: bool, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl WatchEvent { | impl WatchEvent { | ||||||
|     pub fn new(user: String, colour: String, data: WatchEventData) -> Self { |     pub fn new(user: String, colour: String, data: WatchEventData) -> Self { | ||||||
|         WatchEvent { |         WatchEvent { | ||||||
|             user: Some(user), |             user: Some(user), | ||||||
|             colour: Some(colour), |             colour: Some(colour), | ||||||
|             data, |             data, | ||||||
|             reflected: false, |             reflected: false, | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
							
								
								
									
										264
									
								
								src/main.rs
									
									
									
									
									
								
							
							
						
						
									
										264
									
								
								src/main.rs
									
									
									
									
									
								
							|  | @ -1,132 +1,132 @@ | ||||||
| use serde_json::json; | use serde_json::json; | ||||||
| use std::net::IpAddr; | use std::net::IpAddr; | ||||||
| use uuid::Uuid; | use uuid::Uuid; | ||||||
| 
 | 
 | ||||||
| use warb::{hyper::StatusCode, Filter, Reply}; | 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 utils; | ||||||
| mod viewer_connection; | mod viewer_connection; | ||||||
| mod watch_session; | mod watch_session; | ||||||
| 
 | 
 | ||||||
| use serde::Deserialize; | use serde::Deserialize; | ||||||
| 
 | 
 | ||||||
| use crate::{ | use crate::{ | ||||||
|     viewer_connection::ws_subscribe, |     viewer_connection::ws_subscribe, | ||||||
|     watch_session::{get_session, SubtitleTrack, WatchSession, SESSIONS}, |     watch_session::{get_session, SubtitleTrack, WatchSession, SESSIONS}, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| #[derive(Deserialize)] | #[derive(Deserialize)] | ||||||
| struct StartSessionBody { | struct StartSessionBody { | ||||||
|     video_url: String, |     video_url: String, | ||||||
|     #[serde(default = "Vec::new")] |     #[serde(default = "Vec::new")] | ||||||
|     subtitle_tracks: Vec<SubtitleTrack>, |     subtitle_tracks: Vec<SubtitleTrack>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Deserialize)] | #[derive(Deserialize)] | ||||||
| struct SubscribeQuery { | struct SubscribeQuery { | ||||||
|     nickname: String, |     nickname: String, | ||||||
|     colour: String, |     colour: String, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn get_emoji_list() -> Result<impl warb::Reply, warb::Rejection> { | async fn get_emoji_list() -> Result<impl warb::Reply, warb::Rejection> { | ||||||
|     use tokio_stream::{wrappers::ReadDirStream, StreamExt}; |     use tokio_stream::{wrappers::ReadDirStream, StreamExt}; | ||||||
| 
 | 
 | ||||||
|     let dir = tokio::fs::read_dir("frontend/emojis") |     let dir = tokio::fs::read_dir("frontend/emojis") | ||||||
|         .await |         .await | ||||||
|         .expect("Couldn't read emojis directory!"); |         .expect("Couldn't read emojis directory!"); | ||||||
| 
 | 
 | ||||||
|     let files = ReadDirStream::new(dir) |     let files = ReadDirStream::new(dir) | ||||||
|         .filter_map(|r| r.ok()) |         .filter_map(|r| r.ok()) | ||||||
|         .map(|e| e.file_name().to_string_lossy().to_string()) |         .map(|e| e.file_name().to_string_lossy().to_string()) | ||||||
|         .collect::<Vec<_>>() |         .collect::<Vec<_>>() | ||||||
|         .await; |         .await; | ||||||
| 
 | 
 | ||||||
|     Ok(warb::reply::json(&files)) |     Ok(warb::reply::json(&files)) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[tokio::main] | #[tokio::main] | ||||||
| async fn main() { | async fn main() { | ||||||
|     let start_session_route = warb::path!("start_session") |     let start_session_route = warb::path!("start_session") | ||||||
|         .and(warb::path::end()) |         .and(warb::path::end()) | ||||||
|         .and(warb::post()) |         .and(warb::post()) | ||||||
|         .and(warb::body::json()) |         .and(warb::body::json()) | ||||||
|         .map(|body: StartSessionBody| { |         .map(|body: StartSessionBody| { | ||||||
|             let mut sessions = SESSIONS.lock().unwrap(); |             let mut sessions = SESSIONS.lock().unwrap(); | ||||||
|             let session_uuid = Uuid::new_v4(); |             let session_uuid = Uuid::new_v4(); | ||||||
|             let session = WatchSession::new(body.video_url, body.subtitle_tracks); |             let session = WatchSession::new(body.video_url, body.subtitle_tracks); | ||||||
|             let session_view = session.view(); |             let session_view = session.view(); | ||||||
|             sessions.insert(session_uuid, session); |             sessions.insert(session_uuid, session); | ||||||
| 
 | 
 | ||||||
|             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); |     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>), | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     let get_running_session = warb::path::path("sess") |     let get_running_session = warb::path::path("sess") | ||||||
|         .and(warb::path::param::<String>()) |         .and(warb::path::param::<String>()) | ||||||
|         .map(|session_id: String| { |         .map(|session_id: String| { | ||||||
|             if let Ok(uuid) = Uuid::parse_str(&session_id) { |             if let Ok(uuid) = Uuid::parse_str(&session_id) { | ||||||
|                 get_session(uuid) |                 get_session(uuid) | ||||||
|                     .map(|sess| RequestedSession::Session(uuid, sess)) |                     .map(|sess| RequestedSession::Session(uuid, sess)) | ||||||
|                     .unwrap_or_else(|| { |                     .unwrap_or_else(|| { | ||||||
|                         RequestedSession::Error(warb::reply::with_status( |                         RequestedSession::Error(warb::reply::with_status( | ||||||
|                             warb::reply::json(&json!({ "error": "session does not exist" })), |                             warb::reply::json(&json!({ "error": "session does not exist" })), | ||||||
|                             StatusCode::NOT_FOUND, |                             StatusCode::NOT_FOUND, | ||||||
|                         )) |                         )) | ||||||
|                     }) |                     }) | ||||||
|             } else { |             } else { | ||||||
|                 RequestedSession::Error(warb::reply::with_status( |                 RequestedSession::Error(warb::reply::with_status( | ||||||
|                     warb::reply::json(&json!({ "error": "invalid session UUID" })), |                     warb::reply::json(&json!({ "error": "invalid session UUID" })), | ||||||
|                     StatusCode::BAD_REQUEST, |                     StatusCode::BAD_REQUEST, | ||||||
|                 )) |                 )) | ||||||
|             } |             } | ||||||
|         }); |         }); | ||||||
| 
 | 
 | ||||||
|     let get_status_route = get_running_session |     let get_status_route = get_running_session | ||||||
|         .and(warb::path::end()) |         .and(warb::path::end()) | ||||||
|         .map(|requested_session| match requested_session { |         .map(|requested_session| match requested_session { | ||||||
|             RequestedSession::Session(_, sess) => { |             RequestedSession::Session(_, sess) => { | ||||||
|                 warb::reply::with_status(warb::reply::json(&sess.view()), StatusCode::OK) |                 warb::reply::with_status(warb::reply::json(&sess.view()), StatusCode::OK) | ||||||
|             } |             } | ||||||
|             RequestedSession::Error(e) => e, |             RequestedSession::Error(e) => e, | ||||||
|         }); |         }); | ||||||
| 
 | 
 | ||||||
|     let ws_subscribe_route = get_running_session |     let ws_subscribe_route = get_running_session | ||||||
|         .and(warb::path!("subscribe")) |         .and(warb::path!("subscribe")) | ||||||
|         .and(warb::query()) |         .and(warb::query()) | ||||||
|         .and(warb::ws()) |         .and(warb::ws()) | ||||||
|         .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, query.colour, ws)) | ||||||
|                     .into_response(), |                     .into_response(), | ||||||
|                 RequestedSession::Error(error_response) => error_response.into_response(), |                 RequestedSession::Error(error_response) => error_response.into_response(), | ||||||
|             }, |             }, | ||||||
|         ); |         ); | ||||||
| 
 | 
 | ||||||
|     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(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")); | ||||||
| 
 | 
 | ||||||
|     let ip = std::env::var("IP") |     let ip = std::env::var("IP") | ||||||
|         .ok() |         .ok() | ||||||
|         .and_then(|s| s.parse::<IpAddr>().ok()) |         .and_then(|s| s.parse::<IpAddr>().ok()) | ||||||
|         .unwrap_or_else(|| [127, 0, 0, 1].into()); |         .unwrap_or_else(|| [127, 0, 0, 1].into()); | ||||||
|     let port = std::env::var("PORT") |     let port = std::env::var("PORT") | ||||||
|         .ok() |         .ok() | ||||||
|         .and_then(|s| s.parse::<u16>().ok()) |         .and_then(|s| s.parse::<u16>().ok()) | ||||||
|         .unwrap_or(3000); |         .unwrap_or(3000); | ||||||
| 
 | 
 | ||||||
|     println!("Listening at http://{}:{} ...", &ip, &port); |     println!("Listening at http://{}:{} ...", &ip, &port); | ||||||
|     warb::serve(routes).run((ip, port)).await; |     warb::serve(routes).run((ip, port)).await; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,156 +1,156 @@ | ||||||
| use once_cell::sync::Lazy; | use once_cell::sync::Lazy; | ||||||
| use std::{ | use std::{ | ||||||
|     collections::HashMap, |     collections::HashMap, | ||||||
|     sync::atomic::{AtomicUsize, Ordering}, |     sync::atomic::{AtomicUsize, Ordering}, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| use futures::{SinkExt, StreamExt, TryFutureExt}; | use futures::{SinkExt, StreamExt, TryFutureExt}; | ||||||
| use tokio::sync::{ | use tokio::sync::{ | ||||||
|     mpsc::{self, UnboundedSender}, |     mpsc::{self, UnboundedSender}, | ||||||
|     RwLock, |     RwLock, | ||||||
| }; | }; | ||||||
| use tokio_stream::wrappers::UnboundedReceiverStream; | use tokio_stream::wrappers::UnboundedReceiverStream; | ||||||
| 
 | 
 | ||||||
| use uuid::Uuid; | use uuid::Uuid; | ||||||
| use warp::ws::{Message, WebSocket}; | use warp::ws::{Message, WebSocket}; | ||||||
| 
 | 
 | ||||||
| use crate::{ | use crate::{ | ||||||
|     events::{Viewer, WatchEvent, WatchEventData}, |     events::{Viewer, WatchEvent, WatchEventData}, | ||||||
|     utils::truncate_str, |     utils::truncate_str, | ||||||
|     watch_session::{get_session, handle_watch_event_data}, |     watch_session::{get_session, handle_watch_event_data}, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| static CONNECTED_VIEWERS: Lazy<RwLock<HashMap<usize, ConnectedViewer>>> = | static CONNECTED_VIEWERS: Lazy<RwLock<HashMap<usize, ConnectedViewer>>> = | ||||||
|     Lazy::new(|| RwLock::new(HashMap::new())); |     Lazy::new(|| RwLock::new(HashMap::new())); | ||||||
| static NEXT_VIEWER_ID: AtomicUsize = AtomicUsize::new(1); | static NEXT_VIEWER_ID: AtomicUsize = AtomicUsize::new(1); | ||||||
| 
 | 
 | ||||||
| pub struct ConnectedViewer { | pub struct ConnectedViewer { | ||||||
|     pub session: Uuid, |     pub session: Uuid, | ||||||
|     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 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, colour: 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(); | ||||||
| 
 | 
 | ||||||
|     let (tx, rx) = mpsc::unbounded_channel::<WatchEvent>(); |     let (tx, rx) = mpsc::unbounded_channel::<WatchEvent>(); | ||||||
|     let mut rx = UnboundedReceiverStream::new(rx); |     let mut rx = UnboundedReceiverStream::new(rx); | ||||||
| 
 | 
 | ||||||
|     tokio::task::spawn(async move { |     tokio::task::spawn(async move { | ||||||
|         while let Some(event) = rx.next().await { |         while let Some(event) = rx.next().await { | ||||||
|             viewer_ws_tx |             viewer_ws_tx | ||||||
|                 .send(Message::text( |                 .send(Message::text( | ||||||
|                     serde_json::to_string(&event).expect("couldn't convert WatchEvent into JSON"), |                     serde_json::to_string(&event).expect("couldn't convert WatchEvent into JSON"), | ||||||
|                 )) |                 )) | ||||||
|                 .unwrap_or_else(|e| eprintln!("ws send error: {}", e)) |                 .unwrap_or_else(|e| eprintln!("ws send error: {}", e)) | ||||||
|                 .await; |                 .await; | ||||||
|         } |         } | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     let mut colour = colour; |     let mut colour = colour; | ||||||
|     if colour.len() != 6 || !colour.chars().all(|x| x.is_ascii_hexdigit()) { |     if colour.len() != 6 || !colour.chars().all(|x| x.is_ascii_hexdigit()) { | ||||||
|         colour = String::from("7ed0ff"); |         colour = String::from("7ed0ff"); | ||||||
|     } |     } | ||||||
|     let nickname = truncate_str(&nickname, 50).to_string(); |     let nickname = truncate_str(&nickname, 50).to_string(); | ||||||
| 
 | 
 | ||||||
|     CONNECTED_VIEWERS.write().await.insert( |     CONNECTED_VIEWERS.write().await.insert( | ||||||
|         viewer_id, |         viewer_id, | ||||||
|         ConnectedViewer { |         ConnectedViewer { | ||||||
|             viewer_id, |             viewer_id, | ||||||
|             session: session_uuid, |             session: session_uuid, | ||||||
|             tx, |             tx, | ||||||
|             nickname: Some(nickname.clone()), |             nickname: Some(nickname.clone()), | ||||||
|             colour: Some(colour.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(), colour.clone(), WatchEventData::UserJoin), | ||||||
|     ) |     ) | ||||||
|     .await; |     .await; | ||||||
| 
 | 
 | ||||||
|     update_viewer_list(session_uuid).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() | ||||||
|             .ok() |             .ok() | ||||||
|             .and_then(|s| serde_json::from_str(s).ok()) |             .and_then(|s| serde_json::from_str(s).ok()) | ||||||
|         { |         { | ||||||
|             Some(e) => e, |             Some(e) => e, | ||||||
|             None => continue, |             None => continue, | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         let session = &mut get_session(session_uuid).unwrap(); |         let session = &mut get_session(session_uuid).unwrap(); | ||||||
| 
 | 
 | ||||||
|         // server side event modification where neccessary
 |         // server side event modification where neccessary
 | ||||||
|         let event: WatchEventData = match event { |         let event: WatchEventData = match event { | ||||||
|             WatchEventData::SetTime { from: _, to } => WatchEventData::SetTime { |             WatchEventData::SetTime { from: _, to } => WatchEventData::SetTime { | ||||||
|                 from: Some(session.get_time_ms()), |                 from: Some(session.get_time_ms()), | ||||||
|                 to, |                 to, | ||||||
|             }, |             }, | ||||||
|             _ => event, |             _ => event, | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         handle_watch_event_data(session_uuid, session, event.clone()); |         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(), colour.clone(), event), | ||||||
|         ) |         ) | ||||||
|         .await; |         .await; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     ws_publish( |     ws_publish( | ||||||
|         session_uuid, |         session_uuid, | ||||||
|         None, |         None, | ||||||
|         WatchEvent::new(nickname.clone(), colour.clone(), WatchEventData::UserLeave), |         WatchEvent::new(nickname.clone(), colour.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; |     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) { | ||||||
|     for viewer in CONNECTED_VIEWERS.read().await.values() { |     for viewer in CONNECTED_VIEWERS.read().await.values() { | ||||||
|         if viewer.session != session_uuid { |         if viewer.session != session_uuid { | ||||||
|             continue; |             continue; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         let _ = viewer.tx.send(WatchEvent { |         let _ = viewer.tx.send(WatchEvent { | ||||||
|             reflected: skip_viewer_id == Some(viewer.viewer_id), |             reflected: skip_viewer_id == Some(viewer.viewer_id), | ||||||
|             ..event.clone() |             ..event.clone() | ||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn update_viewer_list(session_uuid: Uuid) { | async fn update_viewer_list(session_uuid: Uuid) { | ||||||
|     let mut viewers = Vec::new(); |     let mut viewers = Vec::new(); | ||||||
| 
 | 
 | ||||||
|     for viewer in CONNECTED_VIEWERS.read().await.values() { |     for viewer in CONNECTED_VIEWERS.read().await.values() { | ||||||
|         if viewer.session == session_uuid { |         if viewer.session == session_uuid { | ||||||
|             viewers.push(Viewer { |             viewers.push(Viewer { | ||||||
|                 nickname: viewer.nickname.clone(), |                 nickname: viewer.nickname.clone(), | ||||||
|                 colour: viewer.colour.clone(), |                 colour: viewer.colour.clone(), | ||||||
|             }) |             }) | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     ws_publish( |     ws_publish( | ||||||
|         session_uuid, |         session_uuid, | ||||||
|         None, |         None, | ||||||
|         WatchEvent::new( |         WatchEvent::new( | ||||||
|             String::from("server"), |             String::from("server"), | ||||||
|             String::from(""), |             String::from(""), | ||||||
|             WatchEventData::UpdateViewerList(viewers), |             WatchEventData::UpdateViewerList(viewers), | ||||||
|         ), |         ), | ||||||
|     ) |     ) | ||||||
|     .await; |     .await; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,96 +1,96 @@ | ||||||
| use once_cell::sync::Lazy; | use once_cell::sync::Lazy; | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
| use std::{collections::HashMap, sync::Mutex, time::Instant}; | use std::{collections::HashMap, sync::Mutex, time::Instant}; | ||||||
| use uuid::Uuid; | use uuid::Uuid; | ||||||
| 
 | 
 | ||||||
| use crate::events::WatchEventData; | use crate::events::WatchEventData; | ||||||
| 
 | 
 | ||||||
| #[derive(Serialize, Deserialize, Clone)] | #[derive(Serialize, Deserialize, Clone)] | ||||||
| pub struct SubtitleTrack { | pub struct SubtitleTrack { | ||||||
|     pub url: String, |     pub url: String, | ||||||
|     pub name: String, |     pub name: String, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Clone)] | #[derive(Clone)] | ||||||
| pub struct WatchSession { | pub struct WatchSession { | ||||||
|     pub video_url: String, |     pub video_url: String, | ||||||
|     pub subtitle_tracks: Vec<SubtitleTrack>, |     pub subtitle_tracks: Vec<SubtitleTrack>, | ||||||
| 
 | 
 | ||||||
|     is_playing: bool, |     is_playing: bool, | ||||||
|     playing_from_timestamp: u64, |     playing_from_timestamp: u64, | ||||||
|     playing_from_instant: Instant, |     playing_from_instant: Instant, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Serialize)] | #[derive(Serialize)] | ||||||
| pub struct WatchSessionView { | pub struct WatchSessionView { | ||||||
|     pub video_url: String, |     pub video_url: String, | ||||||
|     pub subtitle_tracks: Vec<SubtitleTrack>, |     pub subtitle_tracks: Vec<SubtitleTrack>, | ||||||
|     pub current_time_ms: u64, |     pub current_time_ms: u64, | ||||||
|     pub is_playing: bool, |     pub is_playing: bool, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl WatchSession { | impl WatchSession { | ||||||
|     pub fn new(video_url: String, subtitle_tracks: Vec<SubtitleTrack>) -> Self { |     pub fn new(video_url: String, subtitle_tracks: Vec<SubtitleTrack>) -> Self { | ||||||
|         WatchSession { |         WatchSession { | ||||||
|             video_url, |             video_url, | ||||||
|             subtitle_tracks, |             subtitle_tracks, | ||||||
|             is_playing: false, |             is_playing: false, | ||||||
|             playing_from_timestamp: 0, |             playing_from_timestamp: 0, | ||||||
|             playing_from_instant: Instant::now(), |             playing_from_instant: Instant::now(), | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     pub fn view(&self) -> WatchSessionView { |     pub fn view(&self) -> WatchSessionView { | ||||||
|         WatchSessionView { |         WatchSessionView { | ||||||
|             video_url: self.video_url.clone(), |             video_url: self.video_url.clone(), | ||||||
|             subtitle_tracks: self.subtitle_tracks.clone(), |             subtitle_tracks: self.subtitle_tracks.clone(), | ||||||
|             current_time_ms: self.get_time_ms() as u64, |             current_time_ms: self.get_time_ms() as u64, | ||||||
|             is_playing: self.is_playing, |             is_playing: self.is_playing, | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     pub fn get_time_ms(&self) -> u64 { |     pub fn get_time_ms(&self) -> u64 { | ||||||
|         if !self.is_playing { |         if !self.is_playing { | ||||||
|             return self.playing_from_timestamp; |             return self.playing_from_timestamp; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         self.playing_from_timestamp + self.playing_from_instant.elapsed().as_millis() as u64 |         self.playing_from_timestamp + self.playing_from_instant.elapsed().as_millis() as u64 | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     pub fn set_time_ms(&mut self, time_ms: u64) { |     pub fn set_time_ms(&mut self, time_ms: u64) { | ||||||
|         self.playing_from_timestamp = time_ms; |         self.playing_from_timestamp = time_ms; | ||||||
|         self.playing_from_instant = Instant::now(); |         self.playing_from_instant = Instant::now(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     pub fn set_playing(&mut self, playing: bool, time_ms: u64) { |     pub fn set_playing(&mut self, playing: bool, time_ms: u64) { | ||||||
|         self.set_time_ms(time_ms); |         self.set_time_ms(time_ms); | ||||||
|         self.is_playing = playing; |         self.is_playing = playing; | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| pub static SESSIONS: Lazy<Mutex<HashMap<Uuid, WatchSession>>> = | pub static SESSIONS: Lazy<Mutex<HashMap<Uuid, WatchSession>>> = | ||||||
|     Lazy::new(|| Mutex::new(HashMap::new())); |     Lazy::new(|| Mutex::new(HashMap::new())); | ||||||
| 
 | 
 | ||||||
| pub fn get_session(uuid: Uuid) -> Option<WatchSession> { | pub fn get_session(uuid: Uuid) -> Option<WatchSession> { | ||||||
|     SESSIONS.lock().unwrap().get(&uuid).cloned() |     SESSIONS.lock().unwrap().get(&uuid).cloned() | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| pub fn handle_watch_event_data( | pub fn handle_watch_event_data( | ||||||
|     uuid: Uuid, |     uuid: Uuid, | ||||||
|     watch_session: &mut WatchSession, |     watch_session: &mut WatchSession, | ||||||
|     event: WatchEventData, |     event: WatchEventData, | ||||||
| ) { | ) { | ||||||
|     match event { |     match event { | ||||||
|         WatchEventData::SetPlaying { playing, time } => { |         WatchEventData::SetPlaying { playing, time } => { | ||||||
|             watch_session.set_playing(playing, time); |             watch_session.set_playing(playing, time); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         WatchEventData::SetTime { from: _, to } => { |         WatchEventData::SetTime { from: _, to } => { | ||||||
|             watch_session.set_time_ms(to); |             watch_session.set_time_ms(to); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         _ => {} |         _ => {} | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|     let _ = SESSIONS.lock().unwrap().insert(uuid, watch_session.clone()); |     let _ = SESSIONS.lock().unwrap().insert(uuid, watch_session.clone()); | ||||||
| } | } | ||||||
|  |  | ||||||
		Loading…
	
		Reference in a new issue