UNPKG

@skylineos/clsp-player

Version:

Skyline Technology Solutions' CLSP Video Player. Stream video in near-real-time in modern browsers.

317 lines (276 loc) 9.16 kB
/** * A wrapper for the default browser `window.MediaSource` class * * @see - https://developers.google.com/web/fundamentals/media/mse/basics * @see - https://github.com/nickdesaulniers/netfix/blob/gh-pages/demo/bufferAll.html */ import interval from 'interval-promise'; import EventEmitter from '../../../utils/EventEmitter'; const DEFAULT_DURATION = 10; // Check at this interval to see if the MediaSource is ready const DEFAULT_IS_READY_INTERVAL = 0.25; // Give the MediaSource this many seconds to become ready const DEFAULT_IS_READY_TIMEOUT = 1; export default class MediaSourceWrapper extends EventEmitter { /** * Events that are emitted by this MediaSourceWrapper */ static events = { // --- Custom events SOURCE_OPEN_ERROR: 'source-open-error', // --- MSE MediaSource events // @todo - create an event name that makes sense in layman's terms, such // as "INITIALIZED" either here or in MSEWrapper SOURCE_OPEN: 'sourceopen', // @todo - create an event name that makes sense in layman's terms, such // as "FINISHED" either here or in MSEWrapper SOURCE_ENDED: 'sourceended', ERROR: 'error', }; /** * Check to see if the passed mimeCodec is supported by this browser. * * @see - https://developer.mozilla.org/en-US/docs/Web/API/MediaSource/isTypeSupported * * @param {string} mimeCodec * The mime code to check for support / compatibility * * @returns {boolean} * - true if the mimeCodec is supported * - false if the mimeCodec is not supported */ static isMimeCodecSupported (mimeCodec) { if (!mimeCodec) { return false; } return window.MediaSource.isTypeSupported(mimeCodec); } /** * Create a new MediaSourceWrapper, which is a wrapper around `window.MediaSource` * * @param {string|object} logId * a string that identifies this MediaSource in log messages * @see - src/js/utils/Destroyable */ static factory (logId) { return new MediaSourceWrapper(logId); } /** * @private * * Create a new MediaSourceWrapper, which is a wrapper around `window.MediaSource` * * @param {string|object} logId * a string that identifies this MediaSourceWrapper in log messages * @see - src/js/utils/Destroyable */ constructor (logId) { super(logId); this.mediaSource = new window.MediaSource(); // @todo - no idea what could cause this situation... if (!this.mediaSource) { throw new Error('The video element or mediaSource is not ready!'); } this.mediaSource.addEventListener('sourceopen', this.#onSourceOpen); this.mediaSource.addEventListener('sourceended', this.#onSourceEnded); this.mediaSource.addEventListener('error', this.#onError); this.objectURL = null; this.DURATION = DEFAULT_DURATION; this.IS_READY_INTERVAL = DEFAULT_IS_READY_INTERVAL; this.IS_READY_TIMEOUT = DEFAULT_IS_READY_TIMEOUT; } /** * Determine if this MediaSource is ready to start using any SourceBuffers * it might have. * * @returns {boolean} * - true if readyState is "open" * - false if readyState is not "open" */ isReady () { if (this.isDestroyComplete) { return false; } // found when stress testing many videos, it is possible for the // media source ready state not to be open even though // source open callback is being called. return this.mediaSource.readyState === 'open'; } /** * @async * * Wait for this MediaSource to become ready. * * @returns {void} */ async waitUntilReady () { if (this.isDestroyComplete) { throw new Error('MediaSource will not become ready because it has already been destroyed'); } if (this.isReady()) { return; } await interval( async (iteration, stop) => { if (this.isReady()) { stop(); } }, this.IS_READY_INTERVAL * 1000, { iterations: (this.IS_READY_TIMEOUT / this.IS_READY_INTERVAL), }, ); if (!this.isReady()) { throw new Error('MediaSource `readyState` timed out waiting to be `open`!'); } } /** * Create the objectURL for this MediaSource that is needed to play a stream * using an HTML5 `<video>` tag. * * @see - https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL * * @returns {DOMString|string} * The objectURL that can be set as the `<video>` tag's `src` attribute */ asObjectURL () { if (this.isDestroyed) { throw new Error('Cannot create objectURL while destroyed'); } if (!this.objectURL) { // Since the value returned by `createObjectURL` appears to be retained // by the `document`, only create one for this instance to reduce the // chance of it creating a memory leak. this.objectURL = window.URL.createObjectURL(this.mediaSource); } return this.objectURL; } /** * @private * * Revoke the objectURL that was created for this MediaSource, if it exists. * Meant to help with memory management. * * @returns {void} */ #revokeObjectURL () { if (this.objectURL) { window.URL.revokeObjectURL(this.objectURL); } this.objectURL = null; } /** * @private * * Event listener for the `sourceopen` event. Broadcasts an event to * indicate that this MediaSource is ready. * * @todo - Does this realy need to be broadcast up? Is there ever a * situation where `sourceopen` will be emitted more than once for a single * MediaSource / SourceBuffer? If not, this event should only be used * privately as part of an initialization method. * * @param {object} event * * @returns {void} */ #onSourceOpen = async (event) => { try { await this.waitUntilReady(); } catch (error) { this.emit(MediaSourceWrapper.events.SOURCE_OPEN_ERROR, { error }); return; } try { // This can only be set when the media source is open. // @todo - does this do memory management for us so we don't have // to call remove on the buffer, which is expensive? It seems // like it... this.mediaSource.duration = this.DURATION; } catch (error) { this.logger.error(`Error while setting mediaSource duration to "${this.DURATION}"`); this.logger.error(error); this.emit(MediaSourceWrapper.events.SOURCE_OPEN_ERROR, { error }); return; } this.emit(MediaSourceWrapper.events.SOURCE_OPEN, event); }; /** * @private * * Event listener for the `sourceended` event. Broadcasts an event to * indicate that this MediaSource is finished. * * @todo - the `sourceended` event is only supposed to be emitted when * `mediaSource.endOfStream` is called, which is only ever done inside this * class. This means we should always be in control of when this event * occurs, and shouldn't need to broadcast it up. Is there some other * condition which could cause this to be emitted? * * @param {object} event * * @returns {void} */ #onSourceEnded = (event) => { this.emit(MediaSourceWrapper.events.SOURCE_ENDED, event); }; /** * @private * * Event listener for the `error` event. Broadcasts an event to indicate * that this MediaSource has encountered an unexpected error. * * @param {object} event * * @returns {void} */ #onError = (event) => { this.emit(MediaSourceWrapper.events.ERROR, event); }; /** * @private * @deprecated * * Intended to clear all SourceBuffers from this MediaSource. We should not * need to do this since we are supposed to only have a single SourceBuffer * that is privately managed (by the MSEWrapper). * * @see - https://developer.mozilla.org/en-US/docs/Web/API/MediaSource/sourceBuffers * * @returns {void} */ #removeAllSourceBuffers () { let sourceBuffers = this.mediaSource.sourceBuffers; if (sourceBuffers.SourceBuffers) { sourceBuffers = sourceBuffers.SourceBuffers(); } for (let i = 0; i < sourceBuffers.length; i++) { const sourceBuffer = sourceBuffers[i]; this.mediaSource.removeSourceBuffer(sourceBuffer); } } async _destroy () { this.mediaSource.removeEventListener('sourceopen', this.#onSourceOpen); this.mediaSource.removeEventListener('sourceended', this.#onSourceEnded); this.mediaSource.removeEventListener('error', this.#onError); try { await this.waitUntilReady(); // `endOfStream` can only be called when the mediaSource is ready this.mediaSource.endOfStream(); } catch (error) { this.logger.info('MediaSource did not become ready while destroying, continuing destroy anyway...'); this.logger.info(error); } // @todo - can this be done in the SourceBuffer destroy? // @todo - if the destroy logic was more properly implemented, this could // be done elsewhere... this.#removeAllSourceBuffers(); // this.mediaSource.removeSourceBuffer(this.sourceBuffer.sourceBuffer); this.#revokeObjectURL(); await super._destroy(); } }