|
@@ -18,6 +18,23 @@
|
|
|
• Codec detection read from the init segment, so the SourceBuffer is
|
|
• Codec detection read from the init segment, so the SourceBuffer is
|
|
|
created with the stream's real profile rather than a guess
|
|
created with the stream's real profile rather than a guess
|
|
|
|
|
|
|
|
|
|
+ Behaving like Safari's native player
|
|
|
|
|
+ The pages using this treat it as a drop-in for native HLS, so it keeps
|
|
|
|
|
+ the same contract: video.seekable spans every segment the playlist lists
|
|
|
|
|
+ (not just what has been downloaded), which is how a page tells a seek it
|
|
|
|
|
+ can make in place from one that needs the transcode restarted.
|
|
|
|
|
+
|
|
|
|
|
+ Recovery
|
|
|
|
|
+ • A segment download that fails on the network or with a server error
|
|
|
|
|
+ is retried with backoff; a 404/403 is not, since it means the server
|
|
|
|
|
+ session is gone and only reopening the stream helps.
|
|
|
|
|
+ • A watchdog steps over small holes between segments, and points the
|
|
|
|
|
+ download cursor back at the playhead if its media is neither buffered
|
|
|
|
|
+ nor on the way.
|
|
|
|
|
+ • Anything it cannot recover from is reported once through
|
|
|
|
|
+ options.onError, after which the player stops. The caller is expected
|
|
|
|
|
+ to reopen the stream (the Movie app restarts the transcode there).
|
|
|
|
|
+
|
|
|
Not supported (by design — the server never produces them)
|
|
Not supported (by design — the server never produces them)
|
|
|
• Master playlists / multiple variants / bitrate switching
|
|
• Master playlists / multiple variants / bitrate switching
|
|
|
• MPEG-TS segments, encryption, discontinuities, subtitle renditions
|
|
• MPEG-TS segments, encryption, discontinuities, subtitle renditions
|
|
@@ -29,6 +46,23 @@
|
|
|
var BUFFER_AHEAD_SECONDS = 30;
|
|
var BUFFER_AHEAD_SECONDS = 30;
|
|
|
// How much already-played media to keep before trimming it out of the buffer.
|
|
// How much already-played media to keep before trimming it out of the buffer.
|
|
|
var BUFFER_BEHIND_SECONDS = 30;
|
|
var BUFFER_BEHIND_SECONDS = 30;
|
|
|
|
|
+// Retries after a failed segment download, the first waiting RETRY_DELAY_MS
|
|
|
|
|
+// and each one after that twice as long as the one before.
|
|
|
|
|
+var SEGMENT_RETRIES = 3;
|
|
|
|
|
+var RETRY_DELAY_MS = 500;
|
|
|
|
|
+// A download with no answer by then is abandoned and retried. A server
|
|
|
|
|
+// segment is a few seconds of video, so this only trips on a hung connection.
|
|
|
|
|
+var SEGMENT_TIMEOUT_MS = 20000;
|
|
|
|
|
+// How often the stall watchdog looks at the element.
|
|
|
|
|
+var STALL_CHECK_MS = 1000;
|
|
|
|
|
+// The largest hole between buffered ranges the watchdog skips over. Holes this
|
|
|
|
|
+// small come from audio and video starting a few frames apart at a segment
|
|
|
|
|
+// boundary; they never fill in, and some browsers will not play across them.
|
|
|
|
|
+var GAP_TOLERANCE_SECONDS = 1;
|
|
|
|
|
+// How long playback may sit stuck on media the playlist already lists before
|
|
|
|
|
+// the player gives up and reports it. Waiting on a transcode that has not yet
|
|
|
|
|
+// produced the next segment never counts towards this.
|
|
|
|
|
+var STALL_FAIL_MS = 30000;
|
|
|
// Fallback codecs when the init segment cannot be parsed: the server always
|
|
// Fallback codecs when the init segment cannot be parsed: the server always
|
|
|
// encodes H.264 High + AAC-LC, so this is the right shape even if the exact
|
|
// encodes H.264 High + AAC-LC, so this is the right shape even if the exact
|
|
|
// profile digits differ.
|
|
// profile digits differ.
|
|
@@ -159,19 +193,40 @@ function Player(videoEl, playlistURL, options) {
|
|
|
this.playlistURL = playlistURL;
|
|
this.playlistURL = playlistURL;
|
|
|
this.options = options || {};
|
|
this.options = options || {};
|
|
|
this.destroyed = false;
|
|
this.destroyed = false;
|
|
|
|
|
+ this.failed = false;
|
|
|
|
|
|
|
|
this.mediaSource = null;
|
|
this.mediaSource = null;
|
|
|
this.sourceBuffer = null;
|
|
this.sourceBuffer = null;
|
|
|
this.playlist = null;
|
|
this.playlist = null;
|
|
|
this.initBuffer = null;
|
|
this.initBuffer = null;
|
|
|
- this.nextIndex = 0;
|
|
|
|
|
- this.appending = false;
|
|
|
|
|
this.refreshTimer = null;
|
|
this.refreshTimer = null;
|
|
|
this.objectURL = null;
|
|
this.objectURL = null;
|
|
|
|
|
|
|
|
|
|
+ // Download cursor. nextIndex is the next segment to fetch; a segment in
|
|
|
|
|
+ // flight is recorded separately, so a seek landing mid-download can move
|
|
|
|
|
+ // the cursor without the finished download moving it again.
|
|
|
|
|
+ this.nextIndex = 0;
|
|
|
|
|
+ this.appending = false;
|
|
|
|
|
+ this.inflightIndex = -1;
|
|
|
|
|
+ this.inflightAbort = null;
|
|
|
|
|
+ // Bumped every time the cursor is moved by something other than a
|
|
|
|
|
+ // download completing. A download started under an older generation is
|
|
|
|
|
+ // discarded when it lands.
|
|
|
|
|
+ this.cursorGeneration = 0;
|
|
|
|
|
+ // A segment that arrived while the buffer was busy (removing, or the
|
|
|
|
|
+ // previous append still running), appended on the next updateend.
|
|
|
|
|
+ this.pendingAppend = null;
|
|
|
|
|
+
|
|
|
|
|
+ // Stall watchdog state
|
|
|
|
|
+ this.stallTicks = 0;
|
|
|
|
|
+ this.healedIndex = -1;
|
|
|
|
|
+ this.watchdogTimer = null;
|
|
|
|
|
+
|
|
|
this._onSourceOpen = this._onSourceOpen.bind(this);
|
|
this._onSourceOpen = this._onSourceOpen.bind(this);
|
|
|
this._pump = this._pump.bind(this);
|
|
this._pump = this._pump.bind(this);
|
|
|
this._onSeeking = this._onSeeking.bind(this);
|
|
this._onSeeking = this._onSeeking.bind(this);
|
|
|
|
|
+ this._onUpdateEnd = this._onUpdateEnd.bind(this);
|
|
|
|
|
+ this._checkStall = this._checkStall.bind(this);
|
|
|
|
|
|
|
|
this.mediaSource = new global.MediaSource();
|
|
this.mediaSource = new global.MediaSource();
|
|
|
this.objectURL = URL.createObjectURL(this.mediaSource);
|
|
this.objectURL = URL.createObjectURL(this.mediaSource);
|
|
@@ -179,10 +234,16 @@ function Player(videoEl, playlistURL, options) {
|
|
|
this.video.addEventListener('timeupdate', this._pump);
|
|
this.video.addEventListener('timeupdate', this._pump);
|
|
|
this.video.addEventListener('seeking', this._onSeeking);
|
|
this.video.addEventListener('seeking', this._onSeeking);
|
|
|
this.video.src = this.objectURL;
|
|
this.video.src = this.objectURL;
|
|
|
|
|
+ this.watchdogTimer = setInterval(this._checkStall, STALL_CHECK_MS);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// Report an unrecoverable failure, once. The player stops working after this:
|
|
|
|
|
+// the caller is expected to tear it down and reopen the stream.
|
|
|
Player.prototype._fail = function (reason, err) {
|
|
Player.prototype._fail = function (reason, err) {
|
|
|
- if (this.destroyed) { return; }
|
|
|
|
|
|
|
+ if (this.destroyed || this.failed) { return; }
|
|
|
|
|
+ this.failed = true;
|
|
|
|
|
+ clearInterval(this.watchdogTimer);
|
|
|
|
|
+ clearTimeout(this.refreshTimer);
|
|
|
if (typeof this.options.onError === 'function') { this.options.onError(reason, err); }
|
|
if (typeof this.options.onError === 'function') { this.options.onError(reason, err); }
|
|
|
};
|
|
};
|
|
|
|
|
|
|
@@ -206,10 +267,11 @@ Player.prototype._onSourceOpen = function () {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
self.sourceBuffer = self.mediaSource.addSourceBuffer(mime);
|
|
self.sourceBuffer = self.mediaSource.addSourceBuffer(mime);
|
|
|
- self.sourceBuffer.addEventListener('updateend', self._pump);
|
|
|
|
|
|
|
+ self.sourceBuffer.addEventListener('updateend', self._onUpdateEnd);
|
|
|
self.sourceBuffer.addEventListener('error', function () {
|
|
self.sourceBuffer.addEventListener('error', function () {
|
|
|
self._fail('The browser rejected a media segment');
|
|
self._fail('The browser rejected a media segment');
|
|
|
});
|
|
});
|
|
|
|
|
+ self._syncDuration();
|
|
|
self._append(buffer);
|
|
self._append(buffer);
|
|
|
});
|
|
});
|
|
|
}).catch(function (err) {
|
|
}).catch(function (err) {
|
|
@@ -217,11 +279,66 @@ Player.prototype._onSourceOpen = function () {
|
|
|
});
|
|
});
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
-Player.prototype._fetch = function (url) {
|
|
|
|
|
- return fetch(url, { credentials: 'same-origin' }).then(function (response) {
|
|
|
|
|
- if (!response.ok) { throw new Error('HTTP ' + response.status + ' for ' + url); }
|
|
|
|
|
- return response.arrayBuffer();
|
|
|
|
|
- });
|
|
|
|
|
|
|
+Player.prototype._onUpdateEnd = function () {
|
|
|
|
|
+ if (this.destroyed) { return; }
|
|
|
|
|
+ if (this.pendingAppend) {
|
|
|
|
|
+ var buffer = this.pendingAppend;
|
|
|
|
|
+ this.pendingAppend = null;
|
|
|
|
|
+ this._append(buffer);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ // Setting the duration is refused while the buffer is updating, so a
|
|
|
|
|
+ // playlist refresh that arrived meanwhile is applied here
|
|
|
|
|
+ this._syncDuration();
|
|
|
|
|
+ this._pump();
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// Fetch one segment (or the init segment), retrying transient failures.
|
|
|
|
|
+//
|
|
|
|
|
+// isCurrent is checked before every retry; once it returns false (a seek has
|
|
|
|
|
+// moved the cursor elsewhere) the download is abandoned instead of retried.
|
|
|
|
|
+Player.prototype._fetch = function (url, isCurrent) {
|
|
|
|
|
+ var self = this;
|
|
|
|
|
+ var attempt = 0;
|
|
|
|
|
+
|
|
|
|
|
+ function tryOnce() {
|
|
|
|
|
+ var controller = global.AbortController ? new global.AbortController() : null;
|
|
|
|
|
+ var timedOut = false;
|
|
|
|
|
+ var timer = null;
|
|
|
|
|
+ if (controller) {
|
|
|
|
|
+ self.inflightAbort = controller;
|
|
|
|
|
+ timer = setTimeout(function () { timedOut = true; controller.abort(); }, SEGMENT_TIMEOUT_MS);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return fetch(url, { credentials: 'same-origin', signal: controller ? controller.signal : undefined })
|
|
|
|
|
+ .then(function (response) {
|
|
|
|
|
+ if (!response.ok) {
|
|
|
|
|
+ var httpErr = new Error('HTTP ' + response.status + ' for ' + url);
|
|
|
|
|
+ // 404/403 mean the session behind this stream is gone or
|
|
|
|
|
+ // was never ours: asking again cannot change the answer
|
|
|
|
|
+ httpErr.retryable = response.status >= 500;
|
|
|
|
|
+ throw httpErr;
|
|
|
|
|
+ }
|
|
|
|
|
+ return response.arrayBuffer();
|
|
|
|
|
+ })
|
|
|
|
|
+ .then(function (buffer) {
|
|
|
|
|
+ clearTimeout(timer);
|
|
|
|
|
+ return buffer;
|
|
|
|
|
+ }, function (err) {
|
|
|
|
|
+ clearTimeout(timer);
|
|
|
|
|
+ if (self.destroyed || (isCurrent && !isCurrent())) { throw err; }
|
|
|
|
|
+ // An abort that was not the timeout came from a seek or teardown
|
|
|
|
|
+ if (err && err.name === 'AbortError' && !timedOut) { throw err; }
|
|
|
|
|
+ if ((err && err.retryable === false) || attempt >= SEGMENT_RETRIES) { throw err; }
|
|
|
|
|
+ var wait = RETRY_DELAY_MS * Math.pow(2, attempt);
|
|
|
|
|
+ attempt++;
|
|
|
|
|
+ return new Promise(function (resolve) { setTimeout(resolve, wait); }).then(function () {
|
|
|
|
|
+ if (self.destroyed || (isCurrent && !isCurrent())) { throw err; }
|
|
|
|
|
+ return tryOnce();
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ return tryOnce();
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
Player.prototype._loadPlaylist = function () {
|
|
Player.prototype._loadPlaylist = function () {
|
|
@@ -234,6 +351,7 @@ Player.prototype._loadPlaylist = function () {
|
|
|
.then(function (text) {
|
|
.then(function (text) {
|
|
|
if (self.destroyed) { return; }
|
|
if (self.destroyed) { return; }
|
|
|
self.playlist = parsePlaylist(text, self.playlistURL);
|
|
self.playlist = parsePlaylist(text, self.playlistURL);
|
|
|
|
|
+ self._syncDuration();
|
|
|
self._scheduleRefresh();
|
|
self._scheduleRefresh();
|
|
|
});
|
|
});
|
|
|
};
|
|
};
|
|
@@ -243,9 +361,15 @@ Player.prototype._loadPlaylist = function () {
|
|
|
Player.prototype._scheduleRefresh = function () {
|
|
Player.prototype._scheduleRefresh = function () {
|
|
|
var self = this;
|
|
var self = this;
|
|
|
clearTimeout(this.refreshTimer);
|
|
clearTimeout(this.refreshTimer);
|
|
|
- if (this.destroyed || !this.playlist || this.playlist.ended) { return; }
|
|
|
|
|
-
|
|
|
|
|
- var wait = Math.max(1000, (this.playlist.targetDuration || 4) * 500);
|
|
|
|
|
|
|
+ if (this.destroyed || this.failed || !this.playlist || this.playlist.ended) { return; }
|
|
|
|
|
+
|
|
|
|
|
+ // Half a segment, but never more than two seconds. The page decides whether
|
|
|
|
|
+ // a seek can be served in place from how far this playlist reaches, and
|
|
|
|
|
+ // the first copy lists a single segment (the server answers as soon as one
|
|
|
|
|
+ // exists). With a hardware encoder that ignores forced keyframes, segments
|
|
|
|
|
+ // run to 10s or more, and a stale playlist turns seeks into needless
|
|
|
|
|
+ // transcode restarts.
|
|
|
|
|
+ var wait = Math.max(1000, Math.min(2000, (this.playlist.targetDuration || 4) * 500));
|
|
|
this.refreshTimer = setTimeout(function () {
|
|
this.refreshTimer = setTimeout(function () {
|
|
|
if (self.destroyed) { return; }
|
|
if (self.destroyed) { return; }
|
|
|
self._loadPlaylist().then(function () { self._pump(); })
|
|
self._loadPlaylist().then(function () { self._pump(); })
|
|
@@ -253,6 +377,29 @@ Player.prototype._scheduleRefresh = function () {
|
|
|
}, wait);
|
|
}, wait);
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
+// Publish the playlist's length as the media duration.
|
|
|
|
|
+//
|
|
|
|
|
+// Media Source reports seekable as 0..duration, and left alone the duration
|
|
|
|
|
+// only ever reaches the end of what has been appended — about half a minute
|
|
|
|
|
+// past the playhead. A page asking "has the transcode got this far?" would
|
|
|
|
|
+// then be told no for segments that exist, and restart ffmpeg for nothing.
|
|
|
|
|
+// Using the playlist's length gives the same answer Safari's native player
|
|
|
|
|
+// does for an EVENT playlist: everything the transcode has produced so far.
|
|
|
|
|
+Player.prototype._syncDuration = function () {
|
|
|
|
|
+ var ms = this.mediaSource;
|
|
|
|
|
+ if (!ms || ms.readyState !== 'open' || !this.playlist) { return; }
|
|
|
|
|
+ // Refused while an append or removal is running; retried on updateend
|
|
|
|
|
+ if (this.sourceBuffer && this.sourceBuffer.updating) { return; }
|
|
|
|
|
+
|
|
|
|
|
+ var target = this.playlist.duration;
|
|
|
|
|
+ if (!(target > 0)) { return; }
|
|
|
|
|
+ // Only ever grow it. Shrinking below the buffered media is refused, and a
|
|
|
|
|
+ // finished stream's endOfStream() settles it a fraction under the
|
|
|
|
|
+ // playlist's rounded total, which is not worth fighting over.
|
|
|
|
|
+ if (!isNaN(ms.duration) && target <= ms.duration + 0.5) { return; }
|
|
|
|
|
+ try { ms.duration = target; } catch (e) { /* retried on the next refresh */ }
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
Player.prototype._bufferedAhead = function () {
|
|
Player.prototype._bufferedAhead = function () {
|
|
|
var buffered = this.video.buffered;
|
|
var buffered = this.video.buffered;
|
|
|
var time = this.video.currentTime;
|
|
var time = this.video.currentTime;
|
|
@@ -264,6 +411,28 @@ Player.prototype._bufferedAhead = function () {
|
|
|
return 0;
|
|
return 0;
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
+// Whether there is media to play at `time`, with a little room after it.
|
|
|
|
|
+Player.prototype._isBufferedAt = function (time) {
|
|
|
|
|
+ var buffered = this.video.buffered;
|
|
|
|
|
+ for (var i = 0; i < buffered.length; i++) {
|
|
|
|
|
+ if (buffered.start(i) <= time && time < buffered.end(i) - 0.1) { return true; }
|
|
|
|
|
+ }
|
|
|
|
|
+ return false;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// Whether a segment's whole span is already in the buffer. The edges are
|
|
|
|
|
+// allowed some slack: audio and video rarely start and end on exactly the
|
|
|
|
|
+// segment's nominal boundary.
|
|
|
|
|
+Player.prototype._isSegmentBuffered = function (segment) {
|
|
|
|
|
+ var buffered = this.video.buffered;
|
|
|
|
|
+ var start = segment.start + 0.2;
|
|
|
|
|
+ var end = segment.start + segment.duration - 0.2;
|
|
|
|
|
+ for (var i = 0; i < buffered.length; i++) {
|
|
|
|
|
+ if (buffered.start(i) <= start && end <= buffered.end(i)) { return true; }
|
|
|
|
|
+ }
|
|
|
|
|
+ return false;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
Player.prototype._segmentIndexForTime = function (time) {
|
|
Player.prototype._segmentIndexForTime = function (time) {
|
|
|
var segments = this.playlist ? this.playlist.segments : [];
|
|
var segments = this.playlist ? this.playlist.segments : [];
|
|
|
for (var i = 0; i < segments.length; i++) {
|
|
for (var i = 0; i < segments.length; i++) {
|
|
@@ -272,31 +441,50 @@ Player.prototype._segmentIndexForTime = function (time) {
|
|
|
return segments.length;
|
|
return segments.length;
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
+// Point the download cursor at `index`, abandoning whatever download is in
|
|
|
|
|
+// flight. The abandoned download's result is dropped when it lands (see the
|
|
|
|
|
+// generation check in _pump), so it can neither be appended out of turn nor
|
|
|
|
|
+// move the cursor off the segment that is now wanted.
|
|
|
|
|
+Player.prototype._retarget = function (index) {
|
|
|
|
|
+ this.cursorGeneration++;
|
|
|
|
|
+ if (this.inflightAbort) {
|
|
|
|
|
+ try { this.inflightAbort.abort(); } catch (e) {}
|
|
|
|
|
+ this.inflightAbort = null;
|
|
|
|
|
+ }
|
|
|
|
|
+ this.appending = false;
|
|
|
|
|
+ this.inflightIndex = -1;
|
|
|
|
|
+ this.nextIndex = index;
|
|
|
|
|
+ this._pump();
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
Player.prototype._onSeeking = function () {
|
|
Player.prototype._onSeeking = function () {
|
|
|
if (this.destroyed || !this.playlist || !this.sourceBuffer) { return; }
|
|
if (this.destroyed || !this.playlist || !this.sourceBuffer) { return; }
|
|
|
var target = this.video.currentTime;
|
|
var target = this.video.currentTime;
|
|
|
|
|
|
|
|
// Already buffered around the target: let the browser play it.
|
|
// Already buffered around the target: let the browser play it.
|
|
|
- var buffered = this.video.buffered;
|
|
|
|
|
- for (var i = 0; i < buffered.length; i++) {
|
|
|
|
|
- if (buffered.start(i) <= target && target < buffered.end(i) - 0.1) { return; }
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if (this._isBufferedAt(target)) { return; }
|
|
|
|
|
|
|
|
- // Otherwise restart the append cursor at the segment covering the target.
|
|
|
|
|
- this.nextIndex = this._segmentIndexForTime(target);
|
|
|
|
|
- this._pump();
|
|
|
|
|
|
|
+ // Otherwise restart the download cursor at the segment covering the target.
|
|
|
|
|
+ this._retarget(this._segmentIndexForTime(target));
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
// Drive downloads: keep a window buffered ahead of the playhead, and trim what
|
|
// Drive downloads: keep a window buffered ahead of the playhead, and trim what
|
|
|
// is far behind so a long session does not grow without bound.
|
|
// is far behind so a long session does not grow without bound.
|
|
|
Player.prototype._pump = function () {
|
|
Player.prototype._pump = function () {
|
|
|
var self = this;
|
|
var self = this;
|
|
|
- if (this.destroyed || !this.sourceBuffer || this.sourceBuffer.updating || this.appending) { return; }
|
|
|
|
|
|
|
+ if (this.destroyed || this.failed || !this.sourceBuffer || this.sourceBuffer.updating || this.appending) { return; }
|
|
|
if (!this.playlist) { return; }
|
|
if (!this.playlist) { return; }
|
|
|
|
|
|
|
|
this._trimBehind();
|
|
this._trimBehind();
|
|
|
|
|
|
|
|
- if (this.nextIndex >= this.playlist.segments.length) {
|
|
|
|
|
|
|
+ // After a backward seek the cursor walks forward over segments that are
|
|
|
|
|
+ // still buffered from before; fetching those again only costs bandwidth.
|
|
|
|
|
+ var segments = this.playlist.segments;
|
|
|
|
|
+ while (this.nextIndex < segments.length && this._isSegmentBuffered(segments[this.nextIndex])) {
|
|
|
|
|
+ this.nextIndex++;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (this.nextIndex >= segments.length) {
|
|
|
if (this.playlist.ended && this.mediaSource.readyState === 'open') {
|
|
if (this.playlist.ended && this.mediaSource.readyState === 'open') {
|
|
|
try { this.mediaSource.endOfStream(); } catch (e) {}
|
|
try { this.mediaSource.endOfStream(); } catch (e) {}
|
|
|
}
|
|
}
|
|
@@ -304,53 +492,143 @@ Player.prototype._pump = function () {
|
|
|
}
|
|
}
|
|
|
if (this._bufferedAhead() > BUFFER_AHEAD_SECONDS) { return; }
|
|
if (this._bufferedAhead() > BUFFER_AHEAD_SECONDS) { return; }
|
|
|
|
|
|
|
|
- var segment = this.playlist.segments[this.nextIndex];
|
|
|
|
|
|
|
+ var index = this.nextIndex;
|
|
|
|
|
+ var generation = this.cursorGeneration;
|
|
|
|
|
+ var isCurrent = function () { return generation === self.cursorGeneration; };
|
|
|
|
|
+
|
|
|
this.appending = true;
|
|
this.appending = true;
|
|
|
- this._fetch(segment.url).then(function (buffer) {
|
|
|
|
|
|
|
+ this.inflightIndex = index;
|
|
|
|
|
+ this._fetch(segments[index].url, isCurrent).then(function (buffer) {
|
|
|
|
|
+ // A seek moved the cursor while this was downloading. It has already
|
|
|
|
|
+ // cleared the in-flight state and started the download it wants.
|
|
|
|
|
+ if (!isCurrent()) { return; }
|
|
|
self.appending = false;
|
|
self.appending = false;
|
|
|
|
|
+ self.inflightIndex = -1;
|
|
|
|
|
+ self.inflightAbort = null;
|
|
|
if (self.destroyed || !self.sourceBuffer) { return; }
|
|
if (self.destroyed || !self.sourceBuffer) { return; }
|
|
|
- self.nextIndex++;
|
|
|
|
|
|
|
+ self.nextIndex = index + 1;
|
|
|
self._append(buffer);
|
|
self._append(buffer);
|
|
|
}).catch(function (err) {
|
|
}).catch(function (err) {
|
|
|
|
|
+ if (!isCurrent()) { return; }
|
|
|
self.appending = false;
|
|
self.appending = false;
|
|
|
|
|
+ self.inflightIndex = -1;
|
|
|
|
|
+ self.inflightAbort = null;
|
|
|
self._fail('A media segment failed to load', err);
|
|
self._fail('A media segment failed to load', err);
|
|
|
});
|
|
});
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
Player.prototype._append = function (buffer) {
|
|
Player.prototype._append = function (buffer) {
|
|
|
if (this.destroyed || !this.sourceBuffer) { return; }
|
|
if (this.destroyed || !this.sourceBuffer) { return; }
|
|
|
|
|
+ if (this.sourceBuffer.updating) {
|
|
|
|
|
+ // A removal started by the trim is still running; append once it ends
|
|
|
|
|
+ this.pendingAppend = buffer;
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
try {
|
|
try {
|
|
|
this.sourceBuffer.appendBuffer(new Uint8Array(buffer));
|
|
this.sourceBuffer.appendBuffer(new Uint8Array(buffer));
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
- // A full buffer is recoverable: drop what is behind and retry once.
|
|
|
|
|
- if (err && err.name === 'QuotaExceededError') {
|
|
|
|
|
- this._trimBehind(true);
|
|
|
|
|
- try { this.sourceBuffer.appendBuffer(new Uint8Array(buffer)); return; } catch (e) {}
|
|
|
|
|
|
|
+ // A full buffer is recoverable: drop what is behind and retry once the
|
|
|
|
|
+ // removal has finished.
|
|
|
|
|
+ if (err && err.name === 'QuotaExceededError' && this._trimBehind(true)) {
|
|
|
|
|
+ this.pendingAppend = buffer;
|
|
|
|
|
+ return;
|
|
|
}
|
|
}
|
|
|
this._fail('The browser rejected a media segment', err);
|
|
this._fail('The browser rejected a media segment', err);
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
+// Remove media far behind the playhead. Returns true when a removal was
|
|
|
|
|
+// started (the buffer is then updating until the next updateend).
|
|
|
Player.prototype._trimBehind = function (aggressive) {
|
|
Player.prototype._trimBehind = function (aggressive) {
|
|
|
- if (!this.sourceBuffer || this.sourceBuffer.updating) { return; }
|
|
|
|
|
|
|
+ if (!this.sourceBuffer || this.sourceBuffer.updating) { return false; }
|
|
|
var keep = aggressive ? 5 : BUFFER_BEHIND_SECONDS;
|
|
var keep = aggressive ? 5 : BUFFER_BEHIND_SECONDS;
|
|
|
var cutoff = this.video.currentTime - keep;
|
|
var cutoff = this.video.currentTime - keep;
|
|
|
- if (cutoff <= 0) { return; }
|
|
|
|
|
|
|
+ if (cutoff <= 0) { return false; }
|
|
|
|
|
|
|
|
var buffered = this.sourceBuffer.buffered;
|
|
var buffered = this.sourceBuffer.buffered;
|
|
|
- if (!buffered.length) { return; }
|
|
|
|
|
|
|
+ if (!buffered.length) { return false; }
|
|
|
if (buffered.start(0) < cutoff) {
|
|
if (buffered.start(0) < cutoff) {
|
|
|
- try { this.sourceBuffer.remove(buffered.start(0), cutoff); } catch (e) {}
|
|
|
|
|
|
|
+ try { this.sourceBuffer.remove(buffered.start(0), cutoff); return true; } catch (e) {}
|
|
|
|
|
+ }
|
|
|
|
|
+ return false;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// Step over a small hole just ahead of the playhead. Returns true if it moved
|
|
|
|
|
+// the playhead.
|
|
|
|
|
+Player.prototype._jumpGap = function (time) {
|
|
|
|
|
+ if (this._isBufferedAt(time)) { return false; }
|
|
|
|
|
+ var buffered = this.video.buffered;
|
|
|
|
|
+ for (var i = 0; i < buffered.length; i++) {
|
|
|
|
|
+ var start = buffered.start(i);
|
|
|
|
|
+ if (start > time && start - time <= GAP_TOLERANCE_SECONDS) {
|
|
|
|
|
+ this.video.currentTime = start + 0.05;
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return false;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// Runs every STALL_CHECK_MS. Playback that cannot move on is nudged past a
|
|
|
|
|
+// small hole first, then has its missing media fetched again, and is only
|
|
|
|
|
+// reported as failed once neither has helped for STALL_FAIL_MS.
|
|
|
|
|
+Player.prototype._checkStall = function () {
|
|
|
|
|
+ if (this.destroyed || this.failed || !this.sourceBuffer || !this.playlist) { return; }
|
|
|
|
|
+ var video = this.video;
|
|
|
|
|
+
|
|
|
|
|
+ // HAVE_FUTURE_DATA or better: the element can move on by itself
|
|
|
|
|
+ if (video.ended || video.readyState >= 3) {
|
|
|
|
|
+ this.stallTicks = 0;
|
|
|
|
|
+ this.healedIndex = -1;
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ this.stallTicks++;
|
|
|
|
|
+ // One quiet tick is normal: the next segment is often mid-append
|
|
|
|
|
+ if (this.stallTicks < 2) { return; }
|
|
|
|
|
+
|
|
|
|
|
+ var time = video.currentTime;
|
|
|
|
|
+ if (this._jumpGap(time)) { return; }
|
|
|
|
|
+
|
|
|
|
|
+ var index = this._segmentIndexForTime(time);
|
|
|
|
|
+ if (index >= this.playlist.segments.length) {
|
|
|
|
|
+ // Waiting on the transcode to produce the next segment. Nothing is
|
|
|
|
|
+ // wrong on this side, so none of this counts as a stall.
|
|
|
|
|
+ this.stallTicks = 0;
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // The media for the playhead is neither buffered nor being downloaded.
|
|
|
|
|
+ // Whatever left the cursor elsewhere, bring it back — once per segment,
|
|
|
|
|
+ // so a segment that genuinely cannot fill the hole does not loop.
|
|
|
|
|
+ var onItsWay = (this.appending && this.inflightIndex === index) ||
|
|
|
|
|
+ this.pendingAppend !== null || this.sourceBuffer.updating;
|
|
|
|
|
+ if (!onItsWay && !this._isBufferedAt(time) && this.healedIndex !== index) {
|
|
|
|
|
+ this.healedIndex = index;
|
|
|
|
|
+ this._retarget(index);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // A download in flight has its own timeout and retries, and a paused
|
|
|
|
|
+ // viewer is not waiting on anything
|
|
|
|
|
+ if (onItsWay || video.paused) { return; }
|
|
|
|
|
+ if (this.stallTicks * STALL_CHECK_MS >= STALL_FAIL_MS) {
|
|
|
|
|
+ this._fail('Playback stalled and could not recover');
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
Player.prototype.destroy = function () {
|
|
Player.prototype.destroy = function () {
|
|
|
this.destroyed = true;
|
|
this.destroyed = true;
|
|
|
clearTimeout(this.refreshTimer);
|
|
clearTimeout(this.refreshTimer);
|
|
|
|
|
+ clearInterval(this.watchdogTimer);
|
|
|
this.video.removeEventListener('timeupdate', this._pump);
|
|
this.video.removeEventListener('timeupdate', this._pump);
|
|
|
this.video.removeEventListener('seeking', this._onSeeking);
|
|
this.video.removeEventListener('seeking', this._onSeeking);
|
|
|
|
|
+ if (this.inflightAbort) {
|
|
|
|
|
+ try { this.inflightAbort.abort(); } catch (e) {}
|
|
|
|
|
+ this.inflightAbort = null;
|
|
|
|
|
+ }
|
|
|
|
|
+ this.pendingAppend = null;
|
|
|
|
|
|
|
|
if (this.sourceBuffer) {
|
|
if (this.sourceBuffer) {
|
|
|
|
|
+ this.sourceBuffer.removeEventListener('updateend', this._onUpdateEnd);
|
|
|
try { this.sourceBuffer.abort(); } catch (e) {}
|
|
try { this.sourceBuffer.abort(); } catch (e) {}
|
|
|
this.sourceBuffer = null;
|
|
this.sourceBuffer = null;
|
|
|
}
|
|
}
|