abfea271 by Gary Katsevman

Merge branch 'master' into saucelabs-take2

Conflicts:
	.travis.yml
2 parents 2279f842 58952618
.DS_Store
dist/*
/node_modules/
*~
*.iml
......
dist/*
*~
*.iml
*.swp
tmp/**
test/**
\ No newline at end of file
......@@ -9,7 +9,10 @@ notifications:
hipchat:
rooms:
secure: l5TTd5JuPAW883PtcyaIBcJI9Chr9JpsZPQAEUBKAgIEwzuS6y7t5arlkS1PwH6gi1FADzYDf+OXSIou4GkTSrIetnBcT/SAgF0gBKgIhj+eRkuCfZ4VaC7BPhfZ0hgYRE+5Ejf5BM2MJafRm0pj7OlqG4xKrQZwtuV1te5r3JY=
irc: chat.freenode.net#videojs
irc:
channels:
- "chat.freenode.net#videojs"
use_notice: true
env:
global:
- secure: dM7svnHPPu5IiUMeFWW5zg+iuWNpwt6SSDi3MmVvhSclNMRLesQoRB+7Qq5J/LiKhmjpv1/GlNVV0CTsHMRhZNwQ3fo38eEuTXv99aAflEITXwSEh/VntKViHbGFubn06EnVkJoH6MX3zJ6kbiwc2QdSQbywKzS6l6quUEpWpd0=
......
......@@ -24,7 +24,6 @@ module.exports = function(grunt) {
dist: {
nonull: true,
src: ['src/videojs-hls.js',
'src/async-queue.js',
'src/flv-tag.js',
'src/exp-golomb.js',
'src/h264-stream.js',
......
[![Build Status](https://travis-ci.org/videojs/videojs-contrib-hls.png)](https://travis-ci.org/videojs/videojs-contrib-hls)
# video.js HLS Plugin
A video.js plugin that plays HLS video on platforms that don't support it but have Flash.
[![Build Status](https://travis-ci.org/videojs/videojs-contrib-hls.svg?branch=master)](https://travis-ci.org/videojs/videojs-contrib-hls)
## Getting Started
Download the [plugin](https://github.com/videojs/videojs-contrib-hls/releases). On your web page:
......@@ -47,9 +47,26 @@ support for:
- Alternate audio and video tracks
- Subtitles
- Segment codecs _other than_ H.264 with AAC audio
- Live streams
- Internet Explorer < 10
### Plugin Options
You may pass in an options object to the hls plugin upon initialization. This
object may contain one of the following properties:
#### withCredentials
Type: `boolean`
When the `withCredentials` property is set to `true`, all XHR requests for
manifests and segments would have `withCredentials` set to `true` as well. This
enables storing and passing cookies from the server that the manifests and
segments live on. This has some implications on CORS because when set, the
`Access-Control-Allow-Origin` header cannot be set to `*`, also, the response
headers require the addition of `Access-Control-Allow-Credentials` header which
is set to `true`.
See html5rocks's [article](http://www.html5rocks.com/en/tutorials/cors/)
for more info.
### Runtime Properties
#### player.hls.master
Type: `object`
......@@ -119,6 +136,8 @@ bandwidth and viewport dimensions.
- [Best RESOLUTION variant] OR [Best BANDWIDTH variant] OR [inital playlist in manifest]
## Release History
- 0.5.0: cookie-based content protection support (see `withCredentials`)
- 0.4.0: Live stream support
- 0.3.0: Performance fixes for high-bitrate streams
- 0.2.0: Basic playback and adaptive bitrate selection
- 0.1.0: Initial release
......
......@@ -10,11 +10,10 @@
<script src="node_modules/video.js/dist/video-js/video.js"></script>
<!-- Media Sources plugin -->
<script src="node_modules/videojs-contrib-media-sources/videojs-media-sources.js"></script>
<script src="node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js"></script>
<!-- HLS plugin -->
<script src="src/videojs-hls.js"></script>
<script src="src/async-queue.js"></script>
<!-- segment handling -->
<script src="src/flv-tag.js"></script>
......
{
"name": "videojs-contrib-hls",
"version": "0.3.2",
"version": "0.5.0",
"engines": {
"node": ">= 0.10.12"
},
......@@ -13,33 +13,34 @@
"test": "grunt test"
},
"devDependencies": {
"grunt": "~0.4.1",
"grunt-concurrent": "0.4.3",
"grunt-contrib-clean": "~0.4.0",
"grunt-contrib-concat": "~0.3.0",
"grunt-contrib-connect": "~0.6.0",
"grunt-contrib-jshint": "~0.6.0",
"grunt-contrib-qunit": "~0.2.0",
"grunt-contrib-concat": "~0.3.0",
"grunt-contrib-uglify": "~0.2.0",
"grunt-contrib-watch": "~0.4.0",
"grunt-contrib-clean": "~0.4.0",
"grunt-contrib-connect": "~0.6.0",
"grunt-concurrent": "0.4.3",
"grunt-karma": "~0.6.2",
"grunt-open": "0.2.3",
"grunt-shell": "0.6.1",
"grunt": "~0.4.1",
"grunt-karma": "~0.6.2",
"karma": "~0.10.0",
"karma-sauce-launcher": "~0.1.8",
"karma-chrome-launcher": "~0.1.2",
"karma-firefox-launcher": "~0.1.3",
"karma-ie-launcher": "~0.1.1",
"karma-opera-launcher": "~0.1.0",
"karma-phantomjs-launcher": "~0.1.1",
"karma-safari-launcher": "~0.1.1",
"karma-qunit": "~0.1.1",
"karma-safari-launcher": "~0.1.1",
"karma-sauce-launcher": "~0.1.8",
"sinon": "^1.9.1",
"video.js": "^4.5"
},
"peerDependencies": {
"video.js": "^4.5"
},
"dependencies": {
"videojs-contrib-media-sources": "git+https://github.com/videojs/videojs-contrib-media-sources.git"
"videojs-contrib-media-sources": "^0.2"
}
}
......
(function(window, videojs, undefined) {
'use strict';
/**
* A queue object that manages tasks that should be processed
* serially but asynchronously. Loosely adapted from
* https://github.com/caolan/async#queue.
* @param worker {function} the callback to invoke with each value
* pushed onto the queue
* @return {object} an object with an array of `tasks` that remain to
* be processed and function `push` to add new tasks
*/
videojs.hls.queue = function(worker) {
var
q = {
tasks: [],
running: false,
push: function(task) {
q.tasks.push(task);
if (!q.running) {
window.setTimeout(process, 0);
q.running = true;
}
}
},
process = function() {
var task;
if (q.tasks.length) {
task = q.tasks.shift();
worker.call(this, task);
window.setTimeout(process, 0);
} else {
q.running = false;
}
};
return q;
};
})(window, window.videojs);
......@@ -31,6 +31,9 @@ videojs.hls = {
};
var
settings,
// the desired length of video to maintain in the buffer, in seconds
goalBufferLength = 5,
......@@ -109,12 +112,26 @@ var
method: 'GET'
},
request;
if (typeof callback !== 'function') {
callback = function() {};
}
if (typeof url === 'object') {
options = videojs.util.mergeOptions(options, url);
url = options.url;
}
request = new window.XMLHttpRequest();
request.open(options.method, url);
if (options.responseType) {
request.responseType = options.responseType;
}
if (settings.withCredentials) {
request.withCredentials = true;
}
request.onreadystatechange = function() {
// wait until the request completes
if (this.readyState !== 4) {
......@@ -204,13 +221,8 @@ var
totalDuration = function(playlist) {
var
duration = 0,
i,
segment;
if (!playlist.segments) {
return 0;
}
i = playlist.segments.length;
segment,
i = (playlist.segments || []).length;
// if present, use the duration specified in the playlist
if (playlist.totalDuration) {
......@@ -277,28 +289,22 @@ var
mediaSource = new videojs.MediaSource(),
segmentParser = new videojs.hls.SegmentParser(),
player = this,
// async queue of Uint8Arrays to be appended to the SourceBuffer
tags = videojs.hls.queue(function(tag) {
player.hls.sourceBuffer.appendBuffer(tag, player);
if (player.hls.mediaIndex === player.hls.media.segments.length) {
mediaSource.endOfStream();
}
}),
srcUrl,
playlistXhr,
segmentXhr,
loadedPlaylist,
fillBuffer,
updateCurrentPlaylist;
updateCurrentPlaylist,
updateDuration;
// if the video element supports HLS natively, do nothing
if (videojs.hls.supportsNativeHls) {
return;
}
settings = videojs.util.mergeOptions({}, options);
srcUrl = (function() {
var
extname,
......@@ -312,7 +318,7 @@ var
// use the URL specified in options if one was provided
if (typeof options === 'string') {
return options;
} else if (options) {
} else if (options && options.url) {
return options.url;
}
......@@ -370,17 +376,35 @@ var
var currentTime = player.currentTime();
player.hls.mediaIndex = getMediaIndexByTime(player.hls.media, currentTime);
// abort any segments still being decoded
player.hls.sourceBuffer.abort();
// cancel outstanding requests and buffer appends
if (segmentXhr) {
segmentXhr.abort();
}
tags.tasks = [];
// begin filling the buffer at the new position
fillBuffer(currentTime * 1000);
});
/**
* Update the player duration
*/
updateDuration = function(playlist) {
var tech;
// update the duration
player.duration(totalDuration(playlist));
// tell the flash tech of the new duration
tech = player.el().querySelector('.vjs-tech');
if(tech.vjs_setProperty) {
tech.vjs_setProperty('duration', player.duration());
}
// manually fire the duration change
player.trigger('durationchange');
};
/**
* 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
......@@ -406,8 +430,7 @@ var
playlist);
player.hls.media = playlist;
// update the duration
player.duration(totalDuration(player.hls.media));
updateDuration(player.hls.media);
}
};
......@@ -558,7 +581,7 @@ var
player.hls.media = player.hls.master.playlists[0];
// update the duration
player.duration(totalDuration(parser.manifest));
updateDuration(parser.manifest);
// periodicaly check if the buffer needs to be refilled
player.on('timeupdate', fillBuffer);
......@@ -585,7 +608,7 @@ var
var
buffered = player.buffered(),
bufferedTime = 0,
segment = player.hls.media.segments[player.hls.mediaIndex],
segment,
segmentUri,
startTime;
......@@ -594,7 +617,13 @@ var
return;
}
// if no segments are available, do nothing
if (!player.hls.media.segments) {
return;
}
// if the video has finished downloading, stop trying to buffer
segment = player.hls.media.segments[player.hls.mediaIndex];
if (!segment) {
return;
}
......@@ -617,24 +646,20 @@ var
segment.uri);
}
// request the next segment
segmentXhr = new window.XMLHttpRequest();
segmentXhr.open('GET', segmentUri);
segmentXhr.responseType = 'arraybuffer';
segmentXhr.onreadystatechange = function() {
// wait until the request completes
if (this.readyState !== 4) {
return;
}
startTime = +new Date();
// request the next segment
segmentXhr = xhr({
url: segmentUri,
responseType: 'arraybuffer'
}, function(error, url) {
// the segment request is no longer outstanding
segmentXhr = null;
// trigger an error if the request was not successful
if (this.status >= 400) {
if (error) {
player.hls.error = {
status: this.status,
message: 'HLS segment request error at URL: ' + segmentUri,
message: 'HLS segment request error at URL: ' + url,
code: (this.status >= 500) ? 4 : 2
};
......@@ -669,17 +694,22 @@ var
// queue up the bytes to be appended to the SourceBuffer
// the queue gives control back to the browser between tags
// so that large segments don't cause a "hiccup" in playback
tags.push(segmentParser.getNextTag().bytes);
player.hls.sourceBuffer.appendBuffer(segmentParser.getNextTag().bytes,
player);
}
player.hls.mediaIndex++;
if (player.hls.mediaIndex === player.hls.media.segments.length) {
mediaSource.endOfStream();
}
// figure out what stream the next segment should be downloaded from
// with the updated bandwidth information
updateCurrentPlaylist();
};
startTime = +new Date();
segmentXhr.send(null);
});
};
// load the MediaSource into the player
......
......@@ -28,6 +28,7 @@
"strictEqual",
"notStrictEqual",
"throws",
"sinon",
"process"
]
}
......
(function(window, queue, undefined) {
var
oldSetTimeout,
callbacks;
module('async queue', {
setup: function() {
oldSetTimeout = window.setTimeout;
callbacks = [];
window.setTimeout = function(callback) {
callbacks.push(callback);
};
},
teardown: function() {
window.setTimeout = oldSetTimeout;
}
});
test('runs tasks asynchronously', function() {
var
run = false,
q = queue(function() {
run = true;
});
q.push(1);
ok(!run, 'tasks are not run immediately');
callbacks[0]();
ok(run, 'tasks are run asynchronously');
});
test('runs one task at a time', function() {
var q = queue(function() {});
q.push(1);
q.push(2);
q.push(3);
q.push(4);
q.push(5);
strictEqual(q.tasks.length, 5, 'all tasks are queued');
strictEqual(1, callbacks.length, 'one callback is registered');
});
test('tasks are scheduled until the queue is empty', function() {
var q = queue(function() {});
q.push(1);
q.push(2);
callbacks.shift()();
strictEqual(1, callbacks.length, 'the next task is scheduled');
callbacks.shift()();
strictEqual(1, callbacks.length, 'nothing is scheduled on an empty queue');
});
test('can be emptied at any time', function() {
var
runs = 0,
q = queue(function() {
runs++;
});
q.push(1);
q.push(2);
callbacks.shift()();
strictEqual(1, runs, 'task one is run');
q.tasks = [];
callbacks.shift()();
strictEqual(1, runs, 'the remaining tasks are cancelled');
});
})(window, window.videojs.hls.queue);
......@@ -40,7 +40,7 @@ module.exports = function(config) {
files: [
'../node_modules/video.js/dist/video-js/video.js',
'../node_modules/videojs-contrib-media-sources/videojs-media-sources.js',
'../node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js',
'../test/karma-qunit-shim.js',
"../src/videojs-hls.js",
"../src/flv-tag.js",
......
......@@ -35,7 +35,7 @@ module.exports = function(config) {
files: [
'../node_modules/video.js/dist/video-js/video.js',
'../node_modules/videojs-contrib-media-sources/videojs-media-sources.js',
'../node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js',
'../test/karma-qunit-shim.js',
"../src/videojs-hls.js",
"../src/flv-tag.js",
......@@ -93,4 +93,4 @@ module.exports = function(config) {
// If browser does not capture in given timeout [ms], kill it
captureTimeout: 60000
});
};
\ No newline at end of file
};
......
......@@ -3,13 +3,19 @@
<head>
<meta charset="utf-8">
<title>video.js HLS Plugin Test Suite</title>
<!-- Load sinon server for fakeXHR -->
<script src="../node_modules/sinon/lib/sinon.js"></script>
<script src="../node_modules/sinon/lib/sinon/util/event.js"></script>
<script src="../node_modules/sinon/lib/sinon/util/xhr_ie.js"></script>
<script src="../node_modules/sinon/lib/sinon/util/fake_xml_http_request.js"></script>
<!-- Load local QUnit. -->
<link rel="stylesheet" href="../libs/qunit/qunit.css" media="screen">
<script src="../libs/qunit/qunit.js"></script>
<!-- video.js -->
<script src="../node_modules/video.js/dist/video-js/video.js"></script>
<script src="../node_modules/videojs-contrib-media-sources/videojs-media-sources.js"></script>
<script src="../node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js"></script>
<!-- HLS plugin -->
<script src="../src/videojs-hls.js"></script>
......@@ -31,9 +37,6 @@
<script src="tsSegment-bc.js"></script>
<script src="../src/bin-utils.js"></script>
<!-- async queue -->
<script src="../src/async-queue.js"></script>
<!-- Test cases -->
<script>
module('environment');
......@@ -48,7 +51,6 @@
<script src="exp-golomb_test.js"></script>
<script src="flv-tag_test.js"></script>
<script src="m3u8_test.js"></script>
<script src="async-queue_test.js"></script>
</head>
<body>
<div id="qunit"></div>
......
......@@ -23,12 +23,40 @@
var
player,
oldFlashSupported,
oldXhr,
oldSegmentParser,
oldSetTimeout,
oldSourceBuffer,
oldSupportsNativeHls,
xhrUrls,
requests,
xhr,
standardXHRResponse = function(request) {
if (!request.url) {
return;
}
var contentType = "application/json",
// contents off the global object
manifestName = (/(?:.*\/)?(.*)\.m3u8/).exec(request.url);
if (manifestName) {
manifestName = manifestName[1];
} else {
manifestName = request.url;
}
if (/\.m3u8?/.test(request.url)) {
contentType = 'application/vnd.apple.mpegurl';
} else if (/\.ts/.test(request.url)) {
contentType = 'video/MP2T';
}
request.response = new Uint8Array([1]).buffer;
request.respond(200,
{'Content-Type': contentType},
window.manifests[manifestName]);
},
mockSegmentParser = function(tags) {
if (tags === undefined) {
......@@ -63,6 +91,7 @@ module('HLS', {
oldSourceBuffer = window.videojs.SourceBuffer;
window.videojs.SourceBuffer = function() {
this.appendBuffer = function() {};
this.abort = function() {};
};
// force native HLS to be ignored
......@@ -87,35 +116,21 @@ module('HLS', {
oldSetTimeout = window.setTimeout;
// make XHRs synchronous
oldXhr = window.XMLHttpRequest;
window.XMLHttpRequest = function() {
this.open = function(method, url) {
xhrUrls.push(url);
};
this.send = function() {
// if the request URL looks like one of the test manifests, grab the
// contents off the global object
var manifestName = (/(?:.*\/)?(.*)\.m3u8/).exec(xhrUrls.slice(-1)[0]);
if (manifestName) {
manifestName = manifestName[1];
}
this.responseText = window.manifests[manifestName || xhrUrls.slice(-1)[0]];
this.response = new Uint8Array([1]).buffer;
this.readyState = 4;
this.onreadystatechange();
};
this.abort = function() {};
xhr = sinon.useFakeXMLHttpRequest();
requests = [];
xhr.onCreate = function(xhr) {
requests.push(xhr);
};
xhrUrls = [];
},
teardown: function() {
videojs.Flash.isSupported = oldFlashSupported;
videojs.hls.supportsNativeHls = oldSupportsNativeHls;
videojs.hls.SegmentParser = oldSegmentParser;
videojs.SourceBuffer = oldSourceBuffer;
window.setTimeout = oldSetTimeout;
window.XMLHttpRequest = oldXhr;
xhr.restore();
}
});
......@@ -130,6 +145,7 @@ test('starts playing if autoplay is specified', function() {
type: 'sourceopen'
});
standardXHRResponse(requests[0]);
strictEqual(1, plays, 'play was called');
});
......@@ -147,6 +163,7 @@ test('loads the specified manifest URL on init', function() {
videojs.mediaSources[player.currentSrc()].trigger({
type: 'sourceopen'
});
standardXHRResponse(requests[0]);
ok(loadedmanifest, 'loadedmanifest fires');
ok(loadedmetadata, 'loadedmetadata fires');
ok(player.hls.master, 'a master is inferred');
......@@ -171,6 +188,8 @@ test('sets the duration if one is available on the playlist', function() {
type: 'sourceopen'
});
standardXHRResponse(requests[0]);
standardXHRResponse(requests[1]);
strictEqual(calls, 2, 'duration is set');
});
......@@ -187,6 +206,8 @@ test('calculates the duration if needed', function() {
type: 'sourceopen'
});
standardXHRResponse(requests[0]);
standardXHRResponse(requests[1]);
strictEqual(durations.length, 2, 'duration is set');
strictEqual(durations[0],
player.hls.media.segments.length * 10,
......@@ -202,7 +223,9 @@ test('starts downloading a segment on loadedmetadata', function() {
type: 'sourceopen'
});
strictEqual(xhrUrls[1],
standardXHRResponse(requests[0]);
standardXHRResponse(requests[1]);
strictEqual(requests[1].url,
window.location.origin +
window.location.pathname.split('/').slice(0, -1).join('/') +
'/manifest/00001.ts',
......@@ -215,7 +238,9 @@ test('recognizes absolute URIs and requests them unmodified', function() {
type: 'sourceopen'
});
strictEqual(xhrUrls[1],
standardXHRResponse(requests[0]);
standardXHRResponse(requests[1]);
strictEqual(requests[1].url,
'http://example.com/00001.ts',
'the first segment is requested');
});
......@@ -226,7 +251,9 @@ test('recognizes domain-relative URLs', function() {
type: 'sourceopen'
});
strictEqual(xhrUrls[1],
standardXHRResponse(requests[0]);
standardXHRResponse(requests[1]);
strictEqual(requests[1].url,
window.location.origin + '/00001.ts',
'the first segment is requested');
});
......@@ -272,13 +299,17 @@ test('downloads media playlists after loading the master', function() {
type: 'sourceopen'
});
strictEqual(xhrUrls[0], 'manifest/master.m3u8', 'master playlist requested');
strictEqual(xhrUrls[1],
standardXHRResponse(requests[0]);
standardXHRResponse(requests[1]);
standardXHRResponse(requests[2]);
strictEqual(requests[0].url, 'manifest/master.m3u8', 'master playlist requested');
strictEqual(requests[1].url,
window.location.origin +
window.location.pathname.split('/').slice(0, -1).join('/') +
'/manifest/media.m3u8',
'media playlist requested');
strictEqual(xhrUrls[2],
strictEqual(requests[2].url,
window.location.origin +
window.location.pathname.split('/').slice(0, -1).join('/') +
'/manifest/00001.ts',
......@@ -309,6 +340,9 @@ test('calculates the bandwidth after downloading a segment', function() {
type: 'sourceopen'
});
standardXHRResponse(requests[0]);
standardXHRResponse(requests[1]);
ok(player.hls.bandwidth, 'bandwidth is calculated');
ok(player.hls.bandwidth > 0,
'bandwidth is positive: ' + player.hls.bandwidth);
......@@ -327,6 +361,10 @@ test('selects a playlist after segment downloads', function() {
type: 'sourceopen'
});
standardXHRResponse(requests[0]);
standardXHRResponse(requests[1]);
standardXHRResponse(requests[2]);
strictEqual(calls, 1, 'selects after the initial segment');
player.currentTime = function() {
return 1;
......@@ -335,28 +373,26 @@ test('selects a playlist after segment downloads', function() {
return videojs.createTimeRange(0, 2);
};
player.trigger('timeupdate');
standardXHRResponse(requests[3]);
strictEqual(calls, 2, 'selects after additional segments');
});
test('moves to the next segment if there is a network error', function() {
var mediaIndex;
player.hls('manifest/master.m3u8');
videojs.mediaSources[player.currentSrc()].trigger({
type: 'sourceopen'
});
// fail the next segment request
window.XMLHttpRequest = function() {
this.open = function() {};
this.send = function() {
this.readyState = 4;
this.status = 400;
this.onreadystatechange();
};
};
standardXHRResponse(requests[0]);
standardXHRResponse(requests[1]);
mediaIndex = player.hls.mediaIndex;
player.trigger('timeupdate');
requests[2].respond(400);
strictEqual(mediaIndex + 1, player.hls.mediaIndex, 'media index is incremented');
});
......@@ -382,6 +418,10 @@ test('updates the duration after switching playlists', function() {
type: 'sourceopen'
});
standardXHRResponse(requests[0]);
standardXHRResponse(requests[1]);
standardXHRResponse(requests[2]);
standardXHRResponse(requests[3]);
ok(selectedPlaylist, 'selected playlist');
strictEqual(calls, 1, 'updates the duration');
});
......@@ -397,6 +437,8 @@ test('downloads additional playlists if required', function() {
type: 'sourceopen'
});
standardXHRResponse(requests[0]);
standardXHRResponse(requests[1]);
// before an m3u8 is downloaded, no segments are available
player.hls.selectPlaylist = function() {
if (!called) {
......@@ -406,13 +448,15 @@ test('downloads additional playlists if required', function() {
playlist.segments = [1, 1, 1];
return playlist;
};
xhrUrls = [];
// the playlist selection is revisited after a new segment is downloaded
player.trigger('timeupdate');
strictEqual(2, xhrUrls.length, 'requests were made');
strictEqual(xhrUrls[1],
standardXHRResponse(requests[2]);
standardXHRResponse(requests[3]);
strictEqual(4, requests.length, 'requests were made');
strictEqual(requests[3].url,
window.location.origin +
window.location.pathname.split('/').slice(0, -1).join('/') +
'/manifest/' +
......@@ -429,6 +473,8 @@ test('selects a playlist below the current bandwidth', function() {
type: 'sourceopen'
});
standardXHRResponse(requests[0]);
// the default playlist has a really high bitrate
player.hls.master.playlists[0].attributes.BANDWIDTH = 9e10;
// playlist 1 has a very low bitrate
......@@ -449,6 +495,8 @@ test('raises the minimum bitrate for a stream proportionially', function() {
type: 'sourceopen'
});
standardXHRResponse(requests[0]);
// the default playlist's bandwidth + 10% is equal to the current bandwidth
player.hls.master.playlists[0].attributes.BANDWIDTH = 10;
player.hls.bandwidth = 11;
......@@ -469,6 +517,8 @@ test('uses the lowest bitrate if no other is suitable', function() {
type: 'sourceopen'
});
standardXHRResponse(requests[0]);
// the lowest bitrate playlist is much greater than 1b/s
player.hls.bandwidth = 1;
playlist = player.hls.selectPlaylist();
......@@ -488,6 +538,8 @@ test('selects the correct rendition by player dimensions', function() {
type: 'sourceopen'
});
standardXHRResponse(requests[0]);
player.width(640);
player.height(360);
player.hls.bandwidth = 3000000;
......@@ -520,9 +572,12 @@ test('does not download the next segment if the buffer is full', function() {
videojs.mediaSources[player.currentSrc()].trigger({
type: 'sourceopen'
});
standardXHRResponse(requests[0]);
player.trigger('timeupdate');
strictEqual(xhrUrls.length, 1, 'no segment request was made');
strictEqual(requests.length, 1, 'no segment request was made');
});
test('downloads the next segment if the buffer is getting low', function() {
......@@ -530,7 +585,11 @@ test('downloads the next segment if the buffer is getting low', function() {
videojs.mediaSources[player.currentSrc()].trigger({
type: 'sourceopen'
});
strictEqual(xhrUrls.length, 2, 'did not make a request');
standardXHRResponse(requests[0]);
standardXHRResponse(requests[1]);
strictEqual(requests.length, 2, 'did not make a request');
player.currentTime = function() {
return 15;
};
......@@ -539,8 +598,10 @@ test('downloads the next segment if the buffer is getting low', function() {
};
player.trigger('timeupdate');
strictEqual(xhrUrls.length, 3, 'made a request');
strictEqual(xhrUrls[2],
standardXHRResponse(requests[2]);
strictEqual(requests.length, 3, 'made a request');
strictEqual(requests[2].url,
window.location.origin +
window.location.pathname.split('/').slice(0, -1).join('/') +
'/manifest/00002.ts',
......@@ -552,7 +613,8 @@ test('stops downloading segments at the end of the playlist', function() {
videojs.mediaSources[player.currentSrc()].trigger({
type: 'sourceopen'
});
xhrUrls = [];
standardXHRResponse(requests[0]);
requests = [];
player.hls.mediaIndex = 4;
player.trigger('timeupdate');
......@@ -565,6 +627,8 @@ test('only makes one segment request at a time', function() {
videojs.mediaSources[player.currentSrc()].trigger({
type: 'sourceopen'
});
xhr.restore();
var oldXHR = window.XMLHttpRequest;
// mock out a long-running XHR
window.XMLHttpRequest = function() {
this.send = function() {};
......@@ -572,11 +636,14 @@ test('only makes one segment request at a time', function() {
openedXhrs++;
};
};
standardXHRResponse(requests[0]);
player.trigger('timeupdate');
strictEqual(1, openedXhrs, 'one XHR is made');
player.trigger('timeupdate');
strictEqual(1, openedXhrs, 'only one XHR is made');
window.XMLHttpRequest = oldXHR;
xhr = sinon.useFakeXMLHttpRequest();
});
test('uses the src attribute if no options are provided and it ends in ".m3u8"', function() {
......@@ -587,7 +654,7 @@ test('uses the src attribute if no options are provided and it ends in ".m3u8"',
type: 'sourceopen'
});
strictEqual(url, xhrUrls[0], 'currentSrc is used');
strictEqual(requests[0].url, url, 'currentSrc is used');
});
test('ignores src attribute if it doesn\'t have the "m3u8" extension', function() {
......@@ -595,27 +662,27 @@ test('ignores src attribute if it doesn\'t have the "m3u8" extension', function(
tech.src = 'basdfasdfasdfliel//.m3u9';
player.hls();
ok(!(player.currentSrc() in videojs.mediaSources), 'no media source is created');
strictEqual(xhrUrls.length, 0, 'no request is made');
strictEqual(requests.length, 0, 'no request is made');
tech.src = '';
player.hls();
ok(!(player.currentSrc() in videojs.mediaSources), 'no media source is created');
strictEqual(xhrUrls.length, 0, 'no request is made');
strictEqual(requests.length, 0, 'no request is made');
tech.src = 'http://example.com/movie.mp4?q=why.m3u8';
player.hls();
ok(!(player.currentSrc() in videojs.mediaSources), 'no media source is created');
strictEqual(xhrUrls.length, 0, 'no request is made');
strictEqual(requests.length, 0, 'no request is made');
tech.src = 'http://example.m3u8/movie.mp4';
player.hls();
ok(!(player.currentSrc() in videojs.mediaSources), 'no media source is created');
strictEqual(xhrUrls.length, 0, 'no request is made');
strictEqual(requests.length, 0, 'no request is made');
tech.src = '//example.com/movie.mp4#http://tricky.com/master.m3u8';
player.hls();
ok(!(player.currentSrc() in videojs.mediaSources), 'no media source is created');
strictEqual(xhrUrls.length, 0, 'no request is made');
strictEqual(requests.length, 0, 'no request is made');
});
test('activates if the first playable source is HLS', function() {
......@@ -639,13 +706,11 @@ test('activates if the first playable source is HLS', function() {
});
test('cancels outstanding XHRs when seeking', function() {
var
aborted = false,
opened = 0;
player.hls('manifest/media.m3u8');
videojs.mediaSources[player.currentSrc()].trigger({
type: 'sourceopen'
});
standardXHRResponse(requests[0]);
player.hls.media = {
segments: [{
uri: '0.ts',
......@@ -656,27 +721,13 @@ test('cancels outstanding XHRs when seeking', function() {
}]
};
// XHR requests will never complete
window.XMLHttpRequest = function() {
this.open = function() {
opened++;
};
this.send = function() {};
this.abort = function() {
aborted = true;
this.readyState = 4;
this.status = 0;
this.onreadystatechange();
};
};
// trigger a segment download request
player.trigger('timeupdate');
opened = 0;
// attempt to seek while the download is in progress
player.trigger('seeking');
ok(aborted, 'XHR aborted');
strictEqual(1, opened, 'opened new XHR');
ok(requests[1].aborted, 'XHR aborted');
strictEqual(requests.length, 3, 'opened new XHR');
});
test('flushes the parser after each segment', function() {
......@@ -698,15 +749,16 @@ test('flushes the parser after each segment', function() {
type: 'sourceopen'
});
strictEqual(1, flushes, 'tags are flushed at the end of a segment');
standardXHRResponse(requests[0]);
standardXHRResponse(requests[1]);
strictEqual(flushes, 1, 'tags are flushed at the end of a segment');
});
test('drops tags before the target timestamp when seeking', function() {
var
i = 10,
callbacks = [],
tags = [],
bytes = [];
var i = 10,
callbacks = [],
tags = [],
bytes = [];
// mock out the parser and source buffer
videojs.hls.SegmentParser = mockSegmentParser(tags);
......@@ -714,6 +766,7 @@ test('drops tags before the target timestamp when seeking', function() {
this.appendBuffer = function(chunk) {
bytes.push(chunk);
};
this.abort = function() {};
};
// capture timeouts
window.setTimeout = function(callback) {
......@@ -727,6 +780,8 @@ test('drops tags before the target timestamp when seeking', function() {
videojs.mediaSources[player.currentSrc()].trigger({
type: 'sourceopen'
});
standardXHRResponse(requests[0]);
standardXHRResponse(requests[1]);
while (callbacks.length) {
callbacks.shift()();
}
......@@ -743,6 +798,7 @@ test('drops tags before the target timestamp when seeking', function() {
return 7;
};
player.trigger('seeking');
standardXHRResponse(requests[2]);
while (callbacks.length) {
callbacks.shift()();
......@@ -755,13 +811,18 @@ test('clears pending buffer updates when seeking', function() {
var
bytes = [],
callbacks = [],
aborts = 0,
tags = [{ pts: 0, bytes: 0 }];
// mock out the parser and source buffer
videojs.hls.SegmentParser = mockSegmentParser(tags);
window.videojs.SourceBuffer = function() {
this.appendBuffer = function(chunk) {
bytes.push(chunk);
};
this.abort = function() {
aborts++;
};
};
// capture timeouts
window.setTimeout = function(callback) {
......@@ -774,18 +835,22 @@ test('clears pending buffer updates when seeking', function() {
type: 'sourceopen'
});
standardXHRResponse(requests[0]);
standardXHRResponse(requests[1]);
// seek to 7s
tags.push({ pts: 7000, bytes: 7 });
player.currentTime = function() {
return 7;
};
player.trigger('seeking');
standardXHRResponse(requests[2]);
while (callbacks.length) {
callbacks.shift()();
}
deepEqual(bytes, ['flv', 7], 'tags queued to be appended should be cancelled');
strictEqual(1, aborts, 'aborted pending buffer');
});
test('playlist 404 should trigger MEDIA_ERR_NETWORK', function() {
......@@ -820,23 +885,12 @@ test('playlist 404 should trigger MEDIA_ERR_NETWORK', function() {
test('segment 404 should trigger MEDIA_ERR_NETWORK', function () {
player.hls('manifest/media.m3u8');
player.on('loadedmanifest', function () {
window.XMLHttpRequest = function () {
this.open = function (method, url) {
xhrUrls.push(url);
};
this.send = function () {
this.readyState = 4;
this.status = 404;
this.onreadystatechange();
};
};
});
videojs.mediaSources[player.currentSrc()].trigger({
type: 'sourceopen'
});
standardXHRResponse(requests[0]);
requests[1].respond(404);
ok(player.hls.error.message, 'an error message is available');
equal(2, player.hls.error.code, 'Player error code should be set to MediaError.MEDIA_ERR_NETWORK');
});
......@@ -844,23 +898,12 @@ test('segment 404 should trigger MEDIA_ERR_NETWORK', function () {
test('segment 500 should trigger MEDIA_ERR_ABORTED', function () {
player.hls('manifest/media.m3u8');
player.on('loadedmanifest', function () {
window.XMLHttpRequest = function () {
this.open = function (method, url) {
xhrUrls.push(url);
};
this.send = function () {
this.readyState = 4;
this.status = 500;
this.onreadystatechange();
};
};
});
videojs.mediaSources[player.currentSrc()].trigger({
type: 'sourceopen'
});
standardXHRResponse(requests[0]);
requests[1].respond(500);
ok(player.hls.error.message, 'an error message is available');
equal(4, player.hls.error.code, 'Player error code should be set to MediaError.MEDIA_ERR_ABORTED');
});
......@@ -883,6 +926,7 @@ test('reloads live playlists', function() {
videojs.mediaSources[player.currentSrc()].trigger({
type: 'sourceopen'
});
standardXHRResponse(requests[0]);
strictEqual(1, callbacks.length, 'refresh was scheduled');
strictEqual(player.hls.media.targetDuration * 1000,
......@@ -896,7 +940,9 @@ test('duration is Infinity for live playlists', function() {
type: 'sourceopen'
});
strictEqual(Infinity, player.duration(), 'duration is infinity');
standardXHRResponse(requests[0]);
strictEqual(player.duration(), Infinity, 'duration is infinity');
});
test('does not reload playlists with an endlist tag', function() {
......@@ -925,19 +971,22 @@ test('reloads a live playlist after half a target duration if it has not ' +
type: 'sourceopen'
});
standardXHRResponse(requests[0]);
standardXHRResponse(requests[1]);
strictEqual(callbacks.length, 1, 'full-length refresh scheduled');
callbacks.pop().callback();
standardXHRResponse(requests[2]);
strictEqual(1, callbacks.length, 'half-length refresh was scheduled');
strictEqual(callbacks.length, 1, 'half-length refresh was scheduled');
strictEqual(callbacks[0].timeout,
player.hls.media.targetDuration / 2 * 1000,
'waited half a target duration');
});
test('merges playlist reloads', function() {
var
oldPlaylist,
callback;
var oldPlaylist,
callback;
// capture timeouts
window.setTimeout = function(cb) {
callback = cb;
......@@ -947,9 +996,12 @@ test('merges playlist reloads', function() {
videojs.mediaSources[player.currentSrc()].trigger({
type: 'sourceopen'
});
standardXHRResponse(requests[0]);
standardXHRResponse(requests[1]);
oldPlaylist = player.hls.media;
callback();
standardXHRResponse(requests[2]);
ok(oldPlaylist !== player.hls.media, 'player.hls.media was updated');
});
......@@ -973,6 +1025,8 @@ test('updates the media index when a playlist reloads', function() {
type: 'sourceopen'
});
standardXHRResponse(requests[0]);
standardXHRResponse(requests[1]);
// play the stream until 2.ts is playing
player.hls.mediaIndex = 3;
......@@ -986,6 +1040,7 @@ test('updates the media index when a playlist reloads', function() {
'#EXTINF:10,\n' +
'3.ts\n';
callback();
standardXHRResponse(requests[2]);
strictEqual(player.hls.mediaIndex, 2, 'mediaIndex is updated after the reload');
});
......@@ -1058,7 +1113,21 @@ test('does not reload master playlists', function() {
});
test('only reloads the active media playlist', function() {
var callbacks = [], urls = [], responses = [];
var callbacks = [],
i = 0,
filteredRequests = [],
customResponse;
customResponse = function(request) {
request.response = new Uint8Array([1]).buffer;
request.respond(200,
{'Content-Type': 'application/vnd.apple.mpegurl'},
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1\n' +
'#EXTINF:10,\n' +
'1.ts\n');
};
window.setTimeout = function(callback) {
callbacks.push(callback);
};
......@@ -1068,23 +1137,11 @@ test('only reloads the active media playlist', function() {
type: 'sourceopen'
});
window.XMLHttpRequest = function() {
this.open = function(method, url) {
urls.push(url);
};
this.send = function() {
var xhr = this;
responses.push(function() {
xhr.readyState = 4;
xhr.responseText = '#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1\n' +
'#EXTINF:10,\n' +
'1.ts\n';
xhr.response = new Uint8Array([1]).buffer;
xhr.onreadystatechange();
});
};
};
standardXHRResponse(requests[0]);
standardXHRResponse(requests[1]);
videojs.mediaSources[player.currentSrc()].endOfStream = function() {};
player.hls.selectPlaylist = function() {
return player.hls.master.playlists[1];
};
......@@ -1094,19 +1151,57 @@ test('only reloads the active media playlist', function() {
player.trigger('timeupdate');
strictEqual(callbacks.length, 1, 'a refresh is scheduled');
strictEqual(responses.length, 1, 'segment requested');
responses.shift()(); // segment response
responses.shift()(); // loaded switched.m3u8
standardXHRResponse(requests[2]); // segment response
customResponse(requests[3]); // loaded witched.m3u8
urls = [];
callbacks.shift()(); // out-of-date refresh of missingEndlist.m3u8
callbacks.shift()(); // refresh switched.m3u8
strictEqual(urls.length, 1, 'one refresh was made');
strictEqual(urls[0],
for (; i < requests.length; i++) {
if (/switched/.test(requests[i].url)) {
filteredRequests.push(requests[i]);
}
}
strictEqual(filteredRequests.length, 2, 'one refresh was made');
strictEqual(filteredRequests[1].url,
'http://example.com/switched.m3u8',
'refreshed the active playlist');
});
test('if withCredentials option is used, withCredentials is set on the XHR object', function() {
player.hls({
url: 'http://example.com/media.m3u8',
withCredentials: true
});
videojs.mediaSources[player.currentSrc()].trigger({
type: 'sourceopen'
});
ok(requests[0].withCredentials, "with credentials should be set to true if that option is passed in");
});
test('does not break if the playlist has no segments', function() {
var customResponse = function(request) {
request.response = new Uint8Array([1]).buffer;
request.respond(200,
{'Content-Type': 'application/vnd.apple.mpegurl'},
'#EXTM3U\n' +
'#EXT-X-PLAYLIST-TYPE:VOD\n' +
'#EXT-X-TARGETDURATION:10\n');
};
player.hls('manifest/master.m3u8');
try {
videojs.mediaSources[player.currentSrc()].trigger({
type: 'sourceopen'
});
customResponse(requests[0]);
} catch(e) {
ok(false, 'an error was thrown');
throw e;
}
ok(true, 'no error was thrown');
strictEqual(requests.length, 1, 'no requests for non-existent segments were queued');
});
})(window, window.videojs);
......