885 lines
		
	
	
		
			23 KiB
		
	
	
	
		
			HTML
		
	
	
	
	
	
			
		
		
	
	
			885 lines
		
	
	
		
			23 KiB
		
	
	
	
		
			HTML
		
	
	
	
	
	
| <!DOCTYPE html>
 | |
| <html onclick="player.unMute()">
 | |
| <head>
 | |
| 	<title>Non-stop MMD</title>
 | |
| 	<meta property="og:title" content="Non-stop MMD" />
 | |
| 	<meta name="description" property="og:description" content="Real-time synchronized live multiplayer video entertainment system" />
 | |
| 	<meta name="theme-color" content="#00FFFF" />
 | |
| 	<link href="https://fonts.googleapis.com/css?family=Noto+Sans+Symbols+2|Bevan&display=block" rel="stylesheet" />
 | |
| 	<script src="https://www.youtube.com/iframe_api"></script>
 | |
| 	<style>
 | |
| 		html {
 | |
| 			width: 100%;
 | |
| 			height: 100%;
 | |
| 			background-color: black;
 | |
| 			user-select: none;
 | |
| 			-webkit-user-select: none;
 | |
| 		}
 | |
| 		body {
 | |
| 			width: 100%;
 | |
| 			height: 100%;
 | |
| 			display: grid;
 | |
| 			place-items: center;
 | |
| 			align-content: center;
 | |
| 			margin: 0;
 | |
| 			overflow: hidden;
 | |
| 		}
 | |
| 		#ytplayer {
 | |
| 			display: block;
 | |
| 			pointer-events: none;
 | |
| 		}
 | |
| 		#mousie_canvas {
 | |
| 			position: fixed;
 | |
| 			top: 0px;
 | |
| 			left: 0px;
 | |
| 			width: 100%;
 | |
| 			height: 100%;
 | |
| 			cursor: none;
 | |
| 		}
 | |
| 		.mousie {
 | |
| 			position: fixed;
 | |
| 			user-select: none;
 | |
| 			color: gray;
 | |
| 			text-shadow: 1px 1px 1px black, -1px -1px 1px black, 1px -1px 1px black, -1px 1px 1px black;
 | |
| 			cursor: none;
 | |
| 		}
 | |
| 		.mousie_pointer {
 | |
| 			font-family: "Noto Sans Symbols 2";
 | |
| 		}
 | |
| 		.blur {
 | |
| 			filter: blur(1px);
 | |
| 		}
 | |
| 		.mousie_talk {
 | |
| 			position: absolute;
 | |
| 			width: 300px;
 | |
| 			bottom: 30px;
 | |
| 			left: -150px;
 | |
| 			text-align: center;
 | |
| 			word-wrap: break-word;
 | |
| 			white-space: break-spaces;
 | |
| 			font-family: monospace;
 | |
| 			color: white;
 | |
| 		}
 | |
| 		.depressed {
 | |
| 			position: relative;
 | |
| 			top: 3px;
 | |
| 		}
 | |
| 		#logo {
 | |
| 			position: fixed;
 | |
| 			left: 10px;
 | |
| 			bottom: 0px;
 | |
| 			font-size: 24pt;
 | |
| 			font-family: Bevan;
 | |
| 			color: rgba(127, 127, 127, 0.5)
 | |
| 		}
 | |
| 		.modalbg {
 | |
| 			position: fixed;
 | |
| 			left: 0px;
 | |
| 			top: 0px;
 | |
| 			width: 100%;
 | |
| 			height: 100%;
 | |
| 			background-color: rgba(127,127,127,0.33);
 | |
| 			display: grid;
 | |
| 			place-items: center;
 | |
| 		}
 | |
| 		.modal {
 | |
| 			background-image: linear-gradient(0deg, #001f4b, #004fc0);
 | |
| 			color: white;
 | |
| 			padding: 16px;
 | |
| 			border-radius: 10px;
 | |
| 			border-color: black;
 | |
| 			border-width: 3px;
 | |
| 			border-style: solid;
 | |
| 			box-shadow: 0px 8px 20px 0px black;
 | |
| 			font-family: Verdana, sans-serif;
 | |
| 			text-shadow: 2px 2px 8px black;
 | |
| 		}
 | |
| 		.modal > :first-child {
 | |
| 			margin-top: 0px;
 | |
| 		}
 | |
| 		.modal > :last-child {
 | |
| 			margin-bottom: 0px;
 | |
| 		}
 | |
| 		.gone {
 | |
| 			visibility: hidden;
 | |
| 			pointer-events: none;
 | |
| 			opacity: 0;
 | |
| 			transition: visibility 0.1s linear, opacity 0.1s linear;
 | |
| 		}
 | |
| 		#channel_select {
 | |
| 			position: fixed;
 | |
| 			top: 5px;
 | |
| 			right: 5px;
 | |
| 			background-color: black;
 | |
| 			color: white;
 | |
| 		}
 | |
| 		#volume_slider {
 | |
| 			position: fixed;
 | |
| 			right: 5px;
 | |
| 			bottom: 5px;
 | |
| 			appearance: slider-vertical;
 | |
| 			width: 10px;
 | |
| 			opacity: 0.5;
 | |
| 		}
 | |
| 		#nowplaying_buttom {
 | |
| 			position: fixed;
 | |
| 			left: 5px;
 | |
| 			top: 5px;
 | |
| 			background-color: black;
 | |
| 			color: white;
 | |
| 			opacity: 0.33;
 | |
| 		}
 | |
| 		#nowplaying_button:hover {
 | |
| 			opacity: 1;
 | |
| 		}
 | |
| 		.radiocontent {
 | |
| 			display: none;
 | |
| 			text-indent: 37px;
 | |
| 		}
 | |
| 		input[type="radio"]:checked ~ .radiocontent {
 | |
| 			display: block;
 | |
| 		}
 | |
| 		.ncform div {
 | |
| 			margin: 10px;
 | |
| 		}
 | |
| 
 | |
| 
 | |
| 		#txtlog_panel {
 | |
| 			position: fixed;
 | |
| 
 | |
| 			backdrop-filter: blur(3px);
 | |
| 			border-radius: 10px;
 | |
| 			box-shadow: 0 0 5px 2px #0000009c;
 | |
| 			background-color: #0000009c;
 | |
| 			border-color: white;
 | |
| 			border-width: 1px;
 | |
| 			border-style: solid;
 | |
| 
 | |
| 			bottom: 0px;
 | |
| 			right: 18vw;
 | |
| 			width: 403px;
 | |
| 			height: 280px;
 | |
| 
 | |
| 			transition: bottom 1s cubic-bezier(0.02, 0.99, 0.58, 1);
 | |
| 		}
 | |
| 		.txtlog_closed {
 | |
| 			bottom: -285px !important;
 | |
| 			transition: bottom 1s cubic-bezier(0.02, 0.99, 0.58, 1);
 | |
| 		}
 | |
| 		#txtlog_handle {
 | |
| 			height: 20px;
 | |
| 			position: absolute;
 | |
| 			right: 20px;
 | |
| 			top: -25px;
 | |
| 			background-color: black;
 | |
| 			color: white;
 | |
| 			opacity: 0.5;
 | |
| 			border-top-left-radius: 6px;
 | |
| 			border-top-right-radius: 6px;
 | |
| 			border: 1px white solid;
 | |
| 			font-family: sans-serif;
 | |
| 			padding-right: 8px;
 | |
| 			padding-left: 8px;
 | |
| 			padding-bottom: 3px;
 | |
| 
 | |
| 		}
 | |
| 		#txtlog {
 | |
| 			word-wrap: break-word;
 | |
| 			white-space: break-spaces;
 | |
| 			font-family: monospace;
 | |
| 			margin: 8px;
 | |
| 			padding: 4px;
 | |
| 			height: 255px;
 | |
| 			overflow: auto;
 | |
| 			user-select: text;
 | |
| 		}
 | |
| 		#txtlog span {
 | |
| 			color: white;
 | |
| 			text-shadow: 1px 1px 1px black, -1px -1px 1px black, 1px -1px 1px black, -1px 1px 1px black;
 | |
| 			overflow-anchor: none;
 | |
| 		}
 | |
| 	</style>
 | |
| </head>
 | |
| <body>
 | |
| 	<div id="ytplayer"></div>
 | |
| 	<div id="logo">NonstopMMD.com</div>
 | |
| 	<canvas id="mousie_canvas"></canvas>
 | |
| 	<template id="mousie_template">
 | |
| 		<div class="mousie">
 | |
| 			<div class="mousie_pointer">🮰</div>
 | |
| 			<div class="mousie_talk"></div>
 | |
| 		</div>
 | |
| 	</template>
 | |
| 	<button id="nowplaying_buttom" onclick="nowPlaying().then(v => open('https://www.youtube.com/watch?v='+v.video.id+'&t='+Math.floor(v.position), '_blank', 'noopener'))">↗</button>
 | |
| 	<input type="range" id="volume_slider" min="0" max="100" value="100" oninput="player.setVolume(this.value)" onmouseover="mousie_self.style.visibility='hidden'" onmouseout="mousie_self.style.visibility='visible'" orient="vertical" />
 | |
| 	<select id="channel_select" onchange="if (this.value == 'newchannel') {channel_select.value = gChannel; newChannelModal()} else changeChannel(this.value)">
 | |
| 		<option value="newchannel" id="optionnewchannel">+ New Channel</option>
 | |
| 	</select>
 | |
| 	<div id="txtlog_panel" class="txtlog_closed" onclick="event.stopPropagation()" onmousedown="event.stopPropagation()" oncontextmenu="event.stopPropagation()"><!--TODO put event handlers on canvas so dont need stopPropagation; why don't they work???!-->
 | |
| 		<div id="txtlog_handle" onclick="txtlog_panel.classList[txtlog_panel.classList.contains('txtlog_closed') ? 'remove' : 'add']('txtlog_closed')">
 | |
| 			<span>log</span>
 | |
| 		</div>
 | |
| 		<div id="txtlog" onwheel="event.stopPropagation()"></div>
 | |
| 	</div>
 | |
| 	<div class="modalbg" id="welcome_modal_bg" onclick="this.classList.add('gone')">
 | |
| 		<div class="modal" id="welcome_modal">
 | |
| 			<h1 style="text-align: center">Welcome to Non-stop MMD!</h1>
 | |
| 			<ul>
 | |
| 				<li><b>Scroll</b> to adjust <b>volume</b></li>
 | |
| 				<li><b>Type</b> to <b>talk</b></li>
 | |
| 				<li><b>Esc</b> to <b>clear text</b></li>
 | |
| 				<li><b>Click</b> to <b>dance</b></li>
 | |
| 				<li><b>Drag</b> to <b>draw</b></li>
 | |
| 				<li><b>Right-click</b> to <b>clear lines</b></li>
 | |
| 				<li><b>Middle-click</b> to <b>sync</b></li>
 | |
| 			</ul>
 | |
| 			<p>Don't like what you see? <b>Change</b> the <b>channel</b> in the upper right corner.</p>
 | |
| 			<p style="text-align: center">Click anywhere to unmute and continue</p>
 | |
| 		</div>
 | |
| 	</div>
 | |
| 	<div class="modalbg gone" id="newchannel_modalbg" onclick="newchannel_modalbg.classList.add('gone');">
 | |
| 		<div class="modal" id="newchannel_modal" onclick="event.stopPropagation()">
 | |
| 			<h1>Create new channel</h1>
 | |
| 			<div class="ncform">
 | |
| 				<!--<div>
 | |
| 					<label for="ncname">Channel name:</label>
 | |
| 					<input type="text" placeholder="foo bar channel" />
 | |
| 				</div>-->
 | |
| 				<div>
 | |
| 					<input type="radio" name="ncradio" id="ncradio1" value="search" />
 | |
| 					<label for="ncr1">From YouTube search</label>
 | |
| 					<div class="radiocontent">
 | |
| 						<div>
 | |
| 							<label>Query:</label>
 | |
| 							<input type="text" id="ncsq" placeholder="funny cat videos" />
 | |
| 						</div>
 | |
| 						<div>
 | |
| 							<label>Number of videos:</label>
 | |
| 							<input type="number" id="ncsn" min="1" max="1000" defaultValue="500" placeHolder="500" value="500" />
 | |
| 						</div>
 | |
| 					</div>
 | |
| 				</div>
 | |
| 				<div>
 | |
| 					<input type="radio" name="ncradio" id="ncradio2" value="channel" />
 | |
| 					<label for="ncr2">From YouTube channel</label>
 | |
| 					<div class="radiocontent">
 | |
| 						Channel handle: @<input type="text" id="nchandle" placeholder="googoo888" />
 | |
| 					</div>
 | |
| 				</div>
 | |
| 				<div>
 | |
| 					<input type="radio" name="ncradio" id="ncradio3" value="playlist" />
 | |
| 					<label for="ncr2">From YouTube playlist</label>
 | |
| 					<div class="radiocontent">
 | |
| 						Playlist URL: <input type="text" id="ncplaylist" placeholder="https://www.youtube.com/playlist?list=PLZof6GKTlRzPBbRFSIRveZsRWIreTzalP" />
 | |
| 					</div>
 | |
| 				</div>
 | |
| 				<input type="submit" id="ncsubmit" />
 | |
| 				<div id="ncerror" style="color: red"></div>
 | |
| 			</div>
 | |
| 		</div>
 | |
| 	</div>
 | |
| 
 | |
| 
 | |
| 
 | |
| 
 | |
| 	<script>
 | |
| 		(onload = onresize = () => {
 | |
| 			ytplayer.style.width = Math.min(innerWidth, 16/9 * innerHeight) + "px";
 | |
| 			ytplayer.style.height = Math.min(innerHeight, 9/16 * innerWidth) + "px";
 | |
| 			mousie_canvas.width = innerWidth * devicePixelRatio;
 | |
| 			mousie_canvas.height = innerHeight * devicePixelRatio;
 | |
| 		})();
 | |
| 
 | |
| 		var gChannel = decodeURIComponent(location.pathname.replace('/','')) || "mmd~500";
 | |
| 
 | |
| 
 | |
| 		var serverTimeOffset = 0; // negative = client > server
 | |
| 		function now() {
 | |
| 			return Date.now() + serverTimeOffset;
 | |
| 		}
 | |
| 
 | |
| 		
 | |
| 		var player;
 | |
| 
 | |
| 		function initializePlayer(id, position) {
 | |
| 			initializePlayer = () => console.warn("player already initialized");
 | |
| 			return new Promise(function (resolve, reject) {
 | |
| 				player = new YT.Player('ytplayer', {
 | |
| 					videoId: id,
 | |
| 					width: "1920",
 | |
| 					height: "1080",
 | |
| 					playerVars: {
 | |
| 						'playsinline': 1,
 | |
| 						"autoplay": 1,
 | |
| 						"controls": 0,
 | |
| 						"disablekb": 1,
 | |
| 						"start": position
 | |
| 					},
 | |
| 					events: {
 | |
| 						'onReady': () => {
 | |
| 							console.log("player ready");
 | |
| 							player.mute();
 | |
| 							player.seekTo(position);
 | |
| 							player.playVideo();
 | |
| 							resolve();
 | |
| 						},
 | |
| 						'onStateChange': event => {
 | |
| 							console.debug("ytplayerstate", Object.entries(YT.PlayerState).find(x=>x[1]==event.data)?.[0] || event.data);
 | |
| 							if (event.data == YT.PlayerState.ENDED) {
 | |
| 								sync();
 | |
| 								// race condition
 | |
| 								setTimeout(sync, 1000);
 | |
| 							}
 | |
| 						}
 | |
| 					}
 | |
| 				});
 | |
| 			});
 | |
| 		}
 | |
| 
 | |
| 		function onYouTubeIframeAPIReady() {
 | |
| 			sync();
 | |
| 			syncInterval = setInterval(sync, 5000);
 | |
| 		}
 | |
| 
 | |
| 		async function sync(force) {
 | |
| 			var {video, position} = await nowPlaying();
 | |
| 
 | |
| 			if (!player) {
 | |
| 				await initializePlayer(video.id, position);
 | |
| 			}
 | |
| 
 | |
| 			var currentId = player.getVideoData().video_id;
 | |
| 			if (currentId != video.id) {
 | |
| 				console.debug("change video", currentId, video.id);
 | |
| 				player.loadVideoById(video.id, position);
 | |
| 				player.playVideo();
 | |
| 			}
 | |
| 
 | |
| 			if ([YT.PlayerState.PAUSED, YT.PlayerState.UNSTARTED].includes(player.getPlayerState())) {
 | |
| 				console.debug("play!");
 | |
| 				player.playVideo();
 | |
| 			}
 | |
| 
 | |
| 			var currentPos = player.getCurrentTime();
 | |
| 
 | |
| 			console.log("desync", currentPos, position, (currentPos - position) * 1000 + "ms");
 | |
| 
 | |
| 			if (force || Math.abs(currentPos - position) > 1) {
 | |
| 				console.debug("change position", currentPos, position);
 | |
| 				player.seekTo(position);
 | |
| 			}
 | |
| 			
 | |
| 		}
 | |
| 
 | |
| 
 | |
| 		var playlist;
 | |
| 
 | |
| 		async function loadPlaylist() {
 | |
| 			playlist = await fetch(`channels/${gChannel}.json`).then(res => res.json())
 | |
| 		}
 | |
| 
 | |
| 		async function nowPlaying() {
 | |
| 			if (!playlist) await loadPlaylist();
 | |
| 
 | |
| 			var totalDurationMs = playlist.videos.reduce((d, v) => d + v.duration, 0) * 1000;
 | |
| 			var elapsedMs = now() - playlist.timestamp;
 | |
| 			var repetition = Math.floor(elapsedMs / totalDurationMs);
 | |
| 			var playlistPosMs = elapsedMs % totalDurationMs;
 | |
| 			var seed = playlist.timestamp + repetition;
 | |
| 
 | |
| 			var videos = seededShuffle(playlist.videos, seed);
 | |
| 			console.debug("shuffle:", videos.map(v => playlist.videos.indexOf(v)));
 | |
| 
 | |
| 			for (var i = 0, pastVideoDurations = 0; i < videos.length; i++) {
 | |
| 				var video = videos[i];
 | |
| 				if (!video) continue;
 | |
| 				var videoDurationMs = video.duration * 1000;
 | |
| 				if (playlistPosMs < pastVideoDurations + videoDurationMs) {
 | |
| 					break;
 | |
| 				}
 | |
| 				pastVideoDurations += videoDurationMs;
 | |
| 			}
 | |
| 
 | |
| 			var np = {
 | |
| 				video,
 | |
| 				position: (playlistPosMs - pastVideoDurations) / 1000
 | |
| 			};
 | |
| 			console.debug("nowPlaying", np, video);
 | |
| 			return np;
 | |
| 		}
 | |
| 
 | |
| 
 | |
| 
 | |
| 		
 | |
| 		var gColor;
 | |
| 		if (localStorage.color) {
 | |
| 			gColor = JSON.parse(localStorage.color);
 | |
| 		} else {
 | |
| 			gColor = [Math.floor(Math.random()*256), Math.floor(Math.random()*256), Math.floor(Math.random()*256)];
 | |
| 			localStorage.color = JSON.stringify(gColor);
 | |
| 		}
 | |
| 
 | |
| 		function changeColor(r, g, b) {
 | |
| 			gColor = [r, g, b];
 | |
| 			var ab = new ArrayBuffer(3);
 | |
| 			var dv = new DataView(ab);
 | |
| 			dv.setUint8(0, r);
 | |
| 			dv.setUint8(1, g);
 | |
| 			dv.setUint8(2, b);
 | |
| 			ws.send(ab);
 | |
| 			updateMousie({color: `rgb(${gColor})`});
 | |
| 			localStorage.color = JSON.stringify(gColor);
 | |
| 		}
 | |
| 
 | |
| 
 | |
| 
 | |
| 
 | |
| 
 | |
| 		var ws;
 | |
| 		(function createWs() {
 | |
| 			ws = new WebSocket(location.origin.replace('http','ws'));
 | |
| 			ws.binaryType = "arraybuffer";
 | |
| 			ws.onopen = () => {
 | |
| 				ws.send(JSON.stringify(["channel", gChannel]));
 | |
| 				var b = new ArrayBuffer(3);
 | |
| 				var dv = new DataView(b);
 | |
| 				dv.setUint8(0, gColor[0]);
 | |
| 				dv.setUint8(1, gColor[1]);
 | |
| 				dv.setUint8(2, gColor[2]);
 | |
| 				ws.send(b);
 | |
| 			};
 | |
| 			ws.onmessage = evt => {
 | |
| 				if (typeof evt.data == "string") {
 | |
| 					var j = JSON.parse(evt.data);
 | |
| 					switch (j[0]) {
 | |
| 						case "txt":
 | |
| 							updateMousie({id: j[1], txt: j[2]});
 | |
| 							break;
 | |
| 						case "channel":
 | |
| 							gChannel = j[1];
 | |
| 							history.pushState({}, "", `/${gChannel}`);
 | |
| 							playlist = undefined;
 | |
| 							sync();
 | |
| 							[...document.getElementsByClassName('mousie')].forEach(m => m.remove());
 | |
| 							lines = [];
 | |
| 							renderCanvas();
 | |
| 							txtlog.innerHTML = "";
 | |
| 							break;
 | |
| 						case "channels":
 | |
| 							setChannels(j[1]);
 | |
| 							break;
 | |
| 						case "txtlog":
 | |
| 							txtlog.innerHTML = "";
 | |
| 							for (var [txt, color] of j[1]) {
 | |
| 								appendTxtLog(txt, color);
 | |
| 							}
 | |
| 							break;
 | |
| 					}
 | |
| 				} else {
 | |
| 					var dv = new DataView(evt.data);
 | |
| 					switch (dv.byteLength) {
 | |
| 						case 5: // user move
 | |
| 							var id = dv.getUint8(0);
 | |
| 							var x = dv.getInt16(1, false);
 | |
| 							var y = dv.getInt16(3, false);
 | |
| 							updateMousie({id, x, y});
 | |
| 							break;
 | |
| 						case 2: // user events
 | |
| 							var id = dv.getUint8(0);
 | |
| 							switch (dv.getUint8(1)) {
 | |
| 								case 1: // click
 | |
| 									updateMousie({id, depressed: true});
 | |
| 									break;
 | |
| 								case 0: // unclick
 | |
| 									updateMousie({id, depressed: false});
 | |
| 									break;
 | |
| 								case 2: // right click
 | |
| 									clearLines(id);
 | |
| 									break;
 | |
| 								case 3: // join
 | |
| 									break;
 | |
| 								case 4: // leave
 | |
| 									document.getElementById("mousie"+id)?.remove();
 | |
| 									clearLines(id);
 | |
| 									break;
 | |
| 								case 5: // blur
 | |
| 									updateMousie({id, blur: true});
 | |
| 									break;
 | |
| 								case 6: // focus
 | |
| 									updateMousie({id, blur: false});
 | |
| 									break;
 | |
| 							}
 | |
| 							break;
 | |
| 						case 4: // user color
 | |
| 							var id = dv.getUint8(0);
 | |
| 							var rgb = [dv.getUint8(1), dv.getUint8(2), dv.getUint8(3)];
 | |
| 							updateMousie({id, color: `rgb(${rgb})`});
 | |
| 							break;
 | |
| 						case 8: // server time
 | |
| 							var serverTime = Number(dv.getBigUint64(0, true));
 | |
| 							serverTimeOffset = serverTime - Date.now();
 | |
| 							console.log("server time", serverTime, "client time", Date.now(), "offset", serverTimeOffset);
 | |
| 						default: // canvas state
 | |
| 							for (var o = 0; o + 12 < dv.byteLength; o += 12) {
 | |
| 								mkline(
 | |
| 									dv.getInt16(o),
 | |
| 									dv.getInt16(o+2),
 | |
| 									dv.getInt16(o+4),
 | |
| 									dv.getInt16(o+6),
 | |
| 									dv.getUint8(o+8),
 | |
| 									`rgb(${[
 | |
| 										dv.getUint8(o+9),
 | |
| 										dv.getUint8(o+10),
 | |
| 										dv.getUint8(o+11)
 | |
| 									]})`
 | |
| 								);
 | |
| 							}
 | |
| 					}
 | |
| 				}
 | |
| 			};
 | |
| 			ws.onclose = () => {
 | |
| 				setTimeout(createWs, 5000);
 | |
| 				playlist = undefined;
 | |
| 				[...document.getElementsByClassName('mousie')].forEach(m => m.remove());
 | |
| 			};
 | |
| 		})();
 | |
| 
 | |
| 		onmousemove = evt => {
 | |
| 			var {left, top, width, height} = ytplayer.getBoundingClientRect();
 | |
| 			var x = (4096 * evt.pageX / width) - (4096 * left / width);
 | |
| 			var y = (4096 * evt.pageY / height) - (4096 * top / height);
 | |
| 			updateMousie({x, y});
 | |
| 			var b = new ArrayBuffer(4);
 | |
| 			var dv = new DataView(b);
 | |
| 			dv.setInt16(0, x, false);
 | |
| 			dv.setInt16(2, y, false);
 | |
| 			if (ws.readyState == WebSocket.OPEN) ws.send(b);
 | |
| 		};
 | |
| 		window.addEventListener("touchmove", evt => {
 | |
| 			evt.preventDefault()
 | |
| 			onmousemove(evt);
 | |
| 		}, {passive: false});
 | |
| 
 | |
| 		window.addEventListener("resize", () => {
 | |
| 			document.querySelectorAll(".mousie").forEach(m => {
 | |
| 				updateMousie({id: m.id.substring(6), x: m.x, y: m.y});
 | |
| 			});
 | |
| 		});
 | |
| 
 | |
| 		function updateMousie({id = "_self", x, y, txt, depressed, blur, color}) {
 | |
| 			var mousie_element = document.getElementById("mousie"+id);
 | |
| 			if (!mousie_element) {
 | |
| 				mousie_element = mousie_template.content.firstElementChild.cloneNode(true);
 | |
| 				mousie_element.id = "mousie"+id;
 | |
| 				if (id == "_self") mousie_element.style.color = `rgb(${gColor})`;
 | |
| 				document.body.insertBefore(mousie_element, mousie_template);
 | |
| 			}
 | |
| 			var previous = {
 | |
| 				x: mousie_element.x,
 | |
| 				y: mousie_element.y,
 | |
| 				depressed: mousie_element.querySelector(".mousie_pointer").classList.contains("depressed")
 | |
| 			};
 | |
| 			var {left, top, width, height} = ytplayer.getBoundingClientRect();
 | |
| 			if (x) {
 | |
| 				mousie_element.x = x;
 | |
| 				mousie_element.style.left = (x * width / 4096 + left) + "px";
 | |
| 			}
 | |
| 			if (y) {
 | |
| 				mousie_element.y = y;
 | |
| 				mousie_element.style.top = (y * height / 4096 + top - 3) + "px";
 | |
| 			}
 | |
| 			if (txt != null) {
 | |
| 				var talk = mousie_element.querySelector(".mousie_talk");
 | |
| 				switch (txt) {
 | |
| 					case "":
 | |
| 						talk.innerText = "";
 | |
| 						appendTxtLog("␛", mousie_element.style.color);
 | |
| 						break;
 | |
| 					case -1:
 | |
| 						talk.innerText = talk.innerText.slice(0,-1);
 | |
| 						appendTxtLog("⌫", mousie_element.style.color);
 | |
| 						break;
 | |
| 					case 1:
 | |
| 						talk.innerText = talk.innerText.slice(1);
 | |
| 						appendTxtLog("⌦", mousie_element.style.color);
 | |
| 						break;
 | |
| 					default:
 | |
| 						talk.innerText += txt;
 | |
| 						appendTxtLog(txt === '\n' ? "⮒" : txt, mousie_element.style.color);
 | |
| 				}
 | |
| 			}
 | |
| 			if (depressed) {
 | |
| 				mousie_element.querySelector(".mousie_pointer").classList.add("depressed");
 | |
| 			} else if (depressed === false) {
 | |
| 				mousie_element.querySelector(".mousie_pointer").classList.remove("depressed");
 | |
| 			}
 | |
| 			if ((x || y) && previous.depressed && depressed !== false /*&& mousie_element.style.visibility != "hidden"*/) {
 | |
| 				mkline(previous.x, previous.y, x, y, id, mousie_element.style.color);
 | |
| 			}
 | |
| 			if (blur) {
 | |
| 				mousie_element.querySelector(".mousie_pointer").classList.add("blur");
 | |
| 			} else if (blur === false) {
 | |
| 				mousie_element.querySelector(".mousie_pointer").classList.remove("blur");
 | |
| 			}
 | |
| 			if (color) {
 | |
| 				mousie_element.style.color = color;
 | |
| 			}
 | |
| 		}
 | |
| 
 | |
| 		onkeypress = evt => {
 | |
| 			switch (evt.key) {
 | |
| 				case "Escape":
 | |
| 					var txt = "";
 | |
| 					break;
 | |
| 				case "Enter":
 | |
| 					var txt = '\n';
 | |
| 					break;
 | |
| 				case "Backspace":
 | |
| 					var txt = -1;
 | |
| 					break;
 | |
| 				case "Delete":
 | |
| 					var txt = 1;
 | |
| 					break;
 | |
| 				default:
 | |
| 					var txt = evt.key;
 | |
| 			}
 | |
| 			ws.send(JSON.stringify(["txt", txt]));
 | |
| 			updateMousie({txt});
 | |
| 		};
 | |
| 
 | |
| 		onkeydown = evt => {
 | |
| 			if (["Backspace", "Delete", "Escape"].includes(evt.key)) onkeypress(evt);
 | |
| 		};
 | |
| 
 | |
| 		document.documentElement.onpaste = evt => {
 | |
| 			var txt = evt.clipboardData.getData("Text");
 | |
| 			if (txt) {
 | |
| 				ws.send(JSON.stringify(["txt", txt]));
 | |
| 				updateMousie({txt});
 | |
| 			}
 | |
| 		};
 | |
| 
 | |
| 		oncontextmenu = evt => {
 | |
| 			evt.preventDefault();
 | |
| 		};
 | |
| 
 | |
| 		onwheel = evt => {
 | |
| 			var volume = player.getVolume()
 | |
| 			if (evt.deltaY > 0) {
 | |
| 				volume += 10;
 | |
| 			} else if (evt.deltaY < 0) {
 | |
| 				volume -= 10;
 | |
| 			}
 | |
| 			player.setVolume(volume);
 | |
| 			volume_slider.value = volume;
 | |
| 		};
 | |
| 
 | |
| 		onmousedown = ontouchstart = evt => {
 | |
| 			if (!evt.button || evt.button == 0) {
 | |
| 				ws.send(new Uint8Array([1]));
 | |
| 				updateMousie({depressed: true});
 | |
| 			} else if (evt.button == 1) {
 | |
| 				evt.preventDefault();
 | |
| 				sync(true);
 | |
| 			} else if (evt.button == 2) {
 | |
| 				ws.send(new Uint8Array([2]));
 | |
| 				clearLines("_self");
 | |
| 			}
 | |
| 		};
 | |
| 		onmouseup = ontouchend = evt => {
 | |
| 			if (evt.button > 0) return;
 | |
| 			ws.send(new Uint8Array([0]));
 | |
| 			updateMousie({depressed: false});
 | |
| 		};
 | |
| 
 | |
| 
 | |
| 		onblur = () => {
 | |
| 			ws.send(new Uint8Array([5]));
 | |
| 			updateMousie({blur: true});
 | |
| 		};
 | |
| 		onfocus = () => {
 | |
| 			ws.send(new Uint8Array([6]));
 | |
| 			updateMousie({blur: false});
 | |
| 		};
 | |
| 
 | |
| 
 | |
| 
 | |
| 
 | |
| 
 | |
| 
 | |
| 		var lines = [];
 | |
| 
 | |
| 		function mkline(x1, y1, x2, y2, owner, color) {
 | |
| 			lines.push({x1, y1, x2, y2, owner, color});;
 | |
| 			drawline(x1, y1, x2, y2, color);
 | |
| 		}
 | |
| 
 | |
| 		function drawline(x1, y1, x2, y2, color) {
 | |
| 			var {left, top, width, height} = ytplayer.getBoundingClientRect();
 | |
| 			x1 = x1 * width / 4096 + left;
 | |
| 			x2 = x2 * width / 4096 + left;
 | |
| 			y1 = y1 * height / 4096 + top + 3;
 | |
| 			y2 = y2 * height / 4096 + top + 3; 
 | |
| 			x1 *= devicePixelRatio;
 | |
| 			x2 *= devicePixelRatio;
 | |
| 			y1 *= devicePixelRatio;
 | |
| 			y2 *= devicePixelRatio;
 | |
| 			var ctx = mousie_canvas.getContext("2d");
 | |
| 			ctx.lineWidth = devicePixelRatio;
 | |
| 			ctx.beginPath();
 | |
| 			ctx.moveTo(x1, y1);
 | |
| 			ctx.lineTo(x2, y2);
 | |
| 			ctx.strokeStyle = color || "gray";
 | |
| 			ctx.shadowColor = "black";
 | |
| 			ctx.shadowBlur = 2;
 | |
| 			ctx.shadowOffsetX = 1;
 | |
| 			ctx.shadowOffsetY = 1;
 | |
| 			ctx.stroke();
 | |
| 		}
 | |
| 
 | |
| 		function renderCanvas() {
 | |
| 			var ctx = mousie_canvas.getContext("2d");
 | |
| 			ctx.clearRect(0, 0, mousie_canvas.width, mousie_canvas.height);
 | |
| 			for (var {x1, y1, x2, y2, color} of lines) {
 | |
| 				drawline(x1, y1, x2, y2, color);
 | |
| 			}
 | |
| 		}
 | |
| 
 | |
| 		window.addEventListener("resize", renderCanvas);
 | |
| 
 | |
| 		function clearLines(owner) {
 | |
| 			var newLines = [];
 | |
| 			for (var line of lines) {
 | |
| 				if (line.owner != owner) newLines.push(line);
 | |
| 			}
 | |
| 			if (newLines.length == lines.length) return;
 | |
| 			lines = newLines;
 | |
| 			renderCanvas();
 | |
| 		}
 | |
| 
 | |
| 		
 | |
| 
 | |
| 
 | |
| 
 | |
| 
 | |
| 		function changeChannel(newChannel) {
 | |
| 			ws.send(JSON.stringify(["channel", newChannel]));
 | |
| 		}
 | |
| 
 | |
| 		function setChannels(channels) {
 | |
| 			channels = Object.fromEntries(channels);
 | |
| 			for (var channel in channels) {
 | |
| 				var option = channel_select.querySelector("[value=\""+channel+"\"]");
 | |
| 				if (!option) {
 | |
| 					option = document.createElement("option");
 | |
| 					option.value = channel;
 | |
| 					channel_select.insertBefore(option, optionnewchannel);
 | |
| 				}
 | |
| 				option.innerText = `(${channels[channel]}) ${channel}`;
 | |
| 				if (channel == gChannel) {
 | |
| 					document.title = `(${channels[channel]}) Non-stop MMD`;
 | |
| 				}
 | |
| 			}
 | |
| 			var toRemove = [];
 | |
| 			for (var option of channel_select.children) {
 | |
| 				if (option.value == "newchannel") continue;
 | |
| 				if (!(option.value in channels)) toRemove.push(option);
 | |
| 			}
 | |
| 			for (var option of toRemove) {
 | |
| 				option.remove();
 | |
| 			}
 | |
| 			channel_select.value = gChannel;
 | |
| 		}
 | |
| 
 | |
| 		fetch("channels").then(res => res.json()).then(setChannels);
 | |
| 
 | |
| 		function newChannelModal() {
 | |
| 			newchannel_modalbg.classList.remove("gone");
 | |
| 		}
 | |
| 
 | |
| 		ncsubmit.onclick = () => {
 | |
| 			var selection = document.querySelector(`input[type="radio"]:checked`)?.value;
 | |
| 			if (!selection) return;
 | |
| 
 | |
| 			ncsubmit.disabled = true;
 | |
| 			ncerror.innerText = "";
 | |
| 
 | |
| 			if (selection == "playlist") {
 | |
| 				var playlistid = ncplaylist.value.match(/playlist\?list=([a-zA-Z0-9-]+)/)?.[1] || ncplaylist.value;
 | |
| 			}
 | |
| 
 | |
| 			fetch(`newchannel?from=${selection}&query=${encodeURIComponent(ncsq.value)}&number=${ncsn.value}&handle=${encodeURIComponent(nchandle.value)}&playlist=${encodeURIComponent(playlistid)}`, {
 | |
| 				method: "POST"
 | |
| 			}).then(res => {
 | |
| 				if (res.status == 200) {
 | |
| 					res.text().then(channel => {
 | |
| 						changeChannel(channel);
 | |
| 						newchannel_modalbg.classList.add("gone");
 | |
| 						ncsq.value = "";
 | |
| 						ncsn.value = "";
 | |
| 						nchandle.value = "";
 | |
| 						ncplaylist.value = "";
 | |
| 						ncsubmit.disabled = false;
 | |
| 					});
 | |
| 				} else {
 | |
| 					ncerror.innerText = "an error occured";
 | |
| 					ncsubmit.disabled = false;
 | |
| 				}
 | |
| 			});
 | |
| 		};
 | |
| 
 | |
| 
 | |
| 
 | |
| 
 | |
| 		
 | |
| 		function appendTxtLog(txt, color) {
 | |
| 			if (color instanceof Array) color = `rgb(${color})`;
 | |
| 			var span = document.createElement("span");
 | |
| 			span.style.backgroundColor = color;
 | |
| 			span.style.backgroundColor = span.style.backgroundColor.replace(')', ',0.5)');
 | |
| 			span.innerText = txt;
 | |
| 			txtlog.appendChild(span);
 | |
| 		}
 | |
| 
 | |
| 
 | |
| 
 | |
| 
 | |
| 
 | |
| 
 | |
| 
 | |
| 
 | |
| 
 | |
| 
 | |
| 		function seededShuffle(array, seed) {
 | |
| 			array = [...array];
 | |
| 			var random = splitmix32(seed);
 | |
| 			for (var i = array.length - 1; i > 0; i--) {
 | |
| 				var j = Math.floor(random(seed) * (i + 1));
 | |
| 				[array[i], array[j]] = [array[j], array[i]];
 | |
| 			}
 | |
| 			return array;
 | |
| 		}
 | |
| 
 | |
| 		function splitmix32(a) {
 | |
| 			return function() {
 | |
| 				a |= 0;
 | |
| 				a = a + 0x9e3779b9 | 0;
 | |
| 				var t = a ^ a >>> 16;
 | |
| 					t = Math.imul(t, 0x21f0aaad);
 | |
| 					t = t ^ t >>> 15;
 | |
| 					t = Math.imul(t, 0x735a2d97);
 | |
| 					t = t ^ t >>> 15;
 | |
| 				return (t >>> 0) / 4294967296;
 | |
| 			}
 | |
| 		}
 | |
| 
 | |
| 
 | |
| 
 | |
| 
 | |
| 
 | |
| 
 | |
| 
 | |
| 
 | |
| 	</script>
 | |
| </body>
 | |
| </html> |