diff --git a/src/playback-watcher.js b/src/playback-watcher.js index ff1afda36..cdfe0636d 100644 --- a/src/playback-watcher.js +++ b/src/playback-watcher.js @@ -29,6 +29,10 @@ const timerCancelEvents = [ * Options object * @param {TimeRange} options.buffered * Current buffer + * @param {TimeRange} options.videoBuffered + * Current buffered from the media source's video buffer + * @param {TimeRange} options.audioBuffered + * Current buffered from the media source's audio buffer * @param {number} options.targetDuration * The active playlist's target duration * @param {number} options.currentTime @@ -36,28 +40,43 @@ const timerCancelEvents = [ * @return {boolean} * Whether the current time should be considered close to the buffer */ -export const closeToBufferedContent = ({ buffered, targetDuration, currentTime }) => { - if (!buffered.length) { - return false; +export const closeToBufferedContent = ({ buffered, audioBuffered, videoBuffered, targetDuration, currentTime }) => { + const twoSegmentDurations = (targetDuration - Ranges.TIME_FUDGE_FACTOR) * 2; + const bufferedToCheck = [audioBuffered, videoBuffered]; + + for (let i = 0; i < bufferedToCheck.length; i++) { + // skip null buffered + if (!bufferedToCheck[i]) { + continue; + } + + const timeAhead = Ranges.timeAheadOf(bufferedToCheck[i], currentTime); + + // if we are less than two video/audio segment durations behind, + // we haven't append enough to be close to buffered content. + if (timeAhead < twoSegmentDurations) { + return false; + } } - // At least two to three segments worth of content should be buffered before there's a - // full enough buffer to consider taking any actions. - if (buffered.end(0) - buffered.start(0) < targetDuration * 2) { - return false; + // default to using buffered, but if we don't have one + // use video or audio buffered + if (!buffered || buffered.length === 0) { + buffered = videoBuffered || audioBuffered; } - // It's possible that, on seek, a remove hasn't completed and the buffered range is - // somewhere past the current time. In that event, don't consider the buffered content - // close. - if (currentTime > buffered.start(0)) { + const nextRange = Ranges.findNextRange(buffered, currentTime); + + // if we don't have a next buffered range, we cannot be close to + // buffered content. + if (!nextRange.length) { return false; } // Since target duration generally represents the max (or close to max) duration of a // segment, if the buffer is within a segment of the current time, the gap probably // won't be closed, and current time should be considered close to buffered content. - return buffered.start(0) - currentTime < targetDuration; + return nextRange.start(0) - currentTime < (targetDuration + Ranges.TIME_FUDGE_FACTOR); }; /** @@ -253,7 +272,7 @@ export default class PlaybackWatcher { * @private */ checkCurrentTime_() { - if (this.tech_.seeking() && this.fixesBadSeeks_()) { + if (this.fixesBadSeeks_()) { this.consecutiveUpdates = 0; this.lastRecordedTime = this.tech_.currentTime(); return; @@ -356,17 +375,22 @@ export default class PlaybackWatcher { return true; } + const sourceUpdater = this.masterPlaylistController_.sourceUpdater_; const buffered = this.tech_.buffered(); if ( closeToBufferedContent({ buffered, + audioBuffered: sourceUpdater.audioBuffer ? sourceUpdater.audioBuffered() : null, + videoBuffered: sourceUpdater.videoBuffer ? sourceUpdater.videoBuffered() : null, targetDuration: this.media().targetDuration, currentTime }) ) { - seekTo = buffered.start(0) + Ranges.SAFE_TIME_DELTA; - this.logger_(`Buffered region starts (${buffered.start(0)}) ` + + const nextRange = Ranges.findNextRange(buffered, currentTime); + + seekTo = nextRange.start(0) + Ranges.SAFE_TIME_DELTA; + this.logger_(`Buffered region starts (${nextRange.start(0)}) ` + ` just beyond seek point (${currentTime}). Seeking to ${seekTo}.`); this.tech_.setCurrentTime(seekTo); @@ -426,7 +450,7 @@ export default class PlaybackWatcher { const seekable = this.seekable(); const currentTime = this.tech_.currentTime(); - if (this.tech_.seeking() && this.fixesBadSeeks_()) { + if (this.fixesBadSeeks_()) { // Tech is seeking or bad seek fixed, no action needed return true; } diff --git a/src/playlist-loader.js b/src/playlist-loader.js index 90deaa392..1314b297a 100644 --- a/src/playlist-loader.js +++ b/src/playlist-loader.js @@ -340,7 +340,8 @@ export const updateMaster = (master, newMedia, unchangedCheck = isPlaylistUnchan * The time in ms to wait before refreshing the live playlist */ export const refreshDelay = (media, update) => { - const lastSegment = media.segments[media.segments.length - 1]; + const segments = media.segments || []; + const lastSegment = segments[segments.length - 1]; const lastPart = lastSegment && lastSegment.parts && lastSegment.parts[lastSegment.parts.length - 1]; const lastDuration = lastPart && lastPart.duration || lastSegment && lastSegment.duration; diff --git a/src/ranges.js b/src/ranges.js index 9621a6cd6..9b4cd0248 100644 --- a/src/ranges.js +++ b/src/ranges.js @@ -445,3 +445,46 @@ export const lastBufferedEnd = function(a) { return a.end(a.length - 1); }; + +/** + * A utility function to add up the amount of time in a timeRange + * after a specified startTime. + * ie:[[0, 10], [20, 40], [50, 60]] with a startTime 0 + * would return 40 as there are 40s seconds after 0 in the timeRange + * + * @param {TimeRange} range + * The range to check against + * @param {number} startTime + * The time in the time range that you should start counting from + * + * @return {number} + * The number of seconds in the buffer passed the specified time. + */ +export const timeAheadOf = function(range, startTime) { + let time = 0; + + if (!range || !range.length) { + return time; + } + + for (let i = 0; i < range.length; i++) { + const start = range.start(i); + const end = range.end(i); + + // startTime is after this range entirely + if (startTime > end) { + continue; + } + + // startTime is within this range + if (startTime > start && startTime <= end) { + time += end - startTime; + continue; + } + + // startTime is before this range. + time += end - start; + } + + return time; +}; diff --git a/test/playback-watcher.test.js b/test/playback-watcher.test.js index dde73c6e3..5d1a073b7 100644 --- a/test/playback-watcher.test.js +++ b/test/playback-watcher.test.js @@ -13,7 +13,7 @@ import { } from '../src/playback-watcher'; // needed for plugin registration import '../src/videojs-http-streaming'; -import { SAFE_TIME_DELTA } from '../src/ranges'; +import { SAFE_TIME_DELTA, bufferIntersection } from '../src/ranges'; let monitorCurrentTime_; @@ -1006,6 +1006,12 @@ QUnit.test('jumps to buffered content if seeking just before', function(assert) currentTime: () => currentTime, buffered: () => buffered }; + + Object.assign(playbackWatcher.masterPlaylistController_.sourceUpdater_, { + videoBuffer: true, + videoBuffered: () => buffered + }); + this.player.tech(true).vhs.setCurrentTime = (time) => seeks.push(time); currentTime = 10; @@ -1042,6 +1048,55 @@ QUnit.test('jumps to buffered content if seeking just before', function(assert) assert.equal(seeks[1], 11.1, 'seeked to seekable range'); }); +QUnit.test('jumps to correct range with gaps', function(assert) { + // target duration is 10 for this manifest + this.player.src({ + src: 'liveStart30sBefore.m3u8', + type: 'application/vnd.apple.mpegurl' + }); + + // start playback normally + this.player.tech_.triggerReady(); + this.clock.tick(1); + standardXHRResponse(this.requests.shift()); + openMediaSource(this.player, this.clock); + this.player.tech_.trigger('play'); + this.player.tech_.trigger('playing'); + this.clock.tick(1); + + const playbackWatcher = this.player.tech_.vhs.playbackWatcher_; + const seeks = []; + let currentTime; + let buffered; + + playbackWatcher.seekable = () => videojs.createTimeRanges([[10, 100]]); + playbackWatcher.tech_ = { + off: () => {}, + seeking: () => true, + setCurrentTime: (time) => { + seeks.push(time); + }, + currentTime: () => currentTime, + buffered: () => buffered + }; + + Object.assign(playbackWatcher.masterPlaylistController_.sourceUpdater_, { + videoBuffer: true, + videoBuffered: () => buffered + }); + + this.player.tech(true).vhs.setCurrentTime = (time) => seeks.push(time); + + currentTime = 40; + buffered = videojs.createTimeRanges([[19, 39], [41, 70]]); + assert.ok( + playbackWatcher.fixesBadSeeks_(), + 'acts when close enough to, and enough, buffer' + ); + assert.equal(seeks.length, 1, 'seeked'); + assert.equal(seeks[0], 41.1, 'player seeked to the start of the closer buffer'); +}); + const loaderTypes = ['audio', 'main', 'subtitle']; const EXCLUDE_APPEND_COUNT = 10; @@ -1627,10 +1682,10 @@ QUnit.test('respects allowSeeksWithinUnsafeLiveWindow flag', function(assert) { QUnit.module('closeToBufferedContent'); -QUnit.test('false if no buffer', function(assert) { +QUnit.test('false if zero length videoBuffered', function(assert) { assert.notOk( closeToBufferedContent({ - buffered: videojs.createTimeRanges(), + videoBuffered: videojs.createTimeRanges(), targetDuration: 4, currentTime: 10 }), @@ -1638,10 +1693,10 @@ QUnit.test('false if no buffer', function(assert) { ); }); -QUnit.test('false if buffer less than two times target duration', function(assert) { +QUnit.test('false if zero length audioBuffered', function(assert) { assert.notOk( closeToBufferedContent({ - buffered: videojs.createTimeRanges([[11, 18.9]]), + audioBuffered: videojs.createTimeRanges(), targetDuration: 4, currentTime: 10 }), @@ -1649,10 +1704,67 @@ QUnit.test('false if buffer less than two times target duration', function(asser ); }); -QUnit.test('false if buffer is beyond target duration from current time', function(assert) { +QUnit.test('false if zero length audioBuffered and videoBuffered', function(assert) { assert.notOk( closeToBufferedContent({ - buffered: videojs.createTimeRanges([[14.1, 30]]), + audioBuffered: videojs.createTimeRanges(), + videoBuffered: videojs.createTimeRanges(), + targetDuration: 4, + currentTime: 10 + }), + 'returned false' + ); +}); + +QUnit.test('false if videoBuffered less than two times target duration', function(assert) { + assert.notOk( + closeToBufferedContent({ + videoBuffered: videojs.createTimeRanges([[11, 18.9]]), + targetDuration: 4, + currentTime: 10 + }), + 'returned false' + ); +}); + +QUnit.test('false if audioBuffered less than two times target duration', function(assert) { + assert.notOk( + closeToBufferedContent({ + audioBuffered: videojs.createTimeRanges([[11, 18.9]]), + targetDuration: 4, + currentTime: 10 + }), + 'returned false' + ); +}); + +QUnit.test('false if either buffer is less than two times target duration', function(assert) { + assert.notOk( + closeToBufferedContent({ + videoBuffered: videojs.createTimeRanges([[11, 18.9]]), + audioBuffered: videojs.createTimeRanges([[11, 18.9]]), + targetDuration: 4, + currentTime: 10 + }), + 'returned false' + ); +}); + +QUnit.test('false if there is not a range ahead', function(assert) { + assert.notOk( + closeToBufferedContent({ + videoBuffered: videojs.createTimeRanges([[11, 18.9]]), + targetDuration: 4, + currentTime: 19 + }), + 'returned false' + ); +}); + +QUnit.test('false if buffer is one segment away from current time', function(assert) { + assert.notOk( + closeToBufferedContent({ + videoBuffered: videojs.createTimeRanges([[14.1, 30]]), targetDuration: 4, currentTime: 10 }), @@ -1663,7 +1775,7 @@ QUnit.test('false if buffer is beyond target duration from current time', functi QUnit.test('true if enough buffer and close to current time', function(assert) { assert.ok( closeToBufferedContent({ - buffered: videojs.createTimeRanges([[13.9, 22]]), + videoBuffered: videojs.createTimeRanges([[13.9, 22]]), targetDuration: 4, currentTime: 10 }), @@ -1671,13 +1783,47 @@ QUnit.test('true if enough buffer and close to current time', function(assert) { ); }); -QUnit.test('false if current time beyond buffer start', function(assert) { - assert.notOk( +QUnit.test('true if enough buffer and close to current time with gaps', function(assert) { + assert.ok( closeToBufferedContent({ - buffered: videojs.createTimeRanges([[13.9, 22]]), + videoBuffered: videojs.createTimeRanges([[19, 22], [24, 30], [31, 34]]), targetDuration: 4, - currentTime: 14 + currentTime: 23 }), - 'returned false' + 'returned true' + ); +}); + +QUnit.test('complex gaps with enough buffer ahead', function(assert) { + const audioBuffered = videojs.createTimeRanges([[3095.04, 3095.46], [3095.55, 3110.57]]); + const videoBuffered = videojs.createTimeRanges([[3093.15, 3095.55], [3095.62, 3110.64], [3112.34, 3114.34]]); + const buffered = bufferIntersection(videoBuffered, audioBuffered); + + assert.ok( + closeToBufferedContent({ + videoBuffered, + audioBuffered, + buffered, + targetDuration: 7, + currentTime: 3095.45 + }), + 'returned true' + ); +}); + +QUnit.test('another complex gaps with enough buffer ahead', function(assert) { + const audioBuffered = videojs.createTimeRanges([[827.32, 832.17], [832.26, 850.12]]); + const videoBuffered = videojs.createTimeRanges([[828.89, 832.26], [832.33, 856.35]]); + const buffered = bufferIntersection(videoBuffered, audioBuffered); + + assert.ok( + closeToBufferedContent({ + videoBuffered, + audioBuffered, + buffered, + targetDuration: 7, + currentTime: 832.16 + }), + 'returned true' ); }); diff --git a/test/ranges.test.js b/test/ranges.test.js index 666218321..cd70bdca3 100644 --- a/test/ranges.test.js +++ b/test/ranges.test.js @@ -545,3 +545,49 @@ QUnit.test('returns empty when other buffer empty', function(assert) { 'returns empty' ); }); + +QUnit.module('timeAheadOf'); + +QUnit.test('empty range returns 0', function(assert) { + const range = createTimeRanges(); + + assert.equal(Ranges.timeAheadOf(range, 0), 0, 'empty range returns 0'); +}); + +QUnit.test('returns the total amount of time ahead of 0', function(assert) { + const range = createTimeRanges([[0, 10]]); + + assert.equal(Ranges.timeAheadOf(range, 0), 10, '10s of time starting at 0s'); +}); + +QUnit.test('takes gaps into account', function(assert) { + const range = createTimeRanges([[0, 10], [20, 40], [50, 60]]); + + assert.equal(Ranges.timeAheadOf(range, 0), 40, '40s of time starting at 0s'); +}); +QUnit.test('takes gaps into account with different time', function(assert) { + const range = createTimeRanges([[0, 10], [20, 40], [50, 60]]); + + assert.equal(Ranges.timeAheadOf(range, 11), 30, '30s of time starting at 11s'); +}); +QUnit.test('takes partial time ranges into account', function(assert) { + const range = createTimeRanges([[0, 10], [20, 40], [50, 60]]); + + assert.equal(Ranges.timeAheadOf(range, 5), 35, '35s of time starting at 5s'); +}); +QUnit.test('exact end of range', function(assert) { + const range = createTimeRanges([[0, 10], [11, 20]]); + + assert.equal(Ranges.timeAheadOf(range, 10), 9, '9s of time starting at 10s'); +}); +QUnit.test('exact start of range', function(assert) { + const range = createTimeRanges([[0, 9], [10, 20]]); + + assert.equal(Ranges.timeAheadOf(range, 10), 10, '10s of time starting at 10s'); +}); +QUnit.test('matching end and start', function(assert) { + const range = createTimeRanges([[0, 10], [10, 20]]); + + assert.equal(Ranges.timeAheadOf(range, 10), 10, '10s of time starting at 10s'); +}); +