736b7c35 by David LaPalomento

Compare the old playlist to the update to determine the new media index

When refreshing a playlist, determine the new media index by comparing segment URIs. Consolidate playlist update logic. "Sliding window" live streams are now working.
1 parent 5448f63f
......@@ -35,8 +35,7 @@
Stream = videojs.hls.Stream,
LineStream,
ParseStream,
Parser,
merge;
Parser;
/**
* A stream that buffers string input and generates a `data` event for each
......@@ -456,52 +455,9 @@
this.lineStream.push('\n');
};
/**
* Merges two versions of a media playlist.
* @param base {object} the earlier version of the media playlist.
* @param update {object} the updates to apply to the base playlist.
* @return {object} a new media playlist object that combines the
* information in the two arguments.
*/
merge = function(base, update) {
var
result = mergeOptions({}, base),
uri = update.segments[0].uri,
i = base.segments ? base.segments.length : 0,
byterange,
segment;
result = mergeOptions(result, update);
// align and apply the updated segments
while (i--) {
segment = base.segments[i];
if (uri === segment.uri) {
// if there is no byterange information, match by URI
if (!segment.byterange) {
result.segments = base.segments.slice(0, i).concat(update.segments);
break;
}
// if a byterange is specified, make sure the segments match exactly
byterange = update.segments[0].byterange || {};
if (segment.byterange.offset === byterange.offset &&
segment.byterange.length === byterange.length) {
result.segments = base.segments.slice(0, i).concat(update.segments);
break;
}
}
}
// concatenate the two arrays if there was no overlap
if (i < 0) {
result.segments = (base.segments || []).concat(update.segments);
}
return result;
};
window.videojs.m3u8 = {
LineStream: LineStream,
ParseStream: ParseStream,
Parser: Parser,
merge: merge
Parser: Parser
};
})(window.videojs, window.parseInt, window.isFinite, window.videojs.util.mergeOptions);
......
......@@ -132,6 +132,12 @@ var
duration = 0,
i = playlist.segments.length,
segment;
// if present, use the duration specified in the playlist
if (playlist.totalDuration) {
return playlist.totalDuration;
}
// duration should be Infinity for live playlists
if (!playlist.endList) {
return window.Infinity;
......@@ -205,7 +211,8 @@ var
segmentXhr,
downloadPlaylist,
fillBuffer;
fillBuffer,
updateCurrentPlaylist;
// if the video element supports HLS natively, do nothing
if (videojs.hls.supportsNativeHls) {
......@@ -293,6 +300,28 @@ var
fillBuffer(currentTime * 1000);
});
/**
* Determine whether the current media playlist should be changed
* and trigger a switch if necessary. If a sufficiently fresh
* version of the target playlist is available, the switch will take
* effect immediately. Otherwise, the target playlist will be
* refreshed.
*/
updateCurrentPlaylist = function() {
var playlist, mediaSequence;
playlist = player.hls.selectPlaylist();
mediaSequence = player.hls.mediaIndex + (player.hls.media.mediaSequence || 0);
if (!playlist.segments ||
mediaSequence < (playlist.mediaSequence || 0) ||
mediaSequence > (playlist.mediaSequence || 0) + playlist.segments.length) {
downloadPlaylist(resolveUrl(srcUrl, playlist.uri));
} else {
player.hls.media = playlist;
// update the duration
player.duration(totalDuration(player.hls.media));
}
};
/**
* Chooses the appropriate media playlist based on the current
......@@ -382,7 +411,27 @@ var
var xhr = new window.XMLHttpRequest();
xhr.open('GET', url);
xhr.onreadystatechange = function() {
var i, parser, playlist, playlistUri, refreshDelay;
var i, parser, playlist, playlistUri, refreshDelay,
updateMediaIndex = function(original, update) {
var
i = update.segments.length,
updatedIndex = 0,
originalSegment;
// no segments have been loaded from the original playlist
if (player.hls.mediaIndex === 0) {
return;
}
originalSegment = original.segments[player.hls.mediaIndex - 1];
while (i--) {
if (originalSegment.uri === update.segments[i].uri) {
updatedIndex = i + 1;
break;
}
}
player.hls.mediaIndex = updatedIndex;
};
// wait until the request completes
if (xhr.readyState !== 4) {
......@@ -430,7 +479,15 @@ var
}
player.hls.master.playlists[i] =
videojs.m3u8.merge(playlist, parser.manifest);
videojs.util.mergeOptions(playlist, parser.manifest);
if (playlist !== player.hls.media) {
continue;
}
// determine the new mediaIndex if we're updating the
// current media playlist
updateMediaIndex(playlist, parser.manifest);
}
}
} else {
......@@ -453,11 +510,7 @@ var
player.hls.media = player.hls.master.playlists[0];
// update the duration
if (parser.manifest.totalDuration) {
player.duration(parser.manifest.totalDuration);
} else {
player.duration(totalDuration(parser.manifest));
}
player.duration(totalDuration(parser.manifest));
// periodicaly check if the buffer needs to be refilled
player.on('timeupdate', fillBuffer);
......@@ -469,19 +522,7 @@ var
}
// select a playlist and download its metadata if necessary
playlist = player.hls.selectPlaylist();
if (!playlist.segments) {
downloadPlaylist(resolveUrl(srcUrl, playlist.uri));
} else {
player.hls.media = playlist;
// update the duration
if (player.hls.media.totalDuration) {
player.duration(player.hls.media.totalDuration);
} else {
player.duration(totalDuration(player.hls.media));
}
}
updateCurrentPlaylist();
player.trigger('loadedmanifest');
};
......@@ -535,8 +576,6 @@ var
segmentXhr.open('GET', segmentUri);
segmentXhr.responseType = 'arraybuffer';
segmentXhr.onreadystatechange = function() {
var playlist;
// wait until the request completes
if (this.readyState !== 4) {
return;
......@@ -591,12 +630,7 @@ var
// figure out what stream the next segment should be downloaded from
// with the updated bandwidth information
playlist = player.hls.selectPlaylist();
if (!playlist.segments) {
downloadPlaylist(resolveUrl(srcUrl, playlist.uri));
} else {
player.hls.media = playlist;
}
updateCurrentPlaylist();
};
startTime = +new Date();
segmentXhr.send(null);
......
......@@ -513,131 +513,6 @@
notStrictEqual(new Parser(), undefined, 'parser is defined');
});
test('merges a manifest that strictly adds to an earlier one', function() {
var key, base, manifest, mid;
for (key in window.manifests) {
if (window.expected[key]) {
manifest = window.manifests[key];
// parse the first half of the manifest
mid = manifest.length / 2;
parser = new Parser();
parser.push(manifest.substring(0, mid));
base = parser.manifest;
if (!base.segments) {
// only test merges for media playlists
continue;
}
// attach the partial manifest to a new parser
parser = new Parser();
parser.push(manifest);
// merge the manifests together
deepEqual(m3u8.merge(base, parser.manifest),
window.expected[key],
key + '.m3u8 was parsed correctly');
}
}
});
test('merges overlapping segments without media sequences', function() {
var base;
parser = new Parser();
parser.push('#EXTM3U\n');
parser.push('#EXTINF:10,\n');
parser.push('0.ts\n');
parser.push('#EXTINF:10,\n');
parser.push('1.ts\n');
base = parser.manifest;
parser = new Parser();
parser.push('#EXTM3U\n');
parser.push('#EXTINF:10,\n');
parser.push('1.ts\n');
parser.push('#EXTINF:10,\n');
parser.push('2.ts\n');
deepEqual({
allowCache: true,
mediaSequence: 0,
segments: [{ duration: 10, uri: '0.ts'},
{ duration: 10, uri: '1.ts' },
{ duration: 10, uri: '2.ts' }]
}, m3u8.merge(base, parser.manifest), 'merges segment additions');
});
test('appends non-overlapping segments without media sequences', function() {
var base;
parser = new Parser();
parser.push('#EXTM3U\n');
parser.push('#EXTINF:10,\n');
parser.push('0.ts\n');
base = parser.manifest;
parser = new Parser();
parser.push('#EXTM3U\n');
parser.push('#EXTINF:10,\n');
parser.push('1.ts\n');
deepEqual({
allowCache: true,
mediaSequence: 0,
segments: [{ duration: 10, uri: '0.ts'},
{ duration: 10, uri: '1.ts' }]
}, m3u8.merge(base, parser.manifest), 'appends segment additions');
});
test('replaces segments when merging with a higher media sequence number', function() {
var base;
parser = new Parser();
parser.push('#EXTM3U\n');
parser.push('#EXT-X-MEDIA-SEQUENCE:3\n');
parser.push('#EXTINF:10,\n');
parser.push('3.ts\n');
base = parser.manifest;
parser = new Parser();
parser.push('#EXTM3U\n');
parser.push('#EXT-X-MEDIA-SEQUENCE:7\n');
parser.push('#EXTINF:10,\n');
parser.push('7.ts\n');
base = parser.manifest;
deepEqual({
allowCache: true,
mediaSequence: 7,
segments: [{ duration: 10, uri: '7.ts' }]
}, m3u8.merge(base, parser.manifest), 'replaces segments');
});
test('replaces overlapping segments when media sequence is present', function() {
var base;
parser = new Parser();
parser.push('#EXTM3U\n');
parser.push('#EXT-X-MEDIA-SEQUENCE:3\n');
parser.push('#EXTINF:10,\n');
parser.push('3.ts\n');
parser.push('#EXTINF:10,\n');
parser.push('4.ts\n');
base = parser.manifest;
parser = new Parser();
parser.push('#EXTM3U\n');
parser.push('#EXT-X-MEDIA-SEQUENCE:4\n');
parser.push('#EXTINF:10,\n');
parser.push('4.ts\n');
parser.push('#EXTINF:10,\n');
parser.push('5.ts\n');
base = parser.manifest;
deepEqual({
allowCache: true,
mediaSequence: 4,
segments: [{ duration: 10, uri: '4.ts' },
{ duration: 10, uri: '5.ts' }]
}, m3u8.merge(base, parser.manifest), 'replaces segments');
});
module('m3u8s');
test('parses static manifests as expected', function() {
......
......@@ -170,7 +170,7 @@ test('sets the duration if one is available on the playlist', function() {
type: 'sourceopen'
});
strictEqual(1, calls, 'duration is set');
strictEqual(calls, 2, 'duration is set');
});
test('calculates the duration if needed', function() {
......@@ -186,7 +186,7 @@ test('calculates the duration if needed', function() {
type: 'sourceopen'
});
strictEqual(durations.length, 1, 'duration is set');
strictEqual(durations.length, 2, 'duration is set');
strictEqual(durations[0],
player.hls.media.segments.length * 10,
'duration is calculated');
......@@ -402,15 +402,12 @@ test('downloads additional playlists if required', function() {
called = true;
return playlist;
}
playlist.segments = [];
playlist.segments = [1, 1, 1];
return playlist;
};
xhrUrls = [];
// the playlist selection is revisited after a new segment is downloaded
player.currentTime = function() {
return 1;
};
player.trigger('timeupdate');
strictEqual(2, xhrUrls.length, 'requests were made');
......@@ -869,7 +866,7 @@ test('segment 500 should trigger MEDIA_ERR_ABORTED', function () {
test('has no effect if native HLS is available', function() {
videojs.hls.supportsNativeHls = true;
player.hls('manifest/master.m3u8');
player.hls('http://example.com/manifest/master.m3u8');
ok(!(player.currentSrc() in videojs.mediaSources),
'no media source was opened');
......@@ -881,7 +878,7 @@ test('reloads live playlists', function() {
window.setTimeout = function(callback, timeout) {
callbacks.push({ callback: callback, timeout: timeout });
};
player.hls('manifest/missingEndlist.m3u8');
player.hls('http://example.com/manifest/missingEndlist.m3u8');
videojs.mediaSources[player.currentSrc()].trigger({
type: 'sourceopen'
});
......@@ -893,7 +890,7 @@ test('reloads live playlists', function() {
});
test('duration is Infinity for live playlists', function() {
player.hls('manifest/missingEndlist.m3u8');
player.hls('http://example.com/manifest/missingEndlist.m3u8');
videojs.mediaSources[player.currentSrc()].trigger({
type: 'sourceopen'
});
......@@ -943,27 +940,114 @@ test('reloads a live playlist after half a target duration if it has not ' +
test('merges playlist reloads', function() {
var
realMerge = videojs.m3u8.merge,
merges = 0,
oldPlaylist,
callback;
// capture timeouts and playlist merges
// capture timeouts
window.setTimeout = function(cb) {
callback = cb;
};
videojs.m3u8.merge = function(base, update) {
merges++;
return update;
};
player.hls('http://example.com/manifest/missingEndlist.m3u8');
videojs.mediaSources[player.currentSrc()].trigger({
type: 'sourceopen'
});
oldPlaylist = player.hls.media;
callback();
ok(oldPlaylist !== player.hls.media, 'player.hls.media was updated');
});
test('updates the media index when a playlist reloads', function() {
var callback;
window.setTimeout = function(cb) {
callback = cb;
};
// the initial playlist
window.manifests['live-updating'] =
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n' +
'#EXTINF:10,\n' +
'1.ts\n' +
'#EXTINF:10,\n' +
'2.ts\n';
player.hls('http://example.com/live-updating.m3u8');
videojs.mediaSources[player.currentSrc()].trigger({
type: 'sourceopen'
});
// play the stream until 2.ts is playing
player.hls.mediaIndex = 3;
// reload the updated playlist
window.manifests['live-updating'] =
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'1.ts\n' +
'#EXTINF:10,\n' +
'2.ts\n' +
'#EXTINF:10,\n' +
'3.ts\n';
callback();
strictEqual(1, merges, 'reloaded playlist was merged');
videojs.m3u8.merge = realMerge;
strictEqual(2, player.hls.mediaIndex, 'mediaIndex is updated after the reload');
});
test('mediaIndex is zero before the first segment loads', function() {
window.manifests['first-seg-load'] =
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n';
window.XMLHttpRequest = function() {
this.open = function() {};
this.send = function() {};
};
player.hls('http://example.com/first-seg-load.m3u8');
videojs.mediaSources[player.currentSrc()].trigger({
type: 'sourceopen'
});
strictEqual(player.hls.mediaIndex, 0, 'mediaIndex is zero');
});
test('reloads out-of-date live playlists when switching variants', function() {
var callback;
// capture timeouts
window.setTimeout = function(cb) {
callback = cb;
};
player.hls('http://example.com/master.m3u8');
videojs.mediaSources[player.currentSrc()].trigger({
type: 'sourceopen'
});
// playing segment 15 on playlist 0
player.hls.master = {
playlists: [{
mediaSequence: 15,
segments: [{}, {}]
}, {
uri: 'http://example.com/variant-update.m3u8',
mediaSequence: 0,
segments: [{}, {}]
}]
};
player.hls.media = player.hls.master.playlists[0];
player.mediaIndex = 0;
window.manifests['variant-update'] = '#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:16\n' +
'#EXTINF:10,\n' +
'16.ts\n';
// switch playlists
player.hls.selectPlaylist = function() {
return player.hls.master.playlists[1];
};
player.trigger('timeupdate');
ok(callback, 'reload is scheduled');
});
})(window, window.videojs);
......