feat(player): initial version
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# web player
|
||||
|
||||
A basic web player with automatic mirror selection and view counting.
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
})();
|
||||
Reference in New Issue
Block a user