UNPKG

rx-player

Version:
498 lines (497 loc) 20.8 kB
import { MediaSource_ } from "../compat/browser_compatibility_types"; import tryToChangeSourceBufferType from "../compat/change_source_buffer_type"; import { onSourceClose, onSourceEnded, onSourceOpen } from "../compat/event_listeners"; import { MediaError, SourceBufferError } from "../errors"; import log from "../log"; import { concat } from "../utils/byte_parsing"; import EventEmitter from "../utils/event_emitter"; import isNullOrUndefined from "../utils/is_null_or_undefined"; import objectAssign from "../utils/object_assign"; import { convertToRanges } from "../utils/ranges"; import TaskCanceller, { CancellationError } from "../utils/task_canceller"; import { maintainEndOfStream } from "./utils/end_of_stream"; import MediaSourceDurationUpdater from "./utils/media_source_duration_updater"; /** * `IMediaSourceInterface` object for when the MSE API are directly available. * @see IMediaSourceInterface * @class {MainMediaSourceInterface} */ export default class MainMediaSourceInterface extends EventEmitter { /** * Creates a new `MainMediaSourceInterface` alongside its `MediaSource` MSE * object. * * You can then obtain a link to that `MediaSource`, for example to link it * to an `HTMLMediaElement`, through the `handle` property. */ constructor(id, forcedMediaSource) { super(); this.id = id; this.sourceBuffers = []; this._canceller = new TaskCanceller(); if (isNullOrUndefined(MediaSource_)) { throw new MediaError("MEDIA_SOURCE_NOT_SUPPORTED", "No MediaSource Object was found in the current browser."); } log.info("mse", "Creating MediaSource"); const mediaSource = forcedMediaSource !== undefined ? new forcedMediaSource() : new MediaSource_(); const handle = mediaSource.handle; this.handle = isNullOrUndefined(handle) ? // eslint-disable-next-line @typescript-eslint/no-restricted-types { type: "media-source", value: mediaSource } : { type: "handle", value: handle }; this._mediaSource = mediaSource; this.readyState = mediaSource.readyState; this._durationUpdater = new MediaSourceDurationUpdater(mediaSource); this._endOfStreamCanceller = null; onSourceOpen(mediaSource, () => { this.readyState = mediaSource.readyState; this.trigger("mediaSourceOpen", null); }, this._canceller.signal); onSourceEnded(mediaSource, () => { this.readyState = mediaSource.readyState; this.trigger("mediaSourceEnded", null); }, this._canceller.signal); onSourceClose(mediaSource, () => { this.readyState = mediaSource.readyState; this.trigger("mediaSourceClose", null); }, this._canceller.signal); if (this._mediaSource.streaming !== undefined) { this.streaming = this._mediaSource.streaming; } this._mediaSource.addEventListener("startstreaming", () => { this.streaming = true; this.trigger("streamingChanged", null); }); this._mediaSource.addEventListener("endstreaming", () => { this.streaming = false; this.trigger("streamingChanged", null); }); } /** @see IMediaSourceInterface */ addSourceBuffer(sbType, codec) { const sourceBuffer = this._mediaSource.addSourceBuffer(codec); const sb = new MainSourceBufferInterface(sbType, codec, sourceBuffer); this.sourceBuffers.push(sb); return sb; } /** @see IMediaSourceInterface */ setDuration(newDuration, isRealEndKnown) { this._durationUpdater.updateDuration(newDuration, isRealEndKnown); } /** @see IMediaSourceInterface */ interruptDurationSetting() { this._durationUpdater.stopUpdating(); } /** @see IMediaSourceInterface */ maintainEndOfStream() { if (this._endOfStreamCanceller === null) { this._endOfStreamCanceller = new TaskCanceller(); this._endOfStreamCanceller.linkToSignal(this._canceller.signal); log.debug("mse", "end-of-stream order received."); maintainEndOfStream(this._mediaSource, this._endOfStreamCanceller.signal); } } /** @see IMediaSourceInterface */ stopEndOfStream() { if (this._endOfStreamCanceller !== null) { log.debug("mse", "resume-stream order received."); this._endOfStreamCanceller.cancel(); this._endOfStreamCanceller = null; } } /** @see IMediaSourceInterface */ dispose() { this.sourceBuffers.forEach((s) => s.dispose()); this._canceller.cancel(); resetMediaSource(this._mediaSource); } } /** * `ISourceBufferInterface` object for when the MSE API are directly available. * @see ISourceBufferInterface * @class {MainSourceBufferInterface} */ export class MainSourceBufferInterface { /** * Creates a new `SourceBufferInterface` linked to the given `SourceBuffer` * instance. * @param {string} sbType * @param {string} codec * @param {SourceBuffer} sourceBuffer */ constructor(sbType, codec, sourceBuffer) { this.type = sbType; this.codec = codec; this._canceller = new TaskCanceller(); this._sourceBuffer = sourceBuffer; this._operationQueue = []; this._currentOperations = []; const onError = this._onError.bind(this); const onUpdateEnd = this._onUpdateEnd.bind(this); sourceBuffer.addEventListener("updateend", onUpdateEnd); sourceBuffer.addEventListener("error", onError); this._canceller.signal.register(() => { sourceBuffer.removeEventListener("updateend", onUpdateEnd); sourceBuffer.removeEventListener("error", onError); }); } /** @see ISourceBufferInterface */ appendBuffer(...args) { log.debug("mse", "receiving order to push data to the SourceBuffer", { type: this.type, }); return this._addToQueue({ operationName: 0 /* SbiOperationName.Push */, params: args, }); } /** @see ISourceBufferInterface */ remove(start, end) { log.debug("mse", "receiving order to remove data from the SourceBuffer", { type: this.type, start, end, }); return this._addToQueue({ operationName: 1 /* SbiOperationName.Remove */, params: [start, end], }); } /** @see ISourceBufferInterface */ getBuffered() { try { return convertToRanges(this._sourceBuffer.buffered); } catch (err) { log.error("mse", "Failed to get buffered time range of SourceBuffer", { type: this.type, }, err instanceof Error ? err : "Unknown Error"); return []; } } /** @see ISourceBufferInterface */ abort() { try { this._sourceBuffer.abort(); } catch (err) { log.debug("mse", "Failed to abort SourceBuffer:", err instanceof Error ? err : "Unknown Error"); } this._emptyCurrentQueue(); } /** @see ISourceBufferInterface */ dispose() { try { this._sourceBuffer.abort(); } catch (_) { // we don't care } this._emptyCurrentQueue(); } _onError(evt) { let error; if (evt instanceof Error) { error = evt; } else if (evt.error instanceof Error) { error = evt.error; } else { error = new Error("Unknown SourceBuffer Error"); } const currentOps = this._currentOperations; this._currentOperations = []; if (currentOps.length === 0) { log.error("mse", "error for an unknown operation", error); } else { const rejected = new SourceBufferError(error.name, error.message, error.name === "QuotaExceededError"); for (const op of currentOps) { op.reject(rejected); } } } _onUpdateEnd() { const currentOps = this._currentOperations; this._currentOperations = []; try { for (const op of currentOps) { op.resolve(convertToRanges(this._sourceBuffer.buffered)); } } catch (err) { for (const op of currentOps) { if (err instanceof Error && err.name === "InvalidStateError") { // Most likely the SourceBuffer just has been removed from the // `MediaSource`. // Just return an empty buffered range. op.resolve([]); } else { op.reject(err); } } } this._performNextOperation(); } _emptyCurrentQueue() { const error = new CancellationError(); if (this._currentOperations.length > 0) { this._currentOperations.forEach((op) => { op.reject(error); }); this._currentOperations = []; } if (this._operationQueue.length > 0) { this._operationQueue.forEach((op) => { op.reject(error); }); this._operationQueue = []; } } _addToQueue(operation) { return new Promise((resolve, reject) => { const shouldRestartQueue = this._operationQueue.length === 0 && this._currentOperations.length === 0; const queueItem = objectAssign({ resolve, reject }, operation); this._operationQueue.push(queueItem); if (shouldRestartQueue) { this._performNextOperation(); } }); } _performNextOperation() { var _a, _b, _c, _d, _e; if (this._currentOperations.length !== 0 || this._sourceBuffer.updating) { return; } const nextElem = this._operationQueue.shift(); if (nextElem === undefined) { return; } else if (nextElem.operationName === 0 /* SbiOperationName.Push */) { this._currentOperations = [ { operationName: 0 /* SbiOperationName.Push */, resolve: nextElem.resolve, reject: nextElem.reject, }, ]; const ogData = nextElem.params[0]; const params = nextElem.params[1]; let segmentData = ogData; // In some cases with very poor performances, tens of appendBuffer // requests could be waiting for their turn here. // // Instead of pushing each one, one by one, waiting in-between for each // one's `"updateend"` event (which would probably have lot of time // overhead involved, even more considering that we're probably // encountering performance issues), the idea is to concatenate all // similar push operations into one huge segment. // // This seems to have a very large positive effect on the more // extreme scenario, such as low-latency CMAF with very small chunks and // huge CPU usage in the thread doing the push operation. // // Because this should still be relatively rare, we pre-check here // the condition. if (this._operationQueue.length > 0 && this._operationQueue[0].operationName === 0 /* SbiOperationName.Push */) { let prevU8; if (ogData instanceof ArrayBuffer) { prevU8 = new Uint8Array(ogData); } else if (ogData instanceof Uint8Array) { prevU8 = ogData; } else { prevU8 = new Uint8Array(ogData.buffer); } const toConcat = [prevU8]; while (((_a = this._operationQueue[0]) === null || _a === void 0 ? void 0 : _a.operationName) === 0 /* SbiOperationName.Push */) { const followingElem = this._operationQueue[0]; const cAw = (_b = params.appendWindow) !== null && _b !== void 0 ? _b : [undefined, undefined]; const fAw = (_c = followingElem.params[1].appendWindow) !== null && _c !== void 0 ? _c : [undefined, undefined]; const cTo = (_d = params.timestampOffset) !== null && _d !== void 0 ? _d : 0; const fTo = (_e = followingElem.params[1].timestampOffset) !== null && _e !== void 0 ? _e : 0; if (cAw[0] === fAw[0] && cAw[1] === fAw[1] && params.codec === followingElem.params[1].codec && cTo === fTo) { const newData = followingElem.params[0]; let newU8; if (newData instanceof ArrayBuffer) { newU8 = new Uint8Array(newData); } else if (newData instanceof Uint8Array) { newU8 = newData; } else { newU8 = new Uint8Array(newData.buffer); } toConcat.push(newU8); this._operationQueue.splice(0, 1); this._currentOperations.push({ operationName: 0 /* SbiOperationName.Push */, resolve: followingElem.resolve, reject: followingElem.reject, }); } else { break; } } if (toConcat.length > 1) { log.info("mse", `: Merging ${toConcat.length} segments together for perf`, { type: this.type, }); segmentData = concat(...toConcat).buffer; } } try { this._appendBufferNow(segmentData, params); } catch (err) { const error = err instanceof Error ? new SourceBufferError(err.name, err.message, err.name === "QuotaExceededError") : new SourceBufferError("Error", "Unknown SourceBuffer Error during appendBuffer", false); this._currentOperations.forEach((op) => { op.reject(error); }); this._currentOperations = []; // A synchronous error probably will not lead to updateend event, so we need to // go to next queue element manually // // FIXME: This here is needed to ensure that we're not left with a // dangling queue of operations. // However it can potentially be counter-productive if e.g. the `appendBuffer` // error was due to a full buffer and if there are pushing operations awaiting in // the queue. // // A better solution might just be to reject all push operations right away here? // Only for a `QuotaExceededError` (to check MSE)? // However this is too disruptive for what is now a hotfix this._performNextOperation(); } } else { // TODO merge contiguous removes? this._currentOperations = [nextElem]; const [start, end] = nextElem.params; log.debug("mse", "removing data from SourceBuffer", { type: this.type, start, end, }); try { this._sourceBuffer.remove(start, end); } catch (err) { const error = err instanceof Error ? new SourceBufferError(err.name, err.message, false) : new SourceBufferError("Error", "Unknown SourceBuffer Error during remove", false); nextElem.reject(error); this._currentOperations.forEach((op) => { op.reject(error); }); this._currentOperations = []; // A synchronous error probably will not lead to updateend event, so we need to // go to next queue element manually this._performNextOperation(); } } } _appendBufferNow(data, params) { const sourceBuffer = this._sourceBuffer; const { codec, timestampOffset, appendWindow = [] } = params; if (codec !== undefined && codec !== this.codec) { log.debug("mse", "updating codec", { prevCodec: this.codec, newCodec: codec }); const hasUpdatedSourceBufferType = tryToChangeSourceBufferType(sourceBuffer, codec); if (hasUpdatedSourceBufferType) { this.codec = codec; } else { log.debug("mse", "could not update codec", { prevCodec: this.codec, newCodec: codec, }); } } if (timestampOffset !== undefined && sourceBuffer.timestampOffset !== timestampOffset) { const newTimestampOffset = timestampOffset; log.debug("mse", "updating timestampOffset", { codec, prevTimestampOffset: sourceBuffer.timestampOffset, newTimestampOffset, }); sourceBuffer.timestampOffset = newTimestampOffset; } if (appendWindow[0] === undefined) { if (sourceBuffer.appendWindowStart > 0) { log.debug("mse", "re-setting `appendWindowStart`", { prevWindowStart: sourceBuffer.appendWindowStart, }); sourceBuffer.appendWindowStart = 0; } } else if (appendWindow[0] !== sourceBuffer.appendWindowStart) { if (appendWindow[0] >= sourceBuffer.appendWindowEnd) { const newWindowEnd = appendWindow[0] + 1; log.debug("mse", "pre-updating `appendWindowEnd`", { prevWindowEnd: sourceBuffer.appendWindowEnd, newWindowEnd, }); sourceBuffer.appendWindowEnd = newWindowEnd; } log.debug("mse", "setting `appendWindowStart`", { appendWindowStart: appendWindow[0], }); sourceBuffer.appendWindowStart = appendWindow[0]; } if (appendWindow[1] === undefined) { if (sourceBuffer.appendWindowEnd !== Infinity) { log.debug("mse", "re-setting `appendWindowEnd`", { prevWindowStart: sourceBuffer.appendWindowStart, }); sourceBuffer.appendWindowEnd = Infinity; } } else if (appendWindow[1] !== sourceBuffer.appendWindowEnd) { log.debug("mse", "setting `appendWindowEnd`", { prevWindowEnd: sourceBuffer.appendWindowEnd, newWindowEnd: appendWindow[1], }); sourceBuffer.appendWindowEnd = appendWindow[1]; } log.debug("mse", "pushing segment", { type: this.type }); sourceBuffer.appendBuffer(data); } } function resetMediaSource(mediaSource) { if (mediaSource.readyState !== "closed") { const { readyState, sourceBuffers } = mediaSource; for (let i = sourceBuffers.length - 1; i >= 0; i--) { const sourceBuffer = sourceBuffers[i]; try { if (readyState === "open") { log.info("mse", "Aborting SourceBuffer before removing"); try { sourceBuffer.abort(); } catch (_) { // We actually don't care at all when resetting } } log.info("mse", "Removing SourceBuffer from mediaSource"); mediaSource.removeSourceBuffer(sourceBuffer); } catch (_) { // We actually don't care at all when resetting } } if (sourceBuffers.length > 0) { log.info("mse", "Not all SourceBuffers could have been removed."); } } }