From aff34f1d21375f2ecc263e1a7585ca43be5cfc83 Mon Sep 17 00:00:00 2001 From: Alexey Khit Date: Thu, 24 Nov 2022 01:59:48 +0300 Subject: [PATCH] Totally rewritten MSE player --- www/mse.html | 85 +---------- www/video-rtc.js | 374 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 381 insertions(+), 78 deletions(-) create mode 100644 www/video-rtc.js diff --git a/www/mse.html b/www/mse.html index 2c3eb029..8a08ce1e 100644 --- a/www/mse.html +++ b/www/mse.html @@ -3,97 +3,26 @@ go2rtc - MSE + - - diff --git a/www/video-rtc.js b/www/video-rtc.js new file mode 100644 index 00000000..11827bc1 --- /dev/null +++ b/www/video-rtc.js @@ -0,0 +1,374 @@ +/** + * Common function for processing MSE and MSE2 data. + * @param ms MediaSource + */ +function MediaSourceHandler(ms) { + let sb, qb = []; + + return ev => { + if (typeof ev.data === "string") { + const msg = JSON.parse(ev.data); + if (msg.type === "mse") { + if (!MediaSource.isTypeSupported(msg.value)) { + console.warn("Not supported: " + msg.value) + return; + } + + sb = ms.addSourceBuffer(msg.value); + sb.mode = "segments"; // segments or sequence + sb.addEventListener("updateend", () => { + if (!sb.updating && qb.length > 0) { + try { + sb.appendBuffer(qb.shift()); + } catch (e) { + // console.warn(e); + } + } + }); + } + } else if (sb.updating || qb.length > 0) { + qb.push(ev.data); + // console.debug("buffer:", qb.length); + } else { + try { + sb.appendBuffer(ev.data); + } catch (e) { + // console.warn(e); + } + } + } +} + +/** + * Dedicated Worker Handler for MSE2 https://chromestatus.com/feature/5177263249162240 + */ +if (typeof importScripts == "function") { + // protect below code (class VideoRTC) from fail inside Worker + HTMLElement = Object; + customElements = {define: Function()}; + + const ms = new MediaSource(); + ms.addEventListener("sourceopen", ev => { + postMessage({type: ev.type}); + }, {once: true}); + + onmessage = MediaSourceHandler(ms); + + postMessage({type: "handle", value: ms.handle}, [ms.handle]); +} + +/** + * Video player for MSE and WebRTC connections. + * + * All modern web technologies are supported in almost any browser except Apple Safari. + * + * Support: + * - RTCPeerConnection for Safari iOS 11.0+ + * - IntersectionObserver for Safari iOS 12.2+ + * - MediaSource in Workers for Chrome 108+ + * + * Doesn't support: + * - MediaSource for Safari iOS all + * - Customized built-in elements (extends HTMLVideoElement) because all Safari + */ +class VideoRTC extends HTMLElement { + DISCONNECT_TIMEOUT = 5000; + RECONNECT_TIMEOUT = 30000; + + CODECS = [ + "avc1.640029", // H.264 high 4.1 (Chromecast 1st and 2nd Gen) + "avc1.64002A", // H.264 high 4.2 (Chromecast 3rd Gen) + "hvc1.1.6.L153.B0", // H.265 main 5.1 (Chromecast Ultra) + "mp4a.40.2", // AAC LC + "mp4a.40.5", // AAC HE + "mp4a.69", // MP3 + "mp4a.6B", // MP3 + ]; + + /** + * Enable MediaSource in Workers mode. + * @type {boolean} + */ + MSE2 = true; + + /** + * Run stream when not displayed on the screen. Default `false`. + * @type {boolean} + */ + background = false; + + /** + * Run stream only when player in the viewport. Stop when user scroll out player. + * Value is percentage of visibility from `0` (not visible) to `1` (full visible). + * Default `0` - disable; + * @type {number} + */ + intersectionThreshold = 0; + + /** + * Run stream only when browser page on the screen. Stop when user change browser + * tab or minimise browser windows. + * @type {boolean} + */ + visibilityCheck = true; + + /** + * @type {HTMLVideoElement} + */ + video = null; + + /** + * @type {RTCPeerConnection} + */ + pc = null; + + /** + * @type {WebSocket} + */ + ws = null; + + /** + * Internal WebSocket connection state. Values: CLOSED, CONNECTING, OPEN + * @type {number} + */ + wsState = WebSocket.CLOSED; + + /** + * Internal WebSocket URL. + * @type {string} + */ + wsURL = ""; + + /** + * Internal disconnect TimeoutID. + * @type {number} + */ + disconnectTimeout = 0; + + /** + * Internal reconnect TimeoutID. + * @type {number} + */ + reconnectTimeout = 0; + + constructor() { + super(); + + console.debug("this.constructor"); + + this.video = document.createElement("video"); + this.video.controls = true; + this.video.playsInline = true; + } + + /** public properties **/ + + /** + * Set video source (WebSocket URL). Support relative path. + * @param value + */ + set src(value) { + if (value.startsWith("/")) { + value = "ws" + location.origin.substr(4) + value; + } else if (value.startsWith("http")) { + value = "ws" + value.substr(4); + } + + this.wsURL = value; + + if (this.isConnected) this.connectedCallback(); + } + + /** + * Play video. Support automute when autoplay blocked. + * https://developer.chrome.com/blog/autoplay/ + */ + play() { + this.video.play().catch(er => { + if (er.name === "NotAllowedError" && !this.video.muted) { + this.video.muted = true; + this.video.play(); + } + }); + } + + get codecs() { + return this.CODECS.filter(value => { + return MediaSource.isTypeSupported(`video/mp4; codecs="${value}"`); + }).join(); + } + + /** + * `CustomElement`. Invoked each time the custom element is appended into a + * document-connected element. + */ + connectedCallback() { + console.debug("this.connectedCallback", this.wsState); + if (this.disconnectTimeout) { + clearTimeout(this.disconnectTimeout); + this.disconnectTimeout = 0; + } + + // because video autopause on disconnected from DOM + const seek = this.video.seekable; + if (seek.length > 0) { + this.video.currentTime = seek.end(seek.length - 1); + this.play(); + } + + if (!this.wsURL || this.wsState !== WebSocket.CLOSED) return; + + // CLOSED => CONNECTING + this.wsState = WebSocket.CONNECTING; + + this.internalInit(); + this.internalConnect(); + } + + /** + * `CustomElement`. Invoked each time the custom element is disconnected from the + * document's DOM. + */ + disconnectedCallback() { + console.debug("this.disconnectedCallback", this.wsState); + if (this.background || this.disconnectTimeout || + this.wsState === WebSocket.CLOSED) return; + + this.disconnectTimeout = setTimeout(() => { + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = 0; + } + + this.disconnectTimeout = 0; + // CONNECTING, OPEN => CLOSED + this.wsState = WebSocket.CLOSED; + + if (this.ws) { + this.ws.close(); + this.ws = null; + } + }, this.DISCONNECT_TIMEOUT); + } + + internalInit() { + if (this.childElementCount) return; + + this.appendChild(this.video); + + if (this.background) return; + + if ("hidden" in document && this.visibilityCheck) { + document.addEventListener("visibilitychange", () => { + if (document.hidden) { + this.disconnectedCallback(); + } else if (this.isConnected) { + this.connectedCallback(); + } + }) + } + + if ("IntersectionObserver" in window && this.intersectionThreshold) { + const observer = new IntersectionObserver(entries => { + entries.forEach(entry => { + if (!entry.isIntersecting) { + this.disconnectedCallback(); + } else if (this.isConnected) { + this.connectedCallback(); + } + }); + }, {threshold: this.intersectionThreshold}); + observer.observe(this); + } + } + + internalConnect() { + if (this.wsState !== WebSocket.CONNECTING) return; + if (this.ws) throw "connect with non null WebSocket"; + + const ts = Date.now(); + + this.ws = new WebSocket(this.wsURL); + this.ws.binaryType = "arraybuffer"; + + this.ws.addEventListener("open", () => { + console.debug("ws.open", this.wsState); + if (this.wsState !== WebSocket.CONNECTING) return; + + // CONNECTING => OPEN + this.wsState = WebSocket.OPEN; + }); + this.ws.addEventListener("close", () => { + console.debug("ws.close", this.wsState); + if (this.wsState === WebSocket.CLOSED) return; + + // CONNECTING, OPEN => CONNECTING + this.wsState = WebSocket.CONNECTING; + this.ws = null; + + // reconnect no more than once every X seconds + const delay = Math.max(this.RECONNECT_TIMEOUT - (Date.now() - ts), 0); + + this.reconnectTimeout = setTimeout(() => { + this.reconnectTimeout = 0; + this.internalConnect(); + }, delay); + }); + + if ("MediaSource" in window && this.MSE2) { + if (MediaSource.canConstructInDedicatedWorker) { + this.internalMSE2(); + } else { + this.internalMSE(); + } + } + + // TODO: this.internalRTC(); + } + + internalMSE() { + console.debug("this.internalMSE"); + this.ws.addEventListener("open", () => { + const ms = new MediaSource(); + ms.addEventListener("sourceopen", () => { + URL.revokeObjectURL(this.video.src); + this.ws.send(JSON.stringify({type: "mse", value: this.codecs})); + }, {once: true}); + + this.video.src = URL.createObjectURL(ms); + this.play(); + + this.ws.addEventListener("message", MediaSourceHandler(ms)); + }); + } + + internalMSE2() { + console.debug("this.internalMSE2"); + const worker = new Worker("video-rtc.js"); + worker.addEventListener("message", ev => { + if (ev.data.type === "handle") { + this.video.srcObject = ev.data.value; + this.play(); + } else if (ev.data.type === "sourceopen") { + this.ws.send(JSON.stringify({type: "mse", value: this.codecs})); + } + }); + + this.ws.addEventListener("message", ev => { + if (typeof ev.data === "string") { + worker.postMessage(ev.data); + } else { + worker.postMessage(ev.data, [ev.data]); + } + }); + this.ws.addEventListener("close", () => { + worker.terminate(); + }); + } + + internalRTC() { + if (!("RTCPeerConnection" in window)) return; // macOS Desktop app + } +} + +customElements.define("video-rtc", VideoRTC);