diff --git a/README.md b/README.md index dabb2ed..e624b44 100644 --- a/README.md +++ b/README.md @@ -285,13 +285,10 @@ The following features are planned for upcoming releases: - SEI timecodes support - WebTransport protocol - Nimble Advertizer integration -- Automatic reconnection - Sync mode - Screenshot capture - Splash/startup image - Extended Player API -- Muted autoplay -- Dynamic latency adjustment - OffscreenCanvas rendering - Resume from pause in DVR mode (no auto-jump to live) diff --git a/src/media/decoders/flow.js b/src/media/decoders/flow.js index a30d439..89ba9ee 100644 --- a/src/media/decoders/flow.js +++ b/src/media/decoders/flow.js @@ -209,14 +209,18 @@ export class DecoderFlow { let srcFirstTsUs = this._switchPeerFlow.firstSwitchTsUs; if (srcFirstTsUs !== null) { - if (Math.abs(frame.timestamp - srcFirstTsUs) >= SWITCH_THRESHOLD_US) { + let absDiff = Math.abs(frame.timestamp - srcFirstTsUs); + if (absDiff >= SWITCH_THRESHOLD_US) { this._logger.debug( `Handle dst switch frame - excessive diff dst ts: ${frame.timestamp}, src ts: ${srcFirstTsUs}`, ); this._switchContext = null; this._switchPeerFlow.destroy(); this._switchPeerFlow = null; - this._onSwitchResult(false); + this._onSwitchResult( + false, + `Rendition switch isn't possible, because timestamps of renditions aren't in sync: the gap is approximately ${(absDiff / 1_000_000).toFixed(1)} seconds. Please check your ABR setup.`, + ); return; } diff --git a/src/nimio-transport.js b/src/nimio-transport.js index 87f1f4f..73c6a9f 100644 --- a/src/nimio-transport.js +++ b/src/nimio-transport.js @@ -11,9 +11,24 @@ export const NimioTransport = { audioSetup: this._onAudioSetupReceived.bind(this), audioCodec: this._onAudioCodecDataReceived.bind(this), audioChunk: this._onAudioChunkReceived.bind(this), + disconnect: this._onDisconnect.bind(this), }; }, + _onDisconnect(data) { + this._state.stop(); + this._sldpManager.resetCurrentStreams(); + if (this._isAutoAbr()) { + this._abrController.stop({ hard: true }); + } + this._resetPlayback(); + if (!this._reconnect.schedule(this._playCb)) { + this._logger.debug("Stop reconnecting"); + return this._ui.drawPlay(); + } + this._logger.debug("Attempt to reconnect"); + }, + _onVideoSetupReceived(data) { if (!data || !data.config) { this._setNoVideo(); diff --git a/src/nimio.js b/src/nimio.js index 6f7bb0b..6bc6383 100644 --- a/src/nimio.js +++ b/src/nimio.js @@ -30,6 +30,7 @@ import { EventBus } from "./event-bus"; import { WorkletLogReceiver } from "./shared/worklet-log-receiver"; import { createSharedBuffer, isSharedBuffer } from "./shared/shared-buffer"; import { resolveContainer } from "./shared/container"; +import { Reconnector } from "./reconnector"; let scriptPath; if (document.currentScript === null) { @@ -111,7 +112,7 @@ export default class Nimio { dropZeroDurationFrames: this._config.dropZeroDurationFrames, }); - this._resetPlaybackTimstamps(); + this._resetPlaybackTimestamps(); this._renderVideoFrame = this._renderVideoFrame.bind(this); this._ctx = this._ui.canvas.getContext("2d"); @@ -120,6 +121,7 @@ export default class Nimio { this._context = PlaybackContext.getInstance(this._instName); this._sldpManager = new SLDPManager(this._instName); this._sldpManager.init(this._transport, this._config); + this._reconnect = new Reconnector(this._instName, this._config.reconnects); this._audioWorkletReady = null; this._audioConfig = new AudioConfig(48000, 1, 1024); // default values @@ -134,8 +136,9 @@ export default class Nimio { this._createLatencyController(); + this._playCb = this.play.bind(this); if (this._config.autoplay) { - setTimeout(() => this.play(), 0); + setTimeout(this._playCb, 0); setTimeout(() => { this._ui.hideControls(true); }, 1000); @@ -143,12 +146,10 @@ export default class Nimio { } play() { - const initialPlay = !this._state.isPaused(); + if (this._state.isPlaying()) return; - if (this._pauseTimeoutId !== null) { - clearTimeout(this._pauseTimeoutId); - this._pauseTimeoutId = null; - } + const initialPlay = !this._state.isPaused(); + this._cancelPauseTimeout(); this._state.start(); this._latencyCtrl.start(); @@ -172,8 +173,11 @@ export default class Nimio { } pause() { + if (this._state.isPaused()) return; + this._state.pause(); this._latencyCtrl.pause(); + this._reconnect.stop(); if (this._isAutoAbr()) this._abrController.stop(); this._pauseTimeoutId = setTimeout(() => { this._logger.debug("Auto stop"); @@ -183,53 +187,16 @@ export default class Nimio { stop(closeConnection) { this._state.stop(); + this._sldpManager.stop({ closeConnection }); + this._reconnect.reset(); + if (this._debugView) this._debugView.stop(); + if (this._isAutoAbr()) { this._abrController.stop({ hard: true }); } - - this._sldpManager.stop(!!closeConnection); - if (this._debugView) { - this._debugView.stop(); - } - - this._videoBuffer.reset(); - this._noVideo = this._config.audioOnly; - - this._stopAudio(); - this._noAudio = this._config.videoOnly; - if (this._audioBuffer) { - this._audioBuffer.reset(); - } - this._latencyCtrl.reset(); - - if (this._nextRenditionData) { - if (this._nextRenditionData.decoderFlow) { - this._nextRenditionData.decoderFlow.destroy(); - } - this._nextRenditionData = null; - } - - ["video", "audio"].forEach((type) => { - if (this._decoderFlows[type]) { - this._decoderFlows[type].destroy(); - this._decoderFlows[type] = null; - } - }); - - this._state.setPlaybackStartTsUs(0); - this._state.setVideoLatestTsUs(0); - this._state.setAudioLatestTsUs(0); - this._state.resetCurrentTsSmp(); - this._resetPlaybackTimstamps(); + this._resetPlayback(); this._ui.drawPlay(); - this._ctx.clearRect(0, 0, this._ctx.canvas.width, this._ctx.canvas.height); - this._pauseTimeoutId = null; - - this._vuMeterSvc.stop(); - this._audioGraphCtrl.dismantle(); - this._audioCtxProvider.reset(); - this._workletLogReceiver.reset(); } destroy() { @@ -325,7 +292,10 @@ export default class Nimio { ? this._onVideoStartTsNotSet.bind(this) : this._onAudioStartTsNotSet.bind(this); decoderFlow.onDecodingError = this._onDecodingError.bind(this); - decoderFlow.onSwitchResult = (done) => { + decoderFlow.onSwitchResult = (done, msg) => { + if (msg && !done) { + this._logger.error(msg); + } this._onRenditionSwitchResult(type, done); }; decoderFlow.onInputCancel = () => { @@ -435,7 +405,54 @@ export default class Nimio { this._state.setPlaybackStartTsUs(this._playbackStartTsUs); } - _resetPlaybackTimstamps() { + _cancelPauseTimeout() { + if (this._pauseTimeoutId === null) return; + clearTimeout(this._pauseTimeoutId); + this._pauseTimeoutId = null; + } + + _resetPlayback() { + this._ctx.clearRect(0, 0, this._ctx.canvas.width, this._ctx.canvas.height); + + this._videoBuffer.reset(); + this._noVideo = this._config.audioOnly; + + this._stopAudio(); + this._noAudio = this._config.videoOnly; + if (this._audioBuffer) { + this._audioBuffer.reset(); + } + this._latencyCtrl.reset(); + + if (this._nextRenditionData) { + if (this._nextRenditionData.decoderFlow) { + this._nextRenditionData.decoderFlow.destroy(); + } + this._nextRenditionData = null; + } + + ["video", "audio"].forEach((type) => { + if (this._decoderFlows[type]) { + this._decoderFlows[type].destroy(); + this._decoderFlows[type] = null; + } + }); + + this._state.setPlaybackStartTsUs(0); + this._state.setVideoLatestTsUs(0); + this._state.setAudioLatestTsUs(0); + this._state.resetCurrentTsSmp(); + this._resetPlaybackTimestamps(); + + this._vuMeterSvc.stop(); + this._audioGraphCtrl.dismantle(); + this._audioCtxProvider.reset(); + + this._cancelPauseTimeout(); + this._workletLogReceiver.reset(); + } + + _resetPlaybackTimestamps() { this._playbackStartTsUs = 0; this._firstAudioFrameTsUs = this._firstVideoFrameTsUs = 0; } diff --git a/src/playback/context.js b/src/playback/context.js index b8aa858..37d58f9 100644 --- a/src/playback/context.js +++ b/src/playback/context.js @@ -162,6 +162,29 @@ class PlaybackContext { return this._curConf[type] && idx === this._curConf[type].idx; } + getStreamsConfig() { + let res = []; + for (let i = 0; i < this._streams.length; i++) { + const streamInfo = this._streams[i].stream_info; + let strm = { + name: this._streams[i].stream, + bandwidth: streamInfo.bandwidth, + }; + if (streamInfo.vcodec) { + strm.width = streamInfo.width; + strm.height = streamInfo.height; + strm.vcodec = streamInfo.vcodec; + strm.video = streamInfo.vcodecSupported ? "supported" : "not supported"; + } + if (streamInfo.acodec) { + strm.acodec = streamInfo.acodec; + strm.audio = streamInfo.acodecSupported ? "supported" : "not supported"; + } + res.push(strm); + } + return res; + } + get streams() { return this._streams; } diff --git a/src/player-config.js b/src/player-config.js index 93a95af..7b5927a 100644 --- a/src/player-config.js +++ b/src/player-config.js @@ -25,6 +25,7 @@ const DEFAULTS = { fullscreen: false, dropZeroDurationFrames: false, hardwareAcceleration: false, + reconnects: 10, }; const REQUIRED_KEYS = ["streamUrl", "container"]; diff --git a/src/reconnector.js b/src/reconnector.js new file mode 100644 index 0000000..49a3359 --- /dev/null +++ b/src/reconnector.js @@ -0,0 +1,39 @@ +import { EventBus } from "@/event-bus"; + +export class Reconnector { + constructor(instName, count) { + this._count = count; + this._eventBus = EventBus.getInstance(instName); + this._eventBus.on("nimio:connection-established", () => this.reset()); + this.reset(); + } + + reset() { + this.stop(); + this._done = 0; + } + + stop() { + if (!this._timer) return; + clearTimeout(this._timer); + this._timer = undefined; + } + + schedule(cb) { + if (this._done >= this._count) return false; + + if (!this._timer) { + const inst = this; + this._timer = setTimeout(function () { + inst._done++; + cb(); + inst._timer = undefined; + }, this._timeout()); + } + return true; + } + + _timeout() { + return this._done < 5 ? 1000 : 5000; + } +} diff --git a/src/shared/service.js b/src/shared/service.js index 59832a7..2ce5da9 100644 --- a/src/shared/service.js +++ b/src/shared/service.js @@ -15,6 +15,10 @@ export function multiInstanceService(Klass) { let instances = {}; return { getInstance: function (instanceId) { + if (!instanceId) { + console.error("multiInstance getInstance is called without instanceId"); + return null; + } if (!instances[instanceId]) { instances[instanceId] = new Klass(instanceId); instances[instanceId].constructor = null; diff --git a/src/sldp/manager.js b/src/sldp/manager.js index 42400de..15ddef0 100644 --- a/src/sldp/manager.js +++ b/src/sldp/manager.js @@ -1,5 +1,6 @@ import { PlaybackContext } from "@/playback/context"; import { LoggersFactory } from "@/shared/logger"; +import { EventBus } from "@/event-bus"; export class SLDPManager { constructor(instName) { @@ -11,6 +12,7 @@ export class SLDPManager { this._context = PlaybackContext.getInstance(instName); this._logger = LoggersFactory.create(instName, "SLDP Manager"); + this._eventBus = EventBus.getInstance(instName); // TODO: set from config.syncBuffer this._useSteady = false; } @@ -42,19 +44,22 @@ export class SLDPManager { }); } - stop(closeConnection) { - let sns = this._curStreams.map((s) => s.sn); + stop(opts = {}) { this._transport.send("stop", { - close: !!closeConnection, - sns: sns, + close: !!opts.closeConnection, + sns: this.resetCurrentStreams(), }); + } + resetCurrentStreams() { + const sns = this._curStreams.map((s) => s.sn); for (let i = 0; i < sns.length; i++) { delete this._reqStreams[sns[i]]; } this._curStreams = []; this._transport.send("removeTimescale", sns); + return sns; } requestStream(type, idx, offset) { @@ -194,6 +199,11 @@ export class SLDPManager { this._transport.runCallback("videoSetup", vsetup); this._transport.runCallback("audioSetup", asetup); this._transport.send("timescale", timescale); + + this._eventBus.emit( + "nimio:connection-established", + this._context.getStreamsConfig(), + ); } _pushCurStream(type, stream) { diff --git a/src/transport/web-socket.js b/src/transport/web-socket.js index d47bbf1..35bca15 100644 --- a/src/transport/web-socket.js +++ b/src/transport/web-socket.js @@ -77,7 +77,7 @@ self.onmessage = (e) => { if (wsEv.target.id === curSocketId) { socket = undefined; - self.postMessage({ type: "disconnect" }); + self.postMessage({ type: "disconnect", data: { code: codeHuman } }); } }; return; diff --git a/tests/shared-audio-buffer.test.js b/tests/shared-audio-buffer.test.js index 9650e34..40c1d28 100644 --- a/tests/shared-audio-buffer.test.js +++ b/tests/shared-audio-buffer.test.js @@ -151,6 +151,10 @@ describe("SharedAudioBuffer", () => { } }); + it("returns correct buffer capacity", () => { + expect(sab.bufferCapacity).toBe(sab.bufferCapacity); + }); + it("returns the buffer is shareable", () => { expect(typeof sab.isShareable).toBe("boolean"); });