feat(player): initial version

This commit is contained in:
2022-01-30 17:49:54 +08:00
parent 59fb6d852c
commit 812b5772a4
4 changed files with 224 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# web player
A basic web player with automatic mirror selection and view counting.
+48
View File
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Restream</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="./player.css" />
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<script src="./player.js"></script>
<script id="config">
window.restream_config = {
upstreams: [
{
region: 'us-east',
url: 'https://us.stream.holopirates.moe',
},
{
region: 'sg',
url: 'https://restream.kitsune.box.ca',
},
{
region: 'eu',
url: 'https://eu.stream.holopirates.moe',
},
{
region: 'jp',
url: 'https://jp.stream.holopirates.moe',
},
],
playlist: '/misc/stream/dance/index.m3u8',
};
</script>
</head>
<body>
<header>
<h1><strong>Restream</strong> | Loading</h1>
<select id="mirror-select">
<option value="">Loading mirrors...</option>
</select>
<div id="viewer-count">
<span id="viewer-count-value">0</span> viewers
</div>
</header>
<video id="video" controls></video>
</body>
</html>
+56
View File
@@ -0,0 +1,56 @@
/* http://meyerweb.com/eric/tools/css/reset/
v2.0 | 20110126
License: none (public domain)
*/
/* prettier-ignore */
@media (min-width:1px) { html, body, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite, code, del, dfn, em, img, ins, kbd, q, s, samp, small, strike, strong, sub, sup, tt, var, b, u, i, center, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td, article, aside, canvas, details, embed, figure, figcaption, footer, header, hgroup, menu, nav, output, ruby, section, summary, time, mark, audio, video { margin: 0; padding: 0; border: 0; font-size: 100%; font: inherit; vertical-align: baseline; } article, aside, details, figcaption, figure, footer, header, hgroup, menu, nav, section { display: block; } body { line-height: 1; } ol, ul { list-style: none; } blockquote, q { quotes: none; } blockquote:before, blockquote:after, q:before, q:after { content: ''; content: none; } table { border-collapse: collapse; border-spacing: 0; } }
/**
* Player styles
*/
body,
html {
min-height: 100%;
background-color: #000;
color: #fff;
font-family: 'Roboto', sans-serif;
}
header {
background-color: #333;
padding: 0.5em;
display: flex;
gap: 0.5em;
}
@media (max-width: 768px) {
header {
flex-direction: column;
align-items: start;
}
}
@media (min-width: 768px) {
header {
flex-direction: row;
align-items: center;
}
}
strong {
font-weight: bold;
}
#viewer-count {
flex: 1;
text-align: right;
}
#video {
position: relative;
width: 100%;
height: 100%;
background-color: black;
overflow: hidden;
}
+117
View File
@@ -0,0 +1,117 @@
(() => {
'use strict';
// Utility functions
const fetchWithTimeout = async (resource, options = {}) => {
const { timeout = 30000 } = options;
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeout);
const response = await fetch(resource, {
...options,
signal: controller.signal,
});
clearTimeout(id);
return response;
};
// Measure the average time it takes to get a response from the url
const ping = async (url) => {
const start = performance.now();
await fetchWithTimeout(url, {
timeout: 1000,
mode: 'cors',
});
const end = performance.now();
return end - start;
};
// Async sleep
const sleep = async (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const measureLatency = async (url, count, gap = 100) => {
const results = [];
for (let i = 0; i < count; i++) {
const latency = await ping(url).catch(() => null);
if (latency) results.push(latency);
await sleep(gap);
}
if (results.length === 0) return null;
return results.reduce((a, b) => a + b, 0) / count;
};
const updateMirrorList = () => {
const mirrorSelect = document.getElementById('mirror-select');
// Clear the current list
while (mirrorSelect.firstChild) {
mirrorSelect.removeChild(mirrorSelect.firstChild);
}
// Add each upstream server to the list
for (const upstream of window.restream_config.upstreams) {
const option = document.createElement('option');
option.value = upstream.url;
option.text =
`[${upstream.region}] ${upstream.url}` +
(typeof upstream.latency === 'number'
? ` (${Math.floor(upstream.latency)}ms)`
: upstream.latency === null
? ' (unavailable)'
: '');
mirrorSelect.add(option);
}
};
const reloadVideo = () => {
const video = document.getElementById('video');
const mirrorSelect = document.getElementById('mirror-select');
const playlistURL = mirrorSelect.value + window.restream_config.playlist;
console.log('Reloading video from', playlistURL);
if (Hls.isSupported()) {
window.restream_config.hls.destroy();
window.restream_config.hls = new Hls();
window.restream_config.hls.attachMedia(video);
window.restream_config.hls.loadSource(playlistURL);
} else {
video.src = playlistURL;
}
};
const init = async () => {
const video = document.getElementById('video');
const mirrorSelect = document.getElementById('mirror-select');
// Set up hls.js
if (Hls.isSupported()) {
window.restream_config.hls = new Hls();
window.restream_config.hls.attachMedia(video);
}
// Set up event listeners
mirrorSelect.addEventListener('change', () => reloadVideo());
const { upstreams } = window.restream_config;
for (const upstream of upstreams) {
const playlistURL = upstream.url + window.restream_config.playlist;
const latency = await measureLatency(playlistURL, 3);
upstream.latency = latency;
updateMirrorList();
}
// Choose the fastest upstream server
const upstream = upstreams
.filter((upstream) => typeof upstream.latency === 'number')
.sort((a, b) => a.latency - b.latency)[0];
mirrorSelect.value = upstream.url;
reloadVideo();
};
if (document.readyState === 'loading')
document.addEventListener('DOMContentLoaded', init);
else init();
})();