diff --git a/samples/advanced/preload.html b/samples/advanced/preload.html new file mode 100644 index 0000000000..5fcbcf5c3f --- /dev/null +++ b/samples/advanced/preload.html @@ -0,0 +1,143 @@ + + + + + Preload example + + + + + + + + + + + + + + +
+
+
+ dashjs logo +
+
+
+
+

Preload content

+

This example shows how to use preload feature of dash.js, which allows to initialize streaming + and start downloading the content before the player is attached to an HTML5 video element. This + feature can be used to optimize content-insertion on platforms which provide only a single + decoder.

+

When this page is loaded, dash.js downloads media segments into a virtual buffer. Once the + "Attach View" button is clicked, a video element is attached to dash.js and the downloaded data + will be appended to the newly created + Source Buffers.

+

Note that for this feature to work "cacheInitSegments" must be activated.

+
+
+
+
+
+ +
+
+
+ +
+ 00:00:00 +
+ +
+
+ +
+ +
+ +
+
+ +
+
+ +
+ 00:00:00 +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+ + + + + + diff --git a/samples/samples.json b/samples/samples.json index 0416860eab..0c05820fa3 100644 --- a/samples/samples.json +++ b/samples/samples.json @@ -728,6 +728,17 @@ "Video", "Audio" ] + }, + { + "title": "Preload content", + "description": "This example shows how to use preload feature of dash.js, which allows to initialize streaming and start downloading the content before the player is attached to an HTML5 video element. This feature can be used to optimize content-insertion on platforms which provide only a single decoder.", + "href": "advanced/preload.html", + "image": "lib/img/livesim-1.jpg", + "labels": [ + "VoD", + "Video", + "Audio" + ] } ] }, diff --git a/src/streaming/MediaPlayer.js b/src/streaming/MediaPlayer.js index ba3cb93a96..b6473d565a 100644 --- a/src/streaming/MediaPlayer.js +++ b/src/streaming/MediaPlayer.js @@ -521,6 +521,28 @@ function MediaPlayer() { --------------------------------------------------------------------------- */ + /** + * Causes the player to begin streaming the media as set by the {@link module:MediaPlayer#attachSource attachSource()} + * method in preparation for playing. It specifically does not require a view to be attached with {@link module:MediaPlayer#attachSource attachView()} to begin preloading. + * When a view is attached after preloading, the buffered data is transferred to the attached mediaSource buffers. + * + * @see {@link module:MediaPlayer#attachSource attachSource()} + * @see {@link module:MediaPlayer#attachView attachView()} + * @memberof module:MediaPlayer + * @throws {@link module:MediaPlayer~SOURCE_NOT_ATTACHED_ERROR SOURCE_NOT_ATTACHED_ERROR} if called before attachSource function + * @instance + */ + function preload() { + if (videoModel.getElement() || streamingInitialized) { + return false; + } + if (source) { + _initializePlayback(); + } else { + throw SOURCE_NOT_ATTACHED_ERROR; + } + } + /** * The play method initiates playback of the media defined by the {@link module:MediaPlayer#attachSource attachSource()} method. * This method will call play on the native Video Element. @@ -2380,6 +2402,7 @@ function MediaPlayer() { attachView, attachSource, isReady, + preload, play, isPaused, pause, diff --git a/src/streaming/PreBufferSink.js b/src/streaming/PreBufferSink.js index 57ca04af14..e3bcf4b156 100644 --- a/src/streaming/PreBufferSink.js +++ b/src/streaming/PreBufferSink.js @@ -75,14 +75,17 @@ function PreBufferSink(onAppendedCallback) { chunk: chunk }); } + return Promise.resolve(); } function remove(start, end) { chunks = chunks.filter(a => !((isNaN(end) || a.start < end) && (isNaN(start) || a.end > start))); //The opposite of the getChunks predicate. + return Promise.resolve(); } //Nothing async, nothing to abort. function abort() { + return Promise.resolve(); } function getAllBufferRanges() { @@ -117,7 +120,7 @@ function PreBufferSink(onAppendedCallback) { } function updateTimestampOffset() { - // Nothing to do + return Promise.resolve(); } function getBuffer() { @@ -154,15 +157,15 @@ function PreBufferSink(onAppendedCallback) { } instance = { - getAllBufferRanges: getAllBufferRanges, - append: append, - remove: remove, - abort: abort, - discharge: discharge, - reset: reset, - updateTimestampOffset: updateTimestampOffset, - waitForUpdateEnd: waitForUpdateEnd, - getBuffer: getBuffer + getAllBufferRanges, + append, + remove, + abort, + discharge, + reset, + updateTimestampOffset, + waitForUpdateEnd, + getBuffer }; setup(); diff --git a/src/streaming/Stream.js b/src/streaming/Stream.js index c849b3aad5..c58c84ba5d 100644 --- a/src/streaming/Stream.js +++ b/src/streaming/Stream.js @@ -225,17 +225,6 @@ function Stream(config) { }); } - /** - * - * @param {object} mediaSource - * @param {array} previousBufferSinks - * @return {Promise} - * @private - */ - function _initializeMedia(mediaSource, previousBufferSinks) { - return _commonMediaInitialization(mediaSource, previousBufferSinks); - } - function startPreloading(mediaSource, previousBuffers) { return new Promise((resolve, reject) => { @@ -262,6 +251,17 @@ function Stream(config) { }); } + /** + * + * @param {object} mediaSource + * @param {array} previousBufferSinks + * @return {Promise} + * @private + */ + function _initializeMedia(mediaSource, previousBufferSinks) { + return _commonMediaInitialization(mediaSource, previousBufferSinks); + } + /** * * @param {object} mediaSource @@ -280,7 +280,8 @@ function Stream(config) { let element = videoModel.getElement(); MEDIA_TYPES.forEach((mediaType) => { - if (mediaType !== Constants.VIDEO || (!element || (element && (/^VIDEO$/i).test(element.nodeName)))) { + // If we are preloading without a video element we can not start texttrack handling. + if (!(mediaType === Constants.TEXT && !mediaSource) && (mediaType !== Constants.VIDEO || (!element || (element && (/^VIDEO$/i).test(element.nodeName))))) { _initializeMediaForType(mediaType, mediaSource); } }); @@ -297,8 +298,10 @@ function Stream(config) { _checkIfInitializationCompleted(); } - // All mediaInfos for texttracks are added to the TextSourceBuffer by now. We can start creating the tracks - textController.createTracks(streamInfo); + if (mediaSource) { + // All mediaInfos for texttracks are added to the TextSourceBuffer by now. We can start creating the tracks + textController.createTracks(streamInfo); + } resolve(bufferSinks); }) @@ -306,9 +309,26 @@ function Stream(config) { reject(e); }); }); - } + /** + * We call this function if segments have been preloaded without a video element. Once the video element is attached MSE is available + * @param mediaSource + * @returns {Promise} + */ + function initializeForTextWithMediaSource(mediaSource) { + return new Promise((resolve, reject) => { + _initializeMediaForType(Constants.TEXT, mediaSource); + createBufferSinkForText() + .then(() => { + textController.createTracks(streamInfo); + resolve() + }) + .catch((e) => { + reject(e); + }) + }) + } /** * Initialize for a given media type. Creates a corresponding StreamProcessor @@ -422,7 +442,6 @@ function Stream(config) { /** * Creates the StreamProcessor for a given media type. - * @param {object} initialMediaInfo * @param {array} allMediaForType * @param {object} mediaSource * @private @@ -499,6 +518,15 @@ function Stream(config) { }); } + function createBufferSinkForText() { + const sp = _getProcessorByType(Constants.TEXT); + if (sp) { + return sp.createBufferSinks() + } + + return Promise.resolve(); + } + /** * Partially resets some of the Stream elements. This function is called when preloading of streams is canceled or a stream switch occurs. * @memberof Stream# @@ -529,21 +557,38 @@ function Stream(config) { } function setMediaSource(mediaSource) { - for (let i = 0; i < streamProcessors.length;) { - if (_isMediaSupported(streamProcessors[i].getMediaInfo())) { - streamProcessors[i].setMediaSource(mediaSource); - i++; - } else { - streamProcessors[i].reset(); - streamProcessors.splice(i, 1); + return new Promise((resolve, reject) => { + const promises = []; + for (let i = 0; i < streamProcessors.length;) { + if (_isMediaSupported(streamProcessors[i].getMediaInfo())) { + promises.push(streamProcessors[i].setMediaSource(mediaSource)); + i++; + } else { + streamProcessors[i].reset(); + streamProcessors.splice(i, 1); + } } - } - if (streamProcessors.length === 0) { - const msg = 'No streams to play.'; - errHandler.error(new DashJSError(Errors.MANIFEST_ERROR_ID_NOSTREAMS_CODE, msg + 'nostreams', manifestModel.getValue())); - logger.fatal(msg); - } + Promise.all(promises) + .then(() => { + for (let i = 0; i < streamProcessors.length; i++) { + //Adding of new tracks to a stream processor isn't guaranteed by the spec after the METADATA_LOADED state + //so do this after the buffers are created above. + streamProcessors[i].dischargePreBuffer(); + } + + if (streamProcessors.length === 0) { + const msg = 'No streams to play.'; + errHandler.error(new DashJSError(Errors.MANIFEST_ERROR_ID_NOSTREAMS_CODE, msg + 'nostreams', manifestModel.getValue())); + logger.fatal(msg); + } + resolve(); + }) + .catch((e) => { + logger.error(e); + reject(e); + }) + }) } function resetInitialSettings(keepBuffers) { @@ -1006,6 +1051,7 @@ function Stream(config) { getHasAudioTrack, getHasVideoTrack, startPreloading, + initializeForTextWithMediaSource, getThumbnailController, getBitrateListFor, updateData, diff --git a/src/streaming/StreamProcessor.js b/src/streaming/StreamProcessor.js index ba4173656c..72d73f412b 100644 --- a/src/streaming/StreamProcessor.js +++ b/src/streaming/StreamProcessor.js @@ -265,7 +265,6 @@ function StreamProcessor(config) { /** * When a seek within the corresponding period occurs this function initiates the clearing of the buffer and sets the correct buffering time. * @param {object} e - * @param {number} oldTime * @private */ function prepareInnerPeriodPlaybackSeeking(e) { @@ -402,6 +401,7 @@ function StreamProcessor(config) { /** * ScheduleController indicates that a media segment is needed + * @param {object} e * @param {boolean} rescheduleIfNoRequest - Defines whether we reschedule in case no valid request could be generated * @private */ @@ -524,13 +524,10 @@ function StreamProcessor(config) { return null; } - // Use time just whenever is strictly needed - const useTime = shouldUseExplicitTimeForRequest; - if (dashHandler) { const representation = representationController && representationInfo ? representationController.getRepresentationForQuality(representationInfo.quality) : null; - if (useTime) { + if (shouldUseExplicitTimeForRequest) { request = dashHandler.getSegmentRequestForTime(mediaInfo, representation, bufferingTime); } else { request = dashHandler.getNextSegmentRequest(mediaInfo, representation); @@ -831,6 +828,10 @@ function StreamProcessor(config) { return bufferController; } + function dischargePreBuffer() { + bufferController.dischargePreBuffer(); + } + function getFragmentModel() { return fragmentModel; } @@ -907,7 +908,7 @@ function StreamProcessor(config) { } function setMediaSource(mediaSource) { - bufferController.setMediaSource(mediaSource); + return bufferController.setMediaSource(mediaSource, mediaInfo); } function getScheduleController() { @@ -950,12 +951,10 @@ function StreamProcessor(config) { const representation = representationController && representationInfo ? representationController.getRepresentationForQuality(representationInfo.quality) : null; - let request = dashHandler.getNextSegmentRequestIdempotent( + return dashHandler.getNextSegmentRequestIdempotent( mediaInfo, representation ); - - return request; } function _onInitFragmentLoaded(e) { @@ -1204,6 +1203,7 @@ function StreamProcessor(config) { getType, isUpdating, getBufferController, + dischargePreBuffer, getFragmentModel, getScheduleController, getRepresentationController, diff --git a/src/streaming/controllers/BufferController.js b/src/streaming/controllers/BufferController.js index 770c6f086a..4e9b42bfa2 100644 --- a/src/streaming/controllers/BufferController.js +++ b/src/streaming/controllers/BufferController.js @@ -32,6 +32,7 @@ import Constants from '../constants/Constants'; import MetricsConstants from '../constants/MetricsConstants'; import FragmentModel from '../models/FragmentModel'; import SourceBufferSink from '../SourceBufferSink'; +import PreBufferSink from '../PreBufferSink'; import EventBus from '../../core/EventBus'; import Events from '../../core/events/Events'; import FactoryMaker from '../../core/FactoryMaker'; @@ -73,6 +74,8 @@ function BufferController(config) { maxAppendedIndex, maximumIndex, sourceBufferSink, + dischargeBuffer, + dischargeFragments, bufferState, appendedBytesInfo, wallclockTicked, @@ -136,9 +139,26 @@ function BufferController(config) { /** * Sets the mediasource. * @param {object} value + * @param {object} mediaInfo */ - function setMediaSource(value) { - mediaSource = value; + function setMediaSource(value, mediaInfo = null) { + return new Promise((resolve, reject) => { + mediaSource = value; + // if we have a prebuffer, we should prepare to discharge it, and make a new sourceBuffer ready + if (sourceBufferSink && mediaInfo && typeof sourceBufferSink.discharge === 'function') { + dischargeBuffer = sourceBufferSink; + createBufferSink(mediaInfo) + .then(() => { + resolve(); + }) + .catch((e) => { + reject(e); + }) + } else { + resolve(); + } + }) + } /** @@ -155,15 +175,50 @@ function BufferController(config) { * Creates a SourceBufferSink object * @param {object} mediaInfo * @param {array} oldBufferSinks - * @return {object|null} SourceBufferSink + * @return {Promise} SourceBufferSink */ function createBufferSink(mediaInfo, oldBufferSinks = []) { return new Promise((resolve, reject) => { - if (!initCache || !mediaInfo || !mediaSource) { + if (!initCache || !mediaInfo) { resolve(null); return; } + if (mediaSource) { + _initializeSinkForMseBuffering(mediaInfo, oldBufferSinks) + .then((sink) => { + resolve(sink); + }) + .catch((e) => { + reject(e); + }) + } else { + _initializeSinkForPrebuffering() + .then((sink) => { + resolve(sink); + }) + .catch((e) => { + reject(e); + }) + } + }); + } + function _initializeSinkForPrebuffering() { + return new Promise((resolve, reject) => { + const requiredQuality = abrController.getQualityFor(type, streamInfo.id); + sourceBufferSink = PreBufferSink(context).create(_onAppended.bind(this)); + updateBufferTimestampOffset(_getRepresentationInfo(requiredQuality)) + .then(() => { + resolve(sourceBufferSink); + }) + .catch(() => { + reject(); + }) + }) + } + + function _initializeSinkForMseBuffering(mediaInfo, oldBufferSinks) { + return new Promise((resolve, reject) => { const requiredQuality = abrController.getQualityFor(type, streamInfo.id); sourceBufferSink = SourceBufferSink(context).create({ mediaSource, @@ -182,7 +237,7 @@ function BufferController(config) { errHandler.error(new DashJSError(Errors.MEDIASOURCE_TYPE_UNSUPPORTED_CODE, Errors.MEDIASOURCE_TYPE_UNSUPPORTED_MESSAGE + type)); reject(e); }); - }); + }) } function _initializeSink(mediaInfo, oldBufferSinks, requiredQuality) { @@ -195,6 +250,45 @@ function BufferController(config) { } } + function dischargePreBuffer() { + if (sourceBufferSink && dischargeBuffer && typeof dischargeBuffer.discharge === 'function') { + const ranges = dischargeBuffer.getAllBufferRanges(); + + if (ranges.length > 0) { + let rangeStr = 'Beginning ' + type + 'PreBuffer discharge, adding buffer for:'; + for (let i = 0; i < ranges.length; i++) { + rangeStr += ' start: ' + ranges.start(i) + ', end: ' + ranges.end(i) + ';'; + } + logger.debug(rangeStr); + } else { + logger.debug('PreBuffer discharge requested, but there were no media segments in the PreBuffer.'); + } + + //A list of fragments to supress bytesAppended events for. This makes transferring from a prebuffer to a sourcebuffer silent. + dischargeFragments = []; + let chunks = dischargeBuffer.discharge(); + let lastInit = null; + for (let j = 0; j < chunks.length; j++) { + const chunk = chunks[j]; + if (chunk.segmentType !== HTTPRequest.INIT_SEGMENT_TYPE) { + const initChunk = initCache.extract(chunk.streamId, chunk.representationId); + if (initChunk) { + if (lastInit !== initChunk) { + dischargeFragments.push(initChunk); + sourceBufferSink.append(initChunk); + lastInit = initChunk; + } + } + } + dischargeFragments.push(chunk); + sourceBufferSink.append(chunk); + } + + dischargeBuffer.reset(); + dischargeBuffer = null; + } + } + /** * Callback handler when init segment has been loaded. Based on settings, the init segment is saved to the cache, and appended to the buffer. @@ -242,6 +336,7 @@ function BufferController(config) { /** * Append data to the MSE buffer using the SourceBufferSink * @param {object} chunk + * @param {object} request * @private */ function _appendToBuffer(chunk, request = null) { @@ -308,7 +403,15 @@ function BufferController(config) { _adjustSeekTarget(); } - if (appendedBytesInfo) { + let suppressAppendedEvent = false; + if (dischargeFragments) { + if (dischargeFragments.indexOf(appendedBytesInfo) > 0) { + suppressAppendedEvent = true; + } + dischargeFragments = null; + } + + if (appendedBytesInfo && !suppressAppendedEvent) { _triggerEvent(Events.BYTES_APPENDED_END_FRAGMENT, { quality: appendedBytesInfo.quality, startTime: appendedBytesInfo.start, @@ -1126,6 +1229,7 @@ function BufferController(config) { getType, getBufferControllerType, createBufferSink, + dischargePreBuffer, getBuffer, getBufferLevel, getRangeAt, diff --git a/src/streaming/controllers/StreamController.js b/src/streaming/controllers/StreamController.js index af807780d2..71ac4684ad 100644 --- a/src/streaming/controllers/StreamController.js +++ b/src/streaming/controllers/StreamController.js @@ -408,8 +408,11 @@ function StreamController() { }); playbackController.initialize(getActiveStreamInfo(), !!previousStream); + // If we have a video element we are not preloading into a virtual buffer if (videoModel.getElement()) { - _openMediaSource(seekTime, keepBuffers); + _openMediaSource(seekTime, keepBuffers, false); + } else { + _activateStream(seekTime, keepBuffers); } } catch (e) { isStreamSwitchingInProgress = false; @@ -420,9 +423,10 @@ function StreamController() { * Setup the Media Source. Open MSE and attach event listeners * @param {number} seekTime * @param {boolean} keepBuffers + * @param {boolean} streamActivated * @private */ - function _openMediaSource(seekTime, keepBuffers) { + function _openMediaSource(seekTime, keepBuffers, streamActivated = false) { let sourceUrl; function _onMediaSourceOpen() { @@ -437,7 +441,16 @@ function StreamController() { _setMediaDuration(); const dvrInfo = dashMetrics.getCurrentDVRInfo(); mediaSourceController.setSeekable(dvrInfo.range.start, dvrInfo.range.end); - _activateStream(seekTime, keepBuffers); + if (streamActivated) { + // Set the media source for all StreamProcessors + activeStream.setMediaSource(mediaSource) + .then(() => { + // Start text processing now that we have a video element + activeStream.initializeForTextWithMediaSource(mediaSource); + }) + } else { + _activateStream(seekTime, keepBuffers); + } } function _open() { @@ -1300,7 +1313,7 @@ function StreamController() { function switchToVideoElement(seekTime) { if (activeStream) { playbackController.initialize(getActiveStreamInfo()); - _openMediaSource(seekTime, false); + _openMediaSource(seekTime, false, true); } } @@ -1380,7 +1393,7 @@ function StreamController() { // Reset MSE logger.warn(`MediaSource has been resetted. Resuming playback from time ${time}`); - _openMediaSource(time, false); + _openMediaSource(time, false, false); } function getActiveStreamInfo() { diff --git a/src/streaming/models/VideoModel.js b/src/streaming/models/VideoModel.js index 3562ecf9aa..172abee8f8 100644 --- a/src/streaming/models/VideoModel.js +++ b/src/streaming/models/VideoModel.js @@ -91,9 +91,9 @@ function VideoModel() { //TODO Move the DVR window calculations from MediaPlayer to Here. function setCurrentTime(currentTime, stickToBuffered) { - _currentTime = currentTime; - waitForReadyState(Constants.VIDEO_ELEMENT_READY_STATES.HAVE_METADATA, () => { - if (element) { + if (element) { + _currentTime = currentTime; + waitForReadyState(Constants.VIDEO_ELEMENT_READY_STATES.HAVE_METADATA, () => { // We don't set the same currentTime because it can cause firing unexpected Pause event in IE11 // providing playbackRate property equals to zero. if (element.currentTime === _currentTime) { @@ -118,8 +118,8 @@ function VideoModel() { }, 400); } } - } - }); + }); + } } function stickTimeToBuffered(time) { diff --git a/src/streaming/text/NotFragmentedTextBufferController.js b/src/streaming/text/NotFragmentedTextBufferController.js index f2b74168c8..5754dabfec 100644 --- a/src/streaming/text/NotFragmentedTextBufferController.js +++ b/src/streaming/text/NotFragmentedTextBufferController.js @@ -118,6 +118,9 @@ function NotFragmentedTextBufferController(config) { return false; } + function dischargePreBuffer() { + } + function getBufferLevel() { return 0; } @@ -227,6 +230,7 @@ function NotFragmentedTextBufferController(config) { getType, getBufferControllerType, createBufferSink, + dischargePreBuffer, getBuffer, getBufferLevel, getRangeAt, diff --git a/test/unit/streaming.Stream.js b/test/unit/streaming.Stream.js index f2b576abad..2c7a32f3aa 100644 --- a/test/unit/streaming.Stream.js +++ b/test/unit/streaming.Stream.js @@ -95,8 +95,10 @@ describe('Stream', function () { }); it('should trigger MANIFEST_ERROR_ID_NOSTREAMS_CODE error when setMediaSource is called but streamProcessors array is empty', () => { - stream.setMediaSource(); - expect(errHandlerMock.errorCode).to.be.equal(Errors.MANIFEST_ERROR_ID_NOSTREAMS_CODE); // jshint ignore:line + stream.setMediaSource() + .then(() => { + expect(errHandlerMock.errorCode).to.be.equal(Errors.MANIFEST_ERROR_ID_NOSTREAMS_CODE); // jshint ignore:line + }) }); it('should return an null when getId is called but streamInfo attribute is null or undefined', () => {