526 lines
13 KiB
HTML
526 lines
13 KiB
HTML
<!DOCTYPE html>
|
|
<html onclick="player.unMute()">
|
|
<head>
|
|
<title>Non-stop MMD</title>
|
|
<meta property="og:title" content="Non-stop MMD" />
|
|
<meta property="og:description" content="Spontaneous entertainment" />
|
|
<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%;
|
|
}
|
|
.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;
|
|
}
|
|
.mousie_pointer {
|
|
font-family: "Noto Sans Symbols 2";
|
|
}
|
|
.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;
|
|
}
|
|
.gone {
|
|
visibility: hidden;
|
|
pointer-events: none;
|
|
opacity: 0;
|
|
transition: visibility 0s 0.1s, 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;
|
|
}
|
|
</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.id, '_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"></select>
|
|
<div class="modalbg" id="welcome_modal_bg" onclick="this.classList.add('gone'); document.documentElement.style.cursor = 'none';">
|
|
<div class="modal" id="welcome_modal">
|
|
<h1 style="margin-top: 0px; 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="margin-bottom: 0px; text-align: center">Click anywhere to unmute and continue</p>
|
|
</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 channel = location.pathname.replace('/','') || "default";
|
|
|
|
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("ytstate", event.data);
|
|
if (event.data == YT.PlayerState.ENDED) {
|
|
sync();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function onYouTubeIframeAPIReady() {
|
|
sync();
|
|
setInterval(sync, 5000);
|
|
}
|
|
|
|
async function sync(force) {
|
|
var {id, position} = await nowPlaying();
|
|
|
|
if (!player) {
|
|
await initializePlayer(id, position);
|
|
}
|
|
|
|
if (player.getVideoData().video_id != id) {
|
|
console.debug("change video");
|
|
player.loadVideoById(id, position);
|
|
player.playVideo();
|
|
}
|
|
|
|
if (player.getPlayerState() != YT.PlayerState.PLAYING) {
|
|
player.playVideo();
|
|
}
|
|
|
|
if (force || Math.abs(player.getCurrentTime() - position) > 0.5) {
|
|
player.seekTo(position);
|
|
}
|
|
|
|
console.log("desync", (player.getCurrentTime() - position) * 1000, "ms");
|
|
}
|
|
|
|
|
|
var playlist;
|
|
|
|
async function nowPlaying() {
|
|
if (!playlist || Date.now() > playlist.timestamp + playlist.totalDuration*1000) {
|
|
playlist = await fetch("playlist?channel=" + channel).then(res => res.json());
|
|
}
|
|
for (var i = 0, pastDurations = 0; i < playlist.videos.length; i++) {
|
|
var video = playlist.videos[i];
|
|
var videoDurationMs = video.duration * 1000;
|
|
if (Date.now() < playlist.timestamp + pastDurations + videoDurationMs) {
|
|
break;
|
|
}
|
|
pastDurations += videoDurationMs;
|
|
}
|
|
return {
|
|
position: (Date.now() - pastDurations - playlist.timestamp) / 1000,
|
|
id: video.id
|
|
};
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
var ws;
|
|
(function createWs() {
|
|
ws = new WebSocket(location.href.replace('http','ws'));
|
|
ws.binaryType = "arraybuffer";
|
|
ws.onopen = () => {
|
|
ws.send(JSON.stringify(["channel", channel]));
|
|
};
|
|
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 "leave":
|
|
document.getElementById("mousie"+j[1])?.remove();
|
|
refreshChannels();
|
|
break;
|
|
case "join":
|
|
refreshChannels();
|
|
break;
|
|
case "channel":
|
|
channel = j[1];
|
|
history.pushState({}, "", channel);
|
|
playlist = undefined;
|
|
sync();
|
|
[...document.getElementsByClassName('mousie')].forEach(m => m.remove());
|
|
lines = [];
|
|
renderCanvas();
|
|
break;
|
|
}
|
|
} else {
|
|
var dv = new DataView(evt.data);
|
|
var id = dv.getUint8(0);
|
|
if (dv.byteLength == 2) {
|
|
switch (dv.getUint8(1)) {
|
|
case 1:
|
|
updateMousie({id, depressed: true});
|
|
break;
|
|
case 0:
|
|
updateMousie({id, depressed: false});
|
|
break;
|
|
case 2:
|
|
clearLines(id);
|
|
break;
|
|
}
|
|
} else if (dv.byteLength == 5) {
|
|
var x = dv.getInt16(1, false);
|
|
var y = dv.getInt16(3, false);
|
|
updateMousie({id, x, y});
|
|
} else {
|
|
|
|
}
|
|
}
|
|
};
|
|
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}) {
|
|
var mousie_element = document.getElementById("mousie"+id);
|
|
if (!mousie_element) {
|
|
mousie_element = mousie_template.content.firstElementChild.cloneNode(true);
|
|
mousie_element.id = "mousie"+id;
|
|
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");
|
|
if (txt === "") talk.innerText = "";
|
|
else if (txt == "Backspace") talk.innerText = talk.innerText.slice(0,-1);
|
|
else if (txt == "Delete") talk.innerText = talk.innerText.slice(1);
|
|
else talk.innerText += txt;
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
|
|
onkeypress = evt => {
|
|
if (evt.key == "Escape") {
|
|
var txt = "";
|
|
} else if (evt.key == "Enter") {
|
|
var txt = '\n';
|
|
} else {
|
|
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();
|
|
//
|
|
};
|
|
|
|
onauxclick = evt => {
|
|
if (evt.button === 1) {
|
|
evt.preventDefault();
|
|
sync(true);
|
|
} else if (evt.button === 2) {
|
|
ws.send(new Uint8Array([2]));
|
|
clearLines("_self");
|
|
}
|
|
};
|
|
|
|
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 !== 0) return;
|
|
ws.send(new Uint8Array([1]));
|
|
updateMousie({depressed: true});
|
|
};
|
|
onmouseup = ontouchend = evt => {
|
|
if (evt.button !== 0) return;
|
|
ws.send(new Uint8Array([0]));
|
|
updateMousie({depressed: false});
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
var lines = [];
|
|
|
|
function mkline(x1, y1, x2, y2, owner) {
|
|
lines.push({x1, y1, x2, y2, owner, ts: Date.now()});;
|
|
drawline(x1, y1, x2, y2);
|
|
}
|
|
|
|
function drawline(x1, y1, x2, y2, opacity = 255) {
|
|
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 = `rgba(127,127,127,${opacity})`;
|
|
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} of lines) {
|
|
drawline(x1, y1, x2, y2, 1);
|
|
}
|
|
}
|
|
|
|
window.addEventListener("resize", renderCanvas);
|
|
|
|
function clearLines(owner) {
|
|
var newLines = [];
|
|
for (var line of lines) {
|
|
if (line.owner != owner) newLines.push(line);
|
|
}
|
|
lines = newLines;
|
|
renderCanvas();
|
|
}
|
|
|
|
|
|
|
|
|
|
function changeChannel(newChannel) {
|
|
ws.send(JSON.stringify(["channel", newChannel]));
|
|
}
|
|
|
|
function refreshChannels() {
|
|
fetch("channels").then(res => res.json()).then(channels => {
|
|
channel_select.innerHTML = "";
|
|
for (let [channel, online] of channels) {
|
|
let o = document.createElement("option");
|
|
o.value = channel;
|
|
o.innerText = `(${online}) ${channel}`;
|
|
channel_select.appendChild(o);
|
|
}
|
|
channel_select.value = channel;
|
|
});
|
|
}
|
|
refreshChannels();
|
|
|
|
channel_select.onclick = refreshChannels;
|
|
channel_select.onchange = evt => {
|
|
changeChannel(evt.target.value);
|
|
};
|
|
|
|
|
|
|
|
</script>
|
|
</body>
|
|
</html> |