diff --git a/CHANGELOG.md b/CHANGELOG.md index 49a4c6b0..c0b314cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ CHANGELOG ========= ## HEAD (Unreleased) -_(none)_ +* buffer at the current time range end instead of incrementing a variable. ([view](https://github.com/videojs/videojs-contrib-hls/pull/423)) -------------------- diff --git a/package.json b/package.json index e25a701a..7de3cdf2 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "karma-sauce-launcher": "~0.1.8", "qunitjs": "^1.18.0", "sinon": "1.10.2", - "video.js": "^5.0.0-rc.96" + "video.js": "^5.1.0" }, "dependencies": { "pkcs7": "^0.2.2", diff --git a/src/playlist-loader.js b/src/playlist-loader.js index 0b1264fd..d9b9d6fd 100644 --- a/src/playlist-loader.js +++ b/src/playlist-loader.js @@ -367,7 +367,17 @@ * closest playback position that is currently available. */ PlaylistLoader.prototype.getMediaIndexForTime_ = function(time) { - var i, j, segment, targetDuration; + var + i, + segment, + originalTime = time, + targetDuration = this.media_.targetDuration || 10, + numSegments = this.media_.segments.length, + lastSegment = numSegments - 1, + startIndex, + endIndex, + knownStart, + knownEnd; if (!this.media_) { return 0; @@ -379,57 +389,105 @@ return 0; } - // 1) Walk backward until we find the latest segment with timeline + // 1) Walk backward until we find the first segment with timeline // information that is earlier than `time` - targetDuration = this.media_.targetDuration || 10; - i = this.media_.segments.length; - while (i--) { + for (i = lastSegment; i >= 0; i--) { segment = this.media_.segments[i]; if (segment.end !== undefined && segment.end <= time) { - time -= segment.end; + startIndex = i + 1; + knownStart = segment.end; + if (startIndex >= numSegments) { + // The last segment claims to end *before* the time we are + // searching for so just return it + return numSegments; + } break; } - if (segment.start !== undefined && segment.start < time) { - + if (segment.start !== undefined && segment.start <= time) { if (segment.end !== undefined && segment.end > time) { // we've found the target segment exactly return i; } + startIndex = i; + knownStart = segment.start; + break; + } + } - time -= segment.start; - time -= segment.duration || targetDuration; - if (time < 0) { - // the segment with start information is also our best guess - // for the momment - return i; + // 2) Walk forward until we find the first segment with timeline + // information that is greater than `time` + for (i = 0; i < numSegments; i++) { + segment = this.media_.segments[i]; + if (segment.start !== undefined && segment.start > time) { + endIndex = i - 1; + knownEnd = segment.start; + if (endIndex < 0) { + // The first segment claims to start *after* the time we are + // searching for so just return it + return -1; } break; } + if (segment.end !== undefined && segment.end > time) { + endIndex = i; + knownEnd = segment.end; + break; + } } - i++; - // 2) Walk forward, testing each segment to see if `time` falls within it - for (j = i; j < this.media_.segments.length; j++) { - segment = this.media_.segments[j]; - time -= segment.duration || targetDuration; + if (startIndex !== undefined) { + // We have a known-start point that is before our desired time so + // walk from that point forwards + time = time - knownStart; + for (i = startIndex; i < (endIndex || numSegments); i++) { + segment = this.media_.segments[i]; + time -= segment.duration || targetDuration; + if (time < 0) { + return i; + } + } - if (time < 0) { - return j; + if (i === endIndex) { + // We haven't found a segment but we did hit a known end point + // so fallback to "Algorithm Jon" - try to interpolate the segment + // index based on the known span of the timeline we are dealing with + // and the number of segments inside that span + return startIndex + Math.floor( + ((originalTime - knownStart) / (knownEnd - knownStart)) * + (endIndex - startIndex)); } - // 2a) If we discover a segment that has timeline information - // before finding the result segment, the playlist information - // must have been inaccurate. Start a binary search for the - // segment which contains `time`. If the guess turns out to be - // incorrect, we'll have more info to work with next time. - if (segment.start !== undefined || segment.end !== undefined) { - return Math.floor((j - i) * 0.5); + // We _still_ haven't found a segment so load the last one + return lastSegment; + } else if (endIndex !== undefined) { + // We _only_ have a known-end point that is after our desired time so + // walk from that point backwards + time = knownEnd - time; + for (i = endIndex; i >= 0; i--) { + segment = this.media_.segments[i]; + time -= segment.duration || targetDuration; + if (time < 0) { + return i; + } } + // We haven't found a segment so load the first one + return 0; + } else { + // We known nothing so use "Algorithm A" - walk from the front + // of the playlist naively subtracking durations until we find + // a segment that contains time and return it + for (i = 0; i < numSegments; i++) { + segment = this.media_.segments[i]; + time -= segment.duration || targetDuration; + if (time < 0) { + return i; + } + } + // We are out of possible candidates so load the last one... + // The last one is the least likely to overlap a buffer and therefore + // the one most likely to tell us something about the timeline + return lastSegment; } - - // the playback position is outside the range of available - // segments so return the length - return this.media_.segments.length; }; videojs.Hls.PlaylistLoader = PlaylistLoader; diff --git a/src/videojs-hls.js b/src/videojs-hls.js index 62fddf60..f0fc79d9 100644 --- a/src/videojs-hls.js +++ b/src/videojs-hls.js @@ -304,13 +304,26 @@ videojs.Hls.prototype.setupSourceBuffer_ = function() { // transition the sourcebuffer to the ended state if we've hit the end of // the playlist this.sourceBuffer.addEventListener('updateend', function() { - var segmentInfo = this.pendingSegment_, segment, currentBuffered, timelineUpdates; + var + segmentInfo = this.pendingSegment_, + segment, + playlist, + currentMediaIndex, + currentBuffered, + timelineUpdates; + + // stop here if the update errored or was aborted + if (!segmentInfo) { + return; + } this.pendingSegment_ = null; // if we've buffered to the end of the video, let the MediaSource know currentBuffered = this.findCurrentBuffered_(); - if (currentBuffered.length && this.duration() === currentBuffered.end(0)) { + if (currentBuffered.length && + this.duration() === currentBuffered.end(0) && + this.mediaSource.readyState === 'open') { this.mediaSource.endOfStream(); } @@ -319,26 +332,46 @@ videojs.Hls.prototype.setupSourceBuffer_ = function() { return; } + // if we switched renditions don't try to add segment timeline + // information to the playlist + if (segmentInfo.playlist.uri !== this.playlists.media().uri) { + return this.fillBuffer(); + } + + playlist = this.playlists.media(); + currentMediaIndex = segmentInfo.mediaIndex + (segmentInfo.mediaSequence - playlist.mediaSequence); + // annotate the segment with any start and end time information // added by the media processing - segment = segmentInfo.playlist.segments[segmentInfo.mediaIndex]; + segment = playlist.segments[currentMediaIndex]; + timelineUpdates = videojs.Hls.bufferedAdditions_(segmentInfo.buffered, this.tech_.buffered()); - timelineUpdates.forEach(function(update) { - if (update.start !== undefined) { - segment.start = update.start; - } - if (update.end !== undefined) { - segment.end = update.end; + + timelineUpdates.forEach(function (update) { + if (segment) { + if (update.start !== undefined) { + segment.start = update.start; + } + if (update.end !== undefined) { + segment.end = update.end; + } } }); if (timelineUpdates.length) { - this.updateDuration(segmentInfo.playlist); + this.updateDuration(playlist); + // check if it's time to download the next segment + this.fillBuffer(); + return; } - // check if it's time to download the next segment - this.checkBuffer_(); + // the last segment append must have been entirely in the + // already buffered time ranges. just buffer forward until we + // find a segment that adds to the buffered time ranges and + // improves subsequent media index calculations. + this.fillBuffer(currentMediaIndex + 1); + return; }.bind(this)); }; @@ -426,11 +459,8 @@ videojs.Hls.prototype.setCurrentTime = function(currentTime) { this.cancelKeyXhr(); } - // clear out the segment being processed - this.pendingSegment_ = null; - // begin filling the buffer at the new position - this.fillBuffer(currentTime); + this.fillBuffer(this.playlists.getMediaIndexForTime_(currentTime)); }; videojs.Hls.prototype.duration = function() { @@ -465,15 +495,25 @@ videojs.Hls.prototype.updateDuration = function(playlist) { this.mediaSource.duration = newDuration; this.tech_.trigger('durationchange'); this.mediaSource.removeEventListener('sourceopen', setDuration); - }.bind(this); + }.bind(this), + seekable = this.seekable(); + + // TODO: Move to videojs-contrib-media-sources + if (seekable.length && newDuration === Infinity) { + if (isNaN(oldDuration)) { + oldDuration = 0; + } + newDuration = Math.max(oldDuration, + seekable.end(0) + playlist.targetDuration * 3); + } // if the duration has changed, invalidate the cached value if (oldDuration !== newDuration) { - if (this.mediaSource.readyState === 'open') { + if (this.mediaSource.readyState !== 'open') { + this.mediaSource.addEventListener('sourceopen', setDuration); + } else if (!this.sourceBuffer || !this.sourceBuffer.updating) { this.mediaSource.duration = newDuration; this.tech_.trigger('durationchange'); - } else { - this.mediaSource.addEventListener('sourceopen', setDuration); } } }; @@ -507,6 +547,8 @@ videojs.Hls.prototype.cancelSegmentXhr = function() { this.segmentXhr_.abort(); this.segmentXhr_ = null; } + // clear out the segment being processed + this.pendingSegment_ = null; }; /** @@ -667,11 +709,17 @@ videojs.Hls.prototype.stopCheckingBuffer_ = function() { */ videojs.Hls.prototype.findCurrentBuffered_ = function() { var - tech = this.tech_, - currentTime = tech.currentTime(), - buffered = this.tech_.buffered(), ranges, - i; + i, + tech = this.tech_, + // !!The order of the next two lines is important!! + // `currentTime` must be equal-to or greater-than the start of the + // buffered range. Flash executes out-of-process so, every value can + // change behind the scenes from line-to-line. By reading `currentTime` + // after `buffered`, we ensure that it is always a current or later + // value during playback. + buffered = tech.buffered(), + currentTime = tech.currentTime(); if (buffered && buffered.length) { // Search for a range containing the play-head @@ -697,13 +745,13 @@ videojs.Hls.prototype.findCurrentBuffered_ = function() { * @param seekToTime (optional) {number} the offset into the downloaded segment * to seek to, in seconds */ -videojs.Hls.prototype.fillBuffer = function(seekToTime) { +videojs.Hls.prototype.fillBuffer = function(mediaIndex) { var tech = this.tech_, currentTime = tech.currentTime(), currentBuffered = this.findCurrentBuffered_(), + currentBufferedEnd = 0, bufferedTime = 0, - mediaIndex = 0, segment, segmentInfo; @@ -739,39 +787,46 @@ videojs.Hls.prototype.fillBuffer = function(seekToTime) { return; } - // find the next segment to download - if (typeof seekToTime === 'number') { - mediaIndex = this.playlists.getMediaIndexForTime_(seekToTime); - } else if (currentBuffered && currentBuffered.length) { - mediaIndex = this.playlists.getMediaIndexForTime_(currentBuffered.end(0)); - bufferedTime = Math.max(0, currentBuffered.end(0) - currentTime); - } else { - mediaIndex = this.playlists.getMediaIndexForTime_(this.tech_.currentTime()); + if (mediaIndex === undefined) { + if (currentBuffered && currentBuffered.length) { + currentBufferedEnd = currentBuffered.end(0); + mediaIndex = this.playlists.getMediaIndexForTime_(currentBufferedEnd); + bufferedTime = Math.max(0, currentBufferedEnd - currentTime); + + // if there is plenty of content in the buffer and we're not + // seeking, relax for awhile + if (bufferedTime >= videojs.Hls.GOAL_BUFFER_LENGTH) { + return; + } + } else { + mediaIndex = this.playlists.getMediaIndexForTime_(this.tech_.currentTime()); + } } segment = this.playlists.media().segments[mediaIndex]; - // if the video has finished downloading, stop trying to buffer + // if the video has finished downloading if (!segment) { return; } - // if there is plenty of content in the buffer and we're not - // seeking, relax for awhile - if (typeof seekToTime !== 'number' && - bufferedTime >= videojs.Hls.GOAL_BUFFER_LENGTH) { - return; + // we have entered a state where we are fetching the same segment, + // try to walk forward + if (this.lastSegmentLoaded_ && + this.lastSegmentLoaded_ === this.playlistUriToUrl(segment.uri)) { + return this.fillBuffer(mediaIndex + 1); } // package up all the work to append the segment segmentInfo = { // resolve the segment URL relative to the playlist uri: this.playlistUriToUrl(segment.uri), - // the segment's mediaIndex at the time it was received + // the segment's mediaIndex & mediaSequence at the time it was requested mediaIndex: mediaIndex, + mediaSequence: this.playlists.media().mediaSequence, // the segment's playlist playlist: this.playlists.media(), - // optionally, a time offset to seek to within the segment - offset: seekToTime, + // The state of the buffer when this segment was requested + currentBufferedEnd: currentBufferedEnd, // unencrypted bytes of the segment bytes: null, // when a key is defined for this segment, the encrypted bytes @@ -856,6 +911,7 @@ videojs.Hls.prototype.loadSegment = function(segmentInfo) { return; } + self.lastSegmentLoaded_ = segmentInfo.uri; self.setBandwidth(request); if (segment.key) { @@ -944,41 +1000,33 @@ videojs.Hls.prototype.drainBuffer = function(event) { event = event || {}; + if (segmentInfo.mediaIndex > 0) { + segmentTimestampOffset = videojs.Hls.Playlist.duration(segmentInfo.playlist, + playlist.mediaSequence + segmentInfo.mediaIndex); + } + // If we have seeked into a non-buffered time-range, remove all buffered // time-ranges because they could have been incorrectly placed originally if (this.tech_.seeking() && outsideBufferedRanges) { - if (hasBufferedContent) { - // In Chrome, it seems that too many independent buffered time-ranges can - // cause playback to fail to resume when seeking so just kill all of them - this.sourceBuffer.remove(0, Infinity); - return; - } - // If there are discontinuities in the playlist, we can't be sure of anything // related to time so we reset the timestamp offset and start appending data // anew on every seek if (segmentInfo.playlist.discontinuityStarts.length) { - if (segmentInfo.mediaIndex > 0) { - segmentTimestampOffset = videojs.Hls.Playlist.duration(segmentInfo.playlist, segmentInfo.mediaIndex); - } - // Now that the forward buffer is clear, we have to set timestamp offset to // the start of the buffered region this.sourceBuffer.timestampOffset = segmentTimestampOffset; } - } else if (segment.discontinuity) { + } else if (segment.discontinuity && currentBuffered.length) { // If we aren't seeking and are crossing a discontinuity, we should set // timestampOffset for new segments to be appended the end of the current // buffered time-range this.sourceBuffer.timestampOffset = currentBuffered.end(0); + } else if (!hasBufferedContent && this.tech_.currentTime() > 0.05) { + // If we are trying to play at a position that is not zero but we aren't + // currently seeking according to the video element + this.sourceBuffer.timestampOffset = segmentTimestampOffset; } - if (currentBuffered.length) { - // Chrome 45 stalls if appends overlap the playhead - this.sourceBuffer.appendWindowStart = Math.min(this.tech_.currentTime(), currentBuffered.end(0)); - } else { - this.sourceBuffer.appendWindowStart = 0; - } this.pendingSegment_.buffered = this.tech_.buffered(); // the segment is asynchronously added to the current buffered data diff --git a/test/playlist-loader_test.js b/test/playlist-loader_test.js index 2a2ad541..0a74bd9e 100644 --- a/test/playlist-loader_test.js +++ b/test/playlist-loader_test.js @@ -653,8 +653,8 @@ equal(loader.getMediaIndexForTime_(3), 0, 'time three is index zero'); equal(loader.getMediaIndexForTime_(10), 2, 'time 10 is index 2'); equal(loader.getMediaIndexForTime_(22), - 3, - 'time greater than the length is index 3'); + 2, + 'time greater than the length is index 2'); }); test('returns the lower index when calculating for a segment boundary', function() { @@ -683,9 +683,9 @@ '1002.ts\n'); loader.media().segments[0].start = 150; - equal(loader.getMediaIndexForTime_(0), 0, 'the lowest returned value is zero'); - equal(loader.getMediaIndexForTime_(45), 0, 'expired content returns zero'); - equal(loader.getMediaIndexForTime_(75), 0, 'expired content returns zero'); + equal(loader.getMediaIndexForTime_(0), -1, 'the lowest returned value is negative one'); + equal(loader.getMediaIndexForTime_(45), -1, 'expired content returns negative one'); + equal(loader.getMediaIndexForTime_(75), -1, 'expired content returns negative one'); equal(loader.getMediaIndexForTime_(50 + 100), 0, 'calculates the earliest available position'); equal(loader.getMediaIndexForTime_(50 + 100 + 2), 0, 'calculates within the first segment'); equal(loader.getMediaIndexForTime_(50 + 100 + 2), 0, 'calculates within the first segment'); diff --git a/test/videojs-hls_test.js b/test/videojs-hls_test.js index 6cbd8e38..65978a2c 100644 --- a/test/videojs-hls_test.js +++ b/test/videojs-hls_test.js @@ -218,6 +218,7 @@ module('HLS', { var el = document.createElement('div'); el.id = 'vjs_mock_flash_' + nextId++; el.className = 'vjs-tech vjs-mock-flash'; + el.duration = Infinity; el.vjs_load = function() {}; el.vjs_getProperty = function(attr) { if (attr === 'buffered') { @@ -1131,7 +1132,7 @@ test('buffers based on the correct TimeRange if multiple ranges exist', function return videojs.createTimeRange(buffered); }; currentTime = 8; - buffered = [[0, 10], [20, 40]]; + buffered = [[0, 10], [20, 30]]; standardXHRResponse(requests[0]); standardXHRResponse(requests[1]); @@ -1144,14 +1145,9 @@ test('buffers based on the correct TimeRange if multiple ranges exist', function currentTime = 22; player.tech_.hls.sourceBuffer.trigger('updateend'); player.tech_.hls.checkBuffer_(); - strictEqual(requests.length, 2, 'made no additional requests'); - - buffered = [[0, 10], [20, 30]]; - player.tech_.hls.checkBuffer_(); - standardXHRResponse(requests[2]); strictEqual(requests.length, 3, 'made three requests'); strictEqual(requests[2].url, - absoluteUrl('manifest/media-00004.ts'), + absoluteUrl('manifest/media-00003.ts'), 'made segment request'); }); @@ -1381,7 +1377,7 @@ test('seeking in an empty playlist is a non-erroring noop', function() { equal(requests.length, requestsLength, 'made no additional requests'); }); -test('duration is Infinity for live playlists', function() { +test('tech\'s duration reports Infinity for live playlists', function() { player.src({ src: 'http://example.com/manifest/missingEndlist.m3u8', type: 'application/vnd.apple.mpegurl' @@ -1390,9 +1386,13 @@ test('duration is Infinity for live playlists', function() { standardXHRResponse(requests[0]); - strictEqual(player.tech_.hls.mediaSource.duration, + strictEqual(player.tech_.duration(), + Infinity, + 'duration on the tech is infinity'); + + notEqual(player.tech_.hls.mediaSource.duration, Infinity, - 'duration is infinity'); + 'duration on the mediaSource is not infinity'); }); test('live playlist starts three target durations before live', function() { @@ -1644,7 +1644,7 @@ test('calls mediaSource\'s timestampOffset on discontinuity', function() { }); test('sets timestampOffset when seeking with discontinuities', function() { - var removes = [], timeRange = videojs.createTimeRange(0, 10); + var timeRange = videojs.createTimeRange(0, 10); player.src({ src: 'discontinuity.m3u8', @@ -1670,18 +1670,12 @@ test('sets timestampOffset when seeking with discontinuities', function() { '3.ts\n' + '#EXT-X-ENDLIST\n'); player.tech_.hls.sourceBuffer.timestampOffset = 0; - player.tech_.hls.sourceBuffer.remove = function(start, end) { - timeRange = videojs.createTimeRange(); - removes.push([start, end]); - }; - player.currentTime(21); clock.tick(1); equal(requests.shift().aborted, true, 'aborted first request'); standardXHRResponse(requests.pop()); // 3.ts clock.tick(1000); equal(player.tech_.hls.sourceBuffer.timestampOffset, 20, 'timestampOffset starts at zero'); - equal(removes.length, 1, 'remove was called'); }); test('can seek before the source buffer opens', function() {