From 812b5772a4a368dc230fe13f8d320b47fb547cc6 Mon Sep 17 00:00:00 2001 From: Hizkia Felix Winata Date: Sun, 30 Jan 2022 17:49:54 +0800 Subject: [PATCH] feat(player): initial version --- player/README.md | 3 ++ player/index.html | 48 +++++++++++++++++++ player/player.css | 56 ++++++++++++++++++++++ player/player.js | 117 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 224 insertions(+) create mode 100644 player/README.md create mode 100644 player/index.html create mode 100644 player/player.css create mode 100644 player/player.js 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(); +})();