diff --git a/player/README.md b/player/README.md
new file mode 100644
index 0000000..cdacb40
--- /dev/null
+++ b/player/README.md
@@ -0,0 +1,3 @@
+# web player
+
+A basic web player with automatic mirror selection and view counting.
diff --git a/player/index.html b/player/index.html
new file mode 100644
index 0000000..2116f03
--- /dev/null
+++ b/player/index.html
@@ -0,0 +1,48 @@
+
+
+
+ Restream
+
+
+
+
+
+
+
+
+
+
+
+ Restream | Loading
+
+
+ 0 viewers
+
+
+
+
+
diff --git a/player/player.css b/player/player.css
new file mode 100644
index 0000000..ca6aabc
--- /dev/null
+++ b/player/player.css
@@ -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;
+}
diff --git a/player/player.js b/player/player.js
new file mode 100644
index 0000000..3b631ed
--- /dev/null
+++ b/player/player.js
@@ -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();
+})();