diff --git a/build/types/core b/build/types/core
index f9f4554860..fea11a5288 100644
--- a/build/types/core
+++ b/build/types/core
@@ -38,6 +38,7 @@
+../../lib/media/segment_reference.js
+../../lib/media/stall_detector.js
+../../lib/media/streaming_engine.js
++../../lib/media/segment_prefetch.js
+../../lib/media/time_ranges_utils.js
+../../lib/media/transmuxer.js
+../../lib/media/video_wrapper.js
diff --git a/demo/common/message_ids.js b/demo/common/message_ids.js
index 6ee5e5fe7f..dbdfd4a6a7 100644
--- a/demo/common/message_ids.js
+++ b/demo/common/message_ids.js
@@ -281,5 +281,6 @@ shakaDemo.MessageIds = {
VIDEO_ROBUSTNESS: 'DEMO_VIDEO_ROBUSTNESS',
VNOVA: 'DEMO_VNOVA',
XLINK_FAIL_GRACEFULLY: 'DEMO_XLINK_FAIL_GRACEFULLY',
+ SEGMENT_PREFETCH_LIMIT: 'DEMO_SEGMENT_PREFETCH_LIMIT',
};
/* eslint-enable max-len */
diff --git a/demo/config.js b/demo/config.js
index 0baca4b6eb..1826793ff9 100644
--- a/demo/config.js
+++ b/demo/config.js
@@ -422,7 +422,9 @@ shakaDemo.Config = class {
.addBoolInput_(MessageIds.OBSERVE_QUALITY_CHANGES,
'streaming.observeQualityChanges')
.addNumberInput_(MessageIds.MAX_DISABLED_TIME,
- 'streaming.maxDisabledTime');
+ 'streaming.maxDisabledTime')
+ .addNumberInput_(MessageIds.SEGMENT_PREFETCH_LIMIT,
+ 'streaming.segmentPrefetchLimit');
if (!shakaDemoMain.getNativeControlsEnabled()) {
this.addBoolInput_(MessageIds.ALWAYS_STREAM_TEXT,
diff --git a/demo/locales/en.json b/demo/locales/en.json
index fe028bd096..d7ec467f99 100644
--- a/demo/locales/en.json
+++ b/demo/locales/en.json
@@ -259,5 +259,6 @@
"DEMO_WIDEVINE": "Widevine DRM",
"DEMO_XLINK": "XLink",
"DEMO_XLINK_FAIL_GRACEFULLY": "Xlink Should Fail Gracefully",
- "DEMO_XLINK_SEARCH": "Filters for assets that have XLINK tags in their manifests, so that they can be broken into multiple files."
+ "DEMO_XLINK_SEARCH": "Filters for assets that have XLINK tags in their manifests, so that they can be broken into multiple files.",
+ "DEMO_SEGMENT_PREFETCH_LIMIT": "Segment Prefetch Limit"
}
diff --git a/demo/locales/source.json b/demo/locales/source.json
index 2a228c4220..ef8963d0c0 100644
--- a/demo/locales/source.json
+++ b/demo/locales/source.json
@@ -1042,5 +1042,9 @@
"DEMO_XLINK_SEARCH": {
"description": "A tooltip for an optional search term.",
"message": "Filters for assets that have [JARGON:XLINK] tags in their manifests, so that they can be broken into multiple files."
+ },
+ "DEMO_SEGMENT_PREFETCH_LIMIT": {
+ "description": "Max number of segments to be prefetched ahead of current time position.",
+ "message": "Segment Prefetch Limit."
}
}
diff --git a/docs/tutorials/config.md b/docs/tutorials/config.md
index 039c3f40e1..b5b7d0450a 100644
--- a/docs/tutorials/config.md
+++ b/docs/tutorials/config.md
@@ -78,6 +78,7 @@ player.getConfiguration();
retryParameters: Object
startAtSegmentBoundary: false
safeSeekOffset: 5
+ segmentPrefetchLimit: 0
textDisplayFactory: Function
diff --git a/externs/shaka/player.js b/externs/shaka/player.js
index dbb718065d..f87b7098de 100644
--- a/externs/shaka/player.js
+++ b/externs/shaka/player.js
@@ -962,7 +962,8 @@ shaka.extern.ManifestConfiguration;
* dispatchAllEmsgBoxes: boolean,
* observeQualityChanges: boolean,
* maxDisabledTime: number,
- * parsePrftBox: boolean
+ * parsePrftBox: boolean,
+ * segmentPrefetchLimit: number
* }}
*
* @description
@@ -1072,6 +1073,11 @@ shaka.extern.ManifestConfiguration;
* start date will not change, and would save parsing the segment multiple
* times needlessly.
* Defaults to false
.
+ * @property {boolean} segmentPrefetchLimit
+ * The maximum number of segments for each active stream to be prefetched
+ * ahead of playhead in parallel.
+ * If 0
, the segments will be fetched sequentially.
+ * Defaults to 0
.
* @exportDoc
*/
shaka.extern.StreamingConfiguration;
diff --git a/lib/media/segment_prefetch.js b/lib/media/segment_prefetch.js
new file mode 100644
index 0000000000..b59e156e8c
--- /dev/null
+++ b/lib/media/segment_prefetch.js
@@ -0,0 +1,193 @@
+/*! @license
+ * Shaka Player
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+goog.require('goog.asserts');
+goog.require('shaka.net.NetworkingEngine');
+goog.require('shaka.media.InitSegmentReference');
+goog.require('shaka.media.SegmentReference');
+goog.provide('shaka.media.SegmentPrefetch');
+goog.require('shaka.log');
+
+/**
+ * @summary
+ * This class manages segment prefetch operations.
+ * Called by StreamingEngine to prefetch next N segments
+ * ahead of playhead, to reduce the chances of rebuffering.
+ */
+shaka.media.SegmentPrefetch = class {
+ /**
+ * @param {number} prefetchLimit
+ * @param {shaka.extern.Stream} stream
+ * @param {shaka.media.SegmentPrefetch.FetchDispatcher} fetchDispatcher
+ */
+ constructor(prefetchLimit, stream, fetchDispatcher) {
+ /** @private {number} */
+ this.prefetchLimit_ = prefetchLimit;
+
+ /** @private {shaka.extern.Stream} */
+ this.stream_ = stream;
+
+ /** @private {number} */
+ this.prefetchPosTime_ = 0;
+
+ /** @private {shaka.media.SegmentPrefetch.FetchDispatcher} */
+ this.fetchDispatcher_ = fetchDispatcher;
+
+ /**
+ * @private {!Map.}
+ */
+ this.segmentPrefetchMap_ = new Map();
+ }
+
+ /**
+ * Fetch next segments ahead of current segment.
+ *
+ * @param {(!shaka.media.SegmentReference)} startReference
+ * @public
+ */
+ prefetchSegments(startReference) {
+ goog.asserts.assert(this.prefetchLimit_ > 0,
+ 'SegmentPrefetch can not be used when prefetchLimit <= 0.');
+
+ const logPrefix = shaka.media.SegmentPrefetch.logPrefix_(this.stream_);
+ if (!this.stream_.segmentIndex) {
+ shaka.log.info(logPrefix, 'missing segmentIndex');
+ return;
+ }
+ const currTime = startReference.startTime;
+ const maxTime = Math.max(currTime, this.prefetchPosTime_);
+ const iterator = this.stream_.segmentIndex.getIteratorForTime(maxTime);
+ let reference = startReference;
+ while (this.segmentPrefetchMap_.size < this.prefetchLimit_ &&
+ reference != null) {
+ if (!this.segmentPrefetchMap_.has(reference)) {
+ const op = this.fetchDispatcher_(reference, this.stream_);
+ this.segmentPrefetchMap_.set(reference, op);
+ }
+ this.prefetchPosTime_ = reference.startTime;
+ reference = iterator.next().value;
+ }
+ }
+
+ /**
+ * Get the result of prefetched segment if already exists.
+ * @param {(!shaka.media.SegmentReference)} reference
+ * @return {?shaka.net.NetworkingEngine.PendingRequest} op
+ * @public
+ */
+ getPrefetchedSegment(reference) {
+ goog.asserts.assert(this.prefetchLimit_ > 0,
+ 'SegmentPrefetch can not be used when prefetchLimit <= 0.');
+ goog.asserts.assert(reference instanceof shaka.media.SegmentReference,
+ 'getPrefetchedSegment is only used for shaka.media.SegmentReference.');
+
+ const logPrefix = shaka.media.SegmentPrefetch.logPrefix_(this.stream_);
+
+ if (this.segmentPrefetchMap_.has(reference)) {
+ const op = this.segmentPrefetchMap_.get(reference);
+ this.segmentPrefetchMap_.delete(reference);
+ shaka.log.info(
+ logPrefix,
+ 'reused prefetched segment at time:', reference.startTime,
+ 'mapSize', this.segmentPrefetchMap_.size);
+ return op;
+ } else {
+ shaka.log.info(
+ logPrefix,
+ 'missed segment at time:', reference.startTime,
+ 'mapSize', this.segmentPrefetchMap_.size);
+ return null;
+ }
+ }
+
+ /**
+ * Clear all segment data.
+ * @public
+ */
+ clearAll() {
+ if (this.segmentPrefetchMap_.size === 0) {
+ return;
+ }
+ const logPrefix = shaka.media.SegmentPrefetch.logPrefix_(this.stream_);
+ for (const reference of this.segmentPrefetchMap_.keys()) {
+ if (reference) {
+ this.abortPrefetchedSegment_(reference);
+ }
+ }
+ shaka.log.info(logPrefix, 'cleared all');
+ this.prefetchPosTime_ = 0;
+ }
+
+ /**
+ * Reset the prefetchLimit and clear all internal states.
+ * Called by StreamingEngine when configure() was called.
+ * @param {number} newPrefetchLimit
+ * @public
+ */
+ resetLimit(newPrefetchLimit) {
+ goog.asserts.assert(newPrefetchLimit >= 0,
+ 'The new prefetch limit must be >= 0.');
+ this.prefetchLimit_ = newPrefetchLimit;
+ const keyArr = Array.from(this.segmentPrefetchMap_.keys());
+ while (keyArr.length > newPrefetchLimit) {
+ const reference = keyArr.pop();
+ if (reference) {
+ this.abortPrefetchedSegment_(reference);
+ }
+ }
+ }
+
+ /**
+ * Called by Streaming Engine when switching variant.
+ * @param {shaka.extern.Stream} stream
+ * @public
+ */
+ switchStream(stream) {
+ if (stream && stream !== this.stream_) {
+ this.clearAll();
+ this.stream_ = stream;
+ }
+ }
+
+ /**
+ * Remove a segment from prefetch map and abort it.
+ * @param {(!shaka.media.SegmentReference)} reference
+ * @private
+ */
+ abortPrefetchedSegment_(reference) {
+ const logPrefix = shaka.media.SegmentPrefetch.logPrefix_(this.stream_);
+ const operation = this.segmentPrefetchMap_.get(reference);
+ this.segmentPrefetchMap_.delete(reference);
+ if (operation) {
+ operation.abort();
+ shaka.log.info(
+ logPrefix,
+ 'pop and abort prefetched segment at time:', reference.startTime);
+ }
+ }
+
+ /**
+ * The prefix of the logs that are created in this class.
+ * @return {string}
+ * @private
+ */
+ static logPrefix_(stream) {
+ return 'SegmentPrefetch(' + stream.type + ':' + stream.id + ')';
+ }
+};
+
+/**
+ * @typedef {function(
+ * !(shaka.media.InitSegmentReference|shaka.media.SegmentReference),
+ * shaka.extern.Stream
+ * ):!shaka.net.NetworkingEngine.PendingRequest}
+ *
+ * @description
+ * A callback function that fetches a segment.
+ * @export
+ */
+shaka.media.SegmentPrefetch.FetchDispatcher;
diff --git a/lib/media/streaming_engine.js b/lib/media/streaming_engine.js
index 927b0d7400..d4cbfa36a5 100644
--- a/lib/media/streaming_engine.js
+++ b/lib/media/streaming_engine.js
@@ -17,6 +17,7 @@ goog.require('shaka.media.InitSegmentReference');
goog.require('shaka.media.MediaSourceEngine');
goog.require('shaka.media.SegmentIterator');
goog.require('shaka.media.SegmentReference');
+goog.require('shaka.media.SegmentPrefetch');
goog.require('shaka.net.Backoff');
goog.require('shaka.net.NetworkingEngine');
goog.require('shaka.util.DelayedTick');
@@ -170,6 +171,21 @@ shaka.media.StreamingEngine = class {
const autoReset = true;
this.failureCallbackBackoff_ =
new shaka.net.Backoff(failureRetryParams, autoReset);
+
+ // Allow configuring the segment prefetch in middle of the playback.
+ for (const type of this.mediaStates_.keys()) {
+ const state = this.mediaStates_.get(type);
+ if (state.segmentPrefetch) {
+ state.segmentPrefetch.resetLimit(config.segmentPrefetchLimit);
+ if (!(config.segmentPrefetchLimit > 0)) {
+ // ResetLimit is still needed in this case,
+ // to abort existing prefetch operations.
+ state.segmentPrefetch = null;
+ }
+ } else if (config.segmentPrefetchLimit > 0) {
+ state.segmentPrefetch = this.createSegmentPrefetch_(state.stream);
+ }
+ }
}
@@ -439,6 +455,10 @@ shaka.media.StreamingEngine = class {
return;
}
+ if (mediaState.segmentPrefetch) {
+ mediaState.segmentPrefetch.switchStream(stream);
+ }
+
if (stream.type == ContentType.TEXT) {
// Mime types are allowed to change for text streams.
// Reinitialize the text parser, but only if we are going to fetch the
@@ -818,6 +838,7 @@ shaka.media.StreamingEngine = class {
stream,
type: stream.type,
segmentIterator: null,
+ segmentPrefetch: this.createSegmentPrefetch_(stream),
lastSegmentReference: null,
lastInitSegmentReference: null,
lastTimestampOffset: null,
@@ -840,6 +861,29 @@ shaka.media.StreamingEngine = class {
});
}
+ /**
+ * Creates a media state.
+ *
+ * @param {shaka.extern.Stream} stream
+ * @return {shaka.media.SegmentPrefetch | null}
+ * @private
+ */
+ createSegmentPrefetch_(stream) {
+ if (
+ stream.type !== shaka.util.ManifestParserUtils.ContentType.VIDEO &&
+ stream.type !== shaka.util.ManifestParserUtils.ContentType.AUDIO
+ ) {
+ return null;
+ }
+ if (this.config_.segmentPrefetchLimit > 0) {
+ return new shaka.media.SegmentPrefetch(
+ this.config_.segmentPrefetchLimit,
+ stream,
+ (reference, stream) => this.dispatchFetch_(reference, stream, null),
+ );
+ }
+ return null;
+ }
/**
* Sets the MediaSource's duration.
@@ -1094,6 +1138,10 @@ shaka.media.StreamingEngine = class {
return this.config_.updateIntervalSeconds;
}
+ if (mediaState.segmentPrefetch && mediaState.segmentIterator) {
+ mediaState.segmentPrefetch.prefetchSegments(reference);
+ }
+
const p = this.fetchAndAppend_(mediaState, presentationTime, reference);
p.catch(() => {}); // TODO(#1993): Handle asynchronous errors.
return null;
@@ -2017,6 +2065,37 @@ shaka.media.StreamingEngine = class {
* @suppress {strictMissingProperties}
*/
async fetch_(mediaState, reference, streamDataCallback) {
+ let op = null;
+ if (
+ mediaState.segmentPrefetch &&
+ reference instanceof shaka.media.SegmentReference
+ ) {
+ op = mediaState.segmentPrefetch.getPrefetchedSegment(reference);
+ }
+ if (!op) {
+ op = this.dispatchFetch_(
+ reference, mediaState.stream, streamDataCallback,
+ );
+ }
+
+ mediaState.operation = op;
+ const response = await op.promise;
+ mediaState.operation = null;
+ return response.data;
+ }
+
+ /**
+ * Fetches the given segment.
+ *
+ * @param {!shaka.extern.Stream} stream
+ * @param {(!shaka.media.InitSegmentReference|!shaka.media.SegmentReference)}
+ * reference
+ * @param {?function(BufferSource):!Promise=} streamDataCallback
+ *
+ * @return {!shaka.net.NetworkingEngine.PendingRequest}
+ * @private
+ */
+ dispatchFetch_(reference, stream, streamDataCallback) {
const requestType = shaka.net.NetworkingEngine.RequestType.SEGMENT;
const request = shaka.util.Networking.createSegmentRequest(
@@ -2027,28 +2106,25 @@ shaka.media.StreamingEngine = class {
streamDataCallback);
shaka.log.v2('fetching: reference=', reference);
-
- const stream = mediaState.stream;
+ let duration = 0;
+ if (reference instanceof shaka.media.SegmentReference) {
+ // start and endTime are not defined in InitSegmentReference
+ duration = reference.endTime - reference.startTime;
+ }
this.playerInterface_.modifySegmentRequest(
request,
{
type: stream.type,
init: reference instanceof shaka.media.InitSegmentReference,
- duration: reference.endTime - reference.startTime,
+ duration: duration,
mimeType: stream.mimeType,
codecs: stream.codecs,
bandwidth: stream.bandwidth,
},
);
-
- const op = this.playerInterface_.netEngine.request(requestType, request);
- mediaState.operation = op;
- const response = await op.promise;
- mediaState.operation = null;
- return response.data;
+ return this.playerInterface_.netEngine.request(requestType, request);
}
-
/**
* Clears the buffer and schedules another update.
* The optional parameter safeMargin allows to retain a certain amount
@@ -2076,6 +2152,9 @@ shaka.media.StreamingEngine = class {
mediaState.segmentIterator = null;
shaka.log.debug(logPrefix, 'clearing buffer');
+ if (mediaState.segmentPrefetch) {
+ mediaState.segmentPrefetch.clearAll();
+ }
if (safeMargin) {
const presentationTime = this.playerInterface_.getPresentationTime();
@@ -2091,6 +2170,7 @@ shaka.media.StreamingEngine = class {
mediaState.type);
}
}
+
this.destroyer_.ensureNotDestroyed();
shaka.log.debug(logPrefix, 'cleared buffer');
@@ -2282,7 +2362,8 @@ shaka.media.StreamingEngine.PlayerInterface;
* adaptation: boolean,
* recovering: boolean,
* hasError: boolean,
- * operation: shaka.net.NetworkingEngine.PendingRequest
+ * operation: shaka.net.NetworkingEngine.PendingRequest,
+ * segmentPrefetch: shaka.media.SegmentPrefetch
* }}
*
* @description
@@ -2336,6 +2417,9 @@ shaka.media.StreamingEngine.PlayerInterface;
* updating.
* @property {shaka.net.NetworkingEngine.PendingRequest} operation
* Operation with the number of bytes to be downloaded.
+ * @property {?shaka.media.SegmentPrefetch} segmentPrefetch
+ * A prefetch object for managing prefetching. Null if unneeded
+ * (if prefetching is disabled, etc).
*/
shaka.media.StreamingEngine.MediaState_;
diff --git a/lib/util/player_configuration.js b/lib/util/player_configuration.js
index 2c8821da05..886561658b 100644
--- a/lib/util/player_configuration.js
+++ b/lib/util/player_configuration.js
@@ -186,6 +186,7 @@ shaka.util.PlayerConfiguration = class {
observeQualityChanges: false,
maxDisabledTime: 30,
parsePrftBox: false,
+ segmentPrefetchLimit: 0,
};
// WebOS, Tizen, and Chromecast have long hardware pipelines that respond
diff --git a/test/media/segment_prefetch_unit.js b/test/media/segment_prefetch_unit.js
new file mode 100644
index 0000000000..aeff189d82
--- /dev/null
+++ b/test/media/segment_prefetch_unit.js
@@ -0,0 +1,243 @@
+/*! @license
+ * Shaka Player
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+
+describe('SegmentPrefetch', () => {
+ const Util = shaka.test.Util;
+ /** @type {shaka.media.SegmentPrefetch} */
+ let segmentPrefetch;
+
+ /** @type {!jasmine.Spy} */
+ let fetchDispatcher;
+
+ /** @type {jasmine.Spy} */
+ let pendingRequestAbort;
+
+ /** @type {shaka.extern.Stream} */
+ let stream;
+
+ const references = [
+ makeReference(uri('0.10'), 0, 10),
+ makeReference(uri('10.20'), 10, 20),
+ makeReference(uri('20.30'), 20, 30),
+ makeReference(uri('30.40'), 30, 40),
+ ];
+
+ beforeEach(() => {
+ pendingRequestAbort =
+ jasmine.createSpy('abort').and.returnValue(Promise.resolve());
+ const pendingRequestAbortFunc = Util.spyFunc(pendingRequestAbort);
+ const bytes = new shaka.net.NetworkingEngine.NumBytesRemainingClass();
+ bytes.setBytes(200);
+ stream = createStream();
+ stream.segmentIndex = new shaka.media.SegmentIndex(references);
+ fetchDispatcher = jasmine.createSpy('appendBuffer')
+ .and.callFake((ref, stream) =>
+ new shaka.net.NetworkingEngine.PendingRequest(
+ Promise.resolve({
+ uri: ref.getUris()[0],
+ data: new ArrayBuffer(0),
+ headers: {},
+ }),
+ pendingRequestAbortFunc,
+ bytes,
+ ),
+ );
+ segmentPrefetch = new shaka.media.SegmentPrefetch(
+ 3, stream, Util.spyFunc(fetchDispatcher),
+ );
+ });
+
+ describe('prefetchSegments', () => {
+ it('should prefetch next 3 segments', async () => {
+ segmentPrefetch.prefetchSegments(references[0]);
+ await expectSegmentsPrefetched(0);
+ const op = segmentPrefetch.getPrefetchedSegment(references[3]);
+ expect(op).toBeNull();
+ expect(fetchDispatcher).toHaveBeenCalledTimes(3);
+ });
+
+ it('prefetch last segment if position is at the end', async () => {
+ segmentPrefetch.prefetchSegments(references[3]);
+ const op = segmentPrefetch.getPrefetchedSegment(references[3]);
+ expect(op).toBeDefined();
+ const response = await op.promise;
+ const startTime = (3 * 10);
+ expect(response.uri).toBe(uri(startTime + '.' + (startTime + 10)));
+
+ for (let i = 0; i < 3; i++) {
+ const op = segmentPrefetch.getPrefetchedSegment(references[i]);
+ expect(op).toBeNull();
+ }
+ expect(fetchDispatcher).toHaveBeenCalledTimes(1);
+ });
+
+ it('do not prefetch already fetched segment', async () => {
+ segmentPrefetch.prefetchSegments(references[1]);
+ // since 2 was alreay pre-fetched when prefetch 1, expect
+ // no extra fetch is made.
+ segmentPrefetch.prefetchSegments(references[2]);
+
+ expect(fetchDispatcher).toHaveBeenCalledTimes(3);
+ await expectSegmentsPrefetched(1);
+ });
+ });
+
+ describe('clearAll', () => {
+ it('clears all prefetched segments', () => {
+ segmentPrefetch.prefetchSegments(references[0]);
+ segmentPrefetch.clearAll();
+ for (let i = 0; i < 3; i++) {
+ const op = segmentPrefetch.getPrefetchedSegment(references[i]);
+ expect(op).toBeNull();
+ }
+ expect(fetchDispatcher).toHaveBeenCalledTimes(3);
+ });
+
+ it('resets time pos so prefetch can happen again', () => {
+ segmentPrefetch.prefetchSegments(references[3]);
+ segmentPrefetch.clearAll();
+ for (let i = 0; i < 3; i++) {
+ const op = segmentPrefetch.getPrefetchedSegment(references[i]);
+ expect(op).toBeNull();
+ }
+
+ segmentPrefetch.prefetchSegments(references[3]);
+ for (let i = 0; i < 3; i++) {
+ const op = segmentPrefetch.getPrefetchedSegment(references[i]);
+ expect(op).toBeNull();
+ }
+ expect(segmentPrefetch.getPrefetchedSegment(references[3])).toBeDefined();
+ expect(fetchDispatcher).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe('switchStream', () => {
+ it('clears all prefetched segments', () => {
+ segmentPrefetch.prefetchSegments(references[0]);
+ segmentPrefetch.switchStream(createStream());
+ for (let i = 0; i < 3; i++) {
+ const op = segmentPrefetch.getPrefetchedSegment(references[i]);
+ expect(op).toBeNull();
+ }
+ expect(fetchDispatcher).toHaveBeenCalledTimes(3);
+ });
+
+ it('do nothing if its same stream', async () => {
+ segmentPrefetch.prefetchSegments(references[0]);
+ segmentPrefetch.switchStream(stream);
+ await expectSegmentsPrefetched(0);
+ });
+ });
+
+ describe('resetLimit', () => {
+ it('do nothing if the new limit is larger', async () => {
+ segmentPrefetch.prefetchSegments(references[0]);
+ segmentPrefetch.resetLimit(4);
+ await expectSegmentsPrefetched(0);
+ });
+
+ it('do nothing if the new limit is the same', async () => {
+ segmentPrefetch.prefetchSegments(references[0]);
+ segmentPrefetch.resetLimit(3);
+ await expectSegmentsPrefetched(0);
+ });
+
+ it('clears all prefetched segments beyond new limit', async () => {
+ segmentPrefetch.prefetchSegments(references[0]);
+ segmentPrefetch.resetLimit(1);
+ // expecting prefetched reference 0 is kept
+ expectSegmentsPrefetched(0, 1);
+ // expecting prefetched references 1 and 2 are removd
+ for (let i = 1; i < 3; i++) {
+ const op = segmentPrefetch.getPrefetchedSegment(references[i]);
+ expect(op).toBeNull();
+ }
+
+ // clear all to test the new limit by re-fetching.
+ segmentPrefetch.clearAll();
+ // prefetch again.
+ segmentPrefetch.prefetchSegments(references[0]);
+ // expect only one is prefetched
+ await expectSegmentsPrefetched(0, 1);
+ // only dispatched fetch one more time.
+ expect(fetchDispatcher).toHaveBeenCalledTimes(3 + 1);
+ });
+ });
+ /**
+ * Creates a URI string.
+ *
+ * @param {string} x
+ * @return {string}
+ */
+ function uri(x) {
+ return 'http://example.com/video_' + x + '.m4s';
+ }
+
+ /**
+ * Creates a real SegmentReference.
+ *
+ * @param {string} uri
+ * @param {number} startTime
+ * @param {number} endTime
+ * @return {shaka.media.SegmentReference}
+ */
+ function makeReference(uri, startTime, endTime) {
+ return new shaka.media.SegmentReference(
+ startTime,
+ endTime,
+ /* getUris= */ () => [uri],
+ /* startByte= */ 0,
+ /* endByte= */ null,
+ /* initSegmentReference= */ null,
+ /* timestampOffset= */ 0,
+ /* appendWindowStart= */ 0,
+ /* appendWindowEnd= */ Infinity,
+ /* partialReferences= */ [],
+ /* tilesLayout= */ undefined,
+ /* tileDuration= */ undefined,
+ /* syncTime= */ undefined,
+ /* status= */ undefined,
+ /* hlsAes128Key= */ null);
+ }
+
+ /**
+ * Creates a stream.
+ * @return {shaka.extern.Stream}
+ */
+ function createStream() {
+ const manifest = shaka.test.ManifestGenerator.generate((manifest) => {
+ manifest.presentationTimeline.setDuration(60);
+ manifest.addVariant(0, (variant) => {
+ variant.addVideo(11, (stream) => {
+ stream.useSegmentTemplate('video-11-%d.mp4', 10);
+ });
+ });
+ });
+
+ const videoStream = manifest.variants[0].video;
+ if (!videoStream) {
+ throw new Error('unexpected stream setup - variant.video is null');
+ }
+ return videoStream;
+ }
+
+ /**
+ * Expects segments have been prefetched within given range.
+ * @param {number} startPos
+ * @param {number} limit
+ */
+ async function expectSegmentsPrefetched(startPos, limit = 3) {
+ for (let i = startPos; i < startPos + limit; i++) {
+ const op = segmentPrefetch.getPrefetchedSegment(references[i]);
+ expect(op).not.toBeNull();
+ /* eslint-disable-next-line no-await-in-loop */
+ const response = await op.promise;
+ const startTime = (i * 10);
+ expect(response.uri).toBe(uri(startTime + '.' + (startTime + 10)));
+ }
+ }
+});
diff --git a/test/media/streaming_engine_unit.js b/test/media/streaming_engine_unit.js
index c1798bea05..a5b3adb9a6 100644
--- a/test/media/streaming_engine_unit.js
+++ b/test/media/streaming_engine_unit.js
@@ -3731,6 +3731,138 @@ describe('StreamingEngine', () => {
});
});
+ describe('prefetch segments', () => {
+ const segmentType = shaka.net.NetworkingEngine.RequestType.SEGMENT;
+
+ beforeEach(() => {
+ shaka.media.SegmentPrefetch = Util.spyFunc(
+ jasmine.createSpy('SegmentPrefetch')
+ .and.callFake((config, stream) =>
+ new shaka.test.FakeSegmentPrefetch(stream, segmentData),
+ ),
+ );
+ setupVod();
+ mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData);
+ createStreamingEngine();
+ const config = shaka.util.PlayerConfiguration.createDefault().streaming;
+ config.segmentPrefetchLimit = 3;
+ streamingEngine.configure(config);
+ });
+
+ it('should use prefetched segment without fetching again', async () => {
+ streamingEngine.switchVariant(variant);
+ await streamingEngine.start();
+ playing = true;
+ expectNoBuffer();
+
+ await runTest();
+
+ expectHasBuffer();
+ expectSegmentRequest(false);
+ });
+
+ it('should re-use prefetch segment when force clear buffer', async () => {
+ streamingEngine.switchVariant(variant);
+ await streamingEngine.start();
+
+ playing = true;
+ expectNoBuffer();
+ await runTest();
+ expectHasBuffer();
+ expectSegmentRequest(false);
+
+ streamingEngine.switchVariant(variant, true, 0, true);
+ presentationTimeInSeconds = 0;
+ await runTest();
+ expectHasBuffer();
+ expectSegmentRequest(false);
+ });
+
+ it('should disable prefetch if reset config in middle', async () => {
+ streamingEngine.switchVariant(variant);
+ await streamingEngine.start();
+
+ playing = true;
+ expectNoBuffer();
+ await runTest();
+ expectHasBuffer();
+ expectSegmentRequest(false);
+
+ const config = shaka.util.PlayerConfiguration.createDefault().streaming;
+ config.segmentPrefetchLimit = 0;
+ streamingEngine.configure(config);
+ streamingEngine.switchVariant(variant, true, 0, true);
+ presentationTimeInSeconds = 0;
+ await runTest();
+ expectHasBuffer();
+ expectSegmentRequest(true);
+ });
+
+ it('should disable prefetch when reset config at begining', async () => {
+ const config = shaka.util.PlayerConfiguration.createDefault().streaming;
+ config.segmentPrefetchLimit = 0;
+ streamingEngine.configure(config);
+ streamingEngine.switchVariant(variant);
+ await streamingEngine.start();
+ playing = true;
+ expectNoBuffer();
+ await runTest();
+ expectHasBuffer();
+ expectSegmentRequest(true);
+ });
+
+ /**
+ * Expect no buffer has been added to MSE.
+ */
+ function expectNoBuffer() {
+ expect(mediaSourceEngine.initSegments).toEqual({
+ audio: [false, false],
+ video: [false, false],
+ text: [],
+ });
+ expect(mediaSourceEngine.segments).toEqual({
+ audio: [false, false, false, false],
+ video: [false, false, false, false],
+ text: [false, false, false, false],
+ });
+ }
+
+ /**
+ * Expect buffers have been added to MSE.
+ */
+ function expectHasBuffer() {
+ expect(mediaSourceEngine.initSegments).toEqual({
+ audio: [false, true],
+ video: [false, true],
+ text: [],
+ });
+ expect(mediaSourceEngine.segments).toEqual({
+ audio: [true, true, true, true],
+ video: [true, true, true, true],
+ text: [false, false, false, false],
+ });
+ }
+
+ /**
+ * @param {?boolean} hasRequest
+ */
+ function expectSegmentRequest(hasRequest) {
+ const requests = [
+ '0_audio_0', '0_video_0', '0_audio_1',
+ '0_video_1', '1_audio_2', '1_video_2',
+ '1_audio_3', '1_video_3',
+ ];
+
+ for (const request of requests) {
+ if (hasRequest) {
+ netEngine.expectRequest(request, segmentType);
+ } else {
+ netEngine.expectNoRequest(request, segmentType);
+ }
+ }
+ }
+ });
+
/**
* Slides the segment availability window forward by 1 second.
*/
diff --git a/test/test/util/fake_segment_prefetch.js b/test/test/util/fake_segment_prefetch.js
new file mode 100644
index 0000000000..c7ada36947
--- /dev/null
+++ b/test/test/util/fake_segment_prefetch.js
@@ -0,0 +1,81 @@
+/*! @license
+ * Shaka Player
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * A fake SegmentPrefetch class that is used for testing
+ * segment prefetching functionality.
+ *
+ * @final
+ * @struct
+ * @extends {shaka.media.SegmentPrefetch}
+ */
+shaka.test.FakeSegmentPrefetch = class {
+ constructor(stream, segmentData) {
+ /** @private {(Set.)} */
+ this.requestedReferences_ = new Set();
+
+ /** @private {shaka.extern.Stream} */
+ this.streamObj_ = stream;
+
+ /**
+ * @private {!Object.}
+ */
+ this.segmentData_ = segmentData;
+ }
+
+ /** @override */
+ prefetchSegments(reference) {
+ if (!(reference instanceof shaka.media.SegmentReference)) {
+ return;
+ }
+ this.requestedReferences_.add(reference);
+ }
+
+ /** @override */
+ switchStream(stream) {
+ if (stream !== this.streamObj_) {
+ this.requestedReferences_.clear();
+ }
+ }
+
+ /** @override */
+ resetLimit(limit) {
+ this.clearAll();
+ }
+
+ /** @override */
+ clearAll() {
+ this.requestedReferences_.clear();
+ }
+
+ /** @override */
+ getPrefetchedSegment(reference) {
+ if (!(reference instanceof shaka.media.SegmentReference)) {
+ return null;
+ }
+ /**
+ * The unit tests assume a segment is already prefetched
+ * if it was ever passed to prefetchSegments() as param.
+ * Otherwise return null so the streaming engine being tested
+ * will do actual fetch.
+ */
+ if (this.requestedReferences_.has(reference)) {
+ const segmentData = this.segmentData_[this.streamObj_.type];
+ return new shaka.net.NetworkingEngine.PendingRequest(
+ Promise.resolve({
+ uri: reference.getUris()[0],
+ data: segmentData.segments[
+ segmentData.segmentStartTimes.indexOf(reference.startTime)
+ ],
+ headers: {},
+ }),
+ () => Promise.resolve(null),
+ new shaka.net.NetworkingEngine.NumBytesRemainingClass(),
+ );
+ }
+ return null;
+ }
+};