forked from lavender/watch-party
		
	use plyr for video controls
This commit is contained in:
		
							parent
							
								
									e43184ab49
								
							
						
					
					
						commit
						1bd7071cec
					
				
					 16 changed files with 1416 additions and 1437 deletions
				
			
		|  | @ -3,6 +3,7 @@ | |||
|   <head> | ||||
|     <meta charset="utf-8" /> | ||||
|     <title>watch party :D</title> | ||||
|     <link rel="stylesheet" href="/lib/plyr-3.7.3.css" /> | ||||
|     <link rel="stylesheet" href="/styles.css?v=bfdcf2" /> | ||||
|   </head> | ||||
| 
 | ||||
|  |  | |||
|  | @ -2,6 +2,7 @@ import { | |||
|   setDebounce, | ||||
|   setVideoTime, | ||||
|   setPlaying, | ||||
|   sync, | ||||
| } from "./watch-session.mjs?v=bfdcf2"; | ||||
| import { emojify, findEmojis } from "./emojis.mjs?v=bfdcf2"; | ||||
| import { linkify } from "./links.mjs?v=bfdcf2"; | ||||
|  | @ -164,14 +165,7 @@ const setupChatboxEvents = (socket) => { | |||
|             handled = true; | ||||
|             break; | ||||
|           case "/sync": | ||||
|             const sessionId = window.location.hash.slice(1); | ||||
|             const { current_time_ms, is_playing } = await fetch( | ||||
|               `/sess/${sessionId}` | ||||
|             ).then((r) => r.json()); | ||||
| 
 | ||||
|             setDebounce(); | ||||
|             setPlaying(is_playing); | ||||
|             setVideoTime(current_time_ms); | ||||
|             await sync(); | ||||
| 
 | ||||
|             const syncMessageContent = document.createElement("span"); | ||||
|             syncMessageContent.appendChild( | ||||
|  |  | |||
|  | @ -54,11 +54,13 @@ const displayPostCreateMessage = () => { | |||
|   if (params.get("created") == "true") { | ||||
|     document.querySelector("#post-create-message").style["display"] = "block"; | ||||
|     window.history.replaceState({}, document.title, `/${window.location.hash}`); | ||||
|     return true; | ||||
|   } | ||||
|   return false; | ||||
| }; | ||||
| 
 | ||||
| export const setupJoinSessionForm = () => { | ||||
|   displayPostCreateMessage(); | ||||
|   const created = displayPostCreateMessage(); | ||||
| 
 | ||||
|   const form = document.querySelector("#join-session-form"); | ||||
|   const nickname = form.querySelector("#join-session-nickname"); | ||||
|  | @ -84,7 +86,7 @@ export const setupJoinSessionForm = () => { | |||
|       state().nickname = nickname.value; | ||||
|       state().sessionId = sessionId.value; | ||||
|       state().colour = colour.value.replace(/^#/, ""); | ||||
|       await joinSession(); | ||||
|       await joinSession(created); | ||||
|     } catch (e) { | ||||
|       alert(e.message); | ||||
|       button.disabled = false; | ||||
|  |  | |||
|  | @ -77,4 +77,3 @@ export const pling = () => { | |||
|   thirdBeep.start(ctx.currentTime + thirdBeepOffset); | ||||
|   thirdBeep.stop(ctx.currentTime + (thirdBeepOffset + duration)); | ||||
| }; | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										1
									
								
								frontend/lib/plyr-3.7.3.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								frontend/lib/plyr-3.7.3.css
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										1
									
								
								frontend/lib/plyr-3.7.3.min.esm.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								frontend/lib/plyr-3.7.3.min.esm.js
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							|  | @ -1,110 +1,56 @@ | |||
| const loadVolume = () => { | ||||
|   try { | ||||
|     const savedVolume = localStorage.getItem("watch-party-volume"); | ||||
|     if (savedVolume != null && savedVolume != "") { | ||||
|       return +savedVolume; | ||||
|     } | ||||
|   } catch (_err) { | ||||
|     // Sometimes localStorage is blocked from use
 | ||||
|   } | ||||
|   // default
 | ||||
|   return 0.5; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * @param {number} volume | ||||
|  */ | ||||
| const saveVolume = (volume) => { | ||||
|   try { | ||||
|     localStorage.setItem("watch-party-volume", volume); | ||||
|   } catch (_err) { | ||||
|     // see loadVolume
 | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| const loadCaptionTrack = () => { | ||||
|   try { | ||||
|     const savedTrack = localStorage.getItem("watch-party-captions"); | ||||
|     if (savedTrack != null && savedTrack != "") { | ||||
|       return +savedTrack; | ||||
|     } | ||||
|   } catch (_err) { | ||||
|     // Sometimes localStorage is blocked from use
 | ||||
|   } | ||||
|   // default
 | ||||
|   return -1; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * @param {number} track | ||||
|  */ | ||||
| const saveCaptionsTrack = (track) => { | ||||
|   try { | ||||
|     localStorage.setItem("watch-party-captions", track); | ||||
|   } catch (_err) { | ||||
|     // see loadCaptionsTrack
 | ||||
|   } | ||||
| }; | ||||
| import Plyr from "./plyr-3.7.3.min.esm.js"; | ||||
| 
 | ||||
| /** | ||||
|  * @param {string} videoUrl | ||||
|  * @param {{name: string, url: string}[]} subtitles | ||||
|  */ | ||||
| const createVideoElement = (videoUrl, subtitles) => { | ||||
|   const oldVideo = document.getElementById("video"); | ||||
| const createVideoElement = (videoUrl, subtitles, created) => { | ||||
|   const oldVideo = document.getElementById(".plyr"); | ||||
|   if (oldVideo) { | ||||
|     oldVideo.remove(); | ||||
|   } | ||||
|   const video = document.createElement("video"); | ||||
|   video.id = "video"; | ||||
|   video.controls = true; | ||||
|   video.autoplay = false; | ||||
|   video.volume = loadVolume(); | ||||
|   video.crossOrigin = "anonymous"; | ||||
| 
 | ||||
|   video.addEventListener("volumechange", async () => { | ||||
|     saveVolume(video.volume); | ||||
|   }); | ||||
| 
 | ||||
|   const source = document.createElement("source"); | ||||
|   source.src = videoUrl; | ||||
| 
 | ||||
|   video.appendChild(source); | ||||
| 
 | ||||
|   const storedTrack = loadCaptionTrack(); | ||||
|   let id = 0; | ||||
|   for (const { name, url } of subtitles) { | ||||
|     const track = document.createElement("track"); | ||||
|     track.label = name; | ||||
|     track.srclang = "xx-" + name.toLowerCase(); | ||||
|     track.src = url; | ||||
|     track.kind = "captions"; | ||||
| 
 | ||||
|     if (id == storedTrack || storedTrack == -1) { | ||||
|       track.default = true; | ||||
|     } | ||||
| 
 | ||||
|     video.appendChild(track); | ||||
|     id++; | ||||
|   } | ||||
| 
 | ||||
|   video.textTracks.addEventListener("change", async () => { | ||||
|     let id = 0; | ||||
|     for (const track of video.textTracks) { | ||||
|       if (track.mode != "disabled") { | ||||
|         saveCaptionsTrack(id); | ||||
|         return; | ||||
|       } | ||||
|       id++; | ||||
|     } | ||||
|     saveCaptionsTrack(-1); | ||||
|   const videoContainer = document.querySelector("#video-container"); | ||||
|   videoContainer.style.display = "block"; | ||||
|   videoContainer.appendChild(video); | ||||
| 
 | ||||
|   const player = new Plyr(video, { | ||||
|     clickToPlay: false, | ||||
|     settings: ["captions", "quality"], | ||||
|     autopause: false, | ||||
|   }); | ||||
| 
 | ||||
|   // watch for attribute changes on the video object to detect hiding/showing of controls
 | ||||
|   // as far as i can tell this is the least hacky solutions to get control visibility change events
 | ||||
|   const observer = new MutationObserver(async (mutations) => { | ||||
|     for (const mutation of mutations) { | ||||
|       if (mutation.attributeName == "controls") { | ||||
|         if (video.controls) { | ||||
|   player.elements.controls.insertAdjacentHTML( | ||||
|     "afterbegin", | ||||
|     `<button type="button" aria-pressed="false" class="plyr__controls__item plyr__control lock-controls"><svg aria-hidden="true" focusable="false" viewBox="0 0 24 24"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zM9 8V6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9z"></path></svg><span class="label--pressed plyr__sr-only">Unlock controls</span><span class="label--not-pressed plyr__sr-only">Lock controls</span></button>` | ||||
|   ); | ||||
|   const lockButton = player.elements.controls.children[0]; | ||||
|   let controlsEnabled = created; | ||||
|   const setControlsEnabled = (enabled) => { | ||||
|     controlsEnabled = enabled; | ||||
|     lockButton.setAttribute("aria-pressed", enabled); | ||||
|     lockButton.classList.toggle("plyr__control--pressed", enabled); | ||||
|     player.elements.buttons.play[0].disabled = | ||||
|       player.elements.buttons.play[1].disabled = | ||||
|       player.elements.inputs.seek.disabled = | ||||
|         !enabled; | ||||
|     if (!enabled) { | ||||
|       // enable media button support
 | ||||
|       navigator.mediaSession.setActionHandler("play", null); | ||||
|       navigator.mediaSession.setActionHandler("pause", null); | ||||
|  | @ -125,13 +71,14 @@ const createVideoElement = (videoUrl, subtitles) => { | |||
|       navigator.mediaSession.setActionHandler("previoustrack", () => {}); | ||||
|       navigator.mediaSession.setActionHandler("nexttrack", () => {}); | ||||
|     } | ||||
|         return; | ||||
|       } | ||||
|     } | ||||
|   }); | ||||
|   observer.observe(video, { attributes: true }); | ||||
|   }; | ||||
|   setControlsEnabled(controlsEnabled); | ||||
|   lockButton.addEventListener("click", () => | ||||
|     setControlsEnabled(!controlsEnabled) | ||||
|   ); | ||||
|   window.__plyr = player; | ||||
| 
 | ||||
|   return video; | ||||
|   return player; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  | @ -140,24 +87,26 @@ const createVideoElement = (videoUrl, subtitles) => { | |||
|  * @param {number} currentTime | ||||
|  * @param {boolean} playing | ||||
|  */ | ||||
| export const setupVideo = async (videoUrl, subtitles, currentTime, playing) => { | ||||
| export const setupVideo = async ( | ||||
|   videoUrl, | ||||
|   subtitles, | ||||
|   currentTime, | ||||
|   playing, | ||||
|   created | ||||
| ) => { | ||||
|   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; | ||||
|   const player = createVideoElement(videoUrl, subtitles, created); | ||||
|   player.currentTime = currentTime / 1000.0; | ||||
| 
 | ||||
|   try { | ||||
|     if (playing) { | ||||
|       await video.play(); | ||||
|       player.play(); | ||||
|     } else { | ||||
|       video.pause(); | ||||
|       player.pause(); | ||||
|     } | ||||
|   } catch (err) { | ||||
|     // Auto-play is probably disabled, we should uhhhhhhh do something about it
 | ||||
|   } | ||||
| 
 | ||||
|   return video; | ||||
|   return player; | ||||
| }; | ||||
|  |  | |||
|  | @ -7,7 +7,7 @@ import { | |||
| } from "./chat.mjs?v=bfdcf2"; | ||||
| import ReconnectingWebSocket from "./reconnecting-web-socket.mjs"; | ||||
| import { state } from "./state.mjs"; | ||||
| 
 | ||||
| let player; | ||||
| /** | ||||
|  * @param {string} sessionId | ||||
|  * @param {string} nickname | ||||
|  | @ -42,26 +42,18 @@ export const setDebounce = () => { | |||
|   }, 500); | ||||
| }; | ||||
| 
 | ||||
| export const setVideoTime = (time, video = null) => { | ||||
|   if (video == null) { | ||||
|     video = document.querySelector("video"); | ||||
|   } | ||||
| 
 | ||||
| export const setVideoTime = (time) => { | ||||
|   const timeSecs = time / 1000.0; | ||||
|   if (Math.abs(video.currentTime - timeSecs) > 0.5) { | ||||
|     video.currentTime = timeSecs; | ||||
|   if (Math.abs(player.currentTime - timeSecs) > 0.5) { | ||||
|     player.currentTime = timeSecs; | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| export const setPlaying = async (playing, video = null) => { | ||||
|   if (video == null) { | ||||
|     video = document.querySelector("video"); | ||||
|   } | ||||
| 
 | ||||
| export const setPlaying = async (playing) => { | ||||
|   if (playing) { | ||||
|     await video.play(); | ||||
|     await player.play(); | ||||
|   } else { | ||||
|     video.pause(); | ||||
|     player.pause(); | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
|  | @ -69,7 +61,7 @@ export const setPlaying = async (playing, video = null) => { | |||
|  * @param {HTMLVideoElement} video | ||||
|  * @param {ReconnectingWebSocket} socket | ||||
|  */ | ||||
| const setupIncomingEvents = (video, socket) => { | ||||
| const setupIncomingEvents = (player, socket) => { | ||||
|   socket.addEventListener("message", async (messageEvent) => { | ||||
|     try { | ||||
|       const event = JSON.parse(messageEvent.data); | ||||
|  | @ -79,16 +71,16 @@ const setupIncomingEvents = (video, socket) => { | |||
|             setDebounce(); | ||||
| 
 | ||||
|             if (event.data.playing) { | ||||
|               await video.play(); | ||||
|               await player.play(); | ||||
|             } else { | ||||
|               video.pause(); | ||||
|               player.pause(); | ||||
|             } | ||||
| 
 | ||||
|             setVideoTime(event.data.time, video); | ||||
|             setVideoTime(event.data.time); | ||||
|             break; | ||||
|           case "SetTime": | ||||
|             setDebounce(); | ||||
|             setVideoTime(event.data, video); | ||||
|             setVideoTime(event.data); | ||||
|             break; | ||||
|           case "UpdateViewerList": | ||||
|             updateViewerList(event.data); | ||||
|  | @ -102,19 +94,19 @@ const setupIncomingEvents = (video, socket) => { | |||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * @param {HTMLVideoElement} video | ||||
|  * @param {Plyr} player | ||||
|  * @param {ReconnectingWebSocket} socket | ||||
|  */ | ||||
| const setupOutgoingEvents = (video, socket) => { | ||||
|   const currentVideoTime = () => (video.currentTime * 1000) | 0; | ||||
| const setupOutgoingEvents = (player, socket) => { | ||||
|   const currentVideoTime = () => (player.currentTime * 1000) | 0; | ||||
| 
 | ||||
|   video.addEventListener("pause", async (event) => { | ||||
|     if (outgoingDebounce || !video.controls) { | ||||
|   player.on("pause", async () => { | ||||
|     if (outgoingDebounce || player.elements.inputs.seek.disabled) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     // don't send a pause event for the video ending
 | ||||
|     if (video.currentTime == video.duration) { | ||||
|     if (player.currentTime == player.duration) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|  | @ -129,8 +121,8 @@ const setupOutgoingEvents = (video, socket) => { | |||
|     ); | ||||
|   }); | ||||
| 
 | ||||
|   video.addEventListener("play", (event) => { | ||||
|     if (outgoingDebounce || !video.controls) { | ||||
|   player.on("play", () => { | ||||
|     if (outgoingDebounce || player.elements.inputs.seek.disabled) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|  | @ -146,14 +138,14 @@ const setupOutgoingEvents = (video, socket) => { | |||
|   }); | ||||
| 
 | ||||
|   let firstSeekComplete = false; | ||||
|   video.addEventListener("seeked", async (event) => { | ||||
|   player.on("seeked", async (event) => { | ||||
|     if (!firstSeekComplete) { | ||||
|       // The first seeked event is performed by the browser when the video is loading
 | ||||
|       firstSeekComplete = true; | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     if (outgoingDebounce || !video.controls) { | ||||
|     if (outgoingDebounce || player.elements.inputs.seek.disabled) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|  | @ -168,7 +160,7 @@ const setupOutgoingEvents = (video, socket) => { | |||
|   }); | ||||
| }; | ||||
| 
 | ||||
| export const joinSession = async () => { | ||||
| export const joinSession = async (created) => { | ||||
|   if (state().activeSession) { | ||||
|     if (state().activeSession === state().sessionId) { | ||||
|       // we are already in this session, dont rejoin
 | ||||
|  | @ -221,30 +213,20 @@ export const joinSession = async () => { | |||
|   const socket = createWebSocket(); | ||||
|   state().socket = socket; | ||||
|   socket.addEventListener("open", async () => { | ||||
|     const video = await setupVideo( | ||||
|     player = await setupVideo( | ||||
|       video_url, | ||||
|       subtitle_tracks, | ||||
|       current_time_ms, | ||||
|       is_playing | ||||
|       is_playing, | ||||
|       created | ||||
|     ); | ||||
| 
 | ||||
|     // TODO: Allow the user to set this somewhere
 | ||||
|     let defaultAllowControls = false; | ||||
|     try { | ||||
|       defaultAllowControls = localStorage.getItem( | ||||
|         "watch-party-default-allow-controls" | ||||
|       ); | ||||
|     } catch (_err) {} | ||||
|     player.on("canplay", () => { | ||||
|       sync(); | ||||
|     }); | ||||
| 
 | ||||
|     // By default, we should disable video controls if the video is already playing.
 | ||||
|     // This solves an issue where Safari users join and seek to 00:00:00 because of
 | ||||
|     // outgoing events.
 | ||||
|     if (current_time_ms != 0 || !defaultAllowControls) { | ||||
|       video.controls = false; | ||||
|     } | ||||
| 
 | ||||
|     setupOutgoingEvents(video, socket); | ||||
|     setupIncomingEvents(video, socket); | ||||
|     setupOutgoingEvents(player, socket); | ||||
|     setupIncomingEvents(player, socket); | ||||
|     setupChat(socket); | ||||
|   }); | ||||
|   socket.addEventListener("reconnecting", (e) => { | ||||
|  | @ -274,3 +256,15 @@ export const createSession = async (videoUrl, subtitleTracks) => { | |||
| 
 | ||||
|   window.location = `/?created=true#${id}`; | ||||
| }; | ||||
| 
 | ||||
| export const sync = async () => { | ||||
|   setDebounce(); | ||||
|   await setPlaying(false); | ||||
|   const { current_time_ms, is_playing } = await fetch( | ||||
|     `/sess/${state().sessionId}` | ||||
|   ).then((r) => r.json()); | ||||
| 
 | ||||
|   setDebounce(); | ||||
|   setVideoTime(current_time_ms); | ||||
|   if (is_playing) await setPlaying(is_playing); | ||||
| }; | ||||
|  |  | |||
|  | @ -25,6 +25,14 @@ | |||
|     ), | ||||
|     linear-gradient(var(--bg), var(--bg)); | ||||
|   --accent-transparent: rgba(var(--accent-rgb), 0.25); | ||||
|   --plyr-color-main: var(--accent); | ||||
|   --plyr-control-radius: 6px; | ||||
|   --plyr-menu-radius: 6px; | ||||
|   --plyr-menu-background: var(--autocomplete-bg); | ||||
|   --plyr-menu-color: var(--fg); | ||||
|   --plyr-menu-arrow-color: var(--fg); | ||||
|   --plyr-menu-back-border-color: var(--fg-transparent); | ||||
|   --plyr-menu-back-border-shadow-color: transparent; | ||||
| } | ||||
| 
 | ||||
| html { | ||||
|  | @ -49,11 +57,41 @@ body { | |||
|   flex-direction: column; | ||||
| } | ||||
| 
 | ||||
| video { | ||||
|   display: block; | ||||
| .lock-controls.plyr__control--pressed svg { | ||||
|   opacity: 0.5; | ||||
| } | ||||
| 
 | ||||
| .plyr { | ||||
|   width: 100%; | ||||
|   height: 100%; | ||||
|   object-fit: contain; | ||||
| } | ||||
| 
 | ||||
| .plyr__menu__container { | ||||
|   --plyr-video-control-background-hover: var(--fg-transparent); | ||||
|   --plyr-video-control-color-hover: var(--fg); | ||||
|   --plyr-control-radius: 4px; | ||||
|   --plyr-control-spacing: calc(0.25rem / 0.7); | ||||
|   --plyr-font-size-menu: 0.75rem; | ||||
|   --plyr-menu-arrow-size: 0; | ||||
|   margin-bottom: 0.48rem; | ||||
|   max-height: 27vmin; | ||||
|   clip-path: inset(0 0 0 0 round 4px); | ||||
|   scrollbar-width: thin; | ||||
| } | ||||
| 
 | ||||
| .plyr__menu__container .plyr__control[role="menuitemradio"]::after { | ||||
|   left: 10px; | ||||
| } | ||||
| 
 | ||||
| .plyr__menu__container | ||||
|   .plyr__control[role="menuitemradio"][aria-checked="true"].plyr__tab-focus::before, | ||||
| .plyr__menu__container | ||||
|   .plyr__control[role="menuitemradio"][aria-checked="true"]:hover::before { | ||||
|   background: var(--accent); | ||||
| } | ||||
| 
 | ||||
| [data-plyr="language"] .plyr__menu__value { | ||||
|   display: none; | ||||
| } | ||||
| 
 | ||||
| #video-container { | ||||
|  | @ -131,7 +169,7 @@ input[type="text"] { | |||
|   overflow-y: scroll; | ||||
| } | ||||
| 
 | ||||
| button { | ||||
| button:not(.plyr button) { | ||||
|   background-color: var(--accent); | ||||
|   border: var(--accent); | ||||
|   border-radius: 6px; | ||||
|  | @ -303,7 +341,7 @@ button.small-button { | |||
|   display: none; | ||||
| } | ||||
| 
 | ||||
| .emoji-option { | ||||
| .emoji-option:not(:root) { | ||||
|   background: transparent; | ||||
|   font-size: 0.75rem; | ||||
|   text-align: left; | ||||
|  |  | |||
		Loading…
	
		Reference in a new issue