53322e5d by jrivera

Merge branch 'master' into development

2 parents b5e60aba 8718c2e2
......@@ -16,3 +16,10 @@ notifications:
before_script:
- export DISPLAY=:99.0
- sh -e /etc/init.d/xvfb start
env:
global:
- secure: dM7svnHPPu5IiUMeFWW5zg+iuWNpwt6SSDi3MmVvhSclNMRLesQoRB+7Qq5J/LiKhmjpv1/GlNVV0CTsHMRhZNwQ3fo38eEuTXv99aAflEITXwSEh/VntKViHbGFubn06EnVkJoH6MX3zJ6kbiwc2QdSQbywKzS6l6quUEpWpd0=
- secure: AnduYGXka5ft1x7V3SuVYqvlKLvJGhUaRNFdy4UDJr3ZVuwpQjE4TMDG8REmJIJvXfHbh4qY4N1cFSGnXkZ4bH21Xk0v9DLhsxbarKz+X2BvPgXs+Af9EQ6vLEy/5S1vMLxfT5+y+Ec5bVNGOsdUZby8Y21CRzSg6ADN9kwPGlE=
addons:
sauce_connect: true
firefox: latest
......
{
"name": "videojs-contrib-hls",
"version": "1.3.5",
"version": "1.3.9",
"description": "Play back HLS with video.js, even where it's not natively supported",
"main": "es5/videojs-contrib-hls.js",
"engines": {
......
......@@ -3,6 +3,16 @@
*/
import {createTimeRange} from 'video.js';
let Playlist = {
/**
* The number of segments that are unsafe to start playback at in
* a live stream. Changing this value can cause playback stalls.
* See HTTP Live Streaming, "Playing the Media Playlist File"
* https://tools.ietf.org/html/draft-pantos-http-live-streaming-18#section-6.3.3
*/
UNSAFE_LIVE_SEGMENTS: 3
};
const backwardDuration = function(playlist, endSequence) {
let result = 0;
let i = endSequence - playlist.mediaSequence;
......@@ -187,12 +197,12 @@ export const seekable = function(playlist) {
start = intervalDuration(playlist, playlist.mediaSequence);
end = intervalDuration(playlist,
playlist.mediaSequence +
Math.max(0, playlist.segments.length - 3));
Math.max(0, playlist.segments.length - Playlist.UNSAFE_LIVE_SEGMENTS));
return createTimeRange(start, end);
};
Playlist.duration = duration;
Playlist.seekable = seekable;
// exports
export default {
duration,
seekable
};
export default Playlist;
......
......@@ -197,6 +197,88 @@ const keyFailed = function(key) {
return key.retries && key.retries >= 2;
};
/**
* Returns the CSS value for the specified property on an element
* using `getComputedStyle`. Firefox has a long-standing issue where
* getComputedStyle() may return null when running in an iframe with
* `display: none`.
* @see https://bugzilla.mozilla.org/show_bug.cgi?id=548397
*/
const safeGetComputedStyle = function(el, property) {
let result;
if (!el) {
return '';
}
result = getComputedStyle(el);
if (!result) {
return '';
}
return result[property];
};
/**
* Updates segment with information about its end-point in time and, optionally,
* the segment duration if we have enough information to determine a segment duration
* accurately.
* @param playlist {object} a media playlist object
* @param segmentIndex {number} the index of segment we last appended
* @param segmentEnd {number} the known of the segment referenced by segmentIndex
*/
const updateSegmentMetadata = function(playlist, segmentIndex, segmentEnd) {
if (!playlist) {
return;
}
let segment = playlist.segments[segmentIndex];
let previousSegment = playlist.segments[segmentIndex - 1];
if (segmentEnd && segment) {
segment.end = segmentEnd;
// fix up segment durations based on segment end data
if (!previousSegment) {
// first segment is always has a start time of 0 making its duration
// equal to the segment end
segment.duration = segment.end;
} else if (previousSegment.end) {
segment.duration = segment.end - previousSegment.end;
}
}
};
/**
* Determines if we should call endOfStream on the media source based on the state
* of the buffer or if appened segment was the final segment in the playlist.
* @param playlist {object} a media playlist object
* @param mediaSource {object} the MediaSource object
* @param segmentIndex {number} the index of segment we last appended
* @param currentBuffered {object} the buffered region that currentTime resides in
* @return {boolean} whether the calling function should call endOfStream on the MediaSource
*/
const detectEndOfStream = function(playlist, mediaSource, segmentIndex, currentBuffered) {
if (!playlist) {
return false;
}
let segments = playlist.segments;
// determine a few boolean values to help make the branch below easier
// to read
let appendedLastSegment = (segmentIndex === segments.length - 1);
let bufferedToEnd = (currentBuffered.length &&
segments[segments.length - 1].end <= currentBuffered.end(0));
// if we've buffered to the end of the video, we need to call endOfStream
// so that MediaSources can trigger the `ended` event when it runs out of
// buffered data instead of waiting for me
return playlist.endList &&
mediaSource.readyState === 'open' &&
(appendedLastSegment || bufferedToEnd);
};
const parseCodecs = function(codecs) {
let result = {
codecCount: 0,
......@@ -592,10 +674,15 @@ export default class HlsHandler extends Component {
duration() {
let playlists = this.playlists;
if (playlists) {
return Hls.Playlist.duration(playlists.media());
if (!playlists) {
return 0;
}
if (this.mediaSource) {
return this.mediaSource.duration;
}
return 0;
return Hls.Playlist.duration(playlists.media());
}
seekable() {
......@@ -635,6 +722,7 @@ export default class HlsHandler extends Component {
updateDuration(playlist) {
let oldDuration = this.mediaSource.duration;
let newDuration = Hls.Playlist.duration(playlist);
let buffered = this.tech_.buffered();
let setDuration = () => {
this.mediaSource.duration = newDuration;
this.tech_.trigger('durationchange');
......@@ -642,6 +730,10 @@ export default class HlsHandler extends Component {
this.mediaSource.removeEventListener('sourceopen', setDuration);
};
if (buffered.length > 0) {
newDuration = Math.max(newDuration, buffered.end(buffered.length - 1));
}
// if the duration has changed, invalidate the cached value
if (oldDuration !== newDuration) {
// update the duration
......@@ -767,8 +859,8 @@ export default class HlsHandler extends Component {
// (this could be the lowest bitrate rendition as we go through all of them above)
variant = null;
width = parseInt(getComputedStyle(this.tech_.el()).width, 10);
height = parseInt(getComputedStyle(this.tech_.el()).height, 10);
width = parseInt(safeGetComputedStyle(this.tech_.el(), 'width'), 10);
height = parseInt(safeGetComputedStyle(this.tech_.el(), 'height'), 10);
// iterate through the bandwidth-filtered playlists and find
// best rendition by player dimension
......@@ -1094,6 +1186,7 @@ export default class HlsHandler extends Component {
let segment = segmentInfo.playlist.segments[segmentInfo.mediaIndex];
let removeToTime = 0;
let seekable = this.seekable();
let currentTime = this.tech_.currentTime();
// Chrome has a hard limit of 150mb of
// buffer and a very conservative "garbage collector"
......@@ -1103,10 +1196,10 @@ export default class HlsHandler extends Component {
if (this.sourceBuffer && !this.sourceBuffer.updating) {
// If we have a seekable range use that as the limit for what can be removed safely
// otherwise remove anything older than 1 minute before the current play head
if (seekable.length && seekable.start(0) > 0) {
if (seekable.length && seekable.start(0) > 0 && seekable.start(0) < currentTime) {
removeToTime = seekable.start(0);
} else {
removeToTime = this.tech_.currentTime() - 60;
removeToTime = currentTime - 60;
}
if (removeToTime > 0) {
......@@ -1260,37 +1353,43 @@ export default class HlsHandler extends Component {
updateEndHandler_() {
let segmentInfo = this.pendingSegment_;
let segment;
let segments;
let playlist;
let currentMediaIndex;
let currentBuffered;
let seekable;
let timelineUpdate;
this.pendingSegment_ = null;
let isEndOfStream;
// stop here if the update errored or was aborted
if (!segmentInfo) {
this.pendingSegment_ = null;
return;
}
// In Firefox, the updateend event is triggered for both removing from the buffer and
// adding to the buffer. To prevent this code from executing on removals, we wait for
// segmentInfo to have a filled in buffered value before we continue processing.
if (!segmentInfo.buffered) {
return;
}
playlist = this.playlists.media();
segments = playlist.segments;
this.pendingSegment_ = null;
playlist = segmentInfo.playlist;
currentMediaIndex = segmentInfo.mediaIndex +
(segmentInfo.mediaSequence - playlist.mediaSequence);
currentBuffered = this.findBufferedRange_();
isEndOfStream = detectEndOfStream(playlist, this.mediaSource, currentMediaIndex, currentBuffered);
// if we switched renditions don't try to add segment timeline
// information to the playlist
if (segmentInfo.playlist.uri !== this.playlists.media().uri) {
if (isEndOfStream) {
return this.mediaSource.endOfStream();
}
return this.fillBuffer();
}
// annotate the segment with any start and end time information
// added by the media processing
segment = playlist.segments[currentMediaIndex];
// when seeking to the beginning of the seekable range, it's
// possible that imprecise timing information may cause the seek to
// end up earlier than the start of the range
......@@ -1313,17 +1412,13 @@ export default class HlsHandler extends Component {
timelineUpdate = Hls.findSoleUncommonTimeRangesEnd_(segmentInfo.buffered,
this.tech_.buffered());
if (timelineUpdate && segment) {
segment.end = timelineUpdate;
}
// Update segment meta-data (duration and end-point) based on timeline
updateSegmentMetadata(playlist, currentMediaIndex, timelineUpdate);
// if we've buffered to the end of the video, let the MediaSource know
if (this.playlists.media().endList &&
currentBuffered.length &&
segments[segments.length - 1].end <= currentBuffered.end(0) &&
this.mediaSource.readyState === 'open') {
this.mediaSource.endOfStream();
return;
// If we decide to signal the end of stream, then we can return instead
// of trying to fetch more segments
if (isEndOfStream) {
return this.mediaSource.endOfStream();
}
if (timelineUpdate !== null ||
......
......@@ -436,7 +436,7 @@ QUnit.test('duration is set when the source opens after the playlist is loaded',
'set the duration');
});
QUnit.test('calls `remove` on sourceBuffer to when loading a live segment', function() {
QUnit.test('calls `remove` based on seekable when loading a live segment', function() {
let removes = [];
let seekable = videojs.createTimeRanges([[60, 120]]);
......@@ -487,7 +487,59 @@ QUnit.test('calls `remove` on sourceBuffer to when loading a live segment', func
'remove called with the right range');
});
QUnit.test('calls `remove` on sourceBuffer to when loading a vod segment', function() {
QUnit.test('calls `remove` based on currentTime when loading a live segment ' +
'if seekable start is after currentTime', function() {
let removes = [];
let seekable = videojs.createTimeRanges([[0, 80]]);
this.player.src({
src: 'liveStart30sBefore.m3u8',
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(this.player, this.clock);
this.player.tech_.hls.seekable = function() {
return seekable;
};
openMediaSource(this.player, this.clock);
this.player.tech_.hls.mediaSource.addSourceBuffer = function() {
return new (videojs.extend(videojs.EventTarget, {
constructor() {},
abort() {},
buffered: videojs.createTimeRange(),
appendBuffer() {},
remove(start, end) {
removes.push([start, end]);
}
}))();
};
this.player.tech_.hls.bandwidth = 20e10;
this.player.tech_.triggerReady();
standardXHRResponse(this.requests[0]);
this.player.tech_.hls.playlists.trigger('loadedmetadata');
this.player.tech_.trigger('canplay');
this.player.tech_.paused = function() {
return false;
};
this.player.tech_.readyState = function() {
return 1;
};
this.player.tech_.trigger('play');
this.clock.tick(1);
// Change seekable so that it starts *after* the currentTime which was set
// based on the previous seekable range (the end of 80)
seekable = videojs.createTimeRanges([[100, 120]]);
standardXHRResponse(this.requests[1]);
QUnit.strictEqual(this.requests[0].url, 'liveStart30sBefore.m3u8', 'master playlist requested');
QUnit.equal(removes.length, 1, 'remove called');
QUnit.deepEqual(removes[0], [0, 80 - 60], 'remove called with the right range');
});
QUnit.test('calls `remove` based on currentTime when loading a vod segment', function() {
let removes = [];
this.player.src({
......@@ -2268,6 +2320,81 @@ QUnit.test('tracks segment end times as they are buffered', function() {
QUnit.equal(this.player.tech_.hls.mediaSource.duration, 10 + 9.5, 'updated duration');
});
QUnit.test('updates first segment duration as it is buffered', function() {
let bufferEnd = 0;
this.player.src({
src: 'media.m3u8',
type: 'application/x-mpegURL'
});
openMediaSource(this.player, this.clock);
// as new segments are downloaded, the buffer end is updated
this.player.tech_.buffered = function() {
return videojs.createTimeRange(0, bufferEnd);
};
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n' +
'#EXTINF:10,\n' +
'1.ts\n' +
'#EXT-X-ENDLIST\n');
// 0.ts is shorter than advertised
standardXHRResponse(this.requests.shift());
QUnit.equal(this.player.tech_.hls.mediaSource.duration, 20, 'original duration is from the m3u8');
QUnit.equal(this.player.tech_.hls.playlists.media().segments[0].duration, 10,
'segment duration initially based on playlist');
bufferEnd = 9.5;
this.player.tech_.hls.sourceBuffer.trigger('update');
this.player.tech_.hls.sourceBuffer.trigger('updateend');
QUnit.equal(this.player.tech_.hls.playlists.media().segments[0].duration, 9.5,
'updated segment duration');
});
QUnit.test('updates segment durations as they are buffered', function() {
let bufferEnd = 0;
this.player.src({
src: 'media.m3u8',
type: 'application/x-mpegURL'
});
openMediaSource(this.player, this.clock);
// as new segments are downloaded, the buffer end is updated
this.player.tech_.buffered = function() {
return videojs.createTimeRange(0, bufferEnd);
};
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n' +
'#EXTINF:10,\n' +
'1.ts\n' +
'#EXT-X-ENDLIST\n');
// 0.ts is shorter than advertised
standardXHRResponse(this.requests.shift());
QUnit.equal(this.player.tech_.hls.mediaSource.duration, 20, 'original duration is from the m3u8');
QUnit.equal(this.player.tech_.hls.playlists.media().segments[1].duration, 10,
'segment duration initially based on playlist');
bufferEnd = 9.5;
this.player.tech_.hls.sourceBuffer.trigger('update');
this.player.tech_.hls.sourceBuffer.trigger('updateend');
this.clock.tick(1);
standardXHRResponse(this.requests.shift());
bufferEnd = 19;
this.player.tech_.hls.sourceBuffer.trigger('update');
this.player.tech_.hls.sourceBuffer.trigger('updateend');
QUnit.equal(this.player.tech_.hls.playlists.media().segments[1].duration, 9.5,
'updated segment duration');
});
QUnit.skip('seeking does not fail when targeted between segments', function() {
let currentTime;
let segmentUrl;
......@@ -2576,7 +2703,7 @@ QUnit.test('can be disposed before finishing initialization', function() {
}
});
QUnit.test('calls ended() on the media source at the end of a playlist', function() {
QUnit.test('calls endOfStream on the media source after appending the last segment', function() {
let endOfStreams = 0;
let buffered = [[]];
......@@ -2591,11 +2718,15 @@ QUnit.test('calls ended() on the media source at the end of a playlist', functio
this.player.tech_.hls.mediaSource.endOfStream = function() {
endOfStreams++;
};
this.player.currentTime(20);
this.clock.tick(1);
// playlist response
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n' +
'#EXTINF:10,\n' +
'1.ts\n' +
'#EXT-X-ENDLIST\n');
// segment response
this.requests[0].response = new ArrayBuffer(17);
......@@ -2604,7 +2735,52 @@ QUnit.test('calls ended() on the media source at the end of a playlist', functio
buffered = [[0, 10]];
this.player.tech_.hls.sourceBuffer.trigger('updateend');
QUnit.strictEqual(endOfStreams, 1, 'ended media source');
QUnit.strictEqual(endOfStreams, 1, 'called endOfStream on the media source');
});
QUnit.test('calls endOfStream on the media source when the current buffer ends at duration', function() {
let endOfStreams = 0;
let buffered = [[]];
this.player.src({
src: 'http://example.com/media.m3u8',
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(this.player, this.clock);
this.player.tech_.buffered = function() {
return videojs.createTimeRanges(buffered);
};
this.player.tech_.hls.mediaSource.endOfStream = function() {
endOfStreams++;
};
this.player.currentTime(19);
this.clock.tick(1);
// playlist response
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n' +
'#EXTINF:10,\n' +
'1.ts\n' +
'#EXT-X-ENDLIST\n');
// segment response
this.requests[0].response = new ArrayBuffer(17);
this.requests.shift().respond(200, null, '');
QUnit.strictEqual(endOfStreams, 0, 'waits for the buffer update to finish');
buffered = [[10, 20]];
this.player.tech_.hls.sourceBuffer.trigger('updateend');
this.player.currentTime(5);
this.clock.tick(1);
// segment response
this.requests[0].response = new ArrayBuffer(17);
this.requests.shift().respond(200, null, '');
buffered = [[0, 20]];
this.player.tech_.hls.sourceBuffer.trigger('updateend');
QUnit.strictEqual(endOfStreams, 2, 'called endOfStream on the media source twice');
});
QUnit.test('calling play() at the end of a video replays', function() {
......@@ -3086,6 +3262,68 @@ QUnit.test('does not download segments if preload option set to none', function(
QUnit.equal(this.requests.length, 0, 'did not download any segments');
});
QUnit.test('does not process update end until buffered value has been set', function() {
let drainBufferCallCount = 0;
let origDrainBuffer;
this.player.src({
src: 'master.m3u8',
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(this.player, this.clock);
origDrainBuffer = this.player.tech_.hls.drainBuffer;
this.player.tech_.hls.drainBuffer = function() {
drainBufferCallCount++;
};
// master
standardXHRResponse(this.requests.shift());
// media
standardXHRResponse(this.requests.shift());
QUnit.equal(drainBufferCallCount, 0, 'drainBuffer not called yet');
// segment
standardXHRResponse(this.requests.shift());
QUnit.ok(this.player.tech_.hls.pendingSegment_, 'pending segment exists');
QUnit.equal(drainBufferCallCount, 1, 'drainBuffer called');
this.player.tech_.hls.sourceBuffer.trigger('updateend');
QUnit.ok(this.player.tech_.hls.pendingSegment_, 'pending segment exists');
this.player.tech_.hls.drainBuffer = origDrainBuffer;
this.player.tech_.hls.drainBuffer();
QUnit.ok(this.player.tech_.hls.pendingSegment_, 'pending segment exists');
this.player.tech_.hls.sourceBuffer.trigger('updateend');
QUnit.ok(!this.player.tech_.hls.pendingSegment_, 'pending segment cleared out');
});
// workaround https://bugzilla.mozilla.org/show_bug.cgi?id=548397
QUnit.test('selectPlaylist does not fail if getComputedStyle returns null', function() {
let oldGetComputedStyle = window.getComputedStyle;
window.getComputedStyle = function() {
return null;
};
this.player.src({
src: 'master.m3u8',
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(this.player, this.clock);
// master
standardXHRResponse(this.requests.shift());
// media
standardXHRResponse(this.requests.shift());
this.player.tech_.hls.selectPlaylist();
QUnit.ok(true, 'should not throw');
window.getComputedStyle = oldGetComputedStyle;
});
QUnit.module('Buffer Inspection');
QUnit.test('detects time range end-point changed by updates', function() {
let edge;
......