2cce0c18 by Jon-Carlos Rivera

Multiple audio track support (#681)

* Support for multiple alternate audio tracks

* Separated segment loading logic into a reusable piece of functionality so that we can have more than one segment loader and they can manage the segment fetch behavior unique to each playlist they are loading from

* Introduced the MasterPlaylistController to coordinate the loading of the master playlist and separate the behavior of the player from videojs-contrib-hls.js leaving the latter to be the glue between HLS and the video element's events

* Added the SourceUpdater to manage the asynchronous bahavior of SourceBuffers and MediaSource objects so that we can treat it as a non-blocking work queue

* Added parsing for MediaGroups in master playlists

* Added support for AudioTrackList objects and events

* Add support for every HLS mime type possible (#684)

* Flash live fixes (#682)
1 parent d33786f1
Showing 93 changed files with 7273 additions and 2238 deletions
sudo: false
sudo: required
dist: trusty
language: node_js
addons:
firefox: "latest"
node_js:
- "stable"
notifications:
......@@ -14,6 +13,7 @@ notifications:
use_notice: true
# Set up a virtual screen for Firefox.
before_script:
- export CHROME_BIN=/usr/bin/google-chrome
- export DISPLAY=:99.0
- sh -e /etc/init.d/xvfb start
env:
......@@ -22,4 +22,8 @@ env:
- secure: AnduYGXka5ft1x7V3SuVYqvlKLvJGhUaRNFdy4UDJr3ZVuwpQjE4TMDG8REmJIJvXfHbh4qY4N1cFSGnXkZ4bH21Xk0v9DLhsxbarKz+X2BvPgXs+Af9EQ6vLEy/5S1vMLxfT5+y+Ec5bVNGOsdUZby8Y21CRzSg6ADN9kwPGlE=
addons:
sauce_connect: true
firefox: latest
apt:
sources:
- google-chrome
packages:
- google-chrome-stable
......
......@@ -9,6 +9,8 @@ Play back HLS with video.js, even where it's not natively supported.
**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)*
- [Getting Started](#getting-started)
- [Known Issues](#known-issues)
- [IE11](#ie11)
- [Documentation](#documentation)
- [Options](#options)
- [withCredentials](#withcredentials)
......@@ -44,7 +46,7 @@ and include it in your page along with video.js:
type="application/x-mpegURL">
</video>
<script src="video.js"></script>
<script src="videojs-hls.min.js"></script>
<script src="videojs-contrib-hls.min.js"></script>
<script>
var player = videojs('example-video');
player.play();
......@@ -53,6 +55,16 @@ player.play();
Check out our [live example](http://videojs.github.io/videojs-contrib-hls/) if you're having trouble.
## Known Issues
Issues that are currenty know about with workarounds. If you want to
help find a solution that would be appreciated!
### IE11
In some IE11 setups there are issues working with it's native HTML
SourceBuffers functionality. This leads to various issues, such as
videos stopping playback with media decode errors. The known workaround
for this issues is to force the player to use flash when running on IE11.
## Documentation
[HTTP Live Streaming](https://developer.apple.com/streaming/) (HLS) has
become a de-facto standard for streaming video on mobile devices
......@@ -89,8 +101,7 @@ are some highlights:
- mid-segment quality switching
- AES-128 segment encryption
- CEA-608 captions are automatically translated into standard HTML5
[caption text
tracks](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/track)
[caption text tracks][0]
- Timed ID3 Metadata is automatically translated into HTML5 metedata
text tracks
- Highly customizable adaptive bitrate selection
......@@ -98,6 +109,10 @@ are some highlights:
- Cross-domain credentials support with CORS
- Tight integration with video.js and a philosophy of exposing as much
as possible with standard HTML APIs
- Stream with multiple audio tracks and switching to those audio tracks
(see the docs folder) for info
[0]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/track
### Options
......@@ -106,11 +121,22 @@ initialization. You can pass in options just like you would for other
parts of video.js:
```javascript
videojs(video, {
// html5 for html hls
videojs(video, {html5: {
hls: {
withCredentials: true
}
});
}});
// or
// flash for flash hls
videojs(video, {flash: {
hls: {
withCredentials: true
}
}});
```
#### withCredentials
......@@ -289,26 +315,8 @@ and most CDNs should have no trouble turning CORS on for your account.
### Testing
For testing, you can either run `npm test` or use `grunt` directly.
If you use `npm test`, it will only run the karma and end-to-end tests using chrome.
You can specify which browsers you want the tests to run via grunt's `test` task.
You can use either grunt-style arguments or comma separated arguments:
```
grunt test:chrome:firefox # grunt-style
grunt test:chrome,firefox # comma-separated
```
Possible options are:
* `chromecanary`
* `phantomjs`
* `opera`
* `chrome`<sup>1</sup>
* `safari`<sup>1, 2</sup>
* `firefox`<sup>1</sup>
* `ie`<sup>1</sup>
_<sup>1</sup>supported end-to-end browsers_<br />
_<sup>2</sup>requires the [SafariDriver extension]( https://code.google.com/p/selenium/wiki/SafariDriver) to be installed_
For testing, you run `npm run test`. This will run tests using any of the
browsers that karma-detect-browsers detects on your machine.
## Release History
Check out the [changelog](CHANGELOG.md) for a summary of each release.
......
# Multiple Alternative Audio Tracks
## General
m3u8 manifests with multiple audio streams will have those streams added to `video.js` in an `AudioTrackList`. The `AudioTrackList` can be accessed using `player.audioTracks()` or `tech.audioTracks()`.
## Mapping m3u8 metadata to AudioTracks
The mapping between `AudioTrack` and the parsed m3u8 file is fairly straight forward. The table below shows the mapping
| m3u8 | AudioTrack |
|---------|------------|
| label | label |
| lang | language |
| default | enabled |
| ??? | kind |
| ??? | id |
As you can see m3u8's do not have a property for `AudioTrack.id`, which means that we let `video.js` randomly generate the id for `AudioTrack`s. This will have no real impact on any part of the system as we do not use the `id` anywhere.
The other property that does not have a mapping in the m3u8 is `AudioTrack.kind`. It was decided that we would set the `kind` to `main` when `default` is set to `true` and in all other cases we set it to `alternative`
Below is a basic example of a mapping
m3u8 layout
``` JavaScript
{
'media-group-1': [{
'audio-track-1': {
default: true,
lang: 'eng'
},
'audio-track-2': {
default: true,
lang: 'fr'
}
}]
}
```
Corresponding AudioTrackList when media-group-1 is used (before any tracks have been changed)
``` JavaScript
[{
label: 'audio-tracks-1',
enabled: true,
language: 'eng',
kind: 'main',
id: 'random'
}, {
label: 'audio-tracks-2',
enabled: false,
language: 'fr',
kind: 'alternative',
id: 'random'
}]
```
## Startup (how tracks are added and used)
> AudioTrack & AudioTrackList live in video.js
1. `HLS` creates a `MasterPlaylistController` and watches for the `loadedmetadata` event
1. `HLS` parses the m3u8 using the `MasterPlaylistController`
1. `MasterPlaylistController` creates a `PlaylistLoader` for the master m3u8
1. `MasterPlaylistController` creates `PlaylistLoader`s for every audio playlist
1. `MasterPlaylistController` creates a `SegmentLoader` for the main m3u8
1. `MasterPlaylistController` creates a `SegmentLoader` for a potential audio playlist
1. `HLS` sees the `loadedmetadata` and finds the currently selected MediaGroup and all the metadata
1. `HLS` removes all `AudioTrack`s from the `AudioTrackList`
1. `HLS` created `AudioTrack`s for the MediaGroup and adds them to the `AudioTrackList`
1. `HLS` calls `MasterPlaylistController`s `useAudio` with no arguments (causes it to use the currently enabled audio)
1. `MasterPlaylistController` turns off the current audio `PlaylistLoader` if it is on
1. `MasterPlaylistController` maps the `label` to the `PlaylistLoader` containing the audio
1. `MasterPlaylistController` turns on that `PlaylistLoader` and the Corresponding `SegmentLoader` (master or audio only)
1. `MediaSource`/`mux.js` determine how to mux
## How tracks are switched
> AudioTrack & AudioTrackList live in video.js
1. `HLS` is setup to watch for the `changed` event on the `AudioTrackList`
1. User selects a new `AudioTrack` from a menu (where only one track can be enabled)
1. `AudioTrackList` enables the new `Audiotrack` and disables all others
1. `AudioTrackList` triggers a `changed` event
1. `HLS` sees the `changed` event and finds the newly enabled `AudioTrack`
1. `HLS` sends the `label` for the new `AudioTrack` to `MasterPlaylistController`s `useAudio` function
1. `MasterPlaylistController` turns off the current audio `PlaylistLoader` if it is on
1. `MasterPlaylistController` maps the `label` to the `PlaylistLoader` containing the audio
1. `MasterPlaylistController` maps the `label` to the `PlaylistLoader` containing the audio
1. `MasterPlaylistController` turns on that `PlaylistLoader` and the Corresponding `SegmentLoader` (master or audio only)
1. `MediaSource`/`mux.js` determine how to mux
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Index</title>
</head>
<body>
<ul>
<li><a href="multiple-alternative-audio-tracks">Multiple Alternative Audio Tracks</a></li>
</ul>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Multiple Alternative Audio Tracks - Example</title>
<link href="/node_modules/video.js/dist/video-js.css" rel="stylesheet">
</head>
<body>
<h1>Multiple Alternative Audio Tracks</h1>
<p>Check the source of this page and the console for detailed information on this example</p>
<video id="maat-player" class="video-js vjs-default-skin" controls>
<source src="https://s3.amazonaws.com/_bc_dml/example-content/bipbop-advanced/bipbop_16x9_variant.m3u8" type="application/x-mpegURL">
</video>
<div id="audioTrackChoice">
<select id="enabled-audio-track" name="enabled-audio-track">
</select>
</div>
<script src="/node_modules/video.js/dist/video.js"></script>
<script src="/dist/videojs-contrib-hls.js"></script>
<script>
(function(window, videojs) {
var player = window.player = videojs('maat-player');
var audioTrackList = player.audioTracks();
var audioTrackSelect = document.getElementById("enabled-audio-track");
// watch for a change on the select element
// then change the enabled audio track
// only one can be enabled at a time, but video.js will
// handle that for us, all we need to do is enable the new
// track
audioTrackSelect.addEventListener('change', function() {
var track = audioTrackList[this.selectedIndex];
console.log('User switched to track ' + track.label);
track.enabled = true;
});
// watch for changes that will be triggered by any change
// to enabled on any audio track. Manually or through the
// select element
audioTrackList.on('change', function() {
for (var i = 0; i < audioTrackList.length; i++) {
var track = audioTrackList[i];
if (track.enabled) {
console.log('A new ' + track.label + ' has been enabled!');
}
}
});
// will be fired twice in this example
audioTrackList.on('addtrack', function() {
console.log('a track has been added to the audio track list');
});
// will not be fired at all unless you call
// audioTrackList.removeTrack(trackObj)
// we typically will not need to do this unless we have to load
// another video for some reason
audioTrackList.on('removetrack', function() {
console.log('a track has been removed from the audio track list');
});
// getting all the possible audio tracks from the track list
// get all of thier properties
// add each track to the select on the page
// this is all filled out by HLS when it parses the m3u8
player.on('loadeddata', function() {
console.log('There are ' + audioTrackList.length + ' audio tracks');
for (var i = 0; i < audioTrackList.length; i++) {
var track = audioTrackList[i];
var option = document.createElement("option");
option.text = track.label;
if (track.enabled) {
option.selected = true;
}
audioTrackSelect.add(option, i);
console.log('Track ' + (i + 1));
['label', 'enabled', 'language', 'id', 'kind'].forEach(function(prop) {
console.log(" " + prop + ": " + track[prop]);
});
}
});
}(window, window.videojs));
</script>
</body>
</html>
......@@ -43,6 +43,7 @@
<ul>
<li><a href="/test/">Run unit tests in browser.</a></li>
<li><a href="/docs/api/">Read generated docs.</a></li>
<li><a href="/examples">Browse Examples</a></li>
</ul>
<script src="/node_modules/video.js/dist/video.js"></script>
......@@ -50,6 +51,7 @@
<script>
(function(window, videojs) {
var player = window.player = videojs('videojs-contrib-hls-player');
// hook up the video switcher
var loadUrl = document.getElementById('load-url');
var url = document.getElementById('url');
......
......@@ -28,8 +28,7 @@
"docs:api": "jsdoc src -r -d docs/api",
"docs:toc": "doctoc README.md",
"lint": "vjsstandard",
"prestart": "npm-run-all docs build",
"start": "npm-run-all -p start:* watch:*",
"start": "npm-run-all -p watch start:*",
"start:serve": "babel-node scripts/server.js",
"pretest": "npm-run-all lint build",
"test": "karma start test/karma/detected.js",
......@@ -40,7 +39,10 @@
"preversion": "npm test",
"version": "npm run build",
"watch": "npm-run-all -p watch:*",
"watch:js": "watchify src/videojs-contrib-hls.js -t babelify -v -o dist/videojs-contrib-hls.js",
"watch:docs": "nodemon --watch src/ --exec npm run docs",
"watch:js": "npm-run-all -p watch:js:babel watch:js:browserify",
"watch:js:babel": "npm run build:js:babel -- --watch",
"watch:js:browserify": "watchify . -v -o dist/videojs-contrib-hls.js",
"watch:test": "npm-run-all -p watch:test:*",
"watch:test:js": "node scripts/watch-test.js",
"watch:test:manifest": "node -e \"var b=require('./scripts/manifest-data.js'); b.watch();\"",
......@@ -73,21 +75,19 @@
},
"files": [
"CONTRIBUTING.md",
"dist-test/",
"dist/",
"docs/",
"es5/",
"index.html",
"scripts/",
"src/",
"test/",
"utils/"
"test/"
],
"dependencies": {
"pkcs7": "^0.2.2",
"video.js": "^5.2.1",
"videojs-contrib-media-sources": "^3.0.0",
"videojs-swf": "^5.0.0"
"video.js": "^5.10.1",
"videojs-contrib-media-sources": "^3.1.0",
"videojs-swf": "^5.0.2"
},
"devDependencies": {
"babel": "^5.8.0",
......@@ -111,6 +111,7 @@
"karma-safari-launcher": "^0.1.0",
"lodash-compat": "^3.10.0",
"minimist": "^1.2.0",
"nodemon": "^1.9.1",
"npm-run-all": "^1.2.0",
"portscanner": "^1.0.0",
"qunitjs": "^1.18.0",
......
......@@ -17,7 +17,7 @@ glob('test/**/*.test.js', function(err, files) {
};
b.on('log', function(msg) {
process.stdout.write(msg + '\n');
process.stdout.write(msg + ' dist-test/videojs-contrib-hls.js\n');
});
b.on('update', bundle);
......
/**
* @file bin-utils.js
*/
/**
* convert a TimeRange to text
*
* @param {TimeRange} range the timerange to use for conversion
* @param {Number} i the iterator on the range to convert
*/
const textRange = function(range, i) {
return range.start(i) + '-' + range.end(i);
};
/**
* format a number as hex string
*
* @param {Number} e The number
* @param {Number} i the iterator
*/
const formatHexString = function(e, i) {
let value = e.toString(16);
......@@ -14,6 +30,9 @@ const formatAsciiString = function(e) {
return '.';
};
/**
* utils to help dump binary data to the console
*/
const utils = {
hexDump(data) {
let bytes = Array.prototype.slice.call(data);
......
/*
* aes.js
/**
* @file decrypter/aes.js
*
* This file contains an adaptation of the AES decryption algorithm
* from the Standford Javascript Cryptography Library. That work is
......@@ -96,7 +96,7 @@ let aesTables = null;
* Schedule out an AES key for both encryption and decryption. This
* is a low-level class. Use a cipher mode to do bulk encryption.
*
* @constructor
* @class AES
* @param key {Array} The key as an array of 4, 6 or 8 words.
*/
export default class AES {
......@@ -184,13 +184,14 @@ export default class AES {
/**
* Decrypt 16 bytes, specified as four 32-bit words.
* @param encrypted0 {number} the first word to decrypt
* @param encrypted1 {number} the second word to decrypt
* @param encrypted2 {number} the third word to decrypt
* @param encrypted3 {number} the fourth word to decrypt
* @param out {Int32Array} the array to write the decrypted words
*
* @param {Number} encrypted0 the first word to decrypt
* @param {Number} encrypted1 the second word to decrypt
* @param {Number} encrypted2 the third word to decrypt
* @param {Number} encrypted3 the fourth word to decrypt
* @param {Int32Array} out the array to write the decrypted words
* into
* @param offset {number} the offset into the output array to start
* @param {Number} offset the offset into the output array to start
* writing results
* @return {Array} The plaintext.
*/
......
/**
* @file decrypter/async-stream.js
*/
import Stream from '../stream';
/**
* A wrapper around the Stream class to use setTiemout
* and run stream "jobs" Asynchronously
*
* @class AsyncStream
* @extends Stream
*/
export default class AsyncStream extends Stream {
constructor() {
......@@ -11,6 +17,12 @@ export default class AsyncStream extends Stream {
this.delay = 1;
this.timeout_ = null;
}
/**
* process an async job
*
* @private
*/
processJob_() {
this.jobs.shift()();
if (this.jobs.length) {
......@@ -20,6 +32,12 @@ export default class AsyncStream extends Stream {
this.timeout_ = null;
}
}
/**
* push a job into the stream
*
* @param {Function} job the job to push into the stream
*/
push(job) {
this.jobs.push(job);
if (!this.timeout_) {
......
/*
* decrypter.js
/**
* @file decrypter/decrypter.js
*
* An asynchronous implementation of AES-128 CBC decryption with
* PKCS#7 padding.
......@@ -20,12 +20,12 @@ const ntoh = function(word) {
(word >>> 24);
};
/* eslint-disable max-len */
/**
* Decrypt bytes using AES-128 with CBC and PKCS#7 padding.
* @param encrypted {Uint8Array} the encrypted bytes
* @param key {Uint32Array} the bytes of the decryption key
* @param initVector {Uint32Array} the initialization vector (IV) to
*
* @param {Uint8Array} encrypted the encrypted bytes
* @param {Uint32Array} key the bytes of the decryption key
* @param {Uint32Array} initVector the initialization vector (IV) to
* use for the first round of CBC.
* @return {Uint8Array} the decrypted bytes
*
......@@ -33,7 +33,6 @@ const ntoh = function(word) {
* @see http://en.wikipedia.org/wiki/Block_cipher_mode_of_operation#Cipher_Block_Chaining_.28CBC.29
* @see https://tools.ietf.org/html/rfc2315
*/
/* eslint-enable max-len */
export const decrypt = function(encrypted, key, initVector) {
// word-level access to the encrypted bytes
let encrypted32 = new Int32Array(encrypted.buffer,
......@@ -106,6 +105,12 @@ export const decrypt = function(encrypted, key, initVector) {
* The `Decrypter` class that manages decryption of AES
* data through `AsyncStream` objects and the `decrypt`
* function
*
* @param {Uint8Array} encrypted the encrypted bytes
* @param {Uint32Array} key the bytes of the decryption key
* @param {Uint32Array} initVector the initialization vector (IV) to
* @param {Function} done the function to run when done
* @class Decrypter
*/
export class Decrypter {
constructor(encrypted, key, initVector, done) {
......@@ -137,6 +142,20 @@ export class Decrypter {
done(null, unpad(decrypted));
});
}
/**
* a getter for step the maximum number of bytes to process at one time
*
* @return {Number} the value of step 32000
*/
static get STEP() {
// 4 * 8000;
return 32000;
}
/**
* @private
*/
decryptChunk_(encrypted, key, initVector, decrypted) {
return function() {
let bytes = decrypt(encrypted, key, initVector);
......@@ -146,10 +165,6 @@ export class Decrypter {
}
}
// the maximum number of bytes to process at one time
// 4 * 8000;
Decrypter.STEP = 32000;
export default {
Decrypter,
decrypt
......
/*
* index.js
/**
* @file decrypter/index.js
*
* Index module to easily import the primary components of AES-128
* decryption. Like this:
......
/**
* @file hls-audio-track.js
*/
import {AudioTrack} from 'video.js';
import PlaylistLoader from './playlist-loader';
/**
* HlsAudioTrack extends video.js audio tracks but adds HLS
* specific data storage such as playlist loaders, mediaGroups
* and default/autoselect
*
* @param {Object} options options to create HlsAudioTrack with
* @class HlsAudioTrack
* @extends AudioTrack
*/
export default class HlsAudioTrack extends AudioTrack {
constructor(options) {
super({
kind: options.default ? 'main' : 'alternative',
enabled: options.default || false,
language: options.language,
label: options.label
});
this.hls = options.hls;
this.autoselect = options.autoselect || false;
this.default = options.default || false;
this.withCredentials = options.withCredentials || false;
this.mediaGroups_ = [];
this.addLoader(options.mediaGroup, options.resolvedUri);
}
/**
* get a PlaylistLoader from this track given a mediaGroup name
*
* @param {String} mediaGroup the mediaGroup to get the loader for
* @return {PlaylistLoader|Null} the PlaylistLoader or null
*/
getLoader(mediaGroup) {
for (let i = 0; i < this.mediaGroups_.length; i++) {
let mgl = this.mediaGroups_[i];
if (mgl.mediaGroup === mediaGroup) {
return mgl.loader;
}
}
}
/**
* add a PlaylistLoader given a mediaGroup, and a uri. for a combined track
* we store null for the playlistloader
*
* @param {String} mediaGroup the mediaGroup to get the loader for
* @param {String} uri the uri to get the audio track/mediaGroup from
*/
addLoader(mediaGroup, uri = null) {
let loader = null;
if (uri) {
// TODO: this should probably happen upstream in Master Playlist
// Controller when we can switch PlaylistLoader sources
// then we can just store the uri here instead
loader = new PlaylistLoader(uri, this.hls, this.withCredentials);
}
this.mediaGroups_.push({mediaGroup, loader});
}
/**
* remove a playlist loader from a track given the mediaGroup
*
* @param {String} mediaGroup the mediaGroup to remove
*/
removeLoader(mediaGroup) {
for (let i = 0; i < this.mediaGroups_.length; i++) {
let mgl = this.mediaGroups_[i];
if (mgl.mediaGroup === mediaGroup) {
if (mgl.loader) {
mgl.loader.dispose();
}
this.mediaGroups_.splice(i, 1);
return;
}
}
}
/**
* Dispose of this audio track and
* the playlist loader that it holds inside
*/
dispose() {
let i = this.mediaGroups_.length;
while (i--) {
this.removeLoader(this.mediaGroups_[i].mediaGroup);
}
}
}
/**
* @file m3u8/index.js
*
* Utilities for parsing M3U8 files. If the entire manifest is available,
* `Parser` will create an object representation with enough detail for managing
* playback. `ParseStream` and `LineStream` are lower-level parsing primitives
......
/**
* @file m3u8/line-stream.js
*/
import Stream from '../stream';
/**
* A stream that buffers string input and generates a `data` event for each
* line.
*
* @class LineStream
* @extends Stream
*/
export default class LineStream extends Stream {
constructor() {
......@@ -11,7 +18,8 @@ export default class LineStream extends Stream {
/**
* Add new data to be parsed.
* @param data {string} the text to process
*
* @param {String} data the text to process
*/
push(data) {
let nextNewline;
......
/**
* @file m3u8/parse-stream.js
*/
import Stream from '../stream';
// "forgiving" attribute list psuedo-grammar:
// attributes -> keyvalue (',' keyvalue)*
// keyvalue -> key '=' value
// key -> [^=]*
// value -> '"' [^"]* '"' | [^,]*
/**
* "forgiving" attribute list psuedo-grammar:
* attributes -> keyvalue (',' keyvalue)*
* keyvalue -> key '=' value
* key -> [^=]*
* value -> '"' [^"]* '"' | [^,]*
*/
const attributeSeparator = function() {
let key = '[^=]*';
let value = '"[^"]*"|[^,]*';
......@@ -13,6 +18,11 @@ const attributeSeparator = function() {
return new RegExp('(?:^|,)(' + keyvalue + ')');
};
/**
* Parse attributes from a line given the seperator
*
* @param {String} attributes the attibute line to parse
*/
const parseAttributes = function(attributes) {
// split the string using attributes as the separator
let attrs = attributes.split(attributeSeparator());
......@@ -57,6 +67,9 @@ const parseAttributes = function(attributes) {
* `#EXT-X-MEDIA-SEQUENCE` becomes `media-sequence` when parsed. Unrecognized
* tags are given the tag type `unknown` and a single additional property
* `data` with the remainder of the input.
*
* @class ParseStream
* @extends Stream
*/
export default class ParseStream extends Stream {
constructor() {
......@@ -65,7 +78,8 @@ export default class ParseStream extends Stream {
/**
* Parses an additional line of input.
* @param line {string} a single line of an M3U8 file to parse
*
* @param {String} line a single line of an M3U8 file to parse
*/
push(line) {
let match;
......@@ -254,6 +268,18 @@ export default class ParseStream extends Stream {
this.trigger('data', event);
return;
}
match = (/^#EXT-X-MEDIA:?(.*)$/).exec(line);
if (match) {
event = {
type: 'tag',
tagType: 'media'
};
if (match[1]) {
event.attributes = parseAttributes(match[1]);
}
this.trigger('data', event);
return;
}
match = (/^#EXT-X-ENDLIST/).exec(line);
if (match) {
this.trigger('data', {
......
/**
* @file m3u8/parser.js
*/
import Stream from '../stream' ;
import LineStream from './line-stream';
import ParseStream from './parse-stream';
......@@ -20,6 +23,9 @@ import {mergeOptions} from 'video.js';
* underlying input is somewhat nonsensical. It emits `info` and `warning`
* events during the parse if it encounters input that seems invalid or
* requires some property of the manifest object to be defaulted.
*
* @class Parser
* @extends Stream
*/
export default class Parser extends Stream {
constructor() {
......@@ -34,6 +40,14 @@ export default class Parser extends Stream {
let currentUri = {};
let key;
let noop = function() {};
let defaultMediaGroups = {
'AUDIO': {},
'VIDEO': {},
'CLOSED-CAPTIONS': {},
'SUBTITLES': {}
};
// group segments into numbered timelines delineated by discontinuities
let currentTimeline = 0;
// the manifest is empty until the parse stream begins delivering data
this.manifest = {
......@@ -43,6 +57,9 @@ export default class Parser extends Stream {
// update the manifest with the m3u8 entry from the parse stream
this.parseStream.on('data', function(entry) {
let mediaGroup;
let rendition;
({
tag() {
// switch based on the tag type
......@@ -96,7 +113,6 @@ export default class Parser extends Stream {
}
this.manifest.segments = uris;
},
key() {
if (!entry.attributes) {
......@@ -149,6 +165,7 @@ export default class Parser extends Stream {
return;
}
this.manifest.discontinuitySequence = entry.number;
currentTimeline = entry.number;
},
'playlist-type'() {
if (!(/VOD|EVENT/).test(entry.playlistType)) {
......@@ -161,6 +178,8 @@ export default class Parser extends Stream {
},
'stream-inf'() {
this.manifest.playlists = uris;
this.manifest.mediaGroups =
this.manifest.mediaGroups || defaultMediaGroups;
if (!entry.attributes) {
this.trigger('warn', {
......@@ -175,7 +194,48 @@ export default class Parser extends Stream {
currentUri.attributes = mergeOptions(currentUri.attributes,
entry.attributes);
},
media() {
this.manifest.mediaGroups =
this.manifest.mediaGroups || defaultMediaGroups;
if (!(entry.attributes &&
entry.attributes.TYPE &&
entry.attributes['GROUP-ID'] &&
entry.attributes.NAME)) {
this.trigger('warn', {
message: 'ignoring incomplete or missing media group'
});
return;
}
// find the media group, creating defaults as necessary
let mediaGroupType = this.manifest.mediaGroups[entry.attributes.TYPE];
mediaGroupType[entry.attributes['GROUP-ID']] =
mediaGroupType[entry.attributes['GROUP-ID']] || {};
mediaGroup = mediaGroupType[entry.attributes['GROUP-ID']];
// collect the rendition metadata
rendition = {
default: (/yes/i).test(entry.attributes.DEFAULT)
};
if (rendition.default) {
rendition.autoselect = true;
} else {
rendition.autoselect = (/yes/i).test(entry.attributes.AUTOSELECT);
}
if (entry.attributes.LANGUAGE) {
rendition.language = entry.attributes.LANGUAGE;
}
if (entry.attributes.URI) {
rendition.uri = entry.attributes.URI;
}
// insert the new rendition
mediaGroup[entry.attributes.NAME] = rendition;
},
discontinuity() {
currentTimeline += 1;
currentUri.discontinuity = true;
this.manifest.discontinuityStarts.push(uris.length);
},
......@@ -215,6 +275,7 @@ export default class Parser extends Stream {
if (key) {
currentUri.key = key;
}
currentUri.timeline = currentTimeline;
// prepare for the next URI
currentUri = {};
......@@ -229,7 +290,8 @@ export default class Parser extends Stream {
/**
* Parse the input string and update the manifest object.
* @param chunk {string} a potentially incomplete portion of the manifest
*
* @param {String} chunk a potentially incomplete portion of the manifest
*/
push(chunk) {
this.lineStream.push(chunk);
......@@ -246,4 +308,3 @@ export default class Parser extends Stream {
}
}
......
/**
* @file master-playlist-controller.js
*/
import PlaylistLoader from './playlist-loader';
import SegmentLoader from './segment-loader';
import Ranges from './ranges';
import videojs from 'video.js';
import HlsAudioTrack from './hls-audio-track';
// 5 minute blacklist
const BLACKLIST_DURATION = 5 * 60 * 1000;
let Hls;
const parseCodecs = function(codecs) {
let result = {
codecCount: 0,
videoCodec: null,
audioProfile: null
};
result.codecCount = codecs.split(',').length;
result.codecCount = result.codecCount || 2;
// parse the video codec but ignore the version
result.videoCodec = (/(^|\s|,)+(avc1)[^ ,]*/i).exec(codecs);
result.videoCodec = result.videoCodec && result.videoCodec[2];
// parse the last field of the audio codec
result.audioProfile = (/(^|\s|,)+mp4a.\d+\.(\d+)/i).exec(codecs);
result.audioProfile = result.audioProfile && result.audioProfile[2];
return result;
};
/**
* the master playlist controller controller all interactons
* between playlists and segmentloaders. At this time this mainly
* involves a master playlist and a series of audio playlists
* if they are available
*
* @class MasterPlaylistController
* @extends videojs.EventTarget
*/
export default class MasterPlaylistController extends videojs.EventTarget {
constructor({
url,
withCredentials,
mode,
tech,
bandwidth,
externHls
}) {
super();
Hls = externHls;
this.withCredentials = withCredentials;
this.tech_ = tech;
this.hls_ = tech.hls;
this.mode_ = mode;
this.audioTracks_ = [];
this.mediaSource = new videojs.MediaSource({ mode });
this.mediaSource.on('audioinfo', (e) => this.trigger(e));
// load the media source into the player
this.mediaSource.addEventListener('sourceopen', this.handleSourceOpen_.bind(this));
let segmentLoaderOptions = {
hls: this.hls_,
mediaSource: this.mediaSource,
currentTime: this.tech_.currentTime.bind(this.tech_),
withCredentials: this.withCredentials,
seekable: () => this.seekable(),
seeking: () => this.tech_.seeking(),
setCurrentTime: (a) => this.setCurrentTime(a),
hasPlayed: () => this.tech_.played().length !== 0,
bandwidth
};
// combined audio/video or just video when alternate audio track is selected
this.mainSegmentLoader_ = new SegmentLoader(segmentLoaderOptions);
// alternate audio track
this.audioSegmentLoader_ = new SegmentLoader(segmentLoaderOptions);
if (!url) {
throw new Error('A non-empty playlist URL is required');
}
this.masterPlaylistLoader_ = new PlaylistLoader(url, this.hls_, this.withCredentials);
this.masterPlaylistLoader_.on('loadedmetadata', () => {
let media = this.masterPlaylistLoader_.media();
// if this isn't a live video and preload permits, start
// downloading segments
if (media.endList && this.tech_.preload() !== 'none') {
this.mainSegmentLoader_.playlist(media);
this.mainSegmentLoader_.expired(this.masterPlaylistLoader_.expired_);
this.mainSegmentLoader_.load();
}
this.setupSourceBuffer_();
this.setupFirstPlay();
this.useAudio();
});
this.masterPlaylistLoader_.on('loadedplaylist', () => {
let updatedPlaylist = this.masterPlaylistLoader_.media();
let seekable;
if (!updatedPlaylist) {
// select the initial variant
this.initialMedia_ = this.selectPlaylist();
this.masterPlaylistLoader_.media(this.initialMedia_);
this.fillAudioTracks_();
this.trigger('selectedinitialmedia');
return;
}
this.mainSegmentLoader_.playlist(updatedPlaylist);
this.mainSegmentLoader_.expired(this.masterPlaylistLoader_.expired_);
this.updateDuration();
// update seekable
seekable = this.seekable();
if (!updatedPlaylist.endList && seekable.length !== 0) {
this.mediaSource.addSeekableRange_(seekable.start(0), seekable.end(0));
}
});
this.masterPlaylistLoader_.on('error', () => {
this.blacklistCurrentPlaylist(this.masterPlaylistLoader_.error);
});
this.masterPlaylistLoader_.on('mediachanging', () => {
this.mainSegmentLoader_.pause();
});
this.masterPlaylistLoader_.on('mediachange', () => {
this.mainSegmentLoader_.abort();
this.mainSegmentLoader_.load();
this.tech_.trigger({
type: 'mediachange',
bubbles: true
});
});
this.mainSegmentLoader_.on('progress', () => {
// figure out what stream the next segment should be downloaded from
// with the updated bandwidth information
this.masterPlaylistLoader_.media(this.selectPlaylist());
this.trigger('progress');
});
this.mainSegmentLoader_.on('error', () => {
this.blacklistCurrentPlaylist(this.mainSegmentLoader_.error());
});
this.audioSegmentLoader_.on('error', () => {
videojs.log.warn('Problem encountered with the current alternate audio track' +
'. Switching back to default.');
this.audioSegmentLoader_.abort();
this.audioPlaylistLoader_ = null;
this.useAudio();
});
this.masterPlaylistLoader_.load();
}
/**
* fill our internal list of HlsAudioTracks with data from
* the master playlist or use a default
*
* @private
*/
fillAudioTracks_() {
let master = this.master();
let mediaGroups = master.mediaGroups || {};
// force a default if we have none or we are not
// in html5 mode (the only mode to support more than one
// audio track)
if (!mediaGroups ||
!mediaGroups.AUDIO ||
Object.keys(mediaGroups.AUDIO).length === 0 ||
this.mode_ !== 'html5') {
// "main" audio group, track name "default"
mediaGroups = videojs.mergeOptions(mediaGroups, {AUDIO: {
main: {default: {default: true}}}
});
}
let tracks = {};
for (let mediaGroup in mediaGroups.AUDIO) {
for (let label in mediaGroups.AUDIO[mediaGroup]) {
let properties = mediaGroups.AUDIO[mediaGroup][label];
// if the track already exists add a new "location"
// since tracks in different mediaGroups are actually the same
// track with different locations to download them from
if (tracks[label]) {
tracks[label].addLoader(mediaGroup, properties.resolvedUri);
continue;
}
let track = new HlsAudioTrack(videojs.mergeOptions(properties, {
hls: this.hls_,
withCredentials: this.withCredential,
mediaGroup,
label
}));
tracks[label] = track;
this.audioTracks_.push(track);
}
}
}
/**
* Call load on our SegmentLoaders
*/
load() {
this.mainSegmentLoader_.load();
if (this.audioPlaylistLoader_) {
this.audioSegmentLoader_.load();
}
}
/**
* Get the current active Media Group for Audio
* given the selected playlist and its attributes
*/
activeAudioGroup() {
let media = this.masterPlaylistLoader_.media();
let mediaGroup = 'main';
if (media && media.attributes && media.attributes.AUDIO) {
mediaGroup = media.attributes.AUDIO;
}
return mediaGroup;
}
/**
* Use any audio track that we have, and start to load it
*/
useAudio() {
let track;
this.audioTracks_.forEach((t) => {
if (!track && t.enabled) {
track = t;
}
});
// called too early or no track is enabled
if (!track) {
return;
}
// Pause any alternative audio
if (this.audioPlaylistLoader_) {
this.audioPlaylistLoader_.pause();
this.audioPlaylistLoader_ = null;
this.audioSegmentLoader_.pause();
}
// If the audio track for the active audio group has
// a playlist loader than it is an alterative audio track
// otherwise it is a part of the mainSegmenLoader
let loader = track.getLoader(this.activeAudioGroup());
if (!loader) {
this.mainSegmentLoader_.clearBuffer();
return;
}
// TODO: it may be better to create the playlist loader here
// when we can change an audioPlaylistLoaders src
this.audioPlaylistLoader_ = loader;
if (this.audioPlaylistLoader_.started) {
this.audioPlaylistLoader_.load();
this.audioSegmentLoader_.load();
this.audioSegmentLoader_.clearBuffer();
return;
}
this.audioPlaylistLoader_.on('loadedmetadata', () => {
/* eslint-disable no-shadow */
let media = this.audioPlaylistLoader_.media();
/* eslint-enable no-shadow */
this.audioSegmentLoader_.playlist(media);
this.addMimeType_(this.audioSegmentLoader_, 'mp4a.40.2', media);
// if the video is already playing, or if this isn't a live video and preload
// permits, start downloading segments
if (!this.tech_.paused() ||
(media.endList && this.tech_.preload() !== 'none')) {
this.audioSegmentLoader_.load();
}
if (!media.endList) {
// trigger the playlist loader to start "expired time"-tracking
this.audioPlaylistLoader_.trigger('firstplay');
}
});
this.audioPlaylistLoader_.on('loadedplaylist', () => {
let updatedPlaylist;
if (this.audioPlaylistLoader_) {
updatedPlaylist = this.audioPlaylistLoader_.media();
}
if (!updatedPlaylist) {
// only one playlist to select
this.audioPlaylistLoader_.media(
this.audioPlaylistLoader_.playlists.master.playlists[0]);
return;
}
this.audioSegmentLoader_.playlist(updatedPlaylist);
});
this.audioPlaylistLoader_.on('error', () => {
videojs.log.warn('Problem encountered loading the alternate audio track' +
'. Switching back to default.');
this.audioSegmentLoader_.abort();
this.audioPlaylistLoader_ = null;
this.useAudio();
});
this.audioSegmentLoader_.clearBuffer();
this.audioPlaylistLoader_.start();
}
/**
* Re-tune playback quality level for the current player
* conditions. This method may perform destructive actions, like
* removing already buffered content, to readjust the currently
* active playlist quickly.
*
* @private
*/
fastQualityChange_() {
let media = this.selectPlaylist();
if (media !== this.masterPlaylistLoader_.media()) {
this.masterPlaylistLoader_.media(media);
this.mainSegmentLoader_.sourceUpdater_.remove(this.currentTimeFunc() + 5, Infinity);
}
}
/**
* Begin playback.
*/
play() {
if (this.setupFirstPlay()) {
return;
}
if (this.tech_.ended()) {
this.tech_.setCurrentTime(0);
}
this.load();
// if the viewer has paused and we fell out of the live window,
// seek forward to the earliest available position
if (this.tech_.duration() === Infinity) {
if (this.tech_.currentTime() < this.tech_.seekable().start(0)) {
return this.tech_.setCurrentTime(this.tech_.seekable().start(0));
}
}
}
/**
* Seek to the latest media position if this is a live video and the
* player and video are loaded and initialized.
*/
setupFirstPlay() {
let seekable;
let media = this.masterPlaylistLoader_.media();
// check that everything is ready to begin buffering
// 1) the active media playlist is available
if (media &&
// 2) the video is a live stream
!media.endList &&
// 3) the player is not paused
!this.tech_.paused() &&
// 4) the player has not started playing
!this.hasPlayed_) {
this.load();
// trigger the playlist loader to start "expired time"-tracking
this.masterPlaylistLoader_.trigger('firstplay');
this.hasPlayed_ = true;
// seek to the latest media position for live videos
seekable = this.seekable();
if (seekable.length) {
this.tech_.setCurrentTime(seekable.end(0));
}
return true;
}
return false;
}
/**
* handle the sourceopen event on the MediaSource
*
* @private
*/
handleSourceOpen_() {
// Only attempt to create the source buffer if none already exist.
// handleSourceOpen is also called when we are "re-opening" a source buffer
// after `endOfStream` has been called (in response to a seek for instance)
this.setupSourceBuffer_();
// if autoplay is enabled, begin playback. This is duplicative of
// code in video.js but is required because play() must be invoked
// *after* the media source has opened.
if (this.tech_.autoplay()) {
this.tech_.play();
}
this.trigger('sourceopen');
}
/**
* Blacklists a playlist when an error occurs for a set amount of time
* making it unavailable for selection by the rendition selection algorithm
* and then forces a new playlist (rendition) selection.
*
* @param {Object=} error an optional error that may include the playlist
* to blacklist
*/
blacklistCurrentPlaylist(error = {}) {
let currentPlaylist;
let nextPlaylist;
// If the `error` was generated by the playlist loader, it will contain
// the playlist we were trying to load (but failed) and that should be
// blacklisted instead of the currently selected playlist which is likely
// out-of-date in this scenario
currentPlaylist = error.playlist || this.masterPlaylistLoader_.media();
// If there is no current playlist, then an error occurred while we were
// trying to load the master OR while we were disposing of the tech
if (!currentPlaylist) {
this.error = error;
return this.mediaSource.endOfStream('network');
}
// Blacklist this playlist
currentPlaylist.excludeUntil = Date.now() + BLACKLIST_DURATION;
// Select a new playlist
nextPlaylist = this.selectPlaylist();
if (nextPlaylist) {
videojs.log.warn('Problem encountered with the current ' +
'HLS playlist. Switching to another playlist.');
return this.masterPlaylistLoader_.media(nextPlaylist);
}
videojs.log.warn('Problem encountered with the current ' +
'HLS playlist. No suitable alternatives found.');
// We have no more playlists we can select so we must fail
this.error = error;
return this.mediaSource.endOfStream('network');
}
/**
* Pause all segment loaders
*/
pauseLoading() {
this.mainSegmentLoader_.pause();
if (this.audioPlaylistLoader_) {
this.audioSegmentLoader_.pause();
}
}
/**
* set the current time on all segment loaders
*
* @param {TimeRange} currentTime the current time to set
* @return {TimeRange} the current time
*/
setCurrentTime(currentTime) {
let buffered = Ranges.findRange(this.tech_.buffered(), currentTime);
if (!(this.masterPlaylistLoader_ && this.masterPlaylistLoader_.media())) {
// return immediately if the metadata is not ready yet
return 0;
}
// it's clearly an edge-case but don't thrown an error if asked to
// seek within an empty playlist
if (!this.masterPlaylistLoader_.media().segments) {
return 0;
}
// if the seek location is already buffered, continue buffering as
// usual
if (buffered && buffered.length) {
return currentTime;
}
// cancel outstanding requests so we begin buffering at the new
// location
this.mainSegmentLoader_.abort();
if (this.audioPlaylistLoader_) {
this.audioSegmentLoader_.abort();
}
if (!this.tech_.paused()) {
this.mainSegmentLoader_.load();
if (this.audioPlaylistLoader_) {
this.audioSegmentLoader_.load();
}
}
}
/**
* get the current duration
*
* @return {TimeRange} the duration
*/
duration() {
if (!this.masterPlaylistLoader_) {
return 0;
}
if (this.mediaSource) {
return this.mediaSource.duration;
}
return Hls.Playlist.duration(this.masterPlaylistLoader_.media());
}
/**
* check the seekable range
*
* @return {TimeRange} the seekable range
*/
seekable() {
let media;
let mainSeekable;
let audioSeekable;
if (!this.masterPlaylistLoader_) {
return videojs.createTimeRanges();
}
media = this.masterPlaylistLoader_.media();
if (!media) {
return videojs.createTimeRanges();
}
mainSeekable = Hls.Playlist.seekable(media,
this.masterPlaylistLoader_.expired_);
if (mainSeekable.length === 0) {
return mainSeekable;
}
if (this.audioPlaylistLoader_) {
audioSeekable = Hls.Playlist.seekable(this.audioPlaylistLoader_.media(),
this.audioPlaylistLoader_.expired_);
if (audioSeekable.length === 0) {
return audioSeekable;
}
}
if (!audioSeekable) {
// seekable has been calculated based on buffering video data so it
// can be returned directly
return mainSeekable;
}
return videojs.createTimeRanges([[
(audioSeekable.start(0) > mainSeekable.start(0)) ? audioSeekable.start(0) :
mainSeekable.start(0),
(audioSeekable.end(0) < mainSeekable.end(0)) ? audioSeekable.end(0) :
mainSeekable.end(0)
]]);
}
/**
* Update the player duration
*/
updateDuration() {
let oldDuration = this.mediaSource.duration;
let newDuration = Hls.Playlist.duration(this.masterPlaylistLoader_.media());
let buffered = this.tech_.buffered();
let setDuration = () => {
this.mediaSource.duration = newDuration;
this.tech_.trigger('durationchange');
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
if (this.mediaSource.readyState !== 'open') {
this.mediaSource.addEventListener('sourceopen', setDuration);
} else {
setDuration();
}
}
}
/**
* dispose of the MasterPlaylistController and everything
* that it controls
*/
dispose() {
this.masterPlaylistLoader_.dispose();
this.audioTracks_.forEach((track) => {
track.dispose();
});
this.audioTracks_.length = 0;
this.mainSegmentLoader_.dispose();
this.audioSegmentLoader_.dispose();
}
/**
* return the master playlist object if we have one
*
* @return {Object} the master playlist object that we parsed
*/
master() {
return this.masterPlaylistLoader_.master;
}
/**
* return the currently selected playlist
*
* @return {Object} the currently selected playlist object that we parsed
*/
media() {
// playlist loader will not return media if it has not been fully loaded
return this.masterPlaylistLoader_.media() || this.initialMedia_;
}
/**
* setup our internal source buffers on our segment Loaders
*
* @private
*/
setupSourceBuffer_() {
let media = this.masterPlaylistLoader_.media();
// wait until a media playlist is available and the Media Source is
// attached
if (!media || this.mediaSource.readyState !== 'open') {
return;
}
this.addMimeType_(this.mainSegmentLoader_, 'avc1.4d400d, mp4a.40.2', media);
// exclude any incompatible variant streams from future playlist
// selection
this.excludeIncompatibleVariants_(media);
}
/**
* add a time type to a segmentLoader
*
* @param {SegmentLoader} segmentLoader the segmentloader to work on
* @param {String} codecs to use by default
* @param {Object} the parsed media object
* @private
*/
addMimeType_(segmentLoader, defaultCodecs, media) {
let mimeType = 'video/mp2t';
// if the codecs were explicitly specified, pass them along to the
// source buffer
if (media.attributes && media.attributes.CODECS) {
mimeType += '; codecs="' + media.attributes.CODECS + '"';
} else {
mimeType += '; codecs="' + defaultCodecs + '"';
}
segmentLoader.mimeType(mimeType);
}
/**
* Blacklist playlists that are known to be codec or
* stream-incompatible with the SourceBuffer configuration. For
* instance, Media Source Extensions would cause the video element to
* stall waiting for video data if you switched from a variant with
* video and audio to an audio-only one.
*
* @param {Object} media a media playlist compatible with the current
* set of SourceBuffers. Variants in the current master playlist that
* do not appear to have compatible codec or stream configurations
* will be excluded from the default playlist selection algorithm
* indefinitely.
* @private
*/
excludeIncompatibleVariants_(media) {
let master = this.masterPlaylistLoader_.master;
let codecCount = 2;
let videoCodec = null;
let audioProfile = null;
let codecs;
if (media.attributes && media.attributes.CODECS) {
codecs = parseCodecs(media.attributes.CODECS);
videoCodec = codecs.videoCodec;
audioProfile = codecs.audioProfile;
codecCount = codecs.codecCount;
}
master.playlists.forEach(function(variant) {
let variantCodecs = {
codecCount: 2,
videoCodec: null,
audioProfile: null
};
if (variant.attributes && variant.attributes.CODECS) {
variantCodecs = parseCodecs(variant.attributes.CODECS);
}
// if the streams differ in the presence or absence of audio or
// video, they are incompatible
if (variantCodecs.codecCount !== codecCount) {
variant.excludeUntil = Infinity;
}
// if h.264 is specified on the current playlist, some flavor of
// it must be specified on all compatible variants
if (variantCodecs.videoCodec !== videoCodec) {
variant.excludeUntil = Infinity;
}
// HE-AAC ("mp4a.40.5") is incompatible with all other versions of
// AAC audio in Chrome 46. Don't mix the two.
if ((variantCodecs.audioProfile === '5' && audioProfile !== '5') ||
(audioProfile === '5' && variantCodecs.audioProfile !== '5')) {
variant.excludeUntil = Infinity;
}
});
}
}
/**
* playlist-loader
* @file playlist-loader.js
*
* A state machine that manages the loading, caching, and updating of
* M3U8 playlists.
......@@ -11,21 +11,53 @@ import Stream from './stream';
import m3u8 from './m3u8';
/**
* Returns a new master playlist that is the result of merging an
* updated media playlist into the original version. If the
* updated media playlist does not match any of the playlist
* entries in the original master playlist, null is returned.
* @param master {object} a parsed master M3U8 object
* @param media {object} a parsed media M3U8 object
* @return {object} a new object that represents the original
* master playlist with the updated media playlist merged in, or
* null if the merge produced no change.
*/
* Returns a new array of segments that is the result of merging
* properties from an older list of segments onto an updated
* list. No properties on the updated playlist will be overridden.
*
* @param {Array} original the outdated list of segments
* @param {Array} update the updated list of segments
* @param {Number=} offset the index of the first update
* segment in the original segment list. For non-live playlists,
* this should always be zero and does not need to be
* specified. For live playlists, it should be the difference
* between the media sequence numbers in the original and updated
* playlists.
* @return a list of merged segment objects
*/
const updateSegments = function(original, update, offset) {
let result = update.slice();
let length;
let i;
offset = offset || 0;
length = Math.min(original.length, update.length + offset);
for (i = offset; i < length; i++) {
result[i - offset] = mergeOptions(original[i], result[i - offset]);
}
return result;
};
/**
* Returns a new master playlist that is the result of merging an
* updated media playlist into the original version. If the
* updated media playlist does not match any of the playlist
* entries in the original master playlist, null is returned.
*
* @param {Object} master a parsed master M3U8 object
* @param {Object} media a parsed media M3U8 object
* @return {Object} a new object that represents the original
* master playlist with the updated media playlist merged in, or
* null if the merge produced no change.
*/
const updateMaster = function(master, media) {
let changed = false;
let result = mergeOptions(master, {});
let i = master.playlists.length;
let playlist;
let segment;
let j;
while (i--) {
playlist = result.playlists[i];
......@@ -45,10 +77,25 @@ const updateMaster = function(master, media) {
// if the update could overlap existing segment information,
// merge the two lists
if (playlist.segments) {
result.playlists[i].segments = updateSegments(playlist.segments,
media.segments,
media.mediaSequence -
playlist.mediaSequence);
result.playlists[i].segments = updateSegments(
playlist.segments,
media.segments,
media.mediaSequence - playlist.mediaSequence
);
}
// resolve any missing segment and key URIs
j = 0;
if (result.playlists[i].segments) {
j = result.playlists[i].segments.length;
}
while (j--) {
segment = result.playlists[i].segments[j];
if (!segment.resolvedUri) {
segment.resolvedUri = resolveUrl(playlist.resolvedUri, segment.uri);
}
if (segment.key && !segment.key.resolvedUri) {
segment.key.resolvedUri = resolveUrl(playlist.resolvedUri, segment.key.uri);
}
}
changed = true;
}
......@@ -57,258 +104,297 @@ const updateMaster = function(master, media) {
};
/**
* Returns a new array of segments that is the result of merging
* properties from an older list of segments onto an updated
* list. No properties on the updated playlist will be overridden.
* @param original {array} the outdated list of segments
* @param update {array} the updated list of segments
* @param offset {number} (optional) the index of the first update
* segment in the original segment list. For non-live playlists,
* this should always be zero and does not need to be
* specified. For live playlists, it should be the difference
* between the media sequence numbers in the original and updated
* playlists.
* @return a list of merged segment objects
* Load a playlist from a remote loacation
*
* @class PlaylistLoader
* @extends Stream
* @param {String} srcUrl the url to start with
* @param {Boolean} withCredentials the withCredentials xhr option
* @constructor
*/
const updateSegments = function(original, update, offset) {
let result = update.slice();
let length;
let i;
const PlaylistLoader = function(srcUrl, hls, withCredentials) {
/* eslint-disable consistent-this */
let loader = this;
/* eslint-enable consistent-this */
let dispose;
let mediaUpdateTimeout;
let request;
let playlistRequestError;
let haveMetadata;
PlaylistLoader.prototype.constructor.call(this);
this.hls_ = hls;
// a flag that disables "expired time"-tracking this setting has
// no effect when not playing a live stream
this.trackExpiredTime_ = false;
if (!srcUrl) {
throw new Error('A non-empty playlist URL is required');
}
offset = offset || 0;
length = Math.min(original.length, update.length + offset);
playlistRequestError = function(xhr, url, startingState) {
loader.setBandwidth(request || xhr);
for (i = offset; i < length; i++) {
result[i - offset] = mergeOptions(original[i], result[i - offset]);
}
return result;
};
// any in-flight request is now finished
request = null;
export default class PlaylistLoader extends Stream {
constructor(srcUrl, hls, withCredentials) {
super();
let loader = this;
let dispose;
let mediaUpdateTimeout;
let request;
let playlistRequestError;
let haveMetadata;
if (startingState) {
loader.state = startingState;
}
this.hls_ = hls;
loader.error = {
playlist: loader.master.playlists[url],
status: xhr.status,
message: 'HLS playlist request error at URL: ' + url,
responseText: xhr.responseText,
code: (xhr.status >= 500) ? 4 : 2
};
// a flag that disables "expired time"-tracking this setting has
// no effect when not playing a live stream
this.trackExpiredTime_ = false;
loader.trigger('error');
};
if (!srcUrl) {
throw new Error('A non-empty playlist URL is required');
}
// update the playlist loader's state in response to a new or
// updated playlist.
haveMetadata = function(xhr, url) {
let parser;
let refreshDelay;
let update;
playlistRequestError = function(xhr, url, startingState) {
loader.setBandwidth(request || xhr);
loader.setBandwidth(request || xhr);
// any in-flight request is now finished
request = null;
// any in-flight request is now finished
request = null;
if (startingState) {
loader.state = startingState;
}
loader.state = 'HAVE_METADATA';
loader.error = {
playlist: loader.master.playlists[url],
status: xhr.status,
message: 'HLS playlist request error at URL: ' + url,
responseText: xhr.responseText,
code: (xhr.status >= 500) ? 4 : 2
};
loader.trigger('error');
};
parser = new m3u8.Parser();
parser.push(xhr.responseText);
parser.end();
parser.manifest.uri = url;
// update the playlist loader's state in response to a new or
// updated playlist.
// merge this playlist into the master
update = updateMaster(loader.master, parser.manifest);
refreshDelay = (parser.manifest.targetDuration || 10) * 1000;
if (update) {
loader.master = update;
loader.updateMediaPlaylist_(parser.manifest);
} else {
// if the playlist is unchanged since the last reload,
// try again after half the target duration
refreshDelay /= 2;
}
haveMetadata = function(xhr, url) {
let parser;
let refreshDelay;
let update;
// refresh live playlists after a target duration passes
if (!loader.media().endList) {
window.clearTimeout(mediaUpdateTimeout);
mediaUpdateTimeout = window.setTimeout(function() {
loader.trigger('mediaupdatetimeout');
}, refreshDelay);
}
loader.setBandwidth(request || xhr);
loader.trigger('loadedplaylist');
};
// any in-flight request is now finished
request = null;
loader.state = 'HAVE_METADATA';
// initialize the loader state
loader.state = 'HAVE_NOTHING';
parser = new m3u8.Parser();
parser.push(xhr.responseText);
parser.end();
parser.manifest.uri = url;
// merge this playlist into the master
update = updateMaster(loader.master, parser.manifest);
refreshDelay = (parser.manifest.targetDuration || 10) * 1000;
if (update) {
loader.master = update;
loader.updateMediaPlaylist_(parser.manifest);
} else {
// if the playlist is unchanged since the last reload,
// try again after half the target duration
refreshDelay /= 2;
}
// track the time that has expired from the live window
// this allows the seekable start range to be calculated even if
// all segments with timing information have expired
this.expired_ = 0;
// refresh live playlists after a target duration passes
if (!loader.media().endList) {
window.clearTimeout(mediaUpdateTimeout);
mediaUpdateTimeout = window.setTimeout(function() {
loader.trigger('mediaupdatetimeout');
}, refreshDelay);
}
// capture the prototype dispose function
dispose = this.dispose;
loader.trigger('loadedplaylist');
};
/**
* Abort any outstanding work and clean up.
*/
loader.dispose = function() {
loader.stopRequest();
window.clearTimeout(mediaUpdateTimeout);
dispose.call(this);
};
// initialize the loader state
loader.state = 'HAVE_NOTHING';
loader.stopRequest = () => {
if (request) {
let oldRequest = request;
// track the time that has expired from the live window
// this allows the seekable start range to be calculated even if
// all segments with timing information have expired
this.expired_ = 0;
request = null;
oldRequest.onreadystatechange = null;
oldRequest.abort();
}
};
/**
* When called without any arguments, returns the currently
* active media playlist. When called with a single argument,
* triggers the playlist loader to asynchronously switch to the
* specified media playlist. Calling this method while the
* loader is in the HAVE_NOTHING causes an error to be emitted
* but otherwise has no effect.
*
* @param {Object=} playlis tthe parsed media playlist
* object to switch to
* @return {Playlist} the current loaded media
*/
loader.media = function(playlist) {
let startingState = loader.state;
let mediaChange;
// getter
if (!playlist) {
return loader.media_;
}
// capture the prototype dispose function
dispose = this.dispose;
// setter
if (loader.state === 'HAVE_NOTHING') {
throw new Error('Cannot switch media playlist from ' + loader.state);
}
/**
* Abort any outstanding work and clean up.
*/
loader.dispose = function() {
// find the playlist object if the target playlist has been
// specified by URI
if (typeof playlist === 'string') {
if (!loader.master.playlists[playlist]) {
throw new Error('Unknown playlist URI: ' + playlist);
}
playlist = loader.master.playlists[playlist];
}
mediaChange = !loader.media_ || playlist.uri !== loader.media_.uri;
// switch to fully loaded playlists immediately
if (loader.master.playlists[playlist.uri].endList) {
// abort outstanding playlist requests
if (request) {
request.onreadystatechange = null;
request.abort();
request = null;
}
window.clearTimeout(mediaUpdateTimeout);
dispose.call(this);
};
/**
* When called without any arguments, returns the currently
* active media playlist. When called with a single argument,
* triggers the playlist loader to asynchronously switch to the
* specified media playlist. Calling this method while the
* loader is in the HAVE_NOTHING causes an error to be emitted
* but otherwise has no effect.
* @param playlist (optional) {object} the parsed media playlist
* object to switch to
*/
loader.media = function(playlist) {
let startingState = loader.state;
let mediaChange;
// getter
if (!playlist) {
return loader.media_;
}
loader.state = 'HAVE_METADATA';
loader.media_ = playlist;
// setter
if (loader.state === 'HAVE_NOTHING') {
throw new Error('Cannot switch media playlist from ' + loader.state);
}
// find the playlist object if the target playlist has been
// specified by URI
if (typeof playlist === 'string') {
if (!loader.master.playlists[playlist]) {
throw new Error('Unknown playlist URI: ' + playlist);
}
playlist = loader.master.playlists[playlist];
// trigger media change if the active media has been updated
if (mediaChange) {
loader.trigger('mediachanging');
loader.trigger('mediachange');
}
return;
}
mediaChange = !loader.media_ || playlist.uri !== loader.media_.uri;
// switching to the active playlist is a no-op
if (!mediaChange) {
return;
}
// switch to fully loaded playlists immediately
if (loader.master.playlists[playlist.uri].endList) {
// abort outstanding playlist requests
if (request) {
request.onreadystatechange = null;
request.abort();
request = null;
}
loader.state = 'HAVE_METADATA';
loader.media_ = playlist;
loader.state = 'SWITCHING_MEDIA';
// trigger media change if the active media has been updated
if (mediaChange) {
loader.trigger('mediachange');
}
// there is already an outstanding playlist request
if (request) {
if (resolveUrl(loader.master.uri, playlist.uri) === request.url) {
// requesting to switch to the same playlist multiple times
// has no effect after the first
return;
}
request.onreadystatechange = null;
request.abort();
request = null;
}
// switching to the active playlist is a no-op
if (!mediaChange) {
// request the new playlist
if (this.media_) {
this.trigger('mediachanging');
}
request = this.hls_.xhr({
uri: resolveUrl(loader.master.uri, playlist.uri),
withCredentials
}, function(error, req) {
// disposed
if (!request) {
return;
}
loader.state = 'SWITCHING_MEDIA';
// there is already an outstanding playlist request
if (request) {
if (resolveUrl(loader.master.uri, playlist.uri) === request.url) {
// requesting to switch to the same playlist multiple times
// has no effect after the first
return;
}
request.onreadystatechange = null;
request.abort();
request = null;
if (error) {
return playlistRequestError(request, playlist.uri, startingState);
}
// request the new playlist
request = this.hls_.xhr({
uri: resolveUrl(loader.master.uri, playlist.uri),
withCredentials
}, function(error, request) {
if (error) {
return playlistRequestError(request, playlist.uri, startingState);
}
haveMetadata(req, playlist.uri);
haveMetadata(request, playlist.uri);
// fire loadedmetadata the first time a media playlist is loaded
if (startingState === 'HAVE_MASTER') {
loader.trigger('loadedmetadata');
} else {
loader.trigger('mediachange');
}
});
};
// fire loadedmetadata the first time a media playlist is loaded
if (startingState === 'HAVE_MASTER') {
loader.trigger('loadedmetadata');
} else {
loader.trigger('mediachange');
}
});
};
/**
* set the bandwidth on an xhr to the bandwidth on the playlist
*/
loader.setBandwidth = function(xhr) {
loader.bandwidth = xhr.bandwidth;
};
// In a live playlist, don't keep track of the expired time
// until HLS tells us that "first play" has commenced
loader.on('firstplay', function() {
this.trackExpiredTime_ = true;
});
// live playlist staleness timeout
loader.on('mediaupdatetimeout', function() {
if (loader.state !== 'HAVE_METADATA') {
// only refresh the media playlist if no other activity is going on
return;
}
loader.setBandwidth = function(xhr) {
loader.bandwidth = xhr.bandwidth;
};
loader.state = 'HAVE_CURRENT_METADATA';
request = this.hls_.xhr({
uri: resolveUrl(loader.master.uri, loader.media().uri),
withCredentials
}, function(error, req) {
// disposed
if (!request) {
return;
}
// In a live list, don't keep track of the expired time until
// HLS tells us that "first play" has commenced
loader.on('firstplay', function() {
this.trackExpiredTime_ = true;
if (error) {
return playlistRequestError(request, loader.media().uri);
}
haveMetadata(request, loader.media().uri);
});
});
// live playlist staleness timeout
loader.on('mediaupdatetimeout', function() {
if (loader.state !== 'HAVE_METADATA') {
// only refresh the media playlist if no other activity is going on
return;
/**
* pause loading of the playlist
*/
loader.pause = () => {
loader.stopRequest();
window.clearTimeout(mediaUpdateTimeout);
};
/**
* start loading of the playlist
*/
loader.load = () => {
if (loader.started) {
if (!loader.media().endList) {
loader.trigger('mediaupdatetimeout');
} else {
loader.trigger('loadedplaylist');
}
} else {
loader.start();
}
};
loader.state = 'HAVE_CURRENT_METADATA';
request = this.hls_.xhr({
uri: resolveUrl(loader.master.uri, loader.media().uri),
withCredentials
}, function(error, request) {
if (error) {
return playlistRequestError(request, loader.media().uri);
}
haveMetadata(request, loader.media().uri);
});
});
/**
* start loading of the playlist
*/
loader.start = () => {
loader.started = true;
// request the specified URL
request = this.hls_.xhr({
......@@ -316,8 +402,14 @@ export default class PlaylistLoader extends Stream {
withCredentials
}, function(error, req) {
let parser;
let playlist;
let i;
// disposed
if (!request) {
return;
}
// clear the loader's request reference
request = null;
......@@ -344,10 +436,23 @@ export default class PlaylistLoader extends Stream {
if (parser.manifest.playlists) {
loader.master = parser.manifest;
// setup by-URI lookups
// setup by-URI lookups and resolve media playlist URIs
i = loader.master.playlists.length;
while (i--) {
loader.master.playlists[loader.master.playlists[i].uri] = loader.master.playlists[i];
playlist = loader.master.playlists[i];
loader.master.playlists[playlist.uri] = playlist;
playlist.resolvedUri = resolveUrl(loader.master.uri, playlist.uri);
}
// resolve any media group URIs
for (let groupKey in loader.master.mediaGroups.AUDIO) {
for (let labelKey in loader.master.mediaGroups.AUDIO[groupKey]) {
let alternateAudio = loader.master.mediaGroups.AUDIO[groupKey][labelKey];
if (alternateAudio.uri) {
alternateAudio.resolvedUri =
resolveUrl(loader.master.uri, alternateAudio.uri);
}
}
}
loader.trigger('loadedplaylist');
......@@ -368,200 +473,82 @@ export default class PlaylistLoader extends Stream {
}]
};
loader.master.playlists[srcUrl] = loader.master.playlists[0];
loader.master.playlists[0].resolvedUri = srcUrl;
haveMetadata(req, srcUrl);
return loader.trigger('loadedmetadata');
});
}
/**
* Update the PlaylistLoader state to reflect the changes in an
* update to the current media playlist.
* @param update {object} the updated media playlist object
*/
updateMediaPlaylist_(update) {
let outdated;
let i;
let segment;
outdated = this.media_;
this.media_ = this.master.playlists[update.uri];
if (!outdated) {
return;
}
};
};
// don't track expired time until this flag is truthy
if (!this.trackExpiredTime_) {
return;
}
PlaylistLoader.prototype = new Stream();
// if the update was the result of a rendition switch do not
// attempt to calculate expired_ since media-sequences need not
// correlate between renditions/variants
if (update.uri !== outdated.uri) {
return;
}
/**
* Update the PlaylistLoader state to reflect the changes in an
* update to the current media playlist.
*
* @param {Object} update the updated media playlist object
*/
PlaylistLoader.prototype.updateMediaPlaylist_ = function(update) {
let outdated;
let i;
let segment;
// try using precise timing from first segment of the updated
// playlist
if (update.segments.length) {
if (update.segments[0].start !== undefined) {
this.expired_ = update.segments[0].start;
return;
} else if (update.segments[0].end !== undefined) {
this.expired_ = update.segments[0].end - update.segments[0].duration;
return;
}
}
outdated = this.media_;
this.media_ = this.master.playlists[update.uri];
// calculate expired by walking the outdated playlist
i = update.mediaSequence - outdated.mediaSequence - 1;
if (!outdated) {
return;
}
for (; i >= 0; i--) {
segment = outdated.segments[i];
// don't track expired time until this flag is truthy
if (!this.trackExpiredTime_) {
return;
}
if (!segment) {
// we missed information on this segment completely between
// playlist updates so we'll have to take an educated guess
// once we begin buffering again, any error we introduce can
// be corrected
this.expired_ += outdated.targetDuration || 10;
continue;
}
// if the update was the result of a rendition switch do not
// attempt to calculate expired_ since media-sequences need not
// correlate between renditions/variants
if (update.uri !== outdated.uri) {
return;
}
if (segment.end !== undefined) {
this.expired_ = segment.end;
return;
}
if (segment.start !== undefined) {
this.expired_ = segment.start + segment.duration;
return;
}
this.expired_ += segment.duration;
// try using precise timing from first segment of the updated
// playlist
if (update.segments.length) {
if (typeof update.segments[0].start !== 'undefined') {
this.expired_ = update.segments[0].start;
return;
} else if (typeof update.segments[0].end !== 'undefined') {
this.expired_ = update.segments[0].end - update.segments[0].duration;
return;
}
}
/**
* Determine the index of the segment that contains a specified
* playback position in the current media playlist. Early versions
* of the HLS specification require segment durations to be rounded
* to the nearest integer which means it may not be possible to
* determine the correct segment for a playback position if that
* position is within .5 seconds of the segment duration. This
* function will always return the lower of the two possible indices
* in those cases.
*
* @param time {number} The number of seconds since the earliest
* possible position to determine the containing segment for
* @returns {number} The number of the media segment that contains
* that time position. If the specified playback position is outside
* the time range of the current set of media segments, the return
* value will be clamped to the index of the segment containing the
* closest playback position that is currently available.
*/
getMediaIndexForTime_(time) {
let i;
let segment;
let originalTime = time;
let numSegments = this.media_.segments.length;
let lastSegment = numSegments - 1;
let startIndex;
let endIndex;
let knownStart;
let knownEnd;
if (!this.media_) {
return 0;
}
// calculate expired by walking the outdated playlist
i = update.mediaSequence - outdated.mediaSequence - 1;
// when the requested position is earlier than the current set of
// segments, return the earliest segment index
if (time < 0) {
return 0;
}
for (; i >= 0; i--) {
segment = outdated.segments[i];
// find segments with known timing information that bound the
// target time
for (i = 0; i < numSegments; i++) {
segment = this.media_.segments[i];
if (segment.end) {
if (segment.end > time) {
knownEnd = segment.end;
endIndex = i;
break;
} else {
knownStart = segment.end;
startIndex = i + 1;
}
}
if (!segment) {
// we missed information on this segment completely between
// playlist updates so we'll have to take an educated guess
// once we begin buffering again, any error we introduce can
// be corrected
this.expired_ += outdated.targetDuration || 10;
continue;
}
// use the bounds we just found and playlist information to
// estimate the segment that contains the time we are looking for
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;
if (time < 0) {
return i;
}
}
if (i >= endIndex) {
// We haven't found a segment but we did hit a known end point
// so fallback to interpolating between 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));
}
// 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;
if (time < 0) {
return i;
}
}
// We haven't found a segment so load the first one if time is zero
if (time === 0) {
return 0;
} else {
return -1;
}
} else {
// We known nothing so walk from the front of the playlist,
// subtracting durations until we find a segment that contains
// time and return it
time = time - this.expired_;
if (time < 0) {
return -1;
}
for (i = 0; i < numSegments; i++) {
segment = this.media_.segments[i];
time -= segment.duration;
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;
if (typeof segment.end !== 'undefined') {
this.expired_ = segment.end;
return;
}
if (typeof segment.start !== 'undefined') {
this.expired_ = segment.start + segment.duration;
return;
}
this.expired_ += segment.duration;
}
}
};
export default PlaylistLoader;
......
/**
* @file playlist.js
*
* Playlist related utilities.
*/
import {createTimeRange} from 'video.js';
......@@ -13,6 +15,14 @@ let Playlist = {
UNSAFE_LIVE_SEGMENTS: 3
};
/**
* walk backward until we find a duration we can use
* or return a failure
*
* @param {Playlist} playlist the playlist to walk through
* @param {Number} endSequence the mediaSequence to stop walking on
*/
const backwardDuration = function(playlist, endSequence) {
let result = 0;
let i = endSequence - playlist.mediaSequence;
......@@ -48,6 +58,13 @@ const backwardDuration = function(playlist, endSequence) {
return { result, precise: false };
};
/**
* walk forward until we find a duration we can use
* or return a failure
*
* @param {Playlist} playlist the playlist to walk through
* @param {Number} endSequence the mediaSequence to stop walking on
*/
const forwardDuration = function(playlist, endSequence) {
let result = 0;
let segment;
......@@ -83,13 +100,15 @@ const forwardDuration = function(playlist, endSequence) {
* playlist. The duration of a subinterval of the available segments
* may be calculated by specifying an end index.
*
* @param playlist {object} a media playlist object
* @param endSequence {number} (optional) an exclusive upper boundary
* @param {Object} playlist a media playlist object
* @param {Number=} endSequence an exclusive upper boundary
* for the playlist. Defaults to playlist length.
* @return {number} the duration between the first available segment
* @param {Number} expired the amount of time that has dropped
* off the front of the playlist in a live scenario
* @return {Number} the duration between the first available segment
* and end index.
*/
const intervalDuration = function(playlist, endSequence) {
const intervalDuration = function(playlist, endSequence, expired) {
let backward;
let forward;
......@@ -120,7 +139,7 @@ const intervalDuration = function(playlist, endSequence) {
}
// return the less-precise, playlist-based duration estimate
return backward.result;
return backward.result + expired;
};
/**
......@@ -128,23 +147,23 @@ const intervalDuration = function(playlist, endSequence) {
* are specified, the duration will be for the subset of the media
* timeline between those two indices. The total duration for live
* playlists is always Infinity.
* @param playlist {object} a media playlist object
* @param endSequence {number} (optional) an exclusive upper
* boundary for the playlist. Defaults to the playlist media
*
* @param {Object} playlist a media playlist object
* @param {Number=} endSequence an exclusive upper
* boundary for the playlist. Defaults to the playlist media
* sequence number plus its length.
* @param includeTrailingTime {boolean} (optional) if false, the
* interval between the final segment and the subsequent segment
* will not be included in the result
* @return {number} the duration between the start index and end
* @param {Number=} expired the amount of time that has
* dropped off the front of the playlist in a live scenario
* @return {Number} the duration between the start index and end
* index.
*/
export const duration = function(playlist, endSequence, includeTrailingTime) {
export const duration = function(playlist, endSequence, expired) {
if (!playlist) {
return 0;
}
if (typeof includeTrailingTime === 'undefined') {
includeTrailingTime = true;
if (typeof expired !== 'number') {
expired = 0;
}
// if a slice of the total duration is not requested, use
......@@ -164,7 +183,7 @@ export const duration = function(playlist, endSequence, includeTrailingTime) {
// calculate the total duration based on the segment durations
return intervalDuration(playlist,
endSequence,
includeTrailingTime);
expired);
};
/**
......@@ -174,16 +193,24 @@ export const duration = function(playlist, endSequence, includeTrailingTime) {
* seekable implementation for live streams would need to offset
* these values by the duration of content that has expired from the
* stream.
* @param playlist {object} a media playlist object
*
* @param {Object} playlist a media playlist object
* @param {Number=} expired the amount of time that has
* dropped off the front of the playlist in a live scenario
* @return {TimeRanges} the periods of time that are valid targets
* for seeking
*/
export const seekable = function(playlist) {
export const seekable = function(playlist, expired) {
let start;
let end;
let endSequence;
if (typeof expired !== 'number') {
expired = 0;
}
// without segments, there are no seekable ranges
if (!playlist.segments) {
if (!playlist || !playlist.segments) {
return createTimeRange();
}
// when the playlist is complete, the entire duration is seekable
......@@ -194,15 +221,142 @@ export const seekable = function(playlist) {
// live playlists should not expose three segment durations worth
// of content from the end of the playlist
// https://tools.ietf.org/html/draft-pantos-http-live-streaming-16#section-6.3.3
start = intervalDuration(playlist, playlist.mediaSequence);
start = intervalDuration(playlist, playlist.mediaSequence, expired);
endSequence = Math.max(0, playlist.segments.length - Playlist.UNSAFE_LIVE_SEGMENTS);
end = intervalDuration(playlist,
playlist.mediaSequence +
Math.max(0, playlist.segments.length - Playlist.UNSAFE_LIVE_SEGMENTS));
playlist.mediaSequence + endSequence,
expired);
return createTimeRange(start, end);
};
/**
* Determine the index of the segment that contains a specified
* playback position in a media playlist.
*
* @param {Object} playlist the media playlist to query
* @param {Number} time The number of seconds since the earliest
* possible position to determine the containing segment for
* @param {Number=} expired the duration of content, in
* seconds, that has been removed from this playlist because it
* expired
* @return {Number} The number of the media segment that contains
* that time position.
*/
export const getMediaIndexForTime_ = function(playlist, time, expired) {
let i;
let segment;
let originalTime = time;
let numSegments = playlist.segments.length;
let lastSegment = numSegments - 1;
let startIndex;
let endIndex;
let knownStart;
let knownEnd;
if (!playlist) {
return 0;
}
// when the requested position is earlier than the current set of
// segments, return the earliest segment index
if (time < 0) {
return 0;
}
expired = expired || 0;
// find segments with known timing information that bound the
// target time
for (i = 0; i < numSegments; i++) {
segment = playlist.segments[i];
if (segment.end) {
if (segment.end > time) {
knownEnd = segment.end;
endIndex = i;
break;
} else {
knownStart = segment.end;
startIndex = i + 1;
}
}
}
// time was equal to or past the end of the last segment in the playlist
if (startIndex === numSegments) {
return numSegments;
}
// use the bounds we just found and playlist information to
// estimate the segment that contains the time we are looking for
if (typeof 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 = playlist.segments[i];
time -= segment.duration;
if (time < 0) {
return i;
}
}
if (i >= endIndex) {
// We haven't found a segment but we did hit a known end point
// so fallback to interpolating between 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));
}
// We _still_ haven't found a segment so load the last one
return lastSegment;
} else if (typeof 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 = playlist.segments[i];
time -= segment.duration;
if (time < 0) {
return i;
}
}
// We haven't found a segment so load the first one if time is zero
if (time === 0) {
return 0;
}
return -1;
}
// We known nothing so walk from the front of the playlist,
// subtracting durations until we find a segment that contains
// time and return it
time = time - expired;
if (time < 0) {
return -1;
}
for (i = 0; i < numSegments; i++) {
segment = playlist.segments[i];
time -= segment.duration;
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;
};
Playlist.duration = duration;
Playlist.seekable = seekable;
Playlist.getMediaIndexForTime_ = getMediaIndexForTime_;
// exports
export default Playlist;
......
/**
* ranges
*
* Utilities for working with TimeRanges.
*
*/
import videojs from 'video.js';
// Fudge factor to account for TimeRanges rounding
const TIME_FUDGE_FACTOR = 1 / 30;
const filterRanges = function(timeRanges, predicate) {
let results = [];
let i;
if (timeRanges && timeRanges.length) {
// Search for ranges that match the predicate
for (i = 0; i < timeRanges.length; i++) {
if (predicate(timeRanges.start(i), timeRanges.end(i))) {
results.push([timeRanges.start(i), timeRanges.end(i)]);
}
}
}
return videojs.createTimeRanges(results);
};
/**
* Attempts to find the buffered TimeRange that contains the specified
* time.
* @param {TimeRanges} buffered - the TimeRanges object to query
* @param {number} time - the time to filter on.
* @returns {TimeRanges} a new TimeRanges object
*/
const findRange = function(buffered, time) {
return filterRanges(buffered, function(start, end) {
return start - TIME_FUDGE_FACTOR <= time &&
end + TIME_FUDGE_FACTOR >= time;
});
};
/**
* Returns the TimeRanges that begin at or later than the specified
* time.
* @param {TimeRanges} timeRanges - the TimeRanges object to query
* @param {number} time - the time to filter on.
* @returns {TimeRanges} a new TimeRanges object.
*/
const findNextRange = function(timeRanges, time) {
return filterRanges(timeRanges, function(start) {
return start - TIME_FUDGE_FACTOR >= time;
});
};
/**
* Search for a likely end time for the segment that was just appened
* based on the state of the `buffered` property before and after the
* append. If we fin only one such uncommon end-point return it.
* @param {TimeRanges} original - the buffered time ranges before the update
* @param {TimeRanges} update - the buffered time ranges after the update
* @returns {Number|null} the end time added between `original` and `update`,
* or null if one cannot be unambiguously determined.
*/
const findSoleUncommonTimeRangesEnd = function(original, update) {
let i;
let start;
let end;
let result = [];
let edges = [];
// In order to qualify as a possible candidate, the end point must:
// 1) Not have already existed in the `original` ranges
// 2) Not result from the shrinking of a range that already existed
// in the `original` ranges
// 3) Not be contained inside of a range that existed in `original`
const overlapsCurrentEnd = function(span) {
return (span[0] <= end && span[1] >= end);
};
if (original) {
// Save all the edges in the `original` TimeRanges object
for (i = 0; i < original.length; i++) {
start = original.start(i);
end = original.end(i);
edges.push([start, end]);
}
}
if (update) {
// Save any end-points in `update` that are not in the `original`
// TimeRanges object
for (i = 0; i < update.length; i++) {
start = update.start(i);
end = update.end(i);
if (edges.some(overlapsCurrentEnd)) {
continue;
}
// at this point it must be a unique non-shrinking end edge
result.push(end);
}
}
// we err on the side of caution and return null if didn't find
// exactly *one* differing end edge in the search above
if (result.length !== 1) {
return null;
}
return result[0];
};
/**
* Calculate the intersection of two TimeRanges
* @param {TimeRanges} bufferA
* @param {TimeRanges} bufferB
* @returns {TimeRanges} The interesection of `bufferA` with `bufferB`
*/
const bufferIntersection = function(bufferA, bufferB) {
let start = null;
let end = null;
let arity = 0;
let extents = [];
let ranges = [];
if (!bufferA || !bufferA.length || !bufferB || !bufferB.length) {
return videojs.createTimeRange();
}
// Handle the case where we have both buffers and create an
// intersection of the two
let count = bufferA.length;
// A) Gather up all start and end times
while (count--) {
extents.push({time: bufferA.start(count), type: 'start'});
extents.push({time: bufferA.end(count), type: 'end'});
}
count = bufferB.length;
while (count--) {
extents.push({time: bufferB.start(count), type: 'start'});
extents.push({time: bufferB.end(count), type: 'end'});
}
// B) Sort them by time
extents.sort(function(a, b) {
return a.time - b.time;
});
// C) Go along one by one incrementing arity for start and decrementing
// arity for ends
for (count = 0; count < extents.length; count++) {
if (extents[count].type === 'start') {
arity++;
// D) If arity is ever incremented to 2 we are entering an
// overlapping range
if (arity === 2) {
start = extents[count].time;
}
} else if (extents[count].type === 'end') {
arity--;
// E) If arity is ever decremented to 1 we leaving an
// overlapping range
if (arity === 1) {
end = extents[count].time;
}
}
// F) Record overlapping ranges
if (start !== null && end !== null) {
ranges.push([start, end]);
start = null;
end = null;
}
}
return videojs.createTimeRanges(ranges);
};
/**
* Calculates the percentage of `segmentRange` that overlaps the
* `buffered` time ranges.
* @param {TimeRanges} segmentRange - the time range that the segment covers
* @param {TimeRanges} buffered - the currently buffered time ranges
* @returns {Number} percent of the segment currently buffered
*/
const calculateBufferedPercent = function(segmentRange, buffered) {
let segmentDuration = segmentRange.end(0) - segmentRange.start(0);
let intersection = bufferIntersection(segmentRange, buffered);
let overlapDuration = 0;
let count = intersection.length;
while (count--) {
overlapDuration += intersection.end(count) - intersection.start(count);
}
return (overlapDuration / segmentDuration) * 100;
};
export default {
findRange,
findNextRange,
findSoleUncommonTimeRangesEnd,
calculateBufferedPercent,
TIME_FUDGE_FACTOR
};
/**
* @file resolve-url.js
*/
import document from 'global/document';
/* eslint-disable max-len */
/**
* Constructs a new URI by interpreting a path relative to another
* URI.
* @param basePath {string} a relative or absolute URI
* @param path {string} a path part to combine with the base
* @return {string} a URI that is equivalent to composing `base`
* with `path`
*
* @see http://stackoverflow.com/questions/470832/getting-an-absolute-url-from-a-relative-one-ie6-issue
* @param {String} basePath a relative or absolute URI
* @param {String} path a path part to combine with the base
* @return {String} a URI that is equivalent to composing `base`
* with `path`
*/
/* eslint-enable max-len */
const resolveUrl = function(basePath, path) {
// use the base element to get the browser to handle URI resolution
let oldBase = document.querySelector('base');
......
/**
* @file segment-loader.js
*/
import Ranges from './ranges';
import {getMediaIndexForTime_ as getMediaIndexForTime, duration} from './playlist';
import videojs from 'video.js';
import SourceUpdater from './source-updater';
import {Decrypter} from './decrypter';
// in ms
const CHECK_BUFFER_DELAY = 500;
// the desired length of video to maintain in the buffer, in seconds
export const GOAL_BUFFER_LENGTH = 30;
/**
* 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 {Object} playlist a media playlist object
* @param {Number} segmentIndex the index of segment we last appended
* @param {Number} segmentEnd the known of the segment referenced by segmentIndex
*/
const updateSegmentMetadata = function(playlist, segmentIndex, segmentEnd) {
if (!playlist) {
return false;
}
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;
}
return true;
}
return false;
};
/**
* 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 {Object} playlist a media playlist object
* @param {Object} mediaSource the MediaSource object
* @param {Number} segmentIndex the index of segment we last appended
* @param {Object} currentBuffered buffered region that currentTime resides in
* @returns {Boolean} do we need to 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);
};
/* Turns segment byterange into a string suitable for use in
* HTTP Range requests
*/
const byterangeStr = function(byterange) {
let byterangeStart;
let byterangeEnd;
// `byterangeEnd` is one less than `offset + length` because the HTTP range
// header uses inclusive ranges
byterangeEnd = byterange.offset + byterange.length - 1;
byterangeStart = byterange.offset;
return 'bytes=' + byterangeStart + '-' + byterangeEnd;
};
/* Defines headers for use in the xhr request for a particular segment.
*/
const segmentXhrHeaders = function(segment) {
let headers = {};
if ('byterange' in segment) {
headers.Range = byterangeStr(segment.byterange);
}
return headers;
};
/**
* An object that manages segment loading and appending.
*
* @class SegmentLoader
* @param {Object} options required and optional options
* @extends videojs.EventTarget
*/
export default class SegmentLoader extends videojs.EventTarget {
constructor(options) {
super();
let settings;
// check pre-conditions
if (!options) {
throw new TypeError('Initialization options are required');
}
if (typeof options.currentTime !== 'function') {
throw new TypeError('No currentTime getter specified');
}
if (!options.mediaSource) {
throw new TypeError('No MediaSource specified');
}
settings = videojs.mergeOptions(videojs.options.hls, options);
// public properties
this.state = 'INIT';
this.bandwidth = settings.bandwidth;
this.roundTrip = NaN;
this.bytesReceived = 0;
// private properties
this.hasPlayed_ = settings.hasPlayed;
this.currentTime_ = settings.currentTime;
this.seekable_ = settings.seekable;
this.seeking_ = settings.seeking;
this.setCurrentTime_ = settings.setCurrentTime;
this.mediaSource_ = settings.mediaSource;
this.withCredentials_ = settings.withCredentials;
this.checkBufferTimeout_ = null;
this.error_ = void 0;
this.expired_ = 0;
this.timeCorrection_ = 0;
this.currentTimeline_ = -1;
this.xhr_ = null;
this.pendingSegment_ = null;
this.sourceUpdater_ = null;
this.hls_ = settings.hls;
}
/**
* dispose of the SegmentLoader and reset to the default state
*/
dispose() {
this.state = 'DISPOSED';
this.abort_();
if (this.sourceUpdater_) {
this.sourceUpdater_.dispose();
}
}
/**
* abort anything that is currently doing on with the SegmentLoader
* and reset to a default state
*/
abort() {
if (this.state !== 'WAITING') {
return;
}
this.abort_();
// don't wait for buffer check timeouts to begin fetching the
// next segment
if (!this.paused()) {
this.state = 'READY';
this.fillBuffer_();
}
}
/**
* set an error on the segment loader and null out any pending segements
*
* @param {Error} error the error to set on the SegmentLoader
* @return {Error} the error that was set or that is currently set
*/
error(error) {
if (typeof error !== 'undefined') {
this.error_ = error;
}
this.pendingSegment_ = null;
return this.error_;
}
/**
* load a playlist and start to fill the buffer
*/
load() {
this.monitorBuffer_();
// if we don't have a playlist yet, keep waiting for one to be
// specified
if (!this.playlist_) {
return;
}
// if we're in the middle of processing a segment already, don't
// kick off an additional segment request
if (!this.sourceUpdater_ ||
(this.state !== 'READY' &&
this.state !== 'INIT')) {
return;
}
this.state = 'READY';
this.fillBuffer_();
}
/**
* set a playlist on the segment loader
*
* @param {PlaylistLoader} media the playlist to set on the segment loader
*/
playlist(media) {
this.playlist_ = media;
// if we were unpaused but waiting for a playlist, start
// buffering now
if (this.sourceUpdater_ &&
media &&
this.state === 'INIT' &&
!this.paused()) {
this.state = 'READY';
return this.fillBuffer_();
}
}
/**
* Prevent the loader from fetching additional segments. If there
* is a segment request outstanding, it will finish processing
* before the loader halts. A segment loader can be unpaused by
* calling load().
*/
pause() {
if (this.checkBufferTimeout_) {
window.clearTimeout(this.checkBufferTimeout_);
this.checkBufferTimeout_ = null;
}
}
/**
* Returns whether the segment loader is fetching additional
* segments when given the opportunity. This property can be
* modified through calls to pause() and load().
*/
paused() {
return this.checkBufferTimeout_ === null;
}
/**
* setter for expired time on the SegmentLoader
*
* @param {Number} expired the exired time to set
*/
expired(expired) {
this.expired_ = expired;
}
/**
* create/set the following mimetype on the SourceBuffer through a
* SourceUpdater
*
* @param {String} mimeType the mime type string to use
*/
mimeType(mimeType) {
// TODO Allow source buffers to be re-created with different mime-types
if (!this.sourceUpdater_) {
this.sourceUpdater_ = new SourceUpdater(this.mediaSource_, mimeType);
this.clearBuffer();
// if we were unpaused but waiting for a sourceUpdater, start
// buffering now
if (this.playlist_ &&
this.state === 'INIT' &&
!this.paused()) {
this.state = 'READY';
return this.fillBuffer_();
}
}
}
/**
* asynchronously/recursively monitor the buffer
*
* @private
*/
monitorBuffer_() {
if (this.state === 'READY') {
this.fillBuffer_();
}
this.checkBufferTimeout_ = window.setTimeout(this.monitorBuffer_.bind(this),
CHECK_BUFFER_DELAY);
}
/**
* Return the amount of a segment specified by the mediaIndex overlaps
* the current buffered content.
*
* @param {Object} playlist the playlist object to fetch segments from
* @param {Number} mediaIndex the index of the segment in the playlist
* @param {TimeRanges} buffered the state of the buffer
* @returns {Number} percentage of the segment's time range that is
* already in `buffered`
*/
getSegmentBufferedPercent_(playlist, mediaIndex, currentTime, buffered) {
let segment = playlist.segments[mediaIndex];
let startOfSegment = duration(playlist,
playlist.mediaSequence + mediaIndex,
this.expired_);
let segmentRange = videojs.createTimeRanges([[
Math.max(currentTime, startOfSegment),
startOfSegment + segment.duration
]]);
return Ranges.calculateBufferedPercent(segmentRange, buffered);
}
/**
* Determines what segment request should be made, given current
* playback state.
*
* @param {TimeRanges} buffered - the state of the buffer
* @param {Object} playlist - the playlist object to fetch segments from
* @param {Number} currentTime - the playback position in seconds
* @returns {Object} a segment info object that describes the
* request that should be made or null if no request is necessary
*/
checkBuffer_(buffered, playlist, currentTime) {
let currentBuffered = Ranges.findRange(buffered, currentTime);
// There are times when MSE reports the first segment as starting a
// little after 0-time so add a fudge factor to try and fix those cases
// or we end up fetching the same first segment over and over
if (currentBuffered.length === 0 && currentTime === 0) {
currentBuffered = Ranges.findRange(buffered,
currentTime + Ranges.TIME_FUDGE_FACTOR);
}
let bufferedTime;
let currentBufferedEnd;
let timestampOffset = this.sourceUpdater_.timestampOffset();
let segment;
let mediaIndex;
if (!playlist.segments.length) {
return;
}
if (currentBuffered.length === 0) {
// find the segment containing currentTime
mediaIndex = getMediaIndexForTime(playlist,
currentTime,
this.expired_ + this.timeCorrection_);
} else {
// find the segment adjacent to the end of the current
// buffered region
currentBufferedEnd = currentBuffered.end(0);
bufferedTime = Math.max(0, currentBufferedEnd - currentTime);
// if the video has not yet played only, and we already have
// one segment downloaded do nothing
if (!this.hasPlayed_() && bufferedTime >= 1) {
return null;
}
// if there is plenty of content buffered, and the video has
// been played before relax for awhile
if (this.hasPlayed_() && bufferedTime >= GOAL_BUFFER_LENGTH) {
return null;
}
mediaIndex = getMediaIndexForTime(playlist,
currentBufferedEnd,
this.expired_ + this.timeCorrection_);
}
if (mediaIndex < 0 || mediaIndex === playlist.segments.length) {
return null;
}
// Sanity check the segment-index determining logic above but calcuating
// the percentage of the chosen segment that is buffered. If more than 90%
// of the segment is buffered then fetching it will likely not help in any
// way
let percentBuffered = this.getSegmentBufferedPercent_(playlist,
mediaIndex,
currentTime,
buffered);
if (percentBuffered >= 90) {
// Retry the buffered calculation with the next segment if there is another
// segment after the currently selected segment
if (mediaIndex + 1 < playlist.segments.length) {
percentBuffered = this.getSegmentBufferedPercent_(playlist,
mediaIndex + 1,
currentTime,
buffered);
}
// If both checks failed return and don't load anything
if (percentBuffered >= 90) {
return;
}
// Otherwise, continue with the next segment
mediaIndex += 1;
}
segment = playlist.segments[mediaIndex];
let startOfSegment = duration(playlist,
playlist.mediaSequence + mediaIndex,
this.expired_);
// We will need to change timestampOffset of the sourceBuffer if either of
// the following conditions are true:
// - The segment.timeline !== this.currentTimeline
// (we are crossing a discontinuity somehow)
// - The "timestampOffset" for the start of this segment is less than
// the currently set timestampOffset
if (segment.timeline !== this.currentTimeline_ ||
startOfSegment < this.sourceUpdater_.timestampOffset()) {
timestampOffset = startOfSegment;
}
return {
// resolve the segment URL relative to the playlist
uri: segment.resolvedUri,
// the segment's mediaIndex at the time it was requested
mediaIndex,
// the segment's playlist
playlist,
// unencrypted bytes of the segment
bytes: null,
// when a key is defined for this segment, the encrypted bytes
encryptedBytes: null,
// the state of the buffer before a segment is appended will be
// stored here so that the actual segment duration can be
// determined after it has been appended
buffered: null,
// The target timestampOffset for this segment when we append it
// to the source buffer
timestampOffset,
// The timeline that the segment is in
timeline: segment.timeline
};
}
/**
* abort all pending xhr requests and null any pending segements
*
* @private
*/
abort_() {
if (this.xhr_) {
this.xhr_.abort();
}
// clear out the segment being processed
this.pendingSegment_ = null;
}
/**
* fill the buffer with segements unless the
* sourceBuffers are currently updating
*
* @private
*/
fillBuffer_() {
if (this.sourceUpdater_.updating()) {
return;
}
// see if we need to begin loading immediately
let request = this.checkBuffer_(this.sourceUpdater_.buffered(),
this.playlist_,
this.currentTime_(),
this.timestampOffset_);
if (request) {
this.loadSegment_(request);
}
}
/**
* load a specific segment from a request into the buffer
*
* @private
*/
loadSegment_(segmentInfo) {
let segment;
let requestTimeout;
let keyXhr;
let segmentXhr;
let seekable = this.seekable_();
let currentTime = this.currentTime_();
let removeToTime = 0;
// Chrome has a hard limit of 150mb of
// buffer and a very conservative "garbage collector"
// We manually clear out the old buffer to ensure
// we don't trigger the QuotaExceeded error
// on the source buffer during subsequent appends
// 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 &&
seekable.start(0) < currentTime) {
removeToTime = seekable.start(0);
} else {
removeToTime = currentTime - 60;
}
if (removeToTime > 0) {
this.sourceUpdater_.remove(0, removeToTime);
}
segment = segmentInfo.playlist.segments[segmentInfo.mediaIndex];
// Set xhr timeout to 150% of the segment duration to allow us
// some time to switch renditions in the event of a catastrophic
// decrease in network performance or a server issue.
requestTimeout = (segment.duration * 1.5) * 1000;
if (segment.key) {
keyXhr = this.hls_.xhr({
uri: segment.key.resolvedUri,
responseType: 'arraybuffer',
withCredentials: this.withCredentials_,
timeout: requestTimeout
}, this.handleResponse_.bind(this));
}
this.pendingSegment_ = segmentInfo;
segmentXhr = this.hls_.xhr({
uri: segmentInfo.uri,
responseType: 'arraybuffer',
withCredentials: this.withCredentials_,
timeout: requestTimeout,
headers: segmentXhrHeaders(segment)
}, this.handleResponse_.bind(this));
this.xhr_ = {
keyXhr,
segmentXhr,
abort() {
if (this.segmentXhr) {
// Prevent error handler from running.
this.segmentXhr.onreadystatechange = null;
this.segmentXhr.abort();
this.segmentXhr = null;
}
if (this.keyXhr) {
// Prevent error handler from running.
this.keyXhr.onreadystatechange = null;
this.keyXhr.abort();
this.keyXhr = null;
}
}
};
this.state = 'WAITING';
}
/**
* triggered when a segment response is received
*
* @private
*/
handleResponse_(error, request) {
let segmentInfo;
let segment;
let keyXhrRequest;
let view;
// timeout of previously aborted request
if (!this.xhr_ ||
(request !== this.xhr_.segmentXhr && request !== this.xhr_.keyXhr)) {
return;
}
segmentInfo = this.pendingSegment_;
segment = segmentInfo.playlist.segments[segmentInfo.mediaIndex];
// if a request times out, reset bandwidth tracking
if (request.timedout) {
this.abort_();
this.bandwidth = 1;
this.roundTrip = NaN;
this.state = 'READY';
return this.trigger('progress');
}
// trigger an event for other errors
if (!request.aborted && error) {
// abort will clear xhr_
keyXhrRequest = this.xhr_.keyXhr;
this.abort_();
this.error({
status: request.status,
message: request === keyXhrRequest ?
'HLS key request error at URL: ' + segment.key.uri :
'HLS segment request error at URL: ' + segmentInfo.uri,
code: 2,
xhr: request
});
this.state = 'READY';
this.pause();
return this.trigger('error');
}
// stop processing if the request was aborted
if (!request.response) {
this.abort_();
return;
}
if (request === this.xhr_.segmentXhr) {
// the segment request is no longer outstanding
this.xhr_.segmentXhr = null;
// calculate the download bandwidth based on segment request
this.roundTrip = request.roundTripTime;
this.bandwidth = request.bandwidth;
this.bytesReceived += request.bytesReceived || 0;
if (segment.key) {
segmentInfo.encryptedBytes = new Uint8Array(request.response);
} else {
segmentInfo.bytes = new Uint8Array(request.response);
}
}
if (request === this.xhr_.keyXhr) {
keyXhrRequest = this.xhr_.segmentXhr;
// the key request is no longer outstanding
this.xhr_.keyXhr = null;
if (request.response.byteLength !== 16) {
this.abort_();
this.error({
status: request.status,
message: 'Invalid HLS key at URL: ' + segment.key.uri,
code: 2,
xhr: request
});
this.state = 'READY';
this.pause();
return this.trigger('error');
}
view = new DataView(request.response);
segment.key.bytes = new Uint32Array([
view.getUint32(0),
view.getUint32(4),
view.getUint32(8),
view.getUint32(12)
]);
// if the media sequence is greater than 2^32, the IV will be incorrect
// assuming 10s segments, that would be about 1300 years
segment.key.iv = segment.key.iv || new Uint32Array([
0, 0, 0, segmentInfo.mediaIndex + segmentInfo.playlist.mediaSequence
]);
}
if (!this.xhr_.segmentXhr && !this.xhr_.keyXhr) {
this.xhr_ = null;
this.processResponse_();
}
}
/**
* clear anything that is currently in the buffer and throw it away
*/
clearBuffer() {
if (this.sourceUpdater_ &&
this.sourceUpdater_.buffered().length) {
this.sourceUpdater_.remove(0, Infinity);
}
}
/**
* Decrypt the segment that is being loaded if necessary
*
* @private
*/
processResponse_() {
let segmentInfo;
let segment;
this.state = 'DECRYPTING';
segmentInfo = this.pendingSegment_;
segment = segmentInfo.playlist.segments[segmentInfo.mediaIndex];
if (segment.key) {
// this is an encrypted segment
// incrementally decrypt the segment
/* eslint-disable no-new, handle-callback-err */
new Decrypter(segmentInfo.encryptedBytes,
segment.key.bytes,
segment.key.iv,
(function(err, bytes) {
// err always null
segmentInfo.bytes = bytes;
this.handleSegment_();
}).bind(this));
/* eslint-enable */
} else {
this.handleSegment_();
}
}
/**
* append a decrypted segement to the SourceBuffer through a SourceUpdater
*
* @private
*/
handleSegment_() {
let segmentInfo;
this.state = 'APPENDING';
segmentInfo = this.pendingSegment_;
segmentInfo.buffered = this.sourceUpdater_.buffered();
this.currentTimeline_ = segmentInfo.timeline;
if (segmentInfo.timestampOffset !== this.sourceUpdater_.timestampOffset()) {
this.sourceUpdater_.timestampOffset(segmentInfo.timestampOffset);
}
this.sourceUpdater_.appendBuffer(segmentInfo.bytes,
this.handleUpdateEnd_.bind(this));
}
/**
* callback to run when appendBuffer is finished. detects if we are
* in a good state to do things with the data we got, or if we need
* to wait for more
*
* @private
*/
handleUpdateEnd_() {
let segmentInfo = this.pendingSegment_;
let currentTime = this.currentTime_();
this.pendingSegment_ = null;
// add segment timeline information if we're still using the
// same playlist
if (segmentInfo && segmentInfo.playlist.uri === this.playlist_.uri) {
this.updateTimeline_(segmentInfo);
this.trigger('progress');
}
let currentMediaIndex = segmentInfo.mediaIndex;
currentMediaIndex +=
segmentInfo.playlist.mediaSequence - this.playlist_.mediaSequence;
let currentBuffered = Ranges.findRange(this.sourceUpdater_.buffered(), currentTime);
// any time an update finishes and the last segment is in the
// buffer, end the stream. this ensures the "ended" event will
// fire if playback reaches that point.
let isEndOfStream = detectEndOfStream(segmentInfo.playlist,
this.mediaSource_,
currentMediaIndex,
currentBuffered);
if (isEndOfStream) {
this.mediaSource_.endOfStream();
}
// 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
// in that case, seek again
let seekable = this.seekable_();
let next = Ranges.findNextRange(this.sourceUpdater_.buffered(), currentTime);
if (this.seeking_() &&
currentBuffered.length === 0) {
if (seekable.length &&
currentTime < seekable.start(0)) {
if (next.length) {
videojs.log('tried seeking to', currentTime,
'but that was too early, retrying at', next.start(0));
this.setCurrentTime_(next.start(0) + Ranges.TIME_FUDGE_FACTOR);
}
}
}
this.state = 'READY';
if (!this.paused()) {
this.fillBuffer_();
}
}
/**
* annotate the segment with any start and end time information
* added by the media processing
*
* @private
* @param {Object} segmentInfo annotate a segment with time info
*/
updateTimeline_(segmentInfo) {
let segment;
let timelineUpdate;
let playlist = segmentInfo.playlist;
let currentMediaIndex = segmentInfo.mediaIndex;
currentMediaIndex += playlist.mediaSequence - this.playlist_.mediaSequence;
segment = playlist.segments[currentMediaIndex];
if (!segment) {
return;
}
timelineUpdate = Ranges.findSoleUncommonTimeRangesEnd(segmentInfo.buffered,
this.sourceUpdater_.buffered());
// Update segment meta-data (duration and end-point) based on timeline
let timelineUpdated = updateSegmentMetadata(playlist,
currentMediaIndex,
timelineUpdate);
// the last segment append must have been entirely in the
// already buffered time ranges. adjust the timeCorrection
// offset to fetch forward until we find a segment that adds
// to the buffered time ranges and improves subsequent media
// index calculations.
if (!timelineUpdated) {
this.timeCorrection_ -= segment.duration;
} else {
this.timeCorrection_ = 0;
}
}
}
/**
* @file source-updater.js
*/
import videojs from 'video.js';
/**
* A queue of callbacks to be serialized and applied when a
* MediaSource and its associated SourceBuffers are not in the
* updating state. It is used by the segment loader to update the
* underlying SourceBuffers when new data is loaded, for instance.
*
* @class SourceUpdater
* @param {MediaSource} mediaSource the MediaSource to create the
* SourceBuffer from
* @param {String} mimeType the desired MIME type of the underlying
* SourceBuffer
*/
export default class SourceUpdater {
constructor(mediaSource, mimeType) {
let createSourceBuffer = () => {
this.sourceBuffer_ = mediaSource.addSourceBuffer(mimeType);
// run completion handlers and process callbacks as updateend
// events fire
this.sourceBuffer_.addEventListener('updateend', () => {
let pendingCallback = this.pendingCallback_;
this.pendingCallback_ = null;
if (pendingCallback) {
pendingCallback();
}
});
this.sourceBuffer_.addEventListener('updateend',
this.runCallback_.bind(this));
this.runCallback_();
};
this.callbacks_ = [];
this.pendingCallback_ = null;
this.timestampOffset_ = 0;
this.mediaSource = mediaSource;
if (mediaSource.readyState === 'closed') {
mediaSource.addEventListener('sourceopen', createSourceBuffer);
} else {
createSourceBuffer();
}
}
/**
* Aborts the current segment and resets the segment parser.
*
* @param {Function} done function to call when done
* @see http://w3c.github.io/media-source/#widl-SourceBuffer-abort-void
*/
abort(done) {
this.queueCallback_(() => {
this.sourceBuffer_.abort();
}, done);
}
/**
* Queue an update to append an ArrayBuffer.
*
* @param {ArrayBuffer} bytes
* @param {Function} done the function to call when done
* @see http://www.w3.org/TR/media-source/#widl-SourceBuffer-appendBuffer-void-ArrayBuffer-data
*/
appendBuffer(bytes, done) {
this.queueCallback_(() => {
this.sourceBuffer_.appendBuffer(bytes);
}, done);
}
/**
* Indicates what TimeRanges are buffered in the managed SourceBuffer.
*
* @see http://www.w3.org/TR/media-source/#widl-SourceBuffer-buffered
*/
buffered() {
if (!this.sourceBuffer_) {
return videojs.createTimeRanges();
}
return this.sourceBuffer_.buffered;
}
/**
* Queue an update to set the duration.
*
* @param {Double} duration what to set the duration to
* @see http://www.w3.org/TR/media-source/#widl-MediaSource-duration
*/
duration(duration) {
this.queueCallback_(() => {
this.sourceBuffer_.duration = duration;
});
}
/**
* Queue an update to remove a time range from the buffer.
*
* @param {Number} start where to start the removal
* @param {Number} end where to end the removal
* @see http://www.w3.org/TR/media-source/#widl-SourceBuffer-remove-void-double-start-unrestricted-double-end
*/
remove(start, end) {
this.queueCallback_(() => {
this.sourceBuffer_.remove(start, end);
});
}
/**
* wether the underlying sourceBuffer is updating or not
*
* @return {Boolean} the updating status of the SourceBuffer
*/
updating() {
return !this.sourceBuffer_ || this.sourceBuffer_.updating;
}
/**
* Set/get the timestampoffset on the SourceBuffer
*
* @return {Number} the timestamp offset
*/
timestampOffset(offset) {
if (typeof offset !== 'undefined') {
this.queueCallback_(() => {
this.sourceBuffer_.timestampOffset = offset;
});
this.timestampOffset_ = offset;
}
return this.timestampOffset_;
}
/**
* que a callback to run
*/
queueCallback_(callback, done) {
this.callbacks_.push([callback.bind(this), done]);
this.runCallback_();
}
/**
* run a queued callback
*/
runCallback_() {
let callbacks;
if (this.sourceBuffer_ &&
!this.sourceBuffer_.updating &&
this.callbacks_.length) {
callbacks = this.callbacks_.shift();
this.pendingCallback_ = callbacks[1];
callbacks[0]();
}
}
/**
* dispose of the source updater and the underlying sourceBuffer
*/
dispose() {
if (this.sourceBuffer_ && this.mediaSource.readyState === 'open') {
this.sourceBuffer_.abort();
}
}
}
/**
* @file stream.js
*/
/**
* A lightweight readable stream implemention that handles event dispatching.
*
* @class Stream
*/
export default class Stream {
constructor() {
......@@ -8,8 +13,9 @@ export default class Stream {
/**
* Add a listener for a specified event type.
* @param type {string} the event name
* @param listener {function} the callback to be invoked when an event of
*
* @param {String} type the event name
* @param {Function} listener the callback to be invoked when an event of
* the specified type occurs
*/
on(type, listener) {
......@@ -21,9 +27,11 @@ export default class Stream {
/**
* Remove a listener for a specified event type.
* @param type {string} the event name
* @param listener {function} a function previously registered for this
*
* @param {String} type the event name
* @param {Function} listener a function previously registered for this
* type of event through `on`
* @return {Boolean} if we could turn it off or not
*/
off(type, listener) {
let index;
......@@ -39,7 +47,8 @@ export default class Stream {
/**
* Trigger an event of the specified type on this stream. Any additional
* arguments to this function are passed as parameters to event listeners.
* @param type {string} the event name
*
* @param {String} type the event name
*/
trigger(type) {
let callbacks;
......@@ -79,7 +88,8 @@ export default class Stream {
* Forwards all `data` events on this stream to the destination stream. The
* destination stream should provide a method `push` to receive the data
* events as they arrive.
* @param destination {stream} the stream that will receive all `data` events
*
* @param {Stream} destination the stream that will receive all `data` events
* @see http://nodejs.org/api/stream.html#stream_readable_pipe_destination_options
*/
pipe(destination) {
......
/**
* videojs-hls
* @file videojs-contrib-hls.js
*
* The main file for the HLS project.
* License: https://github.com/videojs/videojs-contrib-hls/blob/master/LICENSE
*/
import document from 'global/document';
import PlaylistLoader from './playlist-loader';
import Playlist from './playlist';
import xhrFactory from './xhr';
......@@ -11,7 +13,34 @@ import utils from './bin-utils';
import {MediaSource, URL} from 'videojs-contrib-media-sources';
import m3u8 from './m3u8';
import videojs from 'video.js';
import resolveUrl from './resolve-url';
import MasterPlaylistController from './master-playlist-controller';
/**
* determine if an object a is differnt from
* and object b. both only having one dimensional
* properties
*
* @param {Object} a object one
* @param {Object} b object two
* @return {Boolean} if the object has changed or not
*/
const objectChanged = function(a, b) {
if (typeof a !== typeof b) {
return true;
}
// if we have a different number of elements
// something has changed
if (Object.keys(a).length !== Object.keys(b).length) {
return true;
}
for (let prop in a) {
if (!b[prop] || a[prop] !== b[prop]) {
return true;
}
}
return false;
};
const Hls = {
PlaylistLoader,
......@@ -26,183 +55,19 @@ const Hls = {
// the desired length of video to maintain in the buffer, in seconds
Hls.GOAL_BUFFER_LENGTH = 30;
// HLS is a source handler, not a tech. Make sure attempts to use it
// as one do not cause exceptions.
Hls.canPlaySource = function() {
return videojs.log.warn('HLS is no longer a tech. Please remove it from ' +
'your player\'s techOrder.');
};
// Search for a likely end time for the segment that was just appened
// based on the state of the `buffered` property before and after the
// append.
// If we found only one such uncommon end-point return it.
Hls.findSoleUncommonTimeRangesEnd_ = function(original, update) {
let i;
let start;
let end;
let result = [];
let edges = [];
// In order to qualify as a possible candidate, the end point must:
// 1) Not have already existed in the `original` ranges
// 2) Not result from the shrinking of a range that already existed
// in the `original` ranges
// 3) Not be contained inside of a range that existed in `original`
let overlapsCurrentEnd = function(span) {
return (span[0] <= end && span[1] >= end);
};
if (original) {
// Save all the edges in the `original` TimeRanges object
for (i = 0; i < original.length; i++) {
start = original.start(i);
end = original.end(i);
edges.push([start, end]);
}
}
if (update) {
// Save any end-points in `update` that are not in the `original`
// TimeRanges object
for (i = 0; i < update.length; i++) {
start = update.start(i);
end = update.end(i);
if (edges.some(overlapsCurrentEnd)) {
continue;
}
// at this point it must be a unique non-shrinking end edge
result.push(end);
}
}
// we err on the side of caution and return null if didn't find
// exactly *one* differing end edge in the search above
if (result.length !== 1) {
return null;
}
return result[0];
};
/**
* Whether the browser has built-in HLS support.
*/
Hls.supportsNativeHls = (function() {
let video = document.createElement('video');
let xMpegUrl;
let vndMpeg;
// native HLS is definitely not supported if HTML5 video isn't
if (!videojs.getComponent('Html5').isSupported()) {
return false;
}
xMpegUrl = video.canPlayType('application/x-mpegURL');
vndMpeg = video.canPlayType('application/vnd.apple.mpegURL');
return (/probably|maybe/).test(xMpegUrl) ||
(/probably|maybe/).test(vndMpeg);
}());
// HLS is a source handler, not a tech. Make sure attempts to use it
// as one do not cause exceptions.
Hls.isSupported = function() {
return videojs.log.warn('HLS is no longer a tech. Please remove it from ' +
'your player\'s techOrder.');
};
/**
* A comparator function to sort two playlist object by bandwidth.
* @param left {object} a media playlist object
* @param right {object} a media playlist object
* @return {number} Greater than zero if the bandwidth attribute of
* left is greater than the corresponding attribute of right. Less
* than zero if the bandwidth of right is greater than left and
* exactly zero if the two are equal.
*/
Hls.comparePlaylistBandwidth = function(left, right) {
let leftBandwidth;
let rightBandwidth;
if (left.attributes && left.attributes.BANDWIDTH) {
leftBandwidth = left.attributes.BANDWIDTH;
}
leftBandwidth = leftBandwidth || window.Number.MAX_VALUE;
if (right.attributes && right.attributes.BANDWIDTH) {
rightBandwidth = right.attributes.BANDWIDTH;
}
rightBandwidth = rightBandwidth || window.Number.MAX_VALUE;
return leftBandwidth - rightBandwidth;
};
/**
* A comparator function to sort two playlist object by resolution (width).
* @param left {object} a media playlist object
* @param right {object} a media playlist object
* @return {number} Greater than zero if the resolution.width attribute of
* left is greater than the corresponding attribute of right. Less
* than zero if the resolution.width of right is greater than left and
* exactly zero if the two are equal.
*/
Hls.comparePlaylistResolution = function(left, right) {
let leftWidth;
let rightWidth;
if (left.attributes &&
left.attributes.RESOLUTION &&
left.attributes.RESOLUTION.width) {
leftWidth = left.attributes.RESOLUTION.width;
}
leftWidth = leftWidth || window.Number.MAX_VALUE;
if (right.attributes &&
right.attributes.RESOLUTION &&
right.attributes.RESOLUTION.width) {
rightWidth = right.attributes.RESOLUTION.width;
}
rightWidth = rightWidth || window.Number.MAX_VALUE;
// NOTE - Fallback to bandwidth sort as appropriate in cases where multiple renditions
// have the same media dimensions/ resolution
if (leftWidth === rightWidth &&
left.attributes.BANDWIDTH &&
right.attributes.BANDWIDTH) {
return left.attributes.BANDWIDTH - right.attributes.BANDWIDTH;
}
return leftWidth - rightWidth;
};
// A fudge factor to apply to advertised playlist bitrates to account for
// temporary flucations in client bandwidth
const bandwidthVariance = 1.2;
// 5 minute blacklist
const blacklistDuration = 5 * 60 * 1000;
// Fudge factor to account for TimeRanges rounding
const TIME_FUDGE_FACTOR = 1 / 30;
const Component = videojs.getComponent('Component');
// The amount of time to wait between checking the state of the buffer
const bufferCheckInterval = 500;
// returns true if a key has failed to download within a certain amount of retries
const keyFailed = function(key) {
return key.retries && key.retries >= 2;
};
const BANDWIDTH_VARIANCE = 1.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
* @param {HTMLElement} el the htmlelement to work on
* @param {string} the proprety to get the style for
*/
const safeGetComputedStyle = function(el, property) {
let result;
......@@ -220,137 +85,195 @@ const safeGetComputedStyle = function(el, 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
* Chooses the appropriate media playlist based on the current
* bandwidth estimate and the player size.
*
* @return {Playlist} the highest bitrate playlist less than the currently detected
* bandwidth, accounting for some amount of bandwidth variance
*/
const updateSegmentMetadata = function(playlist, segmentIndex, segmentEnd) {
if (!playlist) {
return;
}
Hls.STANDARD_PLAYLIST_SELECTOR = function() {
let effectiveBitrate;
let sortedPlaylists = this.playlists.master.playlists.slice();
let bandwidthPlaylists = [];
let now = +new Date();
let i;
let variant;
let bandwidthBestVariant;
let resolutionPlusOne;
let resolutionPlusOneAttribute;
let resolutionBestVariant;
let width;
let height;
sortedPlaylists.sort(Hls.comparePlaylistBandwidth);
// filter out any playlists that have been excluded due to
// incompatible configurations or playback errors
sortedPlaylists = sortedPlaylists.filter((localVariant) => {
if (typeof localVariant.excludeUntil !== 'undefined') {
return now >= localVariant.excludeUntil;
}
return true;
});
// filter out any variant that has greater effective bitrate
// than the current estimated bandwidth
i = sortedPlaylists.length;
while (i--) {
variant = sortedPlaylists[i];
// ignore playlists without bandwidth information
if (!variant.attributes || !variant.attributes.BANDWIDTH) {
continue;
}
let segment = playlist.segments[segmentIndex];
let previousSegment = playlist.segments[segmentIndex - 1];
effectiveBitrate = variant.attributes.BANDWIDTH * BANDWIDTH_VARIANCE;
if (segmentEnd && segment) {
segment.end = segmentEnd;
if (effectiveBitrate < this.bandwidth) {
bandwidthPlaylists.push(variant);
// 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;
// since the playlists are sorted in ascending order by
// bandwidth, the first viable variant is the best
if (!bandwidthBestVariant) {
bandwidthBestVariant = variant;
}
}
}
};
/**
* 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;
}
i = bandwidthPlaylists.length;
let segments = playlist.segments;
// sort variants by resolution
bandwidthPlaylists.sort(Hls.comparePlaylistResolution);
// 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));
// forget our old variant from above,
// or we might choose that in high-bandwidth scenarios
// (this could be the lowest bitrate rendition as we go through all of them above)
variant = null;
// 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);
};
width = parseInt(safeGetComputedStyle(this.tech_.el(), 'width'), 10);
height = parseInt(safeGetComputedStyle(this.tech_.el(), 'height'), 10);
const parseCodecs = function(codecs) {
let result = {
codecCount: 0,
videoCodec: null,
audioProfile: null
};
// iterate through the bandwidth-filtered playlists and find
// best rendition by player dimension
while (i--) {
variant = bandwidthPlaylists[i];
result.codecCount = codecs.split(',').length;
result.codecCount = result.codecCount || 2;
// ignore playlists without resolution information
if (!variant.attributes ||
!variant.attributes.RESOLUTION ||
!variant.attributes.RESOLUTION.width ||
!variant.attributes.RESOLUTION.height) {
continue;
}
// since the playlists are sorted, the first variant that has
// dimensions less than or equal to the player size is the best
let variantResolution = variant.attributes.RESOLUTION;
// parse the video codec but ignore the version
result.videoCodec = (/(^|\s|,)+(avc1)[^ ,]*/i).exec(codecs);
result.videoCodec = result.videoCodec && result.videoCodec[2];
if (variantResolution.width === width &&
variantResolution.height === height) {
// if we have the exact resolution as the player use it
resolutionPlusOne = null;
resolutionBestVariant = variant;
break;
} else if (variantResolution.width < width &&
variantResolution.height < height) {
// if both dimensions are less than the player use the
// previous (next-largest) variant
break;
} else if (!resolutionPlusOne ||
(variantResolution.width < resolutionPlusOneAttribute.width &&
variantResolution.height < resolutionPlusOneAttribute.height)) {
// If we still haven't found a good match keep a
// reference to the previous variant for the next loop
// iteration
// parse the last field of the audio codec
result.audioProfile = (/(^|\s|,)+mp4a.\d+\.(\d+)/i).exec(codecs);
result.audioProfile = result.audioProfile && result.audioProfile[2];
// By only saving variants if they are smaller than the
// previously saved variant, we ensure that we also pick
// the highest bandwidth variant that is just-larger-than
// the video player
resolutionPlusOne = variant;
resolutionPlusOneAttribute = resolutionPlusOne.attributes.RESOLUTION;
}
}
return result;
// fallback chain of variants
return resolutionPlusOne ||
resolutionBestVariant ||
bandwidthBestVariant ||
sortedPlaylists[0];
};
const filterBufferedRanges = function(predicate) {
return function(time) {
let i;
let ranges = [];
let tech = this.tech_;
// !!The order of the next two assignments 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.
let buffered = tech.buffered();
if (typeof time === 'undefined') {
time = tech.currentTime();
}
// HLS is a source handler, not a tech. Make sure attempts to use it
// as one do not cause exceptions.
Hls.canPlaySource = function() {
return videojs.log.warn('HLS is no longer a tech. Please remove it from ' +
'your player\'s techOrder.');
};
// IE 11 has a bug where it will report a the video as fully buffered
// before any data has been loaded. This is a work around where we
// report a fully empty buffer until SourceBuffers have been created
// which is after a segment has been loaded and transmuxed.
if (!this.mediaSource ||
(this.mediaSource.mediaSource_ &&
!this.mediaSource.mediaSource_.sourceBuffers.length)) {
return videojs.createTimeRanges([]);
}
/**
* Whether the browser has built-in HLS support.
*/
Hls.supportsNativeHls = (function() {
let video = document.createElement('video');
if (buffered && buffered.length) {
// Search for a range containing the play-head
for (i = 0; i < buffered.length; i++) {
if (predicate(buffered.start(i), buffered.end(i), time)) {
ranges.push([buffered.start(i), buffered.end(i)]);
}
}
}
// native HLS is definitely not supported if HTML5 video isn't
if (!videojs.getComponent('Html5').isSupported()) {
return false;
}
return videojs.createTimeRanges(ranges);
};
// HLS manifests can go by many mime-types
let canPlay = [
// Apple santioned
'application/vnd.apple.mpegurl',
// Apple sanctioned for backwards compatibility
'audio/mpegurl',
// Very common
'audio/x-mpegurl',
// Very common
'application/x-mpegurl',
// Included for completeness
'video/x-mpegurl',
'video/mpegurl',
'application/mpegurl'
];
return canPlay.some(function(canItPlay) {
return (/maybe|probably/i).test(video.canPlayType(canItPlay));
});
}());
/**
* HLS is a source handler, not a tech. Make sure attempts to use it
* as one do not cause exceptions.
*/
Hls.isSupported = function() {
return videojs.log.warn('HLS is no longer a tech. Please remove it from ' +
'your player\'s techOrder.');
};
export default class HlsHandler extends Component {
constructor(tech, options) {
const Component = videojs.getComponent('Component');
/**
* The Hls Handler object, where we orchestrate all of the parts
* of HLS to interact with video.js
*
* @class HlsHandler
* @extends videojs.Component
* @param {Object} source the soruce object
* @param {Tech} tech the parent tech object
* @param {Object} options optional and required options
*/
class HlsHandler extends Component {
constructor(source, tech, options) {
super(tech);
let _player;
// tech.player() is deprecated but setup a reference to HLS for
// backwards-compatibility
if (tech.options_ && tech.options_.playerId) {
_player = videojs(tech.options_.playerId);
if (!_player.hls) {
let _player = videojs(tech.options_.playerId);
if (!_player.hasOwnProperty('hls')) {
Object.defineProperty(_player, 'hls', {
get: () => {
videojs.log.warn('player.hls is deprecated. Use player.tech.hls instead.');
......@@ -359,1188 +282,228 @@ export default class HlsHandler extends Component {
});
}
}
this.options_ = videojs.mergeOptions(videojs.options.hls || {}, options.hls);
this.tech_ = tech;
this.source_ = options.source;
this.mode_ = options.mode;
// the segment info object for a segment that is in the process of
// being downloaded or processed
this.pendingSegment_ = null;
this.source_ = source;
// start playlist selection at a reasonable bandwidth for
// broadband internet
// 0.5 Mbps
this.bandwidth = options.bandwidth || 4194304;
this.bandwidth = this.options_.bandwidth || 4194304;
this.bytesReceived = 0;
// loadingState_ tracks how far along the buffering process we
// have been given permission to proceed. There are three possible
// values:
// - none: do not load playlists or segments
// - meta: load playlists but not segments
// - segments: load everything
this.loadingState_ = 'none';
if (this.tech_.preload() !== 'none') {
this.loadingState_ = 'meta';
}
// listen for fullscreenchange events for this player so that we
// can adjust our quality selection quickly
this.on(document, [
'fullscreenchange', 'webkitfullscreenchange',
'mozfullscreenchange', 'MSFullscreenChange'
], (event) => {
let fullscreenElement = document.fullscreenElement ||
document.webkitFullscreenElement ||
document.mozFullScreenElement ||
document.msFullscreenElement;
// periodically check if new data needs to be downloaded or
// buffered data should be appended to the source buffer
this.startCheckingBuffer_();
if (fullscreenElement && fullscreenElement.contains(this.tech_.el())) {
this.masterPlaylistController_.fastQualityChange_();
}
});
this.on(this.tech_, 'seeking', function() {
this.setCurrentTime(this.tech_.currentTime());
});
this.on(this.tech_, 'error', function() {
this.stopCheckingBuffer_();
if (this.masterPlaylistController_) {
this.masterPlaylistController_.pauseLoading();
}
});
this.audioTrackChange_ = () => {
this.masterPlaylistController_.useAudio();
};
this.on(this.tech_, 'play', this.play);
}
src(src) {
let oldMediaPlaylist;
/**
* called when player.src gets called, handle a new source
*
* @param {Object} src the source object to handle
*/
src(src) {
// do nothing if the src is falsey
if (!src) {
return;
}
this.mediaSource = new videojs.MediaSource({ mode: this.mode_ });
// load the MediaSource into the player
this.mediaSource.addEventListener('sourceopen', this.handleSourceOpen.bind(this));
this.options_ = {};
if (typeof this.source_.withCredentials !== 'undefined') {
this.options_.withCredentials = this.source_.withCredentials;
} else if (videojs.options.hls) {
this.options_.withCredentials = videojs.options.hls.withCredentials;
}
this.playlists = new Hls.PlaylistLoader(this.source_.src,
this.tech_.hls,
this.options_.withCredentials);
this.tech_.one('canplay', this.setupFirstPlay.bind(this));
this.playlists.on('loadedmetadata', () => {
oldMediaPlaylist = this.playlists.media();
// if this isn't a live video and preload permits, start
// downloading segments
if (oldMediaPlaylist.endList &&
this.tech_.preload() !== 'metadata' &&
this.tech_.preload() !== 'none') {
this.loadingState_ = 'segments';
['withCredentials', 'bandwidth'].forEach((option) => {
if (typeof this.source_[option] !== 'undefined') {
this.options_[option] = this.source_[option];
}
this.setupSourceBuffer_();
this.setupFirstPlay();
this.fillBuffer();
this.tech_.trigger('loadedmetadata');
});
this.playlists.on('error', () => {
this.blacklistCurrentPlaylist_(this.playlists.error);
this.options_.url = this.source_.src;
this.options_.tech = this.tech_;
this.options_.externHls = Hls;
this.options_.bandwidth = this.bandwidth;
this.masterPlaylistController_ = new MasterPlaylistController(this.options_);
// `this` in selectPlaylist should be the HlsHandler for backwards
// compatibility with < v2
this.masterPlaylistController_.selectPlaylist =
Hls.STANDARD_PLAYLIST_SELECTOR.bind(this);
// re-expose some internal objects for backwards compatibility with < v2
this.playlists = this.masterPlaylistController_.masterPlaylistLoader_;
this.mediaSource = this.masterPlaylistController_.mediaSource;
// Proxy assignment of some properties to the master playlist
// controller. Using a custom property for backwards compatibility
// with < v2
Object.defineProperties(this, {
selectPlaylist: {
get() {
return this.masterPlaylistController_.selectPlaylist;
},
set(selectPlaylist) {
this.masterPlaylistController_.selectPlaylist = selectPlaylist.bind(this);
}
},
bandwidth: {
get() {
return this.masterPlaylistController_.mainSegmentLoader_.bandwidth;
},
set(bandwidth) {
this.masterPlaylistController_.mainSegmentLoader_.bandwidth = bandwidth;
}
}
});
this.playlists.on('loadedplaylist', () => {
let updatedPlaylist = this.playlists.media();
let seekable;
this.tech_.one('canplay',
this.masterPlaylistController_.setupFirstPlay.bind(this.masterPlaylistController_));
this.masterPlaylistController_.on('sourceopen', () => {
this.tech_.audioTracks().addEventListener('change', this.audioTrackChange_);
});
if (!updatedPlaylist) {
// select the initial variant
this.playlists.media(this.selectPlaylist());
this.masterPlaylistController_.on('audioinfo', (e) => {
if (!videojs.browser.IS_FIREFOX ||
!this.audioInfo_ ||
!objectChanged(this.audioInfo_, e.info)) {
this.audioInfo_ = e.info;
return;
}
this.updateDuration(this.playlists.media());
let error = 'had different audio properties (channels, sample rate, etc.) ' +
'or changed in some other way. This behavior is currently ' +
'unsupported in Firefox due to an issue: \n\n' +
'https://bugzilla.mozilla.org/show_bug.cgi?id=1247138\n\n';
let enabledTrack;
let defaultTrack;
this.masterPlaylistController_.audioTracks_.forEach((t) => {
if (!defaultTrack && t.default) {
defaultTrack = t;
}
if (!enabledTrack && t.enabled) {
enabledTrack = t;
}
});
// update seekable
seekable = this.seekable();
if (this.duration() === Infinity &&
seekable.length !== 0) {
this.mediaSource.addSeekableRange_(seekable.start(0), seekable.end(0));
// they did not switch audiotracks
// blacklist the current playlist
if (!enabledTrack.getLoader(this.activeAudioGroup_())) {
error = `The rendition that we tried to switch to ${error}` +
'Unfortunately that means we will have to blacklist ' +
'the current playlist and switch to another. Sorry!';
this.masterPlaylistController_.blacklistCurrentPlaylist();
} else {
error = `The audio track '${enabledTrack.label}' that we tried to ` +
`switch to ${error} Unfortunately this means we will have to ` +
`return you to the main track '${defaultTrack.label}'. Sorry!`;
defaultTrack.enabled = true;
this.tech_.audioTracks().removeTrack(enabledTrack);
}
oldMediaPlaylist = updatedPlaylist;
videojs.log.warn(error);
this.masterPlaylistController_.useAudio();
});
this.playlists.on('mediachange', () => {
this.tech_.trigger({
type: 'mediachange',
bubbles: true
this.masterPlaylistController_.on('selectedinitialmedia', () => {
// clear current audioTracks
this.tech_.clearTracks('audio');
this.masterPlaylistController_.audioTracks_.forEach((track) => {
this.tech_.audioTracks().addTrack(track);
});
});
// the bandwidth of the primary segment loader is our best
// estimate of overall bandwidth
this.on(this.masterPlaylistController_, 'progress', function() {
this.bandwidth = this.masterPlaylistController_.mainSegmentLoader_.bandwidth;
this.tech_.trigger('progress');
});
// do nothing if the tech has been disposed already
// this can occur if someone sets the src in player.ready(), for instance
if (!this.tech_.el()) {
return;
}
this.tech_.src(videojs.URL.createObjectURL(this.mediaSource));
}
handleSourceOpen() {
// Only attempt to create the source buffer if none already exist.
// handleSourceOpen is also called when we are "re-opening" a source buffer
// after `endOfStream` has been called (in response to a seek for instance)
if (!this.sourceBuffer) {
this.setupSourceBuffer_();
}
// if autoplay is enabled, begin playback. This is duplicative of
// code in video.js but is required because play() must be invoked
// *after* the media source has opened.
// NOTE: moving this invocation of play() after
// sourceBuffer.appendBuffer() below caused live streams with
// autoplay to stall
if (this.tech_.autoplay()) {
this.play();
}
this.tech_.src(videojs.URL.createObjectURL(
this.masterPlaylistController_.mediaSource));
}
/**
* Blacklist playlists that are known to be codec or
* stream-incompatible with the SourceBuffer configuration. For
* instance, Media Source Extensions would cause the video element to
* stall waiting for video data if you switched from a variant with
* video and audio to an audio-only one.
* a helper for grabbing the active audio group from MasterPlaylistController
*
* @param media {object} a media playlist compatible with the current
* set of SourceBuffers. Variants in the current master playlist that
* do not appear to have compatible codec or stream configurations
* will be excluded from the default playlist selection algorithm
* indefinitely.
* @private
*/
excludeIncompatibleVariants_(media) {
let master = this.playlists.master;
let codecCount = 2;
let videoCodec = null;
let audioProfile = null;
let codecs;
if (media.attributes && media.attributes.CODECS) {
codecs = parseCodecs(media.attributes.CODECS);
videoCodec = codecs.videoCodec;
audioProfile = codecs.audioProfile;
codecCount = codecs.codecCount;
}
master.playlists.forEach(function(variant) {
let variantCodecs = {
codecCount: 2,
videoCodec: null,
audioProfile: null
};
if (variant.attributes && variant.attributes.CODECS) {
variantCodecs = parseCodecs(variant.attributes.CODECS);
}
// if the streams differ in the presence or absence of audio or
// video, they are incompatible
if (variantCodecs.codecCount !== codecCount) {
variant.excludeUntil = Infinity;
}
// if h.264 is specified on the current playlist, some flavor of
// it must be specified on all compatible variants
if (variantCodecs.videoCodec !== videoCodec) {
variant.excludeUntil = Infinity;
}
// HE-AAC ("mp4a.40.5") is incompatible with all other versions of
// AAC audio in Chrome 46. Don't mix the two.
if ((variantCodecs.audioProfile === '5' && audioProfile !== '5') ||
(audioProfile === '5' && variantCodecs.audioProfile !== '5')) {
variant.excludeUntil = Infinity;
}
});
activeAudioGroup_() {
return this.masterPlaylistController_.activeAudioGroup();
}
setupSourceBuffer_() {
let media = this.playlists.media();
let mimeType;
// wait until a media playlist is available and the Media Source is
// attached
if (!media || this.mediaSource.readyState !== 'open') {
return;
}
// if the codecs were explicitly specified, pass them along to the
// source buffer
mimeType = 'video/mp2t';
if (media.attributes && media.attributes.CODECS) {
mimeType += '; codecs="' + media.attributes.CODECS + '"';
}
this.sourceBuffer = this.mediaSource.addSourceBuffer(mimeType);
// exclude any incompatible variant streams from future playlist
// selection
this.excludeIncompatibleVariants_(media);
// transition the sourcebuffer to the ended state if we've hit the end of
// the playlist
this.sourceBuffer.addEventListener('updateend', this.updateEndHandler_.bind(this));
/**
* Begin playing the video.
*/
play() {
this.masterPlaylistController_.play();
}
/**
* Seek to the latest media position if this is a live video and the
* player and video are loaded and initialized.
* a wrapper around the function in MasterPlaylistController
*/
setupFirstPlay() {
let seekable;
let media = this.playlists.media();
// check that everything is ready to begin buffering
// 1) the video is a live stream of unknown duration
if (this.duration() === Infinity &&
// 2) the player has not played before and is not paused
this.tech_.played().length === 0 &&
!this.tech_.paused() &&
// 3) the Media Source and Source Buffers are ready
this.sourceBuffer &&
// 4) the active media playlist is available
media &&
// 5) the video element or flash player is in a readyState of
// at least HAVE_FUTURE_DATA
this.tech_.readyState() >= 1) {
// trigger the playlist loader to start "expired time"-tracking
this.playlists.trigger('firstplay');
// seek to the latest media position for live videos
seekable = this.seekable();
if (seekable.length) {
this.tech_.setCurrentTime(seekable.end(0));
}
}
setCurrentTime(currentTime) {
this.masterPlaylistController_.setCurrentTime(currentTime);
}
/**
* Begin playing the video.
* a wrapper around the function in MasterPlaylistController
*/
play() {
this.loadingState_ = 'segments';
duration() {
return this.masterPlaylistController_.duration();
}
if (this.tech_.ended()) {
this.tech_.setCurrentTime(0);
}
/**
* a wrapper around the function in MasterPlaylistController
*/
seekable() {
return this.masterPlaylistController_.seekable();
}
if (this.tech_.played().length === 0) {
return this.setupFirstPlay();
/**
* Abort all outstanding work and cleanup.
*/
dispose() {
if (this.masterPlaylistController_) {
this.masterPlaylistController_.dispose();
}
this.tech_.audioTracks().removeEventListener('change', this.audioTrackChange_);
// if the viewer has paused and we fell out of the live window,
// seek forward to the earliest available position
if (this.duration() === Infinity) {
if (this.tech_.currentTime() < this.seekable().start(0)) {
this.tech_.setCurrentTime(this.seekable().start(0));
}
}
}
setCurrentTime(currentTime) {
let buffered = this.findBufferedRange_();
if (!(this.playlists && this.playlists.media())) {
// return immediately if the metadata is not ready yet
return 0;
}
// it's clearly an edge-case but don't thrown an error if asked to
// seek within an empty playlist
if (!this.playlists.media().segments) {
return 0;
}
// if the seek location is already buffered, continue buffering as
// usual
if (buffered && buffered.length) {
return currentTime;
}
// if we are in the middle of appending a segment, let it finish up
if (this.pendingSegment_ && this.pendingSegment_.buffered) {
return currentTime;
}
this.lastSegmentLoaded_ = null;
// cancel outstanding requests and buffer appends
this.cancelSegmentXhr();
// abort outstanding key requests, if necessary
if (this.keyXhr_) {
this.keyXhr_.aborted = true;
this.cancelKeyXhr();
}
// begin filling the buffer at the new position
this.fillBuffer(this.playlists.getMediaIndexForTime_(currentTime));
}
duration() {
let playlists = this.playlists;
if (!playlists) {
return 0;
}
if (this.mediaSource) {
return this.mediaSource.duration;
}
return Hls.Playlist.duration(playlists.media());
}
seekable() {
let media;
let seekable;
if (!this.playlists) {
return videojs.createTimeRanges();
}
media = this.playlists.media();
if (!media) {
return videojs.createTimeRanges();
}
seekable = Hls.Playlist.seekable(media);
if (seekable.length === 0) {
return seekable;
}
// if the seekable start is zero, it may be because the player has
// been paused for a long time and stopped buffering. in that case,
// fall back to the playlist loader's running estimate of expired
// time
if (seekable.start(0) === 0) {
return videojs.createTimeRanges([[this.playlists.expired_,
this.playlists.expired_ + seekable.end(0)]]);
}
// seekable has been calculated based on buffering video data so it
// can be returned directly
return seekable;
}
/**
* Update the player duration
*/
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');
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
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');
}
}
}
/**
* Clear all buffers and reset any state relevant to the current
* source. After this function is called, the tech should be in a
* state suitable for switching to a different video.
*/
resetSrc_() {
this.cancelSegmentXhr();
this.cancelKeyXhr();
if (this.sourceBuffer && this.mediaSource.readyState === 'open') {
this.sourceBuffer.abort();
}
}
cancelKeyXhr() {
if (this.keyXhr_) {
this.keyXhr_.onreadystatechange = null;
this.keyXhr_.abort();
this.keyXhr_ = null;
}
}
cancelSegmentXhr() {
if (this.segmentXhr_) {
// Prevent error handler from running.
this.segmentXhr_.onreadystatechange = null;
this.segmentXhr_.abort();
this.segmentXhr_ = null;
}
// clear out the segment being processed
this.pendingSegment_ = null;
}
/**
* Abort all outstanding work and cleanup.
*/
dispose() {
this.stopCheckingBuffer_();
if (this.playlists) {
this.playlists.dispose();
}
this.resetSrc_();
super.dispose();
}
/**
* Chooses the appropriate media playlist based on the current
* bandwidth estimate and the player size.
* @return the highest bitrate playlist less than the currently detected
* bandwidth, accounting for some amount of bandwidth variance
*/
selectPlaylist() {
let effectiveBitrate;
let sortedPlaylists = this.playlists.master.playlists.slice();
let bandwidthPlaylists = [];
let now = +new Date();
let i;
let variant;
let bandwidthBestVariant;
let resolutionPlusOne;
let resolutionPlusOneAttribute;
let resolutionBestVariant;
let width;
let height;
sortedPlaylists.sort(Hls.comparePlaylistBandwidth);
// filter out any playlists that have been excluded due to
// incompatible configurations or playback errors
sortedPlaylists = sortedPlaylists.filter((localVariant) => {
if (typeof localVariant.excludeUntil !== 'undefined') {
return now >= localVariant.excludeUntil;
}
return true;
});
// filter out any variant that has greater effective bitrate
// than the current estimated bandwidth
i = sortedPlaylists.length;
while (i--) {
variant = sortedPlaylists[i];
// ignore playlists without bandwidth information
if (!variant.attributes || !variant.attributes.BANDWIDTH) {
continue;
}
effectiveBitrate = variant.attributes.BANDWIDTH * bandwidthVariance;
if (effectiveBitrate < this.bandwidth) {
bandwidthPlaylists.push(variant);
// since the playlists are sorted in ascending order by
// bandwidth, the first viable variant is the best
if (!bandwidthBestVariant) {
bandwidthBestVariant = variant;
}
}
}
i = bandwidthPlaylists.length;
// sort variants by resolution
bandwidthPlaylists.sort(Hls.comparePlaylistResolution);
// forget our old variant from above,
// or we might choose that in high-bandwidth scenarios
// (this could be the lowest bitrate rendition as we go through all of them above)
variant = null;
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
while (i--) {
variant = bandwidthPlaylists[i];
// ignore playlists without resolution information
if (!variant.attributes ||
!variant.attributes.RESOLUTION ||
!variant.attributes.RESOLUTION.width ||
!variant.attributes.RESOLUTION.height) {
continue;
}
// since the playlists are sorted, the first variant that has
// dimensions less than or equal to the player size is the best
let variantResolution = variant.attributes.RESOLUTION;
if (variantResolution.width === width &&
variantResolution.height === height) {
// if we have the exact resolution as the player use it
resolutionPlusOne = null;
resolutionBestVariant = variant;
break;
} else if (variantResolution.width < width &&
variantResolution.height < height) {
// if both dimensions are less than the player use the
// previous (next-largest) variant
break;
} else if (!resolutionPlusOne ||
(variantResolution.width < resolutionPlusOneAttribute.width &&
variantResolution.height < resolutionPlusOneAttribute.height)) {
// If we still haven't found a good match keep a
// reference to the previous variant for the next loop
// iteration
// By only saving variants if they are smaller than the
// previously saved variant, we ensure that we also pick
// the highest bandwidth variant that is just-larger-than
// the video player
resolutionPlusOne = variant;
resolutionPlusOneAttribute = resolutionPlusOne.attributes.RESOLUTION;
}
}
// fallback chain of variants
return resolutionPlusOne ||
resolutionBestVariant ||
bandwidthBestVariant ||
sortedPlaylists[0];
}
/**
* Periodically request new segments and append video data.
*/
checkBuffer_() {
// calling this method directly resets any outstanding buffer checks
if (this.checkBufferTimeout_) {
window.clearTimeout(this.checkBufferTimeout_);
this.checkBufferTimeout_ = null;
}
this.fillBuffer();
this.drainBuffer();
// wait awhile and try again
this.checkBufferTimeout_ = window.setTimeout((this.checkBuffer_).bind(this),
bufferCheckInterval);
}
/**
* Setup a periodic task to request new segments if necessary and
* append bytes into the SourceBuffer.
*/
startCheckingBuffer_() {
this.checkBuffer_();
}
/**
* Stop the periodic task requesting new segments and feeding the
* SourceBuffer.
*/
stopCheckingBuffer_() {
if (this.checkBufferTimeout_) {
window.clearTimeout(this.checkBufferTimeout_);
this.checkBufferTimeout_ = null;
}
}
/**
* Determines whether there is enough video data currently in the buffer
* and downloads a new segment if the buffered time is less than the goal.
* @param seekToTime (optional) {number} the offset into the downloaded segment
* to seek to, in seconds
*/
fillBuffer(mediaIndex) {
let tech = this.tech_;
let currentTime = tech.currentTime();
let hasBufferedContent = (this.tech_.buffered().length !== 0);
let currentBuffered = this.findBufferedRange_();
let outsideBufferedRanges = !(currentBuffered && currentBuffered.length);
let currentBufferedEnd = 0;
let bufferedTime = 0;
let segment;
let segmentInfo;
let segmentTimestampOffset;
// if preload is set to "none", do not download segments until playback is requested
if (this.loadingState_ !== 'segments') {
return;
}
// if a video has not been specified, do nothing
if (!tech.currentSrc() || !this.playlists) {
return;
}
// if there is a request already in flight, do nothing
if (this.segmentXhr_) {
return;
}
// wait until the buffer is up to date
if (this.pendingSegment_) {
return;
}
// if no segments are available, do nothing
if (this.playlists.state === 'HAVE_NOTHING' ||
!this.playlists.media() ||
!this.playlists.media().segments) {
return;
}
// if a playlist switch is in progress, wait for it to finish
if (this.playlists.state === 'SWITCHING_MEDIA') {
return;
}
if (typeof 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 >= 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
if (!segment) {
return;
}
// we have entered a state where we are fetching the same segment,
// try to walk forward
if (this.lastSegmentLoaded_ &&
this.playlistUriToUrl(this.lastSegmentLoaded_.uri) === this.playlistUriToUrl(segment.uri) &&
this.lastSegmentLoaded_.byterange === segment.byterange) {
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 & mediaSequence at the time it was requested
mediaIndex,
mediaSequence: this.playlists.media().mediaSequence,
// the segment's playlist
playlist: this.playlists.media(),
// The state of the buffer when this segment was requested
currentBufferedEnd,
// unencrypted bytes of the segment
bytes: null,
// when a key is defined for this segment, the encrypted bytes
encryptedBytes: null,
// optionally, the decrypter that is unencrypting the segment
decrypter: null,
// the state of the buffer before a segment is appended will be
// stored here so that the actual segment duration can be
// determined after it has been appended
buffered: null,
// The target timestampOffset for this segment when we append it
// to the source buffer
timestampOffset: null
};
if (mediaIndex > 0) {
segmentTimestampOffset = Hls.Playlist.duration(segmentInfo.playlist,
segmentInfo.playlist.mediaSequence + mediaIndex) + this.playlists.expired_;
}
if (this.tech_.seeking() && outsideBufferedRanges) {
// 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) {
segmentInfo.timestampOffset = segmentTimestampOffset;
}
} 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
segmentInfo.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
segmentInfo.timestampOffset = segmentTimestampOffset;
}
this.loadSegment(segmentInfo);
}
playlistUriToUrl(segmentRelativeUrl) {
let playListUrl;
// resolve the segment URL relative to the playlist
if (this.playlists.media().uri === this.source_.src) {
playListUrl = resolveUrl(this.source_.src, segmentRelativeUrl);
} else {
playListUrl =
resolveUrl(resolveUrl(this.source_.src, this.playlists.media().uri || ''),
segmentRelativeUrl);
}
return playListUrl;
}
/*
* Turns segment byterange into a string suitable for use in
* HTTP Range requests
*/
byterangeStr_(byterange) {
let byterangeStart;
let byterangeEnd;
// `byterangeEnd` is one less than `offset + length` because the HTTP range
// header uses inclusive ranges
byterangeEnd = byterange.offset + byterange.length - 1;
byterangeStart = byterange.offset;
return 'bytes=' + byterangeStart + '-' + byterangeEnd;
}
/*
* Defines headers for use in the xhr request for a particular segment.
*/
segmentXhrHeaders_(segment) {
let headers = {};
if ('byterange' in segment) {
headers.Range = this.byterangeStr_(segment.byterange);
}
return headers;
}
/*
* Sets `bandwidth`, `segmentXhrTime`, and appends to the `bytesReceived.
* Expects an object with:
* * `roundTripTime` - the round trip time for the request we're setting the time for
* * `bandwidth` - the bandwidth we want to set
* * `bytesReceived` - amount of bytes downloaded
* `bandwidth` is the only required property.
*/
setBandwidth(localXhr) {
// calculate the download bandwidth
this.segmentXhrTime = localXhr.roundTripTime;
this.bandwidth = localXhr.bandwidth;
this.bytesReceived += localXhr.bytesReceived || 0;
this.tech_.trigger('bandwidthupdate');
}
/*
* Blacklists a playlist when an error occurs for a set amount of time
* making it unavailable for selection by the rendition selection algorithm
* and then forces a new playlist (rendition) selection.
*/
blacklistCurrentPlaylist_(error) {
let currentPlaylist;
let nextPlaylist;
// If the `error` was generated by the playlist loader, it will contain
// the playlist we were trying to load (but failed) and that should be
// blacklisted instead of the currently selected playlist which is likely
// out-of-date in this scenario
currentPlaylist = error.playlist || this.playlists.media();
// If there is no current playlist, then an error occurred while we were
// trying to load the master OR while we were disposing of the tech
if (!currentPlaylist) {
this.error = error;
return this.mediaSource.endOfStream('network');
}
// Blacklist this playlist
currentPlaylist.excludeUntil = Date.now() + blacklistDuration;
// Select a new playlist
nextPlaylist = this.selectPlaylist();
if (nextPlaylist) {
videojs.log.warn('Problem encountered with the current ' +
'HLS playlist. Switching to another playlist.');
return this.playlists.media(nextPlaylist);
}
videojs.log.warn('Problem encountered with the current ' +
'HLS playlist. No suitable alternatives found.');
// We have no more playlists we can select so we must fail
this.error = error;
return this.mediaSource.endOfStream('network');
}
loadSegment(segmentInfo) {
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"
// We manually clear out the old buffer to ensure
// we don't trigger the QuotaExceeded error
// on the source buffer during subsequent appends
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 && seekable.start(0) < currentTime) {
removeToTime = seekable.start(0);
} else {
removeToTime = currentTime - 60;
}
if (removeToTime > 0) {
this.sourceBuffer.remove(0, removeToTime);
}
}
// if the segment is encrypted, request the key
if (segment.key) {
this.fetchKey_(segment);
}
// request the next segment
this.segmentXhr_ = this.tech_.hls.xhr({
uri: segmentInfo.uri,
responseType: 'arraybuffer',
withCredentials: this.source_.withCredentials,
// Set xhr timeout to 150% of the segment duration to allow us
// some time to switch renditions in the event of a catastrophic
// decrease in network performance or a server issue.
timeout: (segment.duration * 1.5) * 1000,
headers: this.segmentXhrHeaders_(segment)
}, (error, request) => {
// This is a timeout of a previously aborted segment request
// so simply ignore it
if (!this.segmentXhr_ || request !== this.segmentXhr_) {
return;
}
// the segment request is no longer outstanding
this.segmentXhr_ = null;
// if a segment request times out, we may have better luck with another playlist
if (request.timedout) {
this.bandwidth = 1;
return this.playlists.media(this.selectPlaylist());
}
// otherwise, trigger a network error
if (!request.aborted && error) {
return this.blacklistCurrentPlaylist_({
status: request.status,
message: 'HLS segment request error at URL: ' + segmentInfo.uri,
code: (request.status >= 500) ? 4 : 2
});
}
// stop processing if the request was aborted
if (!request.response) {
return;
}
this.lastSegmentLoaded_ = segment;
this.setBandwidth(request);
if (segment.key) {
segmentInfo.encryptedBytes = new Uint8Array(request.response);
} else {
segmentInfo.bytes = new Uint8Array(request.response);
}
this.pendingSegment_ = segmentInfo;
this.tech_.trigger('progress');
this.drainBuffer();
// figure out what stream the next segment should be downloaded from
// with the updated bandwidth information
this.playlists.media(this.selectPlaylist());
});
}
drainBuffer() {
let segmentInfo;
let mediaIndex;
let playlist;
let bytes;
let segment;
let decrypter;
let segIv;
// if the buffer is empty or the source buffer hasn't been created
// yet, do nothing
if (!this.pendingSegment_ || !this.sourceBuffer) {
return;
}
// the pending segment has already been appended and we're waiting
// for updateend to fire
if (this.pendingSegment_.buffered) {
return;
}
// we can't append more data if the source buffer is busy processing
// what we've already sent
if (this.sourceBuffer.updating) {
return;
}
segmentInfo = this.pendingSegment_;
mediaIndex = segmentInfo.mediaIndex;
playlist = segmentInfo.playlist;
bytes = segmentInfo.bytes;
segment = playlist.segments[mediaIndex];
if (segment.key && !bytes) {
// this is an encrypted segment
// if the key download failed, we want to skip this segment
// but if the key hasn't downloaded yet, we want to try again later
if (keyFailed(segment.key)) {
return this.blacklistCurrentPlaylist_({
message: 'HLS segment key request error.',
code: 4
});
} else if (!segment.key.bytes) {
// waiting for the key bytes, try again later
return;
} else if (segmentInfo.decrypter) {
// decryption is in progress, try again later
return;
}
// if the media sequence is greater than 2^32, the IV will be incorrect
// assuming 10s segments, that would be about 1300 years
segIv = segment.key.iv ||
new Uint32Array([0, 0, 0, mediaIndex + playlist.mediaSequence]);
// create a decrypter to incrementally decrypt the segment
decrypter = new Hls.Decrypter(segmentInfo.encryptedBytes,
segment.key.bytes,
segIv,
function(error, localBytes) {
if (error) {
videojs.log.warn(error);
}
segmentInfo.bytes = localBytes;
});
segmentInfo.decrypter = decrypter;
return;
}
this.pendingSegment_.buffered = this.tech_.buffered();
if (segmentInfo.timestampOffset !== null) {
this.sourceBuffer.timestampOffset = segmentInfo.timestampOffset;
}
// the segment is asynchronously added to the current buffered data
this.sourceBuffer.appendBuffer(bytes);
}
updateEndHandler_() {
let segmentInfo = this.pendingSegment_;
let playlist;
let currentMediaIndex;
let currentBuffered;
let seekable;
let timelineUpdate;
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;
}
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();
}
// 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
// in that case, seek again
seekable = this.seekable();
if (this.tech_.seeking() &&
currentBuffered.length === 0) {
if (seekable.length &&
this.tech_.currentTime() < seekable.start(0)) {
let next = this.findNextBufferedRange_();
if (next.length) {
videojs.log('tried seeking to', this.tech_.currentTime(),
'but that was too early, retrying at', next.start(0));
this.tech_.setCurrentTime(next.start(0) + TIME_FUDGE_FACTOR);
}
}
}
timelineUpdate = Hls.findSoleUncommonTimeRangesEnd_(segmentInfo.buffered,
this.tech_.buffered());
// Update segment meta-data (duration and end-point) based on timeline
updateSegmentMetadata(playlist, currentMediaIndex, timelineUpdate);
// 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 ||
segmentInfo.buffered.length !== this.tech_.buffered().length) {
this.updateDuration(playlist);
// check if it's time to download the next segment
this.fillBuffer();
return;
}
// 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;
}
/**
* Attempt to retrieve the key for a particular media segment.
*/
fetchKey_(segment) {
let key;
let settings;
let receiveKey;
// if there is a pending XHR or no segments, don't do anything
if (this.keyXhr_) {
return;
}
settings = this.options_;
/**
* Handle a key XHR response.
*/
receiveKey = (keyRecieved) => {
return (error, request) => {
let view;
this.keyXhr_ = null;
if (error || !request.response || request.response.byteLength !== 16) {
keyRecieved.retries = keyRecieved.retries || 0;
keyRecieved.retries++;
if (!request.aborted) {
// try fetching again
this.fetchKey_(segment);
}
return;
}
view = new DataView(request.response);
keyRecieved.bytes = new Uint32Array([
view.getUint32(0),
view.getUint32(4),
view.getUint32(8),
view.getUint32(12)
]);
// check to see if this allows us to make progress buffering now
this.checkBuffer_();
};
};
key = segment.key;
// nothing to do if this segment is unencrypted
if (!key) {
return;
}
// request the key if the retry limit hasn't been reached
if (!key.bytes && !keyFailed(key)) {
this.keyXhr_ = this.tech_.hls.xhr({
uri: this.playlistUriToUrl(key.uri),
responseType: 'arraybuffer',
withCredentials: settings.withCredentials
}, receiveKey(key));
return;
}
super.dispose();
}
}
/**
* Attempts to find the buffered TimeRange that contains the specified
* time, or where playback is currently happening if no specific time
* is specified.
* @param time (optional) {number} the time to filter on. Defaults to
* currentTime.
* @return a new TimeRanges object.
*/
HlsHandler.prototype.findBufferedRange_ =
filterBufferedRanges(function(start, end, time) {
return start - TIME_FUDGE_FACTOR <= time &&
end + TIME_FUDGE_FACTOR >= time;
});
/**
* Returns the TimeRanges that begin at or later than the specified
* time.
* @param time (optional) {number} the time to filter on. Defaults to
* currentTime.
* @return a new TimeRanges object.
*/
HlsHandler.prototype.findNextBufferedRange_ =
filterBufferedRanges(function(start, end, time) {
return start - TIME_FUDGE_FACTOR >= time;
});
/**
* The Source Handler object, which informs video.js what additional
* MIME types are supported and sets up playback. It is registered
* automatically to the appropriate tech based on the capabilities of
......@@ -1550,9 +513,16 @@ HlsHandler.prototype.findNextBufferedRange_ =
const HlsSourceHandler = function(mode) {
return {
canHandleSource(srcObj) {
// this forces video.js to skip this tech/mode if its not the one we have been
// overriden to use, by returing that we cannot handle the source.
if (videojs.options.hls &&
videojs.options.hls.mode &&
videojs.options.hls.mode !== mode) {
return false;
}
return HlsSourceHandler.canPlayType(srcObj.type);
},
handleSource(source, tech) {
handleSource(source, tech, options) {
if (mode === 'flash') {
// We need to trigger this asynchronously to give others the chance
// to bind to the event when a source is set at player creation
......@@ -1560,10 +530,10 @@ const HlsSourceHandler = function(mode) {
tech.trigger('loadstart');
}, 1);
}
tech.hls = new HlsHandler(tech, {
source,
mode
});
let settings = videojs.mergeOptions(options, {hls: {mode}});
tech.hls = new HlsHandler(source, tech, settings);
tech.hls.xhr = xhrFactory();
// Use a global `before` function if specified on videojs.Hls.xhr
......@@ -1576,13 +546,81 @@ const HlsSourceHandler = function(mode) {
return tech.hls;
},
canPlayType(type) {
return HlsSourceHandler.canPlayType(type);
if (HlsSourceHandler.canPlayType(type)) {
return 'maybe';
}
return '';
}
};
};
/**
* A comparator function to sort two playlist object by bandwidth.
*
* @param {Object} left a media playlist object
* @param {Object} right a media playlist object
* @return {Number} Greater than zero if the bandwidth attribute of
* left is greater than the corresponding attribute of right. Less
* than zero if the bandwidth of right is greater than left and
* exactly zero if the two are equal.
*/
Hls.comparePlaylistBandwidth = function(left, right) {
let leftBandwidth;
let rightBandwidth;
if (left.attributes && left.attributes.BANDWIDTH) {
leftBandwidth = left.attributes.BANDWIDTH;
}
leftBandwidth = leftBandwidth || window.Number.MAX_VALUE;
if (right.attributes && right.attributes.BANDWIDTH) {
rightBandwidth = right.attributes.BANDWIDTH;
}
rightBandwidth = rightBandwidth || window.Number.MAX_VALUE;
return leftBandwidth - rightBandwidth;
};
/**
* A comparator function to sort two playlist object by resolution (width).
* @param {Object} left a media playlist object
* @param {Object} right a media playlist object
* @return {Number} Greater than zero if the resolution.width attribute of
* left is greater than the corresponding attribute of right. Less
* than zero if the resolution.width of right is greater than left and
* exactly zero if the two are equal.
*/
Hls.comparePlaylistResolution = function(left, right) {
let leftWidth;
let rightWidth;
if (left.attributes &&
left.attributes.RESOLUTION &&
left.attributes.RESOLUTION.width) {
leftWidth = left.attributes.RESOLUTION.width;
}
leftWidth = leftWidth || window.Number.MAX_VALUE;
if (right.attributes &&
right.attributes.RESOLUTION &&
right.attributes.RESOLUTION.width) {
rightWidth = right.attributes.RESOLUTION.width;
}
rightWidth = rightWidth || window.Number.MAX_VALUE;
// NOTE - Fallback to bandwidth sort as appropriate in cases where multiple renditions
// have the same media dimensions/ resolution
if (leftWidth === rightWidth &&
left.attributes.BANDWIDTH &&
right.attributes.BANDWIDTH) {
return left.attributes.BANDWIDTH - right.attributes.BANDWIDTH;
}
return leftWidth - rightWidth;
};
HlsSourceHandler.canPlayType = function(type) {
let mpegurlRE = /^application\/(?:x-|vnd\.apple\.)mpegurl/i;
let mpegurlRE = /^(audio|video|application)\/(x-|vnd\.apple\.)?mpegurl/i;
// favor native HLS support if it's available
if (Hls.supportsNativeHls) {
......@@ -1609,6 +647,8 @@ videojs.HlsHandler = HlsHandler;
videojs.HlsSourceHandler = HlsSourceHandler;
videojs.Hls = Hls;
videojs.m3u8 = m3u8;
videojs.registerComponent('Hls', Hls);
videojs.options.hls = videojs.options.hls || {};
module.exports = {
Hls,
......
/**
* @file xhr.js
*/
/**
* A wrapper for videojs.xhr that tracks bandwidth.
*
* @param {Object} options options for the XHR
* @param {Function} callback the callback to call when done
* @return {Request} the xhr request that is going to be made
*/
import {xhr as videojsXHR, mergeOptions} from 'video.js';
......
import HlsAudioTrack from '../src/hls-audio-track';
import QUnit from 'qunit';
// Most of these tests will be done in video.js.AudioTrack unit tests
QUnit.module('HlsAudioTrack - Props');
QUnit.test('verify that props are readonly and can be set', function() {
let props = {
default: true,
language: 'en',
label: 'English',
autoselect: true,
withCredentials: true,
// below props won't be used, its used for checking
kind: 'main'
};
let track = new HlsAudioTrack(props);
for (let k in props) {
QUnit.equal(track[k], props[k], `${k} should be stored in track`);
}
});
QUnit.test('can start with a mediaGroup that has a uri', function() {
let props = {
default: true,
language: 'en',
label: 'English',
autoselect: true,
mediaGroup: 'foo',
withCredentials: true,
resolvedUri: 'http://some.test.url/playlist.m3u8',
// below props won't be used, its used for checking
enabled: true,
kind: 'main'
};
let track = new HlsAudioTrack(props);
QUnit.equal(track.mediaGroups_.length, 1, 'loader was created');
let loader = track.getLoader('foo');
QUnit.ok(loader, 'can getLoader on foo');
track.dispose();
QUnit.equal(track.mediaGroups_.length, 0, 'loader disposed');
});
QUnit.test('can start with a mediaGroup that has no uri', function() {
let props = {
default: true,
language: 'en',
label: 'English',
autoselect: true,
mediaGroup: 'foo',
withCredentials: true,
// below props won't be used, its used for checking
enabled: true,
kind: 'main'
};
let track = new HlsAudioTrack(props);
QUnit.equal(track.mediaGroups_.length, 1, 'mediaGroupLoader was created for foo');
QUnit.ok(!track.getLoader('foo'), 'can getLoader on foo, but it is undefined');
track.dispose();
QUnit.equal(track.mediaGroups_.length, 0, 'loaders disposed');
});
QUnit.module('HlsAudioTrack - Loader', {
beforeEach() {
this.track = new HlsAudioTrack({
mediaGroup: 'default',
default: true,
language: 'en',
label: 'English',
autoselect: true,
withCredentials: true
});
},
afterEach() {
this.track.dispose();
QUnit.equal(this.track.mediaGroups_.length, 0, 'zero loaders after dispose');
}
});
QUnit.test('can add a playlist loader', function() {
QUnit.equal(this.track.mediaGroups_.length, 1, '1 loader to start');
this.track.addLoader('foo', 'someurl');
this.track.addLoader('bar', 'someurl');
this.track.addLoader('baz', 'someurl');
QUnit.equal(this.track.mediaGroups_.length, 4, 'now has four loaders');
});
QUnit.test('can remove playlist loader', function() {
QUnit.equal(this.track.mediaGroups_.length, 1, 'one loaders to start');
this.track.addLoader('foo', 'someurl');
this.track.addLoader('baz', 'someurl');
QUnit.equal(this.track.mediaGroups_.length, 3, 'now has three loaders');
this.track.removeLoader('baz');
QUnit.equal(this.track.mediaGroups_.length, 2, 'now has two loaders');
});
......@@ -10,13 +10,10 @@ var DEFAULTS = {
'node_modules/sinon/pkg/sinon-ie.js',
'node_modules/video.js/dist/video.js',
'node_modules/video.js/dist/video-js.css',
'test/**/*.test.js'
],
exclude: [
'test/data/**'
],
exclude: [],
plugins: [
'karma-browserify',
......@@ -43,6 +40,13 @@ var DEFAULTS = {
noParse: [
'test/data/**',
]
},
customLaunchers: {
travisChrome: {
base: 'Chrome',
flags: ['--no-sandbox']
}
}
};
......
......@@ -4,12 +4,10 @@ var common = require('./common');
module.exports = function(config) {
// Travis CI should run in its available Firefox headless browser.
if (process.env.TRAVIS) {
config.set(common({
browsers: ['Firefox'],
plugins: ['karma-firefox-launcher']
browsers: ['travisChrome'],
plugins: ['karma-chrome-launcher']
}))
} else {
config.set(common({
......
import QUnit from 'qunit';
import videojs from 'video.js';
import {
useFakeEnvironment,
useFakeMediaSource,
createPlayer,
standardXHRResponse,
openMediaSource
} from './test-helpers.js';
import MasterPlaylistController from '../src/master-playlist-controller';
/* eslint-disable no-unused-vars */
// we need this so that it can register hls with videojs
import { Hls } from '../src/videojs-contrib-hls';
/* eslint-enable no-unused-vars */
import Playlist from '../src/playlist';
QUnit.module('MasterPlaylistController', {
beforeEach() {
this.env = useFakeEnvironment();
this.clock = this.env.clock;
this.requests = this.env.requests;
this.mse = useFakeMediaSource();
// force the HLS tech to run
this.origSupportsNativeHls = videojs.Hls.supportsNativeHls;
videojs.Hls.supportsNativeHls = false;
this.player = createPlayer();
this.player.src({
src: 'manifest/master.m3u8',
type: 'application/vnd.apple.mpegurl'
});
this.masterPlaylistController = this.player.tech_.hls.masterPlaylistController_;
},
afterEach() {
this.env.restore();
this.mse.restore();
videojs.Hls.supportsNativeHls = this.origSupportsNativeHls;
this.player.dispose();
}
});
QUnit.test('throws error when given an empty URL', function() {
let options = {
url: 'test',
currentTimeFunc: () => {},
tech: this.player.tech_
};
QUnit.ok(new MasterPlaylistController(options), 'can create with options');
options.url = '';
QUnit.throws(() => {
new MasterPlaylistController(options); // eslint-disable-line no-new
}, /A non-empty playlist URL is required/, 'requires a non empty url');
});
QUnit.test('obeys none preload option', function() {
this.player.preload('none');
// master
standardXHRResponse(this.requests.shift());
// playlist
standardXHRResponse(this.requests.shift());
openMediaSource(this.player, this.clock);
QUnit.equal(this.requests.length, 0, 'no segment requests');
});
QUnit.test('obeys auto preload option', function() {
this.player.preload('auto');
// master
standardXHRResponse(this.requests.shift());
// playlist
standardXHRResponse(this.requests.shift());
openMediaSource(this.player, this.clock);
QUnit.equal(this.requests.length, 1, '1 segment request');
});
QUnit.test('obeys metadata preload option', function() {
this.player.preload('metadata');
// master
standardXHRResponse(this.requests.shift());
// playlist
standardXHRResponse(this.requests.shift());
openMediaSource(this.player, this.clock);
QUnit.equal(this.requests.length, 1, '1 segment request');
});
QUnit.test('clears some of the buffer for a fast quality change', function() {
let removes = [];
// master
standardXHRResponse(this.requests.shift());
// media
standardXHRResponse(this.requests.shift());
this.masterPlaylistController.mediaSource.trigger('sourceopen');
let segmentLoader = this.masterPlaylistController.mainSegmentLoader_;
segmentLoader.sourceUpdater_.remove = function(start, end) {
removes.push({ start, end });
};
this.masterPlaylistController.selectPlaylist = () => {
return this.masterPlaylistController.master().playlists[0];
};
this.masterPlaylistController.currentTimeFunc = () => 7;
this.masterPlaylistController.fastQualityChange_();
QUnit.equal(removes.length, 1, 'removed buffered content');
QUnit.equal(removes[0].start, 7 + 5, 'removed from a bit after current time');
QUnit.equal(removes[0].end, Infinity, 'removed to the end');
});
QUnit.test('does not clear the buffer when no fast quality change occurs', function() {
let removes = [];
// master
standardXHRResponse(this.requests.shift());
// media
standardXHRResponse(this.requests.shift());
this.masterPlaylistController.mediaSource.trigger('sourceopen');
let segmentLoader = this.masterPlaylistController.mainSegmentLoader_;
segmentLoader.sourceUpdater_.remove = function(start, end) {
removes.push({ start, end });
};
this.masterPlaylistController.fastQualityChange_();
QUnit.equal(removes.length, 0, 'did not remove content');
});
QUnit.test('if buffered, will request second segment byte range', function() {
this.requests.length = 0;
this.player.src({
src: 'manifest/playlist.m3u8',
type: 'application/vnd.apple.mpegurl'
});
this.masterPlaylistController = this.player.tech_.hls.masterPlaylistController_;
// mock that the user has played the video before
this.player.tech_.triggerReady();
this.clock.tick(1);
this.player.tech_.trigger('play');
this.player.tech_.played = () => videojs.createTimeRanges([[0, 20]]);
openMediaSource(this.player, this.clock);
// playlist
standardXHRResponse(this.requests[0]);
this.masterPlaylistController.mainSegmentLoader_.sourceUpdater_.buffered = () => {
return videojs.createTimeRanges([[0, 20]]);
};
// segment
standardXHRResponse(this.requests[1]);
this.masterPlaylistController.mediaSource.sourceBuffers[0].trigger('updateend');
this.clock.tick(10 * 1000);
QUnit.equal(this.requests[2].headers.Range, 'bytes=1823412-2299991');
});
QUnit.test('re-initializes the combined playlist loader when switching sources',
function() {
openMediaSource(this.player, this.clock);
// master
standardXHRResponse(this.requests.shift());
// playlist
standardXHRResponse(this.requests.shift());
// segment
standardXHRResponse(this.requests.shift());
// change the source
this.player.src({
src: 'manifest/master.m3u8',
type: 'application/vnd.apple.mpegurl'
});
this.masterPlaylistController = this.player.tech_.hls.masterPlaylistController_;
// maybe not needed if https://github.com/videojs/video.js/issues/2326 gets fixed
this.clock.tick(1);
QUnit.ok(!this.masterPlaylistController.masterPlaylistLoader_.media(),
'no media playlist');
QUnit.equal(this.masterPlaylistController.masterPlaylistLoader_.state,
'HAVE_NOTHING',
'reset the playlist loader state');
QUnit.equal(this.requests.length, 1, 'requested the new src');
// buffer check
this.clock.tick(10 * 1000);
QUnit.equal(this.requests.length, 1, 'did not request a stale segment');
// sourceopen
openMediaSource(this.player, this.clock);
QUnit.equal(this.requests.length, 1, 'made one request');
QUnit.ok(
this.requests[0].url.indexOf('master.m3u8') >= 0,
'requested only the new playlist'
);
});
QUnit.test('updates the combined segment loader on live playlist refreshes', function() {
let updates = [];
openMediaSource(this.player, this.clock);
// master
standardXHRResponse(this.requests.shift());
// media
standardXHRResponse(this.requests.shift());
this.masterPlaylistController.mainSegmentLoader_.playlist = function(update) {
updates.push(update);
};
this.masterPlaylistController.masterPlaylistLoader_.trigger('loadedplaylist');
QUnit.equal(updates.length, 1, 'updated the segment list');
});
QUnit.test(
'fires a progress event after downloading a segment from combined segment loader',
function() {
let progressCount = 0;
openMediaSource(this.player, this.clock);
// master
standardXHRResponse(this.requests.shift());
// media
standardXHRResponse(this.requests.shift());
this.player.tech_.on('progress', function() {
progressCount++;
});
// segment
standardXHRResponse(this.requests.shift());
this.masterPlaylistController.mainSegmentLoader_.trigger('progress');
QUnit.equal(progressCount, 1, 'fired a progress event');
});
QUnit.test('blacklists switching from video+audio playlists to audio only', function() {
let audioPlaylist;
openMediaSource(this.player, this.clock);
this.player.tech_.hls.bandwidth = 1e10;
// master
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1,CODECS="mp4a.40.2"\n' +
'media.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=10,RESOLUTION=1x1\n' +
'media1.m3u8\n');
// media1
standardXHRResponse(this.requests.shift());
QUnit.equal(this.masterPlaylistController.masterPlaylistLoader_.media(),
this.masterPlaylistController.masterPlaylistLoader_.master.playlists[1],
'selected video+audio');
audioPlaylist = this.masterPlaylistController.masterPlaylistLoader_.master.playlists[0];
QUnit.equal(audioPlaylist.excludeUntil, Infinity, 'excluded incompatible playlist');
});
QUnit.test('blacklists switching from audio-only playlists to video+audio', function() {
let videoAudioPlaylist;
openMediaSource(this.player, this.clock);
this.player.tech_.hls.bandwidth = 1;
// master
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1,CODECS="mp4a.40.2"\n' +
'media.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=10,RESOLUTION=1x1\n' +
'media1.m3u8\n');
// media1
standardXHRResponse(this.requests.shift());
QUnit.equal(this.masterPlaylistController.masterPlaylistLoader_.media(),
this.masterPlaylistController.masterPlaylistLoader_.master.playlists[0],
'selected audio only');
videoAudioPlaylist =
this.masterPlaylistController.masterPlaylistLoader_.master.playlists[1];
QUnit.equal(videoAudioPlaylist.excludeUntil,
Infinity,
'excluded incompatible playlist');
});
QUnit.test('blacklists switching from video-only playlists to video+audio', function() {
let videoAudioPlaylist;
openMediaSource(this.player, this.clock);
this.player.tech_.hls.bandwidth = 1;
// master
this.requests.shift()
.respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1,CODECS="avc1.4d400d"\n' +
'media.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=10,CODECS="avc1.4d400d,mp4a.40.2"\n' +
'media1.m3u8\n');
// media
standardXHRResponse(this.requests.shift());
QUnit.equal(this.masterPlaylistController.masterPlaylistLoader_.media(),
this.masterPlaylistController.masterPlaylistLoader_.master.playlists[0],
'selected video only');
videoAudioPlaylist =
this.masterPlaylistController.masterPlaylistLoader_.master.playlists[1];
QUnit.equal(videoAudioPlaylist.excludeUntil,
Infinity,
'excluded incompatible playlist');
});
QUnit.test('blacklists switching between playlists with incompatible audio codecs',
function() {
let alternatePlaylist;
openMediaSource(this.player, this.clock);
this.player.tech_.hls.bandwidth = 1;
// master
this.requests.shift()
.respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1,CODECS="avc1.4d400d,mp4a.40.5"\n' +
'media.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=10,CODECS="avc1.4d400d,mp4a.40.2"\n' +
'media1.m3u8\n');
// media
standardXHRResponse(this.requests.shift());
QUnit.equal(this.masterPlaylistController.masterPlaylistLoader_.media(),
this.masterPlaylistController.masterPlaylistLoader_.master.playlists[0],
'selected HE-AAC stream');
alternatePlaylist =
this.masterPlaylistController.masterPlaylistLoader_.master.playlists[1];
QUnit.equal(alternatePlaylist.excludeUntil, Infinity, 'excluded incompatible playlist');
});
QUnit.test('updates the combined segment loader on media changes', function() {
let updates = [];
this.masterPlaylistController.mediaSource.trigger('sourceopen');
this.masterPlaylistController.mainSegmentLoader_.bandwidth = 1;
// master
standardXHRResponse(this.requests.shift());
// media
standardXHRResponse(this.requests.shift());
this.masterPlaylistController.mainSegmentLoader_.playlist = function(update) {
updates.push(update);
};
// downloading the new segment will update bandwidth and cause a
// playlist change
// segment 0
standardXHRResponse(this.requests.shift());
this.masterPlaylistController.mediaSource.sourceBuffers[0].trigger('updateend');
// media
standardXHRResponse(this.requests.shift());
QUnit.equal(updates.length, 1, 'updated the segment list');
});
QUnit.test('selects a playlist after main/combined segment downloads', function() {
let calls = 0;
this.masterPlaylistController.selectPlaylist = () => {
calls++;
return this.masterPlaylistController.masterPlaylistLoader_.master.playlists[0];
};
this.masterPlaylistController.mediaSource.trigger('sourceopen');
// master
standardXHRResponse(this.requests.shift());
// media
standardXHRResponse(this.requests.shift());
// "downloaded" a segment
this.masterPlaylistController.mainSegmentLoader_.trigger('progress');
QUnit.strictEqual(calls, 2, 'selects after the initial segment');
// and another
this.masterPlaylistController.mainSegmentLoader_.trigger('progress');
QUnit.strictEqual(calls, 3, 'selects after additional segments');
});
QUnit.test('updates the duration after switching playlists', function() {
let selectedPlaylist = false;
this.masterPlaylistController.mediaSource.trigger('sourceopen');
this.masterPlaylistController.bandwidth = 1e20;
// master
standardXHRResponse(this.requests[0]);
// media
standardXHRResponse(this.requests[1]);
this.masterPlaylistController.selectPlaylist = () => {
selectedPlaylist = true;
// this duration should be overwritten by the playlist change
this.masterPlaylistController.mediaSource.duration = 0;
this.masterPlaylistController.mediaSource.readyState = 'open';
return this.masterPlaylistController.masterPlaylistLoader_.master.playlists[1];
};
// segment 0
standardXHRResponse(this.requests[2]);
this.masterPlaylistController.mediaSource.sourceBuffers[0].trigger('updateend');
// media1
standardXHRResponse(this.requests[3]);
QUnit.ok(selectedPlaylist, 'selected playlist');
QUnit.ok(this.masterPlaylistController.mediaSource.duration !== 0,
'updates the duration');
});
QUnit.test('seekable uses the intersection of alternate audio and combined tracks',
function() {
let origSeekable = Playlist.seekable;
let mainMedia = {};
let audioMedia = {};
let mainTimeRanges = [];
let audioTimeRanges = [];
let assertTimeRangesEqual = (left, right, message) => {
if (left.length === 0 && right.length === 0) {
return;
}
QUnit.equal(left.length, 1, message);
QUnit.equal(right.length, 1, message);
QUnit.equal(left.start(0), right.start(0), message);
QUnit.equal(left.end(0), right.end(0), message);
};
this.masterPlaylistController.masterPlaylistLoader_.media = () => mainMedia;
Playlist.seekable = (media) => {
if (media === mainMedia) {
return videojs.createTimeRanges(mainTimeRanges);
}
return videojs.createTimeRanges(audioTimeRanges);
};
assertTimeRangesEqual(this.masterPlaylistController.seekable(),
videojs.createTimeRanges(),
'empty when main empty');
mainTimeRanges = [[0, 10]];
assertTimeRangesEqual(this.masterPlaylistController.seekable(),
videojs.createTimeRanges([[0, 10]]),
'main when no audio');
this.masterPlaylistController.audioPlaylistLoader_ = {
media: () => audioMedia,
expired_: 0
};
assertTimeRangesEqual(this.masterPlaylistController.seekable(),
videojs.createTimeRanges(),
'empty when both empty');
mainTimeRanges = [[0, 10]];
assertTimeRangesEqual(this.masterPlaylistController.seekable(),
videojs.createTimeRanges(),
'empty when audio empty');
mainTimeRanges = [];
audioTimeRanges = [[0, 10]];
assertTimeRangesEqual(this.masterPlaylistController.seekable(),
videojs.createTimeRanges(),
'empty when main empty');
mainTimeRanges = [[0, 10]];
audioTimeRanges = [[0, 10]];
assertTimeRangesEqual(this.masterPlaylistController.seekable(),
videojs.createTimeRanges([[0, 10]]),
'ranges equal');
mainTimeRanges = [[5, 10]];
assertTimeRangesEqual(this.masterPlaylistController.seekable(),
videojs.createTimeRanges([[5, 10]]),
'main later start');
mainTimeRanges = [[0, 10]];
audioTimeRanges = [[5, 10]];
assertTimeRangesEqual(this.masterPlaylistController.seekable(),
videojs.createTimeRanges([[5, 10]]),
'audio later start');
mainTimeRanges = [[0, 9]];
audioTimeRanges = [[0, 10]];
assertTimeRangesEqual(this.masterPlaylistController.seekable(),
videojs.createTimeRanges([[0, 9]]),
'main earlier end');
mainTimeRanges = [[0, 10]];
audioTimeRanges = [[0, 9]];
assertTimeRangesEqual(this.masterPlaylistController.seekable(),
videojs.createTimeRanges([[0, 9]]),
'audio earlier end');
mainTimeRanges = [[1, 10]];
audioTimeRanges = [[0, 9]];
assertTimeRangesEqual(this.masterPlaylistController.seekable(),
videojs.createTimeRanges([[1, 9]]),
'main later start, audio earlier end');
mainTimeRanges = [[0, 9]];
audioTimeRanges = [[1, 10]];
assertTimeRangesEqual(this.masterPlaylistController.seekable(),
videojs.createTimeRanges([[1, 9]]),
'audio later start, main earlier end');
mainTimeRanges = [[2, 9]];
assertTimeRangesEqual(this.masterPlaylistController.seekable(),
videojs.createTimeRanges([[2, 9]]),
'main later start, main earlier end');
mainTimeRanges = [[1, 10]];
audioTimeRanges = [[2, 9]];
assertTimeRangesEqual(this.masterPlaylistController.seekable(),
videojs.createTimeRanges([[2, 9]]),
'audio later start, audio earlier end');
Playlist.seekable = origSeekable;
});
import sinon from 'sinon';
import QUnit from 'qunit';
import PlaylistLoader from '../src/playlist-loader';
import videojs from 'video.js';
import xhrFactory from '../src/xhr';
import { useFakeEnvironment } from './test-helpers';
// Attempts to produce an absolute URL to a given relative path
// based on window.location.href
const urlTo = function(path) {
......@@ -15,28 +15,15 @@ const urlTo = function(path) {
QUnit.module('Playlist Loader', {
beforeEach() {
// fake XHRs
this.oldXHR = videojs.xhr.XMLHttpRequest;
this.sinonXhr = sinon.useFakeXMLHttpRequest();
this.requests = [];
this.sinonXhr.onCreate = (xhr) => {
// force the XHR2 timeout polyfill
xhr.timeout = null;
this.requests.push(xhr);
};
// fake timers
this.clock = sinon.useFakeTimers();
videojs.xhr.XMLHttpRequest = this.sinonXhr;
this.env = useFakeEnvironment();
this.clock = this.env.clock;
this.requests = this.env.requests;
this.fakeHls = {
xhr: xhrFactory()
};
},
afterEach() {
this.sinonXhr.restore();
this.clock.restore();
videojs.xhr.XMLHttpRequest = this.oldXHR;
this.env.restore();
}
});
......@@ -52,12 +39,16 @@ QUnit.test('throws if the playlist url is empty or undefined', function() {
QUnit.test('starts without any metadata', function() {
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
loader.load();
QUnit.strictEqual(loader.state, 'HAVE_NOTHING', 'no metadata has loaded yet');
});
QUnit.test('starts with no expired time', function() {
let loader = new PlaylistLoader('media.m3u8', this.fakeHls);
loader.load();
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
......@@ -68,9 +59,9 @@ QUnit.test('starts with no expired time', function() {
});
QUnit.test('requests the initial playlist immediately', function() {
/* eslint-disable no-unused-vars */
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
/* eslint-enable no-unused-vars */
loader.load();
QUnit.strictEqual(this.requests.length, 1, 'made a request');
QUnit.strictEqual(this.requests[0].url,
......@@ -82,6 +73,8 @@ QUnit.test('moves to HAVE_MASTER after loading a master playlist', function() {
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
let state;
loader.load();
loader.on('loadedplaylist', function() {
state = loader.state;
});
......@@ -97,6 +90,8 @@ QUnit.test('jumps to HAVE_METADATA when initialized with a media playlist', func
let loadedmetadatas = 0;
let loader = new PlaylistLoader('media.m3u8', this.fakeHls);
loader.load();
loader.on('loadedmetadata', function() {
loadedmetadatas++;
});
......@@ -113,10 +108,156 @@ QUnit.test('jumps to HAVE_METADATA when initialized with a media playlist', func
QUnit.strictEqual(loadedmetadatas, 1, 'fired one loadedmetadata');
});
QUnit.test('resolves relative media playlist URIs', function() {
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
loader.load();
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:\n' +
'video/media.m3u8\n');
QUnit.equal(loader.master.playlists[0].resolvedUri, urlTo('video/media.m3u8'),
'resolved media URI');
});
QUnit.test('recognizes absolute URIs and requests them unmodified', function() {
let loader = new PlaylistLoader('manifest/media.m3u8', this.fakeHls);
loader.load();
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:\n' +
'http://example.com/video/media.m3u8\n');
QUnit.equal(loader.master.playlists[0].resolvedUri,
'http://example.com/video/media.m3u8', 'resolved media URI');
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'http://example.com/00001.ts\n' +
'#EXT-X-ENDLIST\n');
QUnit.equal(loader.media().segments[0].resolvedUri,
'http://example.com/00001.ts', 'resolved segment URI');
});
QUnit.test('recognizes domain-relative URLs', function() {
let loader = new PlaylistLoader('manifest/media.m3u8', this.fakeHls);
loader.load();
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:\n' +
'/media.m3u8\n');
QUnit.equal(loader.master.playlists[0].resolvedUri,
window.location.protocol + '//' +
window.location.host + '/media.m3u8',
'resolved media URI');
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'/00001.ts\n' +
'#EXT-X-ENDLIST\n');
QUnit.equal(loader.media().segments[0].resolvedUri,
window.location.protocol + '//' +
window.location.host + '/00001.ts',
'resolved segment URI');
});
QUnit.test('recognizes key URLs relative to master and playlist', function() {
let loader = new PlaylistLoader('/video/media-encrypted.m3u8', this.fakeHls);
loader.load();
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=17\n' +
'playlist/playlist.m3u8\n' +
'#EXT-X-ENDLIST\n');
QUnit.equal(loader.master.playlists[0].resolvedUri,
window.location.protocol + '//' +
window.location.host + '/video/playlist/playlist.m3u8',
'resolved media URI');
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-TARGETDURATION:15\n' +
'#EXT-X-KEY:METHOD=AES-128,URI="keys/key.php"\n' +
'#EXTINF:2.833,\n' +
'http://example.com/000001.ts\n' +
'#EXT-X-ENDLIST\n');
QUnit.equal(loader.media().segments[0].key.resolvedUri,
window.location.protocol + '//' +
window.location.host + '/video/playlist/keys/key.php',
'resolved multiple relative paths for key URI');
});
QUnit.test('trigger an error event when a media playlist 404s', function() {
let count = 0;
let loader = new PlaylistLoader('manifest/master.m3u8', this.fakeHls);
loader.load();
loader.on('error', function() {
count += 1;
});
// master
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=17\n' +
'playlist/playlist.m3u8\n' +
'#EXT-X-STREAM-INF:PROGRAM-ID=2,BANDWIDTH=170\n' +
'playlist/playlist2.m3u8\n' +
'#EXT-X-ENDLIST\n');
QUnit.equal(count, 0,
'error not triggered before requesting playlist');
// playlist
this.requests.shift().respond(404);
QUnit.equal(count, 1,
'error triggered after playlist 404');
});
QUnit.test('recognizes absolute key URLs', function() {
let loader = new PlaylistLoader('/video/media-encrypted.m3u8', this.fakeHls);
loader.load();
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=17\n' +
'playlist/playlist.m3u8\n' +
'#EXT-X-ENDLIST\n');
QUnit.equal(loader.master.playlists[0].resolvedUri,
window.location.protocol + '//' +
window.location.host + '/video/playlist/playlist.m3u8',
'resolved media URI');
this.requests.shift().respond(
200,
null,
'#EXTM3U\n' +
'#EXT-X-TARGETDURATION:15\n' +
'#EXT-X-KEY:METHOD=AES-128,URI="http://example.com/keys/key.php"\n' +
'#EXTINF:2.833,\n' +
'http://example.com/000001.ts\n' +
'#EXT-X-ENDLIST\n'
);
QUnit.equal(loader.media().segments[0].key.resolvedUri,
'http://example.com/keys/key.php', 'resolved absolute path for key URI');
});
QUnit.test('jumps to HAVE_METADATA when initialized with a live media playlist',
function() {
let loader = new PlaylistLoader('media.m3u8', this.fakeHls);
loader.load();
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
......@@ -131,6 +272,8 @@ QUnit.test('moves to HAVE_METADATA after loading a media playlist', function() {
let loadedMetadata = 0;
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
loader.load();
loader.on('loadedplaylist', function() {
loadedPlaylist++;
});
......@@ -164,6 +307,8 @@ QUnit.test('moves to HAVE_METADATA after loading a media playlist', function() {
QUnit.test('moves to HAVE_CURRENT_METADATA when refreshing the playlist', function() {
let loader = new PlaylistLoader('live.m3u8', this.fakeHls);
loader.load();
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
......@@ -180,6 +325,8 @@ QUnit.test('moves to HAVE_CURRENT_METADATA when refreshing the playlist', functi
QUnit.test('returns to HAVE_METADATA after refreshing the playlist', function() {
let loader = new PlaylistLoader('live.m3u8', this.fakeHls);
loader.load();
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
......@@ -197,6 +344,8 @@ QUnit.test('does not increment expired seconds before firstplay is triggered',
function() {
let loader = new PlaylistLoader('live.m3u8', this.fakeHls);
loader.load();
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
......@@ -227,6 +376,8 @@ function() {
QUnit.test('increments expired seconds after a segment is removed', function() {
let loader = new PlaylistLoader('live.m3u8', this.fakeHls);
loader.load();
loader.trigger('firstplay');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
......@@ -258,6 +409,8 @@ QUnit.test('increments expired seconds after a segment is removed', function() {
QUnit.test('increments expired seconds after a discontinuity', function() {
let loader = new PlaylistLoader('live.m3u8', this.fakeHls);
loader.load();
loader.trigger('firstplay');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
......@@ -306,6 +459,8 @@ QUnit.test('tracks expired seconds properly when two discontinuities expire at o
function() {
let loader = new PlaylistLoader('live.m3u8', this.fakeHls);
loader.load();
loader.trigger('firstplay');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
......@@ -334,6 +489,8 @@ QUnit.test('estimates expired if an entire window elapses between live playlist
function() {
let loader = new PlaylistLoader('live.m3u8', this.fakeHls);
loader.load();
loader.trigger('firstplay');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
......@@ -361,6 +518,8 @@ QUnit.test('emits an error when an initial playlist request fails', function() {
let errors = [];
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
loader.load();
loader.on('error', function() {
errors.push(loader.error);
});
......@@ -374,6 +533,8 @@ QUnit.test('errors when an initial media playlist request fails', function() {
let errors = [];
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
loader.load();
loader.on('error', function() {
errors.push(loader.error);
});
......@@ -393,9 +554,9 @@ QUnit.test('errors when an initial media playlist request fails', function() {
// http://tools.ietf.org/html/draft-pantos-http-live-streaming-12#section-6.3.4
QUnit.test('halves the refresh timeout if a playlist is unchanged since the last reload',
function() {
/* eslint-disable no-unused-vars */
let loader = new PlaylistLoader('live.m3u8', this.fakeHls);
/* eslint-enable no-unused-vars */
loader.load();
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
......@@ -422,6 +583,8 @@ QUnit.test('preserves segment metadata across playlist refreshes', function() {
let loader = new PlaylistLoader('live.m3u8', this.fakeHls);
let segment;
loader.load();
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
......@@ -454,6 +617,8 @@ QUnit.test('clears the update timeout when switching quality', function() {
let loader = new PlaylistLoader('live-master.m3u8', this.fakeHls);
let refreshes = 0;
loader.load();
// track the number of playlist refreshes triggered
loader.on('mediaupdatetimeout', function() {
refreshes++;
......@@ -485,9 +650,9 @@ QUnit.test('clears the update timeout when switching quality', function() {
});
QUnit.test('media-sequence updates are considered a playlist change', function() {
/* eslint-disable no-unused-vars */
let loader = new PlaylistLoader('live.m3u8', this.fakeHls);
/* eslint-enable no-unused-vars */
loader.load();
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
......@@ -512,6 +677,8 @@ QUnit.test('emits an error if a media refresh fails', function() {
let errorResponseText = 'custom error message';
let loader = new PlaylistLoader('live.m3u8', this.fakeHls);
loader.load();
loader.on('error', function() {
errors++;
});
......@@ -534,6 +701,8 @@ QUnit.test('emits an error if a media refresh fails', function() {
QUnit.test('switches media playlists when requested', function() {
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
loader.load();
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
......@@ -563,6 +732,8 @@ QUnit.test('switches media playlists when requested', function() {
QUnit.test('can switch playlists immediately after the master is downloaded', function() {
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
loader.load();
loader.on('loadedplaylist', function() {
loader.media('high.m3u8');
});
......@@ -578,6 +749,8 @@ QUnit.test('can switch playlists immediately after the master is downloaded', fu
QUnit.test('can switch media playlists based on URI', function() {
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
loader.load();
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
......@@ -607,6 +780,8 @@ QUnit.test('can switch media playlists based on URI', function() {
QUnit.test('aborts in-flight playlist refreshes when switching', function() {
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
loader.load();
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
......@@ -629,6 +804,8 @@ QUnit.test('aborts in-flight playlist refreshes when switching', function() {
QUnit.test('switching to the active playlist is a no-op', function() {
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
loader.load();
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
......@@ -649,6 +826,8 @@ QUnit.test('switching to the active playlist is a no-op', function() {
QUnit.test('switching to the active live playlist is a no-op', function() {
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
loader.load();
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
......@@ -668,6 +847,8 @@ QUnit.test('switching to the active live playlist is a no-op', function() {
QUnit.test('switches back to loaded playlists without re-requesting them', function() {
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
loader.load();
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
......@@ -697,6 +878,8 @@ QUnit.test('aborts outstanding requests if switching back to an already loaded p
function() {
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
loader.load();
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
......@@ -731,6 +914,8 @@ QUnit.test('does not abort requests when the same playlist is re-requested',
function() {
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
loader.load();
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
......@@ -753,6 +938,8 @@ function() {
QUnit.test('throws an error if a media switch is initiated too early', function() {
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
loader.load();
QUnit.throws(function() {
loader.media('high.m3u8');
}, 'threw an error from HAVE_NOTHING');
......@@ -769,6 +956,8 @@ QUnit.test('throws an error if a switch to an unrecognized playlist is requested
function() {
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
loader.load();
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
......@@ -782,6 +971,8 @@ function() {
QUnit.test('dispose cancels the refresh timeout', function() {
let loader = new PlaylistLoader('live.m3u8', this.fakeHls);
loader.load();
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
......@@ -797,6 +988,8 @@ QUnit.test('dispose cancels the refresh timeout', function() {
QUnit.test('dispose aborts pending refresh requests', function() {
let loader = new PlaylistLoader('live.m3u8', this.fakeHls);
loader.load();
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
......@@ -815,6 +1008,8 @@ QUnit.test('errors if requests take longer than 45s', function() {
let loader = new PlaylistLoader('media.m3u8', this.fakeHls);
let errors = 0;
loader.load();
loader.on('error', function() {
errors++;
});
......@@ -827,10 +1022,16 @@ QUnit.test('errors if requests take longer than 45s', function() {
QUnit.test('triggers an event when the active media changes', function() {
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
let mediaChanges = 0;
let mediaChangings = 0;
loader.load();
loader.on('mediachange', function() {
mediaChanges++;
});
loader.on('mediachanging', function() {
mediaChangings++;
});
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
......@@ -843,9 +1044,11 @@ QUnit.test('triggers an event when the active media changes', function() {
'#EXTINF:10,\n' +
'low-0.ts\n' +
'#EXT-X-ENDLIST\n');
QUnit.strictEqual(mediaChangings, 0, 'initial selection is not a media changing');
QUnit.strictEqual(mediaChanges, 0, 'initial selection is not a media change');
loader.media('high.m3u8');
QUnit.strictEqual(mediaChangings, 1, 'mediachanging fires immediately');
QUnit.strictEqual(mediaChanges, 0, 'mediachange does not fire immediately');
this.requests.shift().respond(200, null,
......@@ -854,166 +1057,25 @@ QUnit.test('triggers an event when the active media changes', function() {
'#EXTINF:10,\n' +
'high-0.ts\n' +
'#EXT-X-ENDLIST\n');
QUnit.strictEqual(mediaChangings, 1, 'still one mediachanging');
QUnit.strictEqual(mediaChanges, 1, 'fired a mediachange');
// switch back to an already loaded playlist
loader.media('low.m3u8');
QUnit.strictEqual(mediaChangings, 2, 'mediachanging fires');
QUnit.strictEqual(mediaChanges, 2, 'fired a mediachange');
// trigger a no-op switch
loader.media('low.m3u8');
QUnit.strictEqual(mediaChangings, 2, 'mediachanging ignored the no-op');
QUnit.strictEqual(mediaChanges, 2, 'ignored a no-op media change');
});
QUnit.test('can get media index by playback position for non-live videos', function() {
let loader = new PlaylistLoader('media.m3u8', this.fakeHls);
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:4,\n' +
'0.ts\n' +
'#EXTINF:5,\n' +
'1.ts\n' +
'#EXTINF:6,\n' +
'2.ts\n' +
'#EXT-X-ENDLIST\n');
QUnit.equal(loader.getMediaIndexForTime_(-1),
0,
'the index is never less than zero');
QUnit.equal(loader.getMediaIndexForTime_(0), 0, 'time zero is index zero');
QUnit.equal(loader.getMediaIndexForTime_(3), 0, 'time three is index zero');
QUnit.equal(loader.getMediaIndexForTime_(10), 2, 'time 10 is index 2');
QUnit.equal(loader.getMediaIndexForTime_(22),
2,
'time greater than the length is index 2');
});
QUnit.test('returns the lower index when calculating for a segment boundary', function() {
let loader = new PlaylistLoader('media.m3u8', this.fakeHls);
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:4,\n' +
'0.ts\n' +
'#EXTINF:5,\n' +
'1.ts\n' +
'#EXT-X-ENDLIST\n');
QUnit.equal(loader.getMediaIndexForTime_(4), 1, 'rounds up exact matches');
QUnit.equal(loader.getMediaIndexForTime_(3.7), 0, 'rounds down');
QUnit.equal(loader.getMediaIndexForTime_(4.5), 1, 'rounds up at 0.5');
});
QUnit.test('accounts for non-zero starting segment time when calculating media index',
function() {
let loader = new PlaylistLoader('media.m3u8', this.fakeHls);
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1001\n' +
'#EXTINF:4,\n' +
'1001.ts\n' +
'#EXTINF:5,\n' +
'1002.ts\n');
loader.media().segments[0].end = 154;
QUnit.equal(loader.getMediaIndexForTime_(0),
-1,
'the lowest returned value is negative one');
QUnit.equal(loader.getMediaIndexForTime_(45),
-1,
'expired content returns negative one');
QUnit.equal(loader.getMediaIndexForTime_(75),
-1,
'expired content returns negative one');
QUnit.equal(loader.getMediaIndexForTime_(50 + 100),
0,
'calculates the earliest available position');
QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 2),
0,
'calculates within the first segment');
QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 4),
1,
'calculates within the second segment');
QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 4.5),
1,
'calculates within the second segment');
QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 6),
1,
'calculates within the second segment');
});
QUnit.test('prefers precise segment timing when tracking expired time', function() {
let loader = new PlaylistLoader('media.m3u8', this.fakeHls);
loader.trigger('firstplay');
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1001\n' +
'#EXTINF:4,\n' +
'1001.ts\n' +
'#EXTINF:5,\n' +
'1002.ts\n');
// setup the loader with an "imprecise" value as if it had been
// accumulating segment durations as they expire
loader.expired_ = 160;
// annotate the first segment with a start time
// this number would be coming from the Source Buffer in practice
loader.media().segments[0].end = 150;
QUnit.equal(loader.getMediaIndexForTime_(149),
0,
'prefers the value on the first segment');
// trigger a playlist refresh
this.clock.tick(10 * 1000);
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1002\n' +
'#EXTINF:5,\n' +
'1002.ts\n');
QUnit.equal(loader.getMediaIndexForTime_(150 + 4 + 1),
0,
'tracks precise expired times');
});
QUnit.test('accounts for expired time when calculating media index', function() {
let loader = new PlaylistLoader('media.m3u8', this.fakeHls);
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1001\n' +
'#EXTINF:4,\n' +
'1001.ts\n' +
'#EXTINF:5,\n' +
'1002.ts\n');
loader.expired_ = 150;
QUnit.equal(loader.getMediaIndexForTime_(0),
-1,
'expired content returns a negative index');
QUnit.equal(loader.getMediaIndexForTime_(75),
-1,
'expired content returns a negative index');
QUnit.equal(loader.getMediaIndexForTime_(50 + 100),
0,
'calculates the earliest available position');
QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 2),
0,
'calculates within the first segment');
QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 4.5),
1,
'calculates within the second segment');
QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 6),
1,
'calculates within the second segment');
});
QUnit.test('does not misintrepret playlists missing newlines at the end', function() {
let loader = new PlaylistLoader('media.m3u8', this.fakeHls);
loader.load();
// no newline
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
......
import Playlist from '../src/playlist';
import PlaylistLoader from '../src/playlist-loader';
import QUnit from 'qunit';
import xhrFactory from '../src/xhr';
import { useFakeEnvironment } from './test-helpers';
QUnit.module('Playlist Duration');
QUnit.test('total duration for live playlists is Infinity', function() {
......@@ -376,3 +380,251 @@ QUnit.test('seekable end accounts for non-standard target durations', function()
9 - (2 + 2 + 1),
'allows seeking no further than three segments from the end');
});
QUnit.module('Playlist Media Index For Time', {
beforeEach() {
this.env = useFakeEnvironment();
this.clock = this.env.clock;
this.requests = this.env.requests;
this.fakeHls = {
xhr: xhrFactory()
};
},
afterEach() {
this.env.restore();
}
});
QUnit.test('can get media index by playback position for non-live videos', function() {
let media;
let loader = new PlaylistLoader('media.m3u8', this.fakeHls);
loader.load();
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:4,\n' +
'0.ts\n' +
'#EXTINF:5,\n' +
'1.ts\n' +
'#EXTINF:6,\n' +
'2.ts\n' +
'#EXT-X-ENDLIST\n'
);
media = loader.media();
QUnit.equal(Playlist.getMediaIndexForTime_(media, -1), 0,
'the index is never less than zero');
QUnit.equal(Playlist.getMediaIndexForTime_(media, 0), 0, 'time zero is index zero');
QUnit.equal(Playlist.getMediaIndexForTime_(media, 3), 0, 'time three is index zero');
QUnit.equal(Playlist.getMediaIndexForTime_(media, 10), 2, 'time 10 is index 2');
QUnit.equal(Playlist.getMediaIndexForTime_(media, 22), 2,
'time greater than the length is index 2');
});
QUnit.test('returns the lower index when calculating for a segment boundary', function() {
let media;
let loader = new PlaylistLoader('media.m3u8', this.fakeHls);
loader.load();
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:4,\n' +
'0.ts\n' +
'#EXTINF:5,\n' +
'1.ts\n' +
'#EXT-X-ENDLIST\n'
);
media = loader.media();
QUnit.equal(Playlist.getMediaIndexForTime_(media, 4), 1, 'rounds up exact matches');
QUnit.equal(Playlist.getMediaIndexForTime_(media, 3.7), 0, 'rounds down');
QUnit.equal(Playlist.getMediaIndexForTime_(media, 4.5), 1, 'rounds up at 0.5');
});
QUnit.test(
'accounts for non-zero starting segment time when calculating media index',
function() {
let media;
let loader = new PlaylistLoader('media.m3u8', this.fakeHls);
loader.load();
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1001\n' +
'#EXTINF:4,\n' +
'1001.ts\n' +
'#EXTINF:5,\n' +
'1002.ts\n'
);
loader.media().segments[0].end = 154;
media = loader.media();
QUnit.equal(
Playlist.getMediaIndexForTime_(media, 0),
-1,
'the lowest returned value is negative one'
);
QUnit.equal(
Playlist.getMediaIndexForTime_(media, 45),
-1,
'expired content returns negative one'
);
QUnit.equal(
Playlist.getMediaIndexForTime_(media, 75),
-1,
'expired content returns negative one'
);
QUnit.equal(
Playlist.getMediaIndexForTime_(media, 50 + 100),
0,
'calculates the earliest available position'
);
QUnit.equal(
Playlist.getMediaIndexForTime_(media, 50 + 100 + 2),
0,
'calculates within the first segment'
);
QUnit.equal(
Playlist.getMediaIndexForTime_(media, 50 + 100 + 2),
0,
'calculates within the first segment'
);
QUnit.equal(
Playlist.getMediaIndexForTime_(media, 50 + 100 + 4),
1,
'calculates within the second segment'
);
QUnit.equal(
Playlist.getMediaIndexForTime_(media, 50 + 100 + 4.5),
1,
'calculates within the second segment'
);
QUnit.equal(
Playlist.getMediaIndexForTime_(media, 50 + 100 + 6),
1,
'calculates within the second segment'
);
loader.media().segments[1].end = 159;
QUnit.equal(
Playlist.getMediaIndexForTime_(media, 159),
2,
'returns number of segments when time is equal to end of last segment'
);
QUnit.equal(
Playlist.getMediaIndexForTime_(media, 159.1),
2,
'returns number of segments when time is past end of last segment'
);
});
QUnit.test('prefers precise segment timing when tracking expired time', function() {
let media;
let loader = new PlaylistLoader('media.m3u8', this.fakeHls);
loader.load();
loader.trigger('firstplay');
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1001\n' +
'#EXTINF:4,\n' +
'1001.ts\n' +
'#EXTINF:5,\n' +
'1002.ts\n'
);
// setup the loader with an "imprecise" value as if it had been
// accumulating segment durations as they expire
loader.expired_ = 160;
// annotate the first segment with a start time
// this number would be coming from the Source Buffer in practice
loader.media().segments[0].end = 150;
media = loader.media();
QUnit.equal(
Playlist.getMediaIndexForTime_(media, 149),
0,
'prefers the value on the first segment'
);
// trigger a playlist refresh
this.clock.tick(10 * 1000);
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1002\n' +
'#EXTINF:5,\n' +
'1002.ts\n'
);
media = loader.media();
QUnit.equal(
Playlist.getMediaIndexForTime_(media, 150 + 4 + 1),
0,
'tracks precise expired times'
);
});
QUnit.test('accounts for expired time when calculating media index', function() {
let media;
let loader = new PlaylistLoader('media.m3u8', this.fakeHls);
let expired = 150;
loader.load();
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1001\n' +
'#EXTINF:4,\n' +
'1001.ts\n' +
'#EXTINF:5,\n' +
'1002.ts\n'
);
media = loader.media();
QUnit.equal(
Playlist.getMediaIndexForTime_(media, 0, expired),
-1,
'expired content returns a negative index'
);
QUnit.equal(
Playlist.getMediaIndexForTime_(media, 75, expired),
-1,
'expired content returns a negative index'
);
QUnit.equal(
Playlist.getMediaIndexForTime_(media, 50 + 100, expired),
0,
'calculates the earliest available position'
);
QUnit.equal(
Playlist.getMediaIndexForTime_(media, 50 + 100 + 2, expired),
0,
'calculates within the first segment'
);
QUnit.equal(
Playlist.getMediaIndexForTime_(media, 50 + 100 + 2, expired),
0,
'calculates within the first segment'
);
QUnit.equal(
Playlist.getMediaIndexForTime_(media, 50 + 100 + 4.5, expired),
1,
'calculates within the second segment'
);
QUnit.equal(
Playlist.getMediaIndexForTime_(media, 50 + 100 + 6, expired),
1,
'calculates within the second segment'
);
});
......
import Ranges from '../src/ranges';
import {createTimeRanges} from 'video.js';
import QUnit from 'qunit';
QUnit.module('TimeRanges Utilities');
QUnit.test('finds the overlapping time range', function() {
let range = Ranges.findRange(createTimeRanges([[0, 5], [6, 12]]), 3);
QUnit.equal(range.length, 1, 'found one range');
QUnit.equal(range.end(0), 5, 'inside the first buffered region');
range = Ranges.findRange(createTimeRanges([[0, 5], [6, 12]]), 6);
QUnit.equal(range.length, 1, 'found one range');
QUnit.equal(range.end(0), 12, 'inside the second buffered region');
});
QUnit.module('Buffer Inpsection');
QUnit.test('detects time range end-point changed by updates', function() {
let edge;
// Single-range changes
edge = Ranges.findSoleUncommonTimeRangesEnd(createTimeRanges([[0, 10]]),
createTimeRanges([[0, 11]]));
QUnit.strictEqual(edge, 11, 'detected a forward addition');
edge = Ranges.findSoleUncommonTimeRangesEnd(createTimeRanges([[5, 10]]),
createTimeRanges([[0, 10]]));
QUnit.strictEqual(edge, null, 'ignores backward addition');
edge = Ranges.findSoleUncommonTimeRangesEnd(createTimeRanges([[5, 10]]),
createTimeRanges([[0, 11]]));
QUnit.strictEqual(edge, 11,
'detected a forward addition & ignores a backward addition');
edge = Ranges.findSoleUncommonTimeRangesEnd(createTimeRanges([[0, 10]]),
createTimeRanges([[0, 9]]));
QUnit.strictEqual(edge, null,
'ignores a backwards addition resulting from a shrinking range');
edge = Ranges.findSoleUncommonTimeRangesEnd(createTimeRanges([[0, 10]]),
createTimeRanges([[2, 7]]));
QUnit.strictEqual(edge, null,
'ignores a forward & backwards addition resulting from a shrinking ' +
'range');
edge = Ranges.findSoleUncommonTimeRangesEnd(createTimeRanges([[2, 10]]),
createTimeRanges([[0, 7]]));
QUnit.strictEqual(
edge,
null,
'ignores a forward & backwards addition resulting from a range shifted backward'
);
edge = Ranges.findSoleUncommonTimeRangesEnd(createTimeRanges([[2, 10]]),
createTimeRanges([[5, 15]]));
QUnit.strictEqual(edge, 15,
'detected a forwards addition resulting from a range shifted foward');
// Multiple-range changes
edge = Ranges.findSoleUncommonTimeRangesEnd(createTimeRanges([[0, 10]]),
createTimeRanges([[0, 11], [12, 15]]));
QUnit.strictEqual(edge, null, 'ignores multiple new forward additions');
edge = Ranges.findSoleUncommonTimeRangesEnd(createTimeRanges([[0, 10], [20, 40]]),
createTimeRanges([[20, 50]]));
QUnit.strictEqual(edge, 50, 'detected a forward addition & ignores range removal');
edge = Ranges.findSoleUncommonTimeRangesEnd(createTimeRanges([[0, 10], [20, 40]]),
createTimeRanges([[0, 50]]));
QUnit.strictEqual(edge, 50, 'detected a forward addition & ignores merges');
edge = Ranges.findSoleUncommonTimeRangesEnd(createTimeRanges([[0, 10], [20, 40]]),
createTimeRanges([[0, 40]]));
QUnit.strictEqual(edge, null, 'ignores merges');
// Empty input
edge = Ranges.findSoleUncommonTimeRangesEnd(createTimeRanges(),
createTimeRanges([[0, 11]]));
QUnit.strictEqual(edge, 11, 'handle an empty original TimeRanges object');
edge = Ranges.findSoleUncommonTimeRangesEnd(createTimeRanges([[0, 11]]),
createTimeRanges());
QUnit.strictEqual(edge, null, 'handle an empty update TimeRanges object');
// Null input
edge = Ranges.findSoleUncommonTimeRangesEnd(null, createTimeRanges([[0, 11]]));
QUnit.strictEqual(edge, 11, 'treat null original buffer as an empty TimeRanges object');
edge = Ranges.findSoleUncommonTimeRangesEnd(createTimeRanges([[0, 11]]), null);
QUnit.strictEqual(edge, null, 'treat null update buffer as an empty TimeRanges object');
});
import QUnit from 'qunit';
import {GOAL_BUFFER_LENGTH, default as SegmentLoader} from '../src/segment-loader';
import videojs from 'video.js';
import xhrFactory from '../src/xhr';
import { useFakeEnvironment, useFakeMediaSource } from './test-helpers.js';
const playlistWithDuration = function(time, conf) {
let result = {
mediaSequence: conf && conf.mediaSequence ? conf.mediaSequence : 0,
discontinuityStarts: [],
segments: [],
endList: true
};
let count = Math.floor(time / 10);
let remainder = time % 10;
let i;
let isEncrypted = conf && conf.isEncrypted;
for (i = 0; i < count; i++) {
result.segments.push({
uri: i + '.ts',
resolvedUri: i + '.ts',
duration: 10
});
if (isEncrypted) {
result.segments[i].key = {
uri: i + '-key.php',
resolvedUri: i + '-key.php'
};
}
}
if (remainder) {
result.segments.push({
uri: i + '.ts',
duration: remainder
});
}
return result;
};
let currentTime;
let mediaSource;
let loader;
QUnit.module('Segment Loader', {
beforeEach() {
this.env = useFakeEnvironment();
this.clock = this.env.clock;
this.requests = this.env.requests;
this.mse = useFakeMediaSource();
this.seekable = {
length: 0
};
this.mimeType = 'video/mp2t';
this.fakeHls = {
xhr: xhrFactory()
};
currentTime = 0;
mediaSource = new videojs.MediaSource();
mediaSource.trigger('sourceopen');
loader = new SegmentLoader({
hls: this.fakeHls,
currentTime() {
return currentTime;
},
seekable: () => this.seekable,
seeking: () => false,
hasPlayed: () => true,
mediaSource
});
},
afterEach() {
this.env.restore();
this.mse.restore();
}
});
QUnit.test('fails without required initialization options', function() {
/* eslint-disable no-new */
QUnit.throws(function() {
new SegmentLoader();
}, 'requires options');
QUnit.throws(function() {
new SegmentLoader({});
}, 'requires a currentTime callback');
QUnit.throws(function() {
new SegmentLoader({
currentTime() {}
});
}, 'requires a media source');
/* eslint-enable */
});
QUnit.test('load waits until a playlist and mime type are specified to proceed',
function() {
loader.load();
QUnit.equal(loader.state, 'INIT', 'waiting in init');
QUnit.equal(loader.paused(), false, 'not paused');
loader.playlist(playlistWithDuration(10));
QUnit.equal(this.requests.length, 0, 'have not made a request yet');
loader.mimeType(this.mimeType);
QUnit.equal(this.requests.length, 1, 'made a request');
QUnit.equal(loader.state, 'WAITING', 'transitioned states');
});
QUnit.test('calling mime type and load begins buffering', function() {
QUnit.equal(loader.state, 'INIT', 'starts in the init state');
loader.playlist(playlistWithDuration(10));
QUnit.equal(loader.state, 'INIT', 'starts in the init state');
QUnit.ok(loader.paused(), 'starts paused');
loader.mimeType(this.mimeType);
QUnit.equal(loader.state, 'INIT', 'still in the init state');
loader.load();
QUnit.equal(loader.state, 'WAITING', 'moves to the ready state');
QUnit.ok(!loader.paused(), 'loading is not paused');
QUnit.equal(this.requests.length, 1, 'requested a segment');
});
QUnit.test('calling load is idempotent', function() {
loader.playlist(playlistWithDuration(20));
loader.mimeType(this.mimeType);
loader.load();
QUnit.equal(loader.state, 'WAITING', 'moves to the ready state');
QUnit.equal(this.requests.length, 1, 'made one request');
loader.load();
QUnit.equal(loader.state, 'WAITING', 'still in the ready state');
QUnit.equal(this.requests.length, 1, 'still one request');
// some time passes and a response is received
this.clock.tick(100);
this.requests[0].response = new Uint8Array(10).buffer;
this.requests.shift().respond(200, null, '');
loader.load();
QUnit.equal(this.requests.length, 0, 'load has no effect');
});
QUnit.test('calling load should unpause', function() {
let sourceBuffer;
loader.playlist(playlistWithDuration(20));
loader.pause();
loader.mimeType(this.mimeType);
sourceBuffer = mediaSource.sourceBuffers[0];
loader.load();
QUnit.equal(loader.paused(), false, 'loading unpauses');
loader.pause();
this.clock.tick(1);
this.requests[0].response = new Uint8Array(10).buffer;
this.requests.shift().respond(200, null, '');
QUnit.equal(loader.paused(), true, 'stayed paused');
loader.load();
QUnit.equal(loader.paused(), false, 'unpaused during processing');
loader.pause();
sourceBuffer.trigger('updateend');
QUnit.equal(loader.state, 'READY', 'finished processing');
QUnit.ok(loader.paused(), 'stayed paused');
loader.load();
QUnit.equal(loader.paused(), false, 'unpaused');
});
QUnit.test('regularly checks the buffer while unpaused', function() {
let sourceBuffer;
loader.playlist(playlistWithDuration(90));
loader.mimeType(this.mimeType);
loader.load();
sourceBuffer = mediaSource.sourceBuffers[0];
// fill the buffer
this.clock.tick(1);
this.requests[0].response = new Uint8Array(10).buffer;
this.requests.shift().respond(200, null, '');
sourceBuffer.buffered = videojs.createTimeRanges([[
0, GOAL_BUFFER_LENGTH
]]);
sourceBuffer.trigger('updateend');
QUnit.equal(this.requests.length, 0, 'no outstanding requests');
// play some video to drain the buffer
currentTime = GOAL_BUFFER_LENGTH;
this.clock.tick(10 * 1000);
QUnit.equal(this.requests.length, 1, 'requested another segment');
});
QUnit.test('does not check the buffer while paused', function() {
let sourceBuffer;
loader.playlist(playlistWithDuration(90));
loader.mimeType(this.mimeType);
loader.load();
sourceBuffer = mediaSource.sourceBuffers[0];
loader.pause();
this.clock.tick(1);
this.requests[0].response = new Uint8Array(10).buffer;
this.requests.shift().respond(200, null, '');
sourceBuffer.trigger('updateend');
this.clock.tick(10 * 1000);
QUnit.equal(this.requests.length, 0, 'did not make a request');
});
QUnit.test('calculates bandwidth after downloading a segment', function() {
loader.playlist(playlistWithDuration(10));
loader.mimeType(this.mimeType);
loader.load();
// some time passes and a response is received
this.clock.tick(100);
this.requests[0].response = new Uint8Array(10).buffer;
this.requests.shift().respond(200, null, '');
QUnit.equal(loader.bandwidth, (10 / 100) * 8 * 1000, 'calculated bandwidth');
QUnit.equal(loader.roundTrip, 100, 'saves request round trip time');
QUnit.equal(loader.bytesReceived, 10, 'saves bytes received');
});
QUnit.test('segment request timeouts reset bandwidth', function() {
loader.playlist(playlistWithDuration(10));
loader.mimeType(this.mimeType);
loader.load();
// a lot of time passes so the request times out
this.requests[0].timedout = true;
this.clock.tick(100 * 1000);
QUnit.equal(loader.bandwidth, 1, 'reset bandwidth');
QUnit.ok(isNaN(loader.roundTrip), 'reset round trip time');
});
QUnit.test('appending a segment triggers progress', function() {
let progresses = 0;
loader.on('progress', function() {
progresses++;
});
loader.playlist(playlistWithDuration(10));
loader.mimeType(this.mimeType);
loader.load();
// some time passes and a response is received
this.requests[0].response = new Uint8Array(10).buffer;
this.requests.shift().respond(200, null, '');
mediaSource.sourceBuffers[0].trigger('updateend');
QUnit.equal(progresses, 1, 'fired progress');
});
QUnit.test('only requests one segment at a time', function() {
loader.playlist(playlistWithDuration(10));
loader.mimeType(this.mimeType);
loader.load();
// a bunch of time passes without recieving a response
this.clock.tick(20 * 1000);
QUnit.equal(this.requests.length, 1, 'only one request was made');
});
QUnit.test('only appends one segment at a time', function() {
loader.playlist(playlistWithDuration(10));
loader.mimeType(this.mimeType);
loader.load();
// some time passes and a segment is received
this.clock.tick(100);
this.requests[0].response = new Uint8Array(10).buffer;
this.requests.shift().respond(200, null, '');
// a lot of time goes by without "updateend"
this.clock.tick(20 * 1000);
QUnit.equal(mediaSource.sourceBuffers[0].updates_.filter(
update => update.append).length, 1, 'only one append');
QUnit.equal(this.requests.length, 0, 'only made one request');
});
QUnit.test('adjusts the playlist offset if no buffering progress is made', function() {
let sourceBuffer;
let playlist;
playlist = playlistWithDuration(40);
playlist.endList = false;
loader.playlist(playlist);
loader.mimeType(this.mimeType);
loader.load();
sourceBuffer = mediaSource.sourceBuffers[0];
// buffer some content and switch playlists on progress
this.clock.tick(1);
this.requests[0].response = new Uint8Array(10).buffer;
this.requests.shift().respond(200, null, '');
loader.on('progress', function f() {
loader.off('progress', f);
// switch playlists
playlist = playlistWithDuration(40);
playlist.uri = 'alternate.m3u8';
playlist.endList = false;
loader.playlist(playlist);
});
sourceBuffer.buffered = videojs.createTimeRanges([[0, 5]]);
sourceBuffer.trigger('updateend');
// the next segment doesn't increase the buffer at all
QUnit.equal(this.requests[0].url, '0.ts', 'requested the same segment');
this.clock.tick(1);
this.requests[0].response = new Uint8Array(10).buffer;
this.requests.shift().respond(200, null, '');
sourceBuffer.trigger('updateend');
// so the loader should try the next segment
QUnit.equal(this.requests[0].url, '1.ts', 'moved ahead a segment');
});
QUnit.test('never attempt to load a segment that ' +
'is greater than 90% buffered', function() {
let sourceBuffer;
let playlist;
playlist = playlistWithDuration(40);
playlist.endList = false;
loader.playlist(playlist);
loader.mimeType(this.mimeType);
loader.load();
sourceBuffer = mediaSource.sourceBuffers[0];
// buffer some content and switch playlists on progress
this.clock.tick(1);
this.requests[0].response = new Uint8Array(10).buffer;
this.requests.shift().respond(200, null, '');
loader.on('progress', function f() {
loader.off('progress', f);
// switch playlists
playlist = playlistWithDuration(40);
playlist.uri = 'alternate.m3u8';
playlist.endList = false;
loader.playlist(playlist);
});
sourceBuffer.buffered = videojs.createTimeRanges([[0, 9.2]]);
sourceBuffer.trigger('updateend');
// the next segment doesn't increase the buffer at all
QUnit.equal(this.requests[0].url, '1.ts', 'requested the next segment');
this.clock.tick(1);
this.requests[0].response = new Uint8Array(10).buffer;
this.requests.shift().respond(200, null, '');
sourceBuffer.trigger('updateend');
// so the loader should try the next segment
QUnit.equal(this.requests[0].url, '1.ts', 'moved ahead a segment');
});
QUnit.test('adjusts the playlist offset if no buffering progress is made', function() {
let sourceBuffer;
let playlist;
playlist = playlistWithDuration(40);
playlist.endList = false;
loader.playlist(playlist);
loader.mimeType(this.mimeType);
loader.load();
sourceBuffer = mediaSource.sourceBuffers[0];
// buffer some content and switch playlists on progress
this.clock.tick(1);
this.requests[0].response = new Uint8Array(10).buffer;
this.requests.shift().respond(200, null, '');
loader.on('progress', function f() {
loader.off('progress', f);
// switch playlists
playlist = playlistWithDuration(40);
playlist.uri = 'alternate.m3u8';
playlist.endList = false;
loader.playlist(playlist);
});
sourceBuffer.buffered = videojs.createTimeRanges([[0, 5]]);
sourceBuffer.trigger('updateend');
// the next segment doesn't increase the buffer at all
QUnit.equal(this.requests[0].url, '0.ts', 'requested the same segment');
this.clock.tick(1);
this.requests[0].response = new Uint8Array(10).buffer;
this.requests.shift().respond(200, null, '');
sourceBuffer.trigger('updateend');
// so the loader should try the next segment
QUnit.equal(this.requests[0].url, '1.ts', 'moved ahead a segment');
});
QUnit.test('cancels outstanding requests on abort', function() {
loader.playlist(playlistWithDuration(20));
loader.mimeType(this.mimeType);
loader.load();
loader.xhr_.segmentXhr.onreadystatechange = function() {
throw new Error('onreadystatechange should not be called');
};
loader.abort();
QUnit.ok(this.requests[0].aborted, 'aborted the first request');
QUnit.equal(this.requests.length, 2, 'started a new request');
QUnit.equal(loader.state, 'WAITING', 'back to the waiting state');
});
QUnit.test('abort does not cancel segment processing in progress', function() {
loader.playlist(playlistWithDuration(20));
loader.mimeType(this.mimeType);
loader.load();
this.requests[0].response = new Uint8Array(10).buffer;
this.requests.shift().respond(200, null, '');
loader.abort();
QUnit.equal(loader.state, 'APPENDING', 'still appending');
});
QUnit.test('sets the timestampOffset on timeline change', function() {
let playlist = playlistWithDuration(40);
playlist.discontinuityStarts = [1];
playlist.segments[1].timeline = 1;
loader.playlist(playlist);
loader.mimeType(this.mimeType);
loader.load();
// segment 0
this.requests[0].response = new Uint8Array(10).buffer;
this.requests.shift().respond(200, null, '');
mediaSource.sourceBuffers[0].buffered = videojs.createTimeRanges([[0, 10]]);
mediaSource.sourceBuffers[0].trigger('updateend');
// segment 1, discontinuity
this.requests[0].response = new Uint8Array(10).buffer;
this.requests.shift().respond(200, null, '');
QUnit.equal(mediaSource.sourceBuffers[0].timestampOffset, 10, 'set timestampOffset');
});
QUnit.test('tracks segment end times as they are buffered', function() {
let playlist = playlistWithDuration(20);
loader.playlist(playlist);
loader.mimeType(this.mimeType);
loader.load();
this.requests[0].response = new Uint8Array(10).buffer;
this.requests.shift().respond(200, null, '');
mediaSource.sourceBuffers[0].buffered = videojs.createTimeRanges([
[0, 9.5]
]);
mediaSource.sourceBuffers[0].trigger('updateend');
QUnit.equal(playlist.segments[0].end, 9.5, 'updated duration');
});
QUnit.test('segment 404s should trigger an error', function() {
let errors = [];
loader.playlist(playlistWithDuration(10));
loader.mimeType(this.mimeType);
loader.load();
loader.on('error', function(error) {
errors.push(error);
});
this.requests.shift().respond(404, null, '');
QUnit.equal(errors.length, 1, 'triggered an error');
QUnit.equal(loader.error().code, 2, 'triggered MEDIA_ERR_NETWORK');
QUnit.ok(loader.error().xhr, 'included the request object');
QUnit.ok(loader.paused(), 'paused the loader');
QUnit.equal(loader.state, 'READY', 'returned to the ready state');
});
QUnit.test('segment 5xx status codes trigger an error', function() {
let errors = [];
loader.playlist(playlistWithDuration(10));
loader.mimeType(this.mimeType);
loader.load();
loader.on('error', function(error) {
errors.push(error);
});
this.requests.shift().respond(500, null, '');
QUnit.equal(errors.length, 1, 'triggered an error');
QUnit.equal(loader.error().code, 2, 'triggered MEDIA_ERR_NETWORK');
QUnit.ok(loader.error().xhr, 'included the request object');
QUnit.ok(loader.paused(), 'paused the loader');
QUnit.equal(loader.state, 'READY', 'returned to the ready state');
});
QUnit.test('fires ended at the end of a playlist', function() {
let endOfStreams = 0;
loader.playlist(playlistWithDuration(10));
loader.mimeType(this.mimeType);
loader.load();
loader.mediaSource_ = {
readyState: 'open',
sourceBuffers: mediaSource.sourceBuffers,
endOfStream() {
endOfStreams++;
}
};
this.requests[0].response = new Uint8Array(10).buffer;
this.requests.shift().respond(200, null, '');
mediaSource.sourceBuffers[0].buffered = videojs.createTimeRanges([[0, 10]]);
mediaSource.sourceBuffers[0].trigger('updateend');
QUnit.equal(endOfStreams, 1, 'triggered ended');
});
QUnit.test('live playlists do not trigger ended', function() {
let endOfStreams = 0;
let playlist;
playlist = playlistWithDuration(10);
playlist.endList = false;
loader.playlist(playlist);
loader.mimeType(this.mimeType);
loader.load();
loader.mediaSource_ = {
readyState: 'open',
sourceBuffers: mediaSource.sourceBuffers,
endOfStream() {
endOfStreams++;
}
};
this.requests[0].response = new Uint8Array(10).buffer;
this.requests.shift().respond(200, null, '');
mediaSource.sourceBuffers[0].buffered = videojs.createTimeRanges([[0, 10]]);
mediaSource.sourceBuffers[0].trigger('updateend');
QUnit.equal(endOfStreams, 0, 'did not trigger ended');
});
QUnit.test('respects the global withCredentials option', function() {
let hlsOptions = videojs.options.hls;
videojs.options.hls = {
withCredentials: true
};
loader = new SegmentLoader({
hls: this.fakeHls,
currentTime() {
return currentTime;
},
seekable: () => this.seekable,
mediaSource
});
loader.playlist(playlistWithDuration(10, {isEncrypted: true}));
loader.mimeType(this.mimeType);
loader.load();
QUnit.equal(this.requests[0].url, '0-key.php', 'requested the first segment\'s key');
QUnit.ok(this.requests[0].withCredentials, 'key request used withCredentials');
QUnit.equal(this.requests[1].url, '0.ts', 'requested the first segment');
QUnit.ok(this.requests[1].withCredentials, 'segment request used withCredentials');
videojs.options.hls = hlsOptions;
});
QUnit.test('respects the withCredentials option', function() {
loader = new SegmentLoader({
hls: this.fakeHls,
currentTime() {
return currentTime;
},
seekable: () => this.seekable,
mediaSource,
withCredentials: true
});
loader.playlist(playlistWithDuration(10, {isEncrypted: true}));
loader.mimeType(this.mimeType);
loader.load();
QUnit.equal(this.requests[0].url, '0-key.php', 'requested the first segment\'s key');
QUnit.ok(this.requests[0].withCredentials, 'key request used withCredentials');
QUnit.equal(this.requests[1].url, '0.ts', 'requested the first segment');
QUnit.ok(this.requests[1].withCredentials, 'segment request used withCredentials');
});
QUnit.test('the withCredentials option overrides the global', function() {
let hlsOptions = videojs.options.hls;
videojs.options.hls = {
withCredentials: true
};
loader = new SegmentLoader({
hls: this.fakeHls,
currentTime() {
return currentTime;
},
mediaSource,
seekable: () => this.seekable,
withCredentials: false
});
loader.playlist(playlistWithDuration(10, {isEncrypted: true}));
loader.mimeType(this.mimeType);
loader.load();
QUnit.equal(this.requests[0].url, '0-key.php', 'requested the first segment\'s key');
QUnit.ok(!this.requests[0].withCredentials, 'overrode key request withCredentials');
QUnit.equal(this.requests[1].url, '0.ts', 'requested the first segment');
QUnit.ok(!this.requests[1].withCredentials, 'overrode segment request withCredentials');
videojs.options.hls = hlsOptions;
});
QUnit.test('remains ready if there are no segments', function() {
loader.playlist(playlistWithDuration(0));
loader.mimeType(this.mimeType);
loader.load();
QUnit.equal(loader.state, 'READY', 'in the ready state');
});
QUnit.test('dispose cleans up outstanding work', function() {
loader.playlist(playlistWithDuration(20));
loader.mimeType(this.mimeType);
loader.load();
loader.dispose();
QUnit.ok(this.requests[0].aborted, 'aborted segment request');
QUnit.equal(this.requests.length, 1, 'did not open another request');
mediaSource.sourceBuffers.forEach((sourceBuffer, i) => {
let lastOperation = sourceBuffer.updates_.slice(-1)[0];
QUnit.ok(lastOperation.abort, 'aborted source buffer ' + i);
});
});
// ----------
// Decryption
// ----------
QUnit.test('calling load with an encrypted segment requests key and segment', function() {
QUnit.equal(loader.state, 'INIT', 'starts in the init state');
loader.playlist(playlistWithDuration(10, {isEncrypted: true}));
QUnit.equal(loader.state, 'INIT', 'starts in the init state');
QUnit.ok(loader.paused(), 'starts paused');
loader.mimeType(this.mimeType);
loader.load();
QUnit.equal(loader.state, 'WAITING', 'moves to the ready state');
QUnit.ok(!loader.paused(), 'loading is not paused');
QUnit.equal(this.requests.length, 2, 'requested a segment and key');
QUnit.equal(this.requests[0].url, '0-key.php', 'requested the first segment\'s key');
QUnit.equal(this.requests[1].url, '0.ts', 'requested the first segment');
});
QUnit.test('cancels outstanding key request on abort', function() {
loader.playlist(playlistWithDuration(20, {isEncrypted: true}));
loader.mimeType(this.mimeType);
loader.load();
loader.xhr_.keyXhr.onreadystatechange = function() {
throw new Error('onreadystatechange should not be called');
};
QUnit.equal(this.requests.length, 2, 'requested a segment and key');
loader.abort();
QUnit.equal(this.requests[0].url, '0-key.php', 'requested the first segment\'s key');
QUnit.ok(this.requests[0].aborted, 'aborted the first key request');
QUnit.equal(this.requests.length, 4, 'started a new request');
QUnit.equal(loader.state, 'WAITING', 'back to the waiting state');
});
QUnit.test('dispose cleans up key requests for encrypted segments', function() {
loader.playlist(playlistWithDuration(20, {isEncrypted: true}));
loader.mimeType(this.mimeType);
loader.load();
loader.dispose();
QUnit.equal(this.requests.length, 2, 'requested a segment and key');
QUnit.equal(this.requests[0].url, '0-key.php', 'requested the first segment\'s key');
QUnit.ok(this.requests[0].aborted, 'aborted the first segment\s key request');
QUnit.equal(this.requests.length, 2, 'did not open another request');
});
QUnit.test('key 404s should trigger an error', function() {
let errors = [];
loader.playlist(playlistWithDuration(10, {isEncrypted: true}));
loader.mimeType(this.mimeType);
loader.load();
loader.on('error', function(error) {
errors.push(error);
});
this.requests.shift().respond(404, null, '');
QUnit.equal(errors.length, 1, 'triggered an error');
QUnit.equal(loader.error().code, 2, 'triggered MEDIA_ERR_NETWORK');
QUnit.equal(loader.error().message, 'HLS key request error at URL: 0-key.php',
'receieved a key error message');
QUnit.ok(loader.error().xhr, 'included the request object');
QUnit.ok(loader.paused(), 'paused the loader');
QUnit.equal(loader.state, 'READY', 'returned to the ready state');
});
QUnit.test('key 5xx status codes trigger an error', function() {
let errors = [];
loader.playlist(playlistWithDuration(10, {isEncrypted: true}));
loader.mimeType(this.mimeType);
loader.load();
loader.on('error', function(error) {
errors.push(error);
});
this.requests.shift().respond(500, null, '');
QUnit.equal(errors.length, 1, 'triggered an error');
QUnit.equal(loader.error().code, 2, 'triggered MEDIA_ERR_NETWORK');
QUnit.equal(loader.error().message, 'HLS key request error at URL: 0-key.php',
'receieved a key error message');
QUnit.ok(loader.error().xhr, 'included the request object');
QUnit.ok(loader.paused(), 'paused the loader');
QUnit.equal(loader.state, 'READY', 'returned to the ready state');
});
QUnit.test('the key is saved to the segment in the correct format', function() {
let keyRequest;
let segmentRequest;
let segment;
let segmentInfo;
loader.playlist(playlistWithDuration(10, {isEncrypted: true}));
loader.mimeType(this.mimeType);
loader.load();
// stop processing so we can examine segment info
loader.processResponse_ = function() {};
keyRequest = this.requests.shift();
keyRequest.response = new Uint32Array([0, 1, 2, 3]).buffer;
keyRequest.respond(200, null, '');
segmentRequest = this.requests.shift();
segmentRequest.response = new Uint8Array(10).buffer;
segmentRequest.respond(200, null, '');
segmentInfo = loader.pendingSegment_;
segment = segmentInfo.playlist.segments[segmentInfo.mediaIndex];
QUnit.deepEqual(segment.key.bytes,
new Uint32Array([0, 0x01000000, 0x02000000, 0x03000000]),
'passed the specified segment key');
});
QUnit.test('supplies media sequence of current segment as the IV by default, if no IV ' +
'is specified',
function() {
let keyRequest;
let segmentRequest;
let segment;
let segmentInfo;
loader.playlist(playlistWithDuration(10, {isEncrypted: true, mediaSequence: 5}));
loader.mimeType(this.mimeType);
loader.load();
// stop processing so we can examine segment info
loader.processResponse_ = function() {};
keyRequest = this.requests.shift();
keyRequest.response = new Uint32Array([0, 0, 0, 0]).buffer;
keyRequest.respond(200, null, '');
segmentRequest = this.requests.shift();
segmentRequest.response = new Uint8Array(10).buffer;
segmentRequest.respond(200, null, '');
segmentInfo = loader.pendingSegment_;
segment = segmentInfo.playlist.segments[segmentInfo.mediaIndex];
QUnit.deepEqual(segment.key.iv, new Uint32Array([0, 0, 0, 5]),
'the IV for the segment is the media sequence');
});
QUnit.test('segment with key has decrypted bytes appended during processing', function() {
let keyRequest;
let segmentRequest;
// stop processing so we can examine segment info
loader.handleSegment_ = function() {};
loader.playlist(playlistWithDuration(10, {isEncrypted: true}));
loader.mimeType(this.mimeType);
loader.load();
segmentRequest = this.requests.pop();
segmentRequest.response = new Uint8Array(8).buffer;
segmentRequest.respond(200, null, '');
QUnit.ok(loader.pendingSegment_.encryptedBytes, 'encrypted bytes in segment');
QUnit.ok(!loader.pendingSegment_.bytes, 'no decrypted bytes in segment');
keyRequest = this.requests.shift();
keyRequest.response = new Uint32Array([0, 0, 0, 0]).buffer;
keyRequest.respond(200, null, '');
// Allow the decrypter to decrypt
this.clock.tick(1);
// Allow the decrypter's async stream to run the callback
this.clock.tick(1);
QUnit.ok(loader.pendingSegment_.bytes, 'decrypted bytes in segment');
});
QUnit.test('calling load with an encrypted segment waits for both key and segment ' +
'before processing', function() {
let keyRequest;
let segmentRequest;
loader.playlist(playlistWithDuration(10, {isEncrypted: true}));
loader.mimeType(this.mimeType);
loader.load();
QUnit.equal(loader.state, 'WAITING', 'moves to waiting state');
QUnit.equal(this.requests.length, 2, 'requested a segment and key');
QUnit.equal(this.requests[0].url, '0-key.php', 'requested the first segment\'s key');
QUnit.equal(this.requests[1].url, '0.ts', 'requested the first segment');
// respond to the segment first
segmentRequest = this.requests.pop();
segmentRequest.response = new Uint8Array(10).buffer;
segmentRequest.respond(200, null, '');
QUnit.equal(loader.state, 'WAITING', 'still in waiting state');
// then respond to the key
keyRequest = this.requests.shift();
keyRequest.response = new Uint32Array([0, 0, 0, 0]).buffer;
keyRequest.respond(200, null, '');
QUnit.equal(loader.state, 'DECRYPTING', 'moves to decrypting state');
});
QUnit.test('key request timeouts reset bandwidth', function() {
loader.playlist(playlistWithDuration(10, {isEncrypted: true}));
loader.mimeType(this.mimeType);
loader.load();
QUnit.equal(this.requests[0].url, '0-key.php', 'requested the first segment\'s key');
QUnit.equal(this.requests[1].url, '0.ts', 'requested the first segment');
// a lot of time passes so the request times out
this.requests[0].timedout = true;
this.clock.tick(100 * 1000);
QUnit.equal(loader.bandwidth, 1, 'reset bandwidth');
QUnit.ok(isNaN(loader.roundTrip), 'reset round trip time');
});
QUnit.module('Segment Loading Calculation', {
beforeEach() {
this.env = useFakeEnvironment();
this.mse = useFakeMediaSource();
this.hasPlayed = true;
currentTime = 0;
loader = new SegmentLoader({
currentTime() {
return currentTime;
},
mediaSource: new videojs.MediaSource(),
hasPlayed: () => this.hasPlayed
});
},
afterEach() {
this.env.restore();
this.mse.restore();
}
});
QUnit.test('requests the first segment with an empty buffer', function() {
loader.mimeType(this.mimeType);
let segmentInfo = loader.checkBuffer_(videojs.createTimeRanges(),
playlistWithDuration(20),
0);
QUnit.ok(segmentInfo, 'generated a request');
QUnit.equal(segmentInfo.uri, '0.ts', 'requested the first segment');
});
QUnit.test('no request if video not played and 1 segment is buffered', function() {
this.hasPlayed = false;
loader.mimeType(this.mimeType);
let segmentInfo = loader.checkBuffer_(videojs.createTimeRanges([[0, 1]]),
playlistWithDuration(20),
0);
QUnit.ok(!segmentInfo, 'no request generated');
});
QUnit.test('does not download the next segment if the buffer is full', function() {
let buffered;
let segmentInfo;
loader.mimeType(this.mimeType);
buffered = videojs.createTimeRanges([
[0, 15 + GOAL_BUFFER_LENGTH]
]);
segmentInfo = loader.checkBuffer_(buffered, playlistWithDuration(30), 15);
QUnit.ok(!segmentInfo, 'no segment request generated');
});
QUnit.test('downloads the next segment if the buffer is getting low', function() {
let buffered;
let segmentInfo;
loader.mimeType(this.mimeType);
buffered = videojs.createTimeRanges([[0, 19.999]]);
segmentInfo = loader.checkBuffer_(buffered, playlistWithDuration(30), 15);
QUnit.ok(segmentInfo, 'made a request');
QUnit.equal(segmentInfo.uri, '2.ts', 'requested the third segment');
});
QUnit.test('buffers based on the correct TimeRange if multiple ranges exist', function() {
let buffered;
let segmentInfo;
loader.mimeType(this.mimeType);
buffered = videojs.createTimeRanges([[0, 10], [20, 30]]);
segmentInfo = loader.checkBuffer_(buffered, playlistWithDuration(40), 8);
QUnit.ok(segmentInfo, 'made a request');
QUnit.equal(segmentInfo.uri, '1.ts', 'requested the second segment');
segmentInfo = loader.checkBuffer_(buffered, playlistWithDuration(40), 20);
QUnit.ok(segmentInfo, 'made a request');
QUnit.equal(segmentInfo.uri, '3.ts', 'requested the fourth segment');
});
QUnit.test('stops downloading segments at the end of the playlist', function() {
let buffered;
let segmentInfo;
loader.mimeType(this.mimeType);
buffered = videojs.createTimeRanges([[0, 60]]);
segmentInfo = loader.checkBuffer_(buffered, playlistWithDuration(60), 0);
QUnit.ok(!segmentInfo, 'no request was made');
});
QUnit.test('stops downloading segments if buffered past reported end of the playlist',
function() {
let buffered;
let segmentInfo;
let playlist;
loader.mimeType(this.mimeType);
buffered = videojs.createTimeRanges([[0, 59.9]]);
playlist = playlistWithDuration(60);
playlist.segments[playlist.segments.length - 1].end = 59.9;
segmentInfo = loader.checkBuffer_(buffered, playlist, 50);
QUnit.ok(!segmentInfo, 'no request was made');
});
QUnit.test('calculates timestampOffset for discontinuities', function() {
let segmentInfo;
let playlist;
loader.mimeType(this.mimeType);
playlist = playlistWithDuration(60);
playlist.segments[3].end = 37.9;
playlist.discontinuityStarts = [4];
playlist.segments[4].discontinuity = true;
playlist.segments[4].timeline = 1;
segmentInfo = loader.checkBuffer_(videojs.createTimeRanges([[0, 37.9]]), playlist, 36);
QUnit.equal(segmentInfo.timestampOffset, 37.9, 'placed the discontinuous segment');
});
QUnit.test('adjusts calculations based on expired time', function() {
let buffered;
let playlist;
let segmentInfo;
loader.mimeType(this.mimeType);
buffered = videojs.createTimeRanges([[0, 30]]);
playlist = playlistWithDuration(50);
loader.expired(10);
segmentInfo = loader.checkBuffer_(buffered,
playlist,
40 - GOAL_BUFFER_LENGTH);
QUnit.ok(segmentInfo, 'fetched a segment');
QUnit.equal(segmentInfo.uri, '2.ts', 'accounted for expired time');
});
import SourceUpdater from '../src/source-updater';
import QUnit from 'qunit';
import videojs from 'video.js';
import { useFakeMediaSource } from './test-helpers';
QUnit.module('Source Updater', {
beforeEach() {
this.mse = useFakeMediaSource();
this.mediaSource = new videojs.MediaSource();
},
afterEach() {
this.mse.restore();
}
});
QUnit.test('waits for sourceopen to create a source buffer', function() {
new SourceUpdater(this.mediaSource, 'video/mp2t'); // eslint-disable-line no-new
QUnit.equal(this.mediaSource.sourceBuffers.length, 0,
'waited to create the source buffer');
this.mediaSource.trigger('sourceopen');
QUnit.equal(this.mediaSource.sourceBuffers.length, 1, 'created one source buffer');
QUnit.equal(this.mediaSource.sourceBuffers[0].mimeType_, 'video/mp2t',
'assigned the correct MIME type');
});
QUnit.test('runs a callback when the source buffer is created', function() {
let updater = new SourceUpdater(this.mediaSource, 'video/mp2t');
let sourceBuffer;
updater.appendBuffer(new Uint8Array([0, 1, 2]));
this.mediaSource.trigger('sourceopen');
sourceBuffer = this.mediaSource.sourceBuffers[0];
QUnit.equal(sourceBuffer.updates_.length, 1, 'called the source buffer once');
QUnit.deepEqual(sourceBuffer.updates_[0].append, new Uint8Array([0, 1, 2]),
'appended the bytes');
});
QUnit.test('runs the completion callback when updateend fires', function() {
let updater = new SourceUpdater(this.mediaSource, 'video/mp2t');
let updateends = 0;
let sourceBuffer;
this.mediaSource.trigger('sourceopen');
sourceBuffer = this.mediaSource.sourceBuffers[0];
updater.appendBuffer(new Uint8Array([0, 1, 2]), function() {
updateends++;
});
updater.appendBuffer(new Uint8Array([2, 3, 4]), function() {
throw new Error('Wrong completion callback invoked!');
});
QUnit.equal(updateends, 0, 'no completions yet');
sourceBuffer.trigger('updateend');
QUnit.equal(updateends, 1, 'ran the completion callback');
});
QUnit.test('runs the next callback after updateend fires', function() {
let updater = new SourceUpdater(this.mediaSource, 'video/mp2t');
let sourceBuffer;
updater.appendBuffer(new Uint8Array([0, 1, 2]));
this.mediaSource.trigger('sourceopen');
sourceBuffer = this.mediaSource.sourceBuffers[0];
updater.appendBuffer(new Uint8Array([2, 3, 4]));
QUnit.equal(sourceBuffer.updates_.length, 1, 'delayed the update');
sourceBuffer.trigger('updateend');
QUnit.equal(sourceBuffer.updates_.length, 2, 'updated twice');
QUnit.deepEqual(sourceBuffer.updates_[1].append, new Uint8Array([2, 3, 4]),
'appended the bytes');
});
QUnit.test('runs only one callback at a time', function() {
let updater = new SourceUpdater(this.mediaSource, 'video/mp2t');
let sourceBuffer;
updater.appendBuffer(new Uint8Array([0]));
updater.appendBuffer(new Uint8Array([1]));
this.mediaSource.trigger('sourceopen');
sourceBuffer = this.mediaSource.sourceBuffers[0];
updater.appendBuffer(new Uint8Array([2]));
QUnit.equal(sourceBuffer.updates_.length, 1, 'queued some updates');
QUnit.deepEqual(sourceBuffer.updates_[0].append, new Uint8Array([0]),
'ran the first update');
sourceBuffer.trigger('updateend');
QUnit.equal(sourceBuffer.updates_.length, 2, 'queued some updates');
QUnit.deepEqual(sourceBuffer.updates_[1].append, new Uint8Array([1]),
'ran the second update');
updater.appendBuffer(new Uint8Array([3]));
sourceBuffer.trigger('updateend');
QUnit.equal(sourceBuffer.updates_.length, 3, 'queued the updates');
QUnit.deepEqual(sourceBuffer.updates_[2].append, new Uint8Array([2]),
'ran the third update');
sourceBuffer.trigger('updateend');
QUnit.equal(sourceBuffer.updates_.length, 4, 'finished the updates');
QUnit.deepEqual(sourceBuffer.updates_[3].append, new Uint8Array([3]),
'ran the fourth update');
});
QUnit.test('runs updates immediately if possible', function() {
let updater = new SourceUpdater(this.mediaSource, 'video/mp2t');
let sourceBuffer;
this.mediaSource.trigger('sourceopen');
sourceBuffer = this.mediaSource.sourceBuffers[0];
updater.appendBuffer(new Uint8Array([0]));
QUnit.equal(sourceBuffer.updates_.length, 1, 'ran an update');
QUnit.deepEqual(sourceBuffer.updates_[0].append, new Uint8Array([0]),
'appended the bytes');
});
QUnit.test('supports abort', function() {
let updater = new SourceUpdater(this.mediaSource, 'video/mp2t');
let sourceBuffer;
updater.abort();
this.mediaSource.trigger('sourceopen');
sourceBuffer = this.mediaSource.sourceBuffers[0];
QUnit.ok(sourceBuffer.updates_[0].abort, 'aborted the source buffer');
});
QUnit.test('supports buffered', function() {
let updater = new SourceUpdater(this.mediaSource, 'video/mp2t');
QUnit.equal(updater.buffered().length, 0, 'buffered is empty');
this.mediaSource.trigger('sourceopen');
QUnit.ok(updater.buffered(), 'buffered is defined');
});
QUnit.test('supports removeBuffer', function() {
let updater = new SourceUpdater(this.mediaSource, 'video/mp2t');
let sourceBuffer;
this.mediaSource.trigger('sourceopen');
sourceBuffer = this.mediaSource.sourceBuffers[0];
updater.remove(1, 14);
QUnit.equal(sourceBuffer.updates_.length, 1, 'ran an update');
QUnit.deepEqual(sourceBuffer.updates_[0].remove, [1, 14], 'removed the time range');
});
QUnit.test('supports setting duration', function() {
let updater = new SourceUpdater(this.mediaSource, 'video/mp2t');
let sourceBuffer;
this.mediaSource.trigger('sourceopen');
sourceBuffer = this.mediaSource.sourceBuffers[0];
updater.duration(21);
QUnit.equal(sourceBuffer.updates_.length, 1, 'ran an update');
QUnit.deepEqual(sourceBuffer.updates_[0].duration, 21, 'changed duration');
});
QUnit.test('supports timestampOffset', function() {
let updater = new SourceUpdater(this.mediaSource, 'video/mp2t');
let sourceBuffer;
this.mediaSource.trigger('sourceopen');
sourceBuffer = this.mediaSource.sourceBuffers[0];
QUnit.equal(updater.timestampOffset(), 0, 'intialized to zero');
updater.timestampOffset(21);
QUnit.equal(updater.timestampOffset(), 21, 'reflects changes immediately');
QUnit.equal(sourceBuffer.timestampOffset, 21, 'applied the update');
updater.appendBuffer(new Uint8Array(2));
updater.timestampOffset(14);
QUnit.equal(updater.timestampOffset(), 14, 'reflects changes immediately');
QUnit.equal(sourceBuffer.timestampOffset, 21, 'queues application after updates');
sourceBuffer.trigger('updateend');
QUnit.equal(sourceBuffer.timestampOffset, 14, 'applied the update');
});
import document from 'global/document';
import sinon from 'sinon';
import videojs from 'video.js';
import QUnit from 'qunit';
/* eslint-disable no-unused-vars */
// needed so MediaSource can be registered with videojs
import MediaSource from 'videojs-contrib-media-sources';
/* eslint-enable */
import testDataManifests from './test-manifests.js';
import xhrFactory from '../src/xhr';
// a SourceBuffer that tracks updates but otherwise is a noop
class MockSourceBuffer extends videojs.EventTarget {
constructor() {
super();
this.updates_ = [];
this.updating = false;
this.on('updateend', function() {
this.updating = false;
});
this.buffered = videojs.createTimeRanges();
this.duration_ = NaN;
Object.defineProperty(this, 'duration', {
get() {
return this.duration_;
},
set(duration) {
this.updates_.push({
duration
});
this.duration_ = duration;
}
});
}
abort() {
this.updates_.push({
abort: true
});
}
appendBuffer(bytes) {
this.updates_.push({
append: bytes
});
this.updating = true;
}
remove(start, end) {
this.updates_.push({
remove: [start, end]
});
}
}
class MockMediaSource extends videojs.EventTarget {
constructor() {
super();
this.readyState = 'closed';
this.on('sourceopen', function() {
this.readyState = 'open';
});
this.sourceBuffers = [];
this.duration = NaN;
this.seekable = videojs.createTimeRange();
}
addSeekableRange_(start, end) {
this.seekable = videojs.createTimeRange(start, end);
}
addSourceBuffer(mime) {
let sourceBuffer = new MockSourceBuffer();
sourceBuffer.mimeType_ = mime;
this.sourceBuffers.push(sourceBuffer);
return sourceBuffer;
}
endOfStream(error) {
this.readyState = 'closed';
this.error_ = error;
}
}
export const useFakeMediaSource = function() {
let RealMediaSource = videojs.MediaSource;
let realCreateObjectURL = window.URL.createObjectURL;
let id = 0;
videojs.MediaSource = MockMediaSource;
videojs.MediaSource.supportsNativeMediaSources =
RealMediaSource.supportsNativeMediaSources;
videojs.URL.createObjectURL = function() {
id++;
return 'blob:videojs-contrib-hls-mock-url' + id;
};
return {
restore() {
videojs.MediaSource = RealMediaSource;
videojs.URL.createObjectURL = realCreateObjectURL;
}
};
};
let fakeEnvironment = {
requests: [],
restore() {
this.clock.restore();
videojs.xhr.XMLHttpRequest = window.XMLHttpRequest;
this.xhr.restore();
['warn', 'error'].forEach((level) => {
if (this.log && this.log[level] && this.log[level].restore) {
QUnit.equal(this.log[level].callCount, 0, `no unexpected logs on ${level}`);
this.log[level].restore();
}
});
}
};
export const useFakeEnvironment = function() {
fakeEnvironment.log = {};
['warn', 'error'].forEach((level) => {
// you can use .log[level].args to get args
sinon.stub(videojs.log, level);
fakeEnvironment.log[level] = videojs.log[level];
Object.defineProperty(videojs.log[level], 'calls', {
get() {
// reset callCount to 0 so they don't have to
let callCount = this.callCount;
this.callCount = 0;
return callCount;
}
});
});
fakeEnvironment.clock = sinon.useFakeTimers();
fakeEnvironment.xhr = sinon.useFakeXMLHttpRequest();
fakeEnvironment.requests.length = 0;
fakeEnvironment.xhr.onCreate = function(xhr) {
fakeEnvironment.requests.push(xhr);
};
videojs.xhr.XMLHttpRequest = fakeEnvironment.xhr;
return fakeEnvironment;
};
// patch over some methods of the provided tech so it can be tested
// synchronously with sinon's fake timers
export const mockTech = function(tech) {
if (tech.isMocked_) {
// make this function idempotent because HTML and Flash based
// playback have very different lifecycles. For HTML, the tech
// is available on player creation. For Flash, the tech isn't
// ready until the source has been loaded and one tick has
// expired.
return;
}
tech.isMocked_ = true;
tech.src_ = null;
tech.time_ = null;
tech.paused_ = !tech.autoplay();
tech.paused = function() {
return tech.paused_;
};
if (!tech.currentTime_) {
tech.currentTime_ = tech.currentTime;
}
tech.currentTime = function() {
return tech.time_ === null ? tech.currentTime_() : tech.time_;
};
tech.setSrc = function(src) {
tech.src_ = src;
};
tech.src = function(src) {
if (src !== null) {
return tech.setSrc(src);
}
return tech.src_ === null ? tech.src : tech.src_;
};
tech.currentSrc_ = tech.currentSrc;
tech.currentSrc = function() {
return tech.src_ === null ? tech.currentSrc_() : tech.src_;
};
tech.play_ = tech.play;
tech.play = function() {
tech.play_();
tech.paused_ = false;
tech.trigger('play');
};
tech.pause_ = tech.pause_;
tech.pause = function() {
tech.pause_();
tech.paused_ = true;
tech.trigger('pause');
};
tech.setCurrentTime = function(time) {
tech.time_ = time;
setTimeout(function() {
tech.trigger('seeking');
setTimeout(function() {
tech.trigger('seeked');
}, 1);
}, 1);
};
};
export const createPlayer = function(options) {
let video;
let player;
video = document.createElement('video');
video.className = 'video-js';
document.querySelector('#qunit-fixture').appendChild(video);
player = videojs(video, options || {
flash: {
swf: ''
}
});
player.buffered = function() {
return videojs.createTimeRange(0, 0);
};
mockTech(player.tech_);
return player;
};
export const openMediaSource = function(player, clock) {
// ensure the Flash tech is ready
player.tech_.triggerReady();
clock.tick(1);
// mock the tech *after* it has finished loading so that we don't
// mock a tech that will be unloaded on the next tick
mockTech(player.tech_);
player.tech_.hls.xhr = xhrFactory();
// simulate the sourceopen event
player.tech_.hls.mediaSource.readyState = 'open';
player.tech_.hls.mediaSource.dispatchEvent({
type: 'sourceopen',
swfId: player.tech_.el().id
});
};
export const standardXHRResponse = function(request) {
if (!request.url) {
return;
}
let contentType = 'application/json';
// contents off the global object
let 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(16).buffer;
request.respond(200, { 'Content-Type': contentType },
testDataManifests[manifestName]);
};
// return an absolute version of a page-relative URL
export const absoluteUrl = function(relativeUrl) {
return window.location.protocol + '//' +
window.location.host +
(window.location.pathname
.split('/')
.slice(0, -1)
.concat(relativeUrl)
.join('/')
);
};
This diff could not be displayed because it is too large.
......@@ -5,18 +5,22 @@
"segments": [
{
"duration": 10,
"timeline": 0,
"uri": "http://example.com/00001.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "https://example.com/00002.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "//example.com/00003.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "http://example.com/00004.ts"
}
],
......@@ -24,4 +28,4 @@
"endList": true,
"discontinuitySequence": 0,
"discontinuityStarts": []
}
\ No newline at end of file
}
......
......@@ -9,6 +9,7 @@
"offset": 0
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -17,6 +18,7 @@
"offset": 522828
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -25,6 +27,7 @@
"offset": 1110328
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -33,6 +36,7 @@
"offset": 1823412
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -41,6 +45,7 @@
"offset": 2299992
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -49,6 +54,7 @@
"offset": 2835604
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -57,6 +63,7 @@
"offset": 3042780
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -65,6 +72,7 @@
"offset": 3498680
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -73,6 +81,7 @@
"offset": 4155928
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -81,6 +90,7 @@
"offset": 4727636
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -89,6 +99,7 @@
"offset": 5212676
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -97,6 +108,7 @@
"offset": 5921812
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -105,6 +117,7 @@
"offset": 6651816
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -113,6 +126,7 @@
"offset": 7108092
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -121,6 +135,7 @@
"offset": 7576776
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -129,6 +144,7 @@
"offset": 8021772
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -137,6 +153,7 @@
"offset": 8353216
},
"duration": 1.4167,
"timeline": 0,
"uri": "hls_450k_video.ts"
}
],
......@@ -144,4 +161,4 @@
"endList": true,
"discontinuitySequence": 0,
"discontinuityStarts": []
}
\ No newline at end of file
}
......
......@@ -9,6 +9,7 @@
"offset": 0
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
}
],
......@@ -16,4 +17,4 @@
"endList": true,
"discontinuitySequence": 0,
"discontinuityStarts": []
}
\ No newline at end of file
}
......
{
allowCache: true,
discontinuityStarts: [],
mediaGroups: {
// TYPE
AUDIO: {
// GROUP-ID
"audio": {
// NAME
"English": {
language: 'eng',
autoselect: true,
default: true,
uri: "eng/prog_index.m3u8"
},
// NAME
"Français": {
language: "fre",
autoselect: true,
default: false,
uri: "fre/prog_index.m3u8"
},
// NAME
"Espanol": {
language: "sp",
autoselect: true,
default: false,
uri: "sp/prog_index.m3u8"
}
}
},
VIDEO: {},
"CLOSED-CAPTIONS": {},
SUBTITLES: {}
},
playlists: [{
attributes: {
"PROGRAM-ID": 1,
BANDWIDTH: 195023,
CODECS: "avc1.42e00a,mp4a.40.2",
AUDIO: 'audio'
},
timeline: 0,
uri: "lo/prog_index.m3u8"
}, {
attributes: {
"PROGRAM-ID": 1,
BANDWIDTH: 591680,
CODECS: "avc1.42e01e,mp4a.40.2",
AUDIO: 'audio'
},
timeline: 0,
uri: "hi/prog_index.m3u8"
}]
}
#EXTM3U
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",LANGUAGE="eng",NAME="English",AUTOSELECT=YES, DEFAULT=YES,URI="eng/prog_index.m3u8"
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",LANGUAGE="fre",NAME="Français",AUTOSELECT=YES, DEFAULT=NO,URI="fre/prog_index.m3u8"
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",LANGUAGE="sp",NAME="Espanol",AUTOSELECT=YES, DEFAULT=NO,URI="sp/prog_index.m3u8"
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=195023,CODECS="avc1.42e00a,mp4a.40.2",AUDIO="audio"
lo/prog_index.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=591680,CODECS="avc1.42e01e,mp4a.40.2",AUDIO="audio"
hi/prog_index.m3u8
\ No newline at end of file
{
allowCache: true,
discontinuityStarts: [],
mediaGroups: {
AUDIO: {
aac: {
English: {
autoselect: true,
default: true,
language: "eng",
uri: "eng/prog_index.m3u8"
}
}
},
VIDEO: {
"500kbs": {
Angle1: {
autoselect: true,
default: true
},
Angle2: {
autoselect: true,
default: false,
uri: "Angle2/500kbs/prog_index.m3u8"
},
Angle3: {
autoselect: true,
default: false,
uri: "Angle3/500kbs/prog_index.m3u8"
}
}
},
"CLOSED-CAPTIONS": {},
SUBTITLES: {}
},
playlists: [{
attributes: {
"PROGRAM-ID": 1,
BANDWIDTH: 754857,
CODECS: "mp4a.40.2,avc1.4d401e",
AUDIO: "aac",
VIDEO: "500kbs"
},
timeline: 0,
uri: "Angle1/500kbs/prog_index.m3u8"
}]
}
#EXTM3U
#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="500kbs",NAME="Angle1",AUTOSELECT=YES,DEFAULT=YES
#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="500kbs",NAME="Angle2",AUTOSELECT=YES,DEFAULT=NO,URI="Angle2/500kbs/prog_index.m3u8"
#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="500kbs",NAME="Angle3",AUTOSELECT=YES,DEFAULT=NO,URI="Angle3/500kbs/prog_index.m3u8"
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac",LANGUAGE="eng",NAME="English",AUTOSELECT=YES,DEFAULT=YES,URI="eng/prog_index.m3u8"
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=754857,CODECS="mp4a.40.2,avc1.4d401e",VIDEO="500kbs",AUDIO="aac"
Angle1/500kbs/prog_index.m3u8
\ No newline at end of file
......@@ -10,6 +10,7 @@
"height": 224
}
},
"timeline": 0,
"uri": "http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824686811001&videoId=1824650741001"
},
{
......@@ -17,6 +18,7 @@
"PROGRAM-ID": 1,
"BANDWIDTH": 40000
},
"timeline": 0,
"uri": "http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824683759001&videoId=1824650741001"
},
{
......@@ -28,6 +30,7 @@
"height": 224
}
},
"timeline": 0,
"uri": "http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824686593001&videoId=1824650741001"
},
{
......@@ -39,8 +42,15 @@
"height": 540
}
},
"timeline": 0,
"uri": "http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824687660001&videoId=1824650741001"
}
],
"discontinuityStarts": []
}
\ No newline at end of file
"discontinuityStarts": [],
"mediaGroups": {
"VIDEO": {},
"AUDIO": {},
"CLOSED-CAPTIONS": {},
"SUBTITLES": {}
}
}
......
......@@ -5,6 +5,7 @@
"segments": [
{
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -13,6 +14,7 @@
"offset": 522828
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -21,6 +23,7 @@
"offset": 0
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video2.ts"
},
{
......@@ -29,6 +32,7 @@
"offset": 1823412
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -37,6 +41,7 @@
"offset": 2299992
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -45,6 +50,7 @@
"offset": 2835604
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -53,6 +59,7 @@
"offset": 3042780
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -61,6 +68,7 @@
"offset": 3498680
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -69,6 +77,7 @@
"offset": 4155928
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -77,6 +86,7 @@
"offset": 4727636
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -85,6 +95,7 @@
"offset": 5212676
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -93,6 +104,7 @@
"offset": 5921812
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -101,6 +113,7 @@
"offset": 6651816
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -109,6 +122,7 @@
"offset": 7108092
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -117,6 +131,7 @@
"offset": 7576776
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -125,6 +140,7 @@
"offset": 8021772
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -133,6 +149,7 @@
"offset": 8353216
},
"duration": 1.4167,
"timeline": 0,
"uri": "hls_450k_video.ts"
}
],
......@@ -140,4 +157,4 @@
"endList": true,
"discontinuitySequence": 0,
"discontinuityStarts": []
}
\ No newline at end of file
}
......
......@@ -9,6 +9,7 @@
"offset": 0
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
}
],
......@@ -16,4 +17,4 @@
"endList": true,
"discontinuitySequence": 0,
"discontinuityStarts": []
}
\ No newline at end of file
}
......
......@@ -5,19 +5,23 @@
"segments": [
{
"duration": 10,
"timeline": 3,
"uri": "001.ts"
},
{
"duration": 19,
"timeline": 3,
"uri": "002.ts"
},
{
"discontinuity": true,
"duration": 10,
"timeline": 4,
"uri": "003.ts"
},
{
"duration": 11,
"timeline": 4,
"uri": "004.ts"
}
],
......
......@@ -5,41 +5,50 @@
"segments": [
{
"duration": 10,
"timeline": 0,
"uri": "001.ts"
},
{
"duration": 19,
"timeline": 0,
"uri": "002.ts"
},
{
"discontinuity": true,
"duration": 10,
"timeline": 1,
"uri": "003.ts"
},
{
"duration": 11,
"timeline": 1,
"uri": "004.ts"
},
{
"discontinuity": true,
"duration": 10,
"timeline": 2,
"uri": "005.ts"
},
{
"duration": 10,
"timeline": 2,
"uri": "006.ts"
},
{
"duration": 10,
"timeline": 2,
"uri": "007.ts"
},
{
"discontinuity": true,
"duration": 10,
"timeline": 3,
"uri": "008.ts"
},
{
"duration": 16,
"timeline": 3,
"uri": "009.ts"
}
],
......
......@@ -5,18 +5,22 @@
"segments": [
{
"duration": 10,
"timeline": 0,
"uri": "/00001.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "/subdir/00002.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "/00003.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "/00004.ts"
}
],
......@@ -24,4 +28,4 @@
"endList": true,
"discontinuitySequence": 0,
"discontinuityStarts": []
}
\ No newline at end of file
}
......
......@@ -9,6 +9,7 @@
"offset": 0
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
}
],
......@@ -16,4 +17,4 @@
"endList": true,
"discontinuitySequence": 0,
"discontinuityStarts": []
}
\ No newline at end of file
}
......
......@@ -5,18 +5,22 @@
"segments": [
{
"duration": 6.64,
"timeline": 0,
"uri": "/test/ts-files/tvy7/8a5e2822668b5370f4eb1438b2564fb7ab12ffe1-hi720.ts"
},
{
"duration": 6.08,
"timeline": 0,
"uri": "/test/ts-files/tvy7/56be1cef869a1c0cc8e38864ad1add17d187f051-hi720.ts"
},
{
"duration": 6.6,
"timeline": 0,
"uri": "/test/ts-files/tvy7/549c8c77f55f049741a06596e5c1e01dacaa46d0-hi720.ts"
},
{
"duration": 5,
"timeline": 0,
"uri": "/test/ts-files/tvy7/6cfa378684ffeb1c455a64dae6c103290a1f53d4-hi720.ts"
}
],
......@@ -24,4 +28,4 @@
"endList": true,
"discontinuitySequence": 0,
"discontinuityStarts": []
}
\ No newline at end of file
}
......
......@@ -4,26 +4,32 @@
"segments": [
{
"duration": 10,
"timeline": 0,
"uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00001.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00002.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00003.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00004.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00005.ts"
},
{
"duration": 8,
"timeline": 0,
"uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00006.ts"
}
],
......@@ -31,4 +37,4 @@
"endList": true,
"discontinuitySequence": 0,
"discontinuityStarts": []
}
\ No newline at end of file
}
......
......@@ -10,6 +10,7 @@
"height": 224
}
},
"timeline": 0,
"uri": "http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824686811001&videoId=1824650741001"
},
{
......@@ -17,6 +18,7 @@
"PROGRAM-ID": 1,
"BANDWIDTH": 40000
},
"timeline": 0,
"uri": "http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824683759001&videoId=1824650741001"
},
{
......@@ -28,6 +30,7 @@
"height": 224
}
},
"timeline": 0,
"uri": "http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824686593001&videoId=1824650741001"
},
{
......@@ -39,8 +42,15 @@
"height": 540
}
},
"timeline": 0,
"uri": "http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824687660001&videoId=1824650741001"
}
],
"discontinuityStarts": []
}
\ No newline at end of file
"discontinuityStarts": [],
"mediaGroups": {
"VIDEO": {},
"AUDIO": {},
"CLOSED-CAPTIONS": {},
"SUBTITLES": {}
}
}
......
......@@ -6,6 +6,7 @@
"segments": [
{
"duration": 2.833,
"timeline": 0,
"key": {
"method": "AES-128",
"uri": "https://priv.example.com/key.php?r=52"
......@@ -14,6 +15,7 @@
},
{
"duration": 15,
"timeline": 0,
"key": {
"method": "AES-128",
"uri": "https://priv.example.com/key.php?r=52"
......@@ -22,6 +24,7 @@
},
{
"duration": 13.333,
"timeline": 0,
"key": {
"method": "AES-128",
"uri": "https://priv.example.com/key.php?r=52"
......@@ -30,6 +33,7 @@
},
{
"duration": 15,
"timeline": 0,
"key": {
"method": "AES-128",
"uri": "https://priv.example.com/key.php?r=53"
......@@ -38,6 +42,7 @@
},
{
"duration": 14,
"timeline": 0,
"key": {
"method": "AES-128",
"uri": "https://priv.example.com/key.php?r=54",
......@@ -47,6 +52,7 @@
},
{
"duration": 15,
"timeline": 0,
"uri": "http://media.example.com/fileSequence53-B.ts"
}
],
......
......@@ -5,26 +5,32 @@
"segments": [
{
"duration": 10,
"timeline": 0,
"uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00001.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00002.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00003.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00004.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00005.ts"
},
{
"duration": 8,
"timeline": 0,
"uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00006.ts"
}
],
......@@ -32,4 +38,4 @@
"endList": true,
"discontinuitySequence": 0,
"discontinuityStarts": []
}
\ No newline at end of file
}
......
......@@ -4,6 +4,7 @@
"segments": [
{
"duration": 6.64,
"timeline": 0,
"uri": "/test/ts-files/tvy7/8a5e2822668b5370f4eb1438b2564fb7ab12ffe1-hi720.ts"
}
],
......@@ -11,4 +12,4 @@
"endList": true,
"discontinuitySequence": 0,
"discontinuityStarts": []
}
\ No newline at end of file
}
......
......@@ -9,6 +9,7 @@
"offset": 0
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -17,6 +18,7 @@
"offset": 522828
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -25,6 +27,7 @@
"offset": 1110328
},
"duration": 5,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -33,6 +36,7 @@
"offset": 1823412
},
"duration": 9.7,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -41,6 +45,7 @@
"offset": 2299992
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -49,6 +54,7 @@
"offset": 2835604
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -57,6 +63,7 @@
"offset": 3042780
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -65,6 +72,7 @@
"offset": 3498680
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -73,6 +81,7 @@
"offset": 4155928
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -81,6 +90,7 @@
"offset": 4727636
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -89,6 +99,7 @@
"offset": 5212676
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -97,6 +108,7 @@
"offset": 5921812
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -105,6 +117,7 @@
"offset": 6651816
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -113,6 +126,7 @@
"offset": 7108092
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -121,6 +135,7 @@
"offset": 7576776
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -129,6 +144,7 @@
"offset": 8021772
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -137,6 +153,7 @@
"offset": 8353216
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
}
],
......@@ -144,4 +161,4 @@
"endList": true,
"discontinuitySequence": 0,
"discontinuityStarts": []
}
\ No newline at end of file
}
......
......@@ -9,6 +9,7 @@
"offset": 0
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
}
],
......@@ -16,4 +17,4 @@
"endList": true,
"discontinuitySequence": 0,
"discontinuityStarts": []
}
\ No newline at end of file
}
......
......@@ -5,18 +5,22 @@
"segments": [
{
"duration": 6.64,
"timeline": 0,
"uri": "/test/ts-files/tvy7/8a5e2822668b5370f4eb1438b2564fb7ab12ffe1-hi720.ts"
},
{
"duration": 6.08,
"timeline": 0,
"uri": "/test/ts-files/tvy7/56be1cef869a1c0cc8e38864ad1add17d187f051-hi720.ts"
},
{
"duration": 6.6,
"timeline": 0,
"uri": "/test/ts-files/tvy7/549c8c77f55f049741a06596e5c1e01dacaa46d0-hi720.ts"
},
{
"duration": 5,
"timeline": 0,
"uri": "/test/ts-files/tvy7/6cfa378684ffeb1c455a64dae6c103290a1f53d4-hi720.ts"
}
],
......@@ -24,4 +28,4 @@
"endList": true,
"discontinuitySequence": 0,
"discontinuityStarts": []
}
\ No newline at end of file
}
......
......@@ -4,26 +4,32 @@
"segments": [
{
"duration": 10,
"timeline": 0,
"uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00001.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00002.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00003.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00004.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00005.ts"
},
{
"duration": 8,
"timeline": 0,
"uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00006.ts"
}
],
......@@ -31,4 +37,4 @@
"endList": true,
"discontinuitySequence": 0,
"discontinuityStarts": []
}
\ No newline at end of file
}
......
......@@ -9,6 +9,7 @@
"offset": 0
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -17,6 +18,7 @@
"offset": 522828
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -25,6 +27,7 @@
"offset": 1110328
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -33,6 +36,7 @@
"offset": 1823412
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -41,6 +45,7 @@
"offset": 2299992
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -49,6 +54,7 @@
"offset": 2835604
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -57,6 +63,7 @@
"offset": 3042780
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -65,6 +72,7 @@
"offset": 3498680
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -73,6 +81,7 @@
"offset": 4155928
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -81,6 +90,7 @@
"offset": 4727636
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -89,6 +99,7 @@
"offset": 5212676
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -97,6 +108,7 @@
"offset": 5921812
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -105,6 +117,7 @@
"offset": 6651816
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -113,6 +126,7 @@
"offset": 7108092
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -121,6 +135,7 @@
"offset": 7576776
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -129,6 +144,7 @@
"offset": 8021772
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -137,10 +153,11 @@
"offset": 8353216
},
"duration": 1.4167,
"timeline": 0,
"uri": "hls_450k_video.ts"
}
],
"endList": true,
"discontinuitySequence": 0,
"discontinuityStarts": []
}
\ No newline at end of file
}
......
......@@ -5,18 +5,21 @@
"segments": [
{
"duration": 6.64,
"timeline": 0,
"uri": "/test/ts-files/tvy7/8a5e2822668b5370f4eb1438b2564fb7ab12ffe1-hi720.ts"
},
{
"duration": 8,
"timeline": 0,
"uri": "/test/ts-files/tvy7/56be1cef869a1c0cc8e38864ad1add17d187f051-hi720.ts"
},
{
"duration": 8,
"timeline": 0,
"uri": "/test/ts-files/tvy7/549c8c77f55f049741a06596e5c1e01dacaa46d0-hi720.ts"
}
],
"targetDuration": 8,
"discontinuitySequence": 0,
"discontinuityStarts": []
}
\ No newline at end of file
}
......
......@@ -4,42 +4,51 @@
"segments": [
{
"duration": 10,
"timeline": 0,
"uri": "001.ts"
},
{
"duration": 19,
"timeline": 0,
"uri": "002.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "003.ts"
},
{
"duration": 11,
"timeline": 0,
"uri": "004.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "005.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "006.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "007.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "008.ts"
},
{
"duration": 16,
"timeline": 0,
"uri": "009.ts"
}
],
"targetDuration": 10,
"discontinuitySequence": 0,
"discontinuityStarts": []
}
\ No newline at end of file
}
......
......@@ -4,10 +4,11 @@
"segments": [
{
"duration": 10,
"timeline": 0,
"uri": "/test/ts-files/zencoder/gogo/00001.ts"
}
],
"endList": true,
"discontinuitySequence": 0,
"discontinuityStarts": []
}
\ No newline at end of file
}
......
......@@ -4,22 +4,27 @@
"segments": [
{
"duration": 10,
"timeline": 0,
"uri": "/test/ts-files/zencoder/gogo/00001.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "/test/ts-files/zencoder/gogo/00002.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "/test/ts-files/zencoder/gogo/00003.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "/test/ts-files/zencoder/gogo/00004.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "/test/ts-files/zencoder/gogo/00005.ts"
}
],
......@@ -27,4 +32,4 @@
"endList": true,
"discontinuitySequence": 0,
"discontinuityStarts": []
}
\ No newline at end of file
}
......
......@@ -4,6 +4,7 @@
"segments": [
{
"duration": 10,
"timeline": 0,
"uri": "/test/ts-files/zencoder/gogo/00001.ts"
}
],
......@@ -11,4 +12,4 @@
"endList": true,
"discontinuitySequence": 0,
"discontinuityStarts": []
}
\ No newline at end of file
}
......
......@@ -10,6 +10,7 @@
"height": 224
}
},
"timeline": 0,
"uri": "media.m3u8"
},
{
......@@ -17,6 +18,7 @@
"PROGRAM-ID": 1,
"BANDWIDTH": 40000
},
"timeline": 0,
"uri": "media1.m3u8"
},
{
......@@ -28,6 +30,7 @@
"height": 224
}
},
"timeline": 0,
"uri": "media2.m3u8"
},
{
......@@ -39,8 +42,15 @@
"height": 540
}
},
"timeline": 0,
"uri": "media3.m3u8"
}
],
"discontinuityStarts": []
}
\ No newline at end of file
"discontinuityStarts": [],
"mediaGroups": {
"VIDEO": {},
"AUDIO": {},
"CLOSED-CAPTIONS": {},
"SUBTITLES": {}
}
}
......
......@@ -5,18 +5,22 @@
"segments": [
{
"duration": 10,
"timeline": 0,
"uri": "media-00001.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "media-00002.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "media-00003.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "media-00004.ts"
}
],
......@@ -24,4 +28,4 @@
"endList": true,
"discontinuitySequence": 0,
"discontinuityStarts": []
}
\ No newline at end of file
}
......
......@@ -5,18 +5,22 @@
"segments": [
{
"duration": 6.64,
"timeline": 0,
"uri": "/test/ts-files/tvy7/8a5e2822668b5370f4eb1438b2564fb7ab12ffe1-hi720.ts"
},
{
"duration": 6.08,
"timeline": 0,
"uri": "/test/ts-files/tvy7/56be1cef869a1c0cc8e38864ad1add17d187f051-hi720.ts"
},
{
"duration": 6.6,
"timeline": 0,
"uri": "/test/ts-files/tvy7/549c8c77f55f049741a06596e5c1e01dacaa46d0-hi720.ts"
},
{
"duration": 5,
"timeline": 0,
"uri": "/test/ts-files/tvy7/6cfa378684ffeb1c455a64dae6c103290a1f53d4-hi720.ts"
}
],
......@@ -24,4 +28,4 @@
"endList": true,
"discontinuitySequence": 0,
"discontinuityStarts": []
}
\ No newline at end of file
}
......
......@@ -4,14 +4,16 @@
"segments": [
{
"duration": 10,
"timeline": 0,
"uri": "00001.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "00002.ts"
}
],
"targetDuration": 10,
"discontinuitySequence": 0,
"discontinuityStarts": []
}
\ No newline at end of file
}
......
......@@ -5,14 +5,17 @@
"segments": [
{
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
}
],
......@@ -20,4 +23,4 @@
"endList": true,
"discontinuitySequence": 0,
"discontinuityStarts": []
}
\ No newline at end of file
}
......
......@@ -5,18 +5,22 @@
"segments": [
{
"duration": 6.64,
"timeline": 0,
"uri": "/test/ts-files/tvy7/8a5e2822668b5370f4eb1438b2564fb7ab12ffe1-hi720.ts"
},
{
"duration": 6.08,
"timeline": 0,
"uri": "/test/ts-files/tvy7/56be1cef869a1c0cc8e38864ad1add17d187f051-hi720.ts"
},
{
"duration": 6.6,
"timeline": 0,
"uri": "/test/ts-files/tvy7/549c8c77f55f049741a06596e5c1e01dacaa46d0-hi720.ts"
},
{
"duration": 5,
"timeline": 0,
"uri": "/test/ts-files/tvy7/6cfa378684ffeb1c455a64dae6c103290a1f53d4-hi720.ts"
}
],
......@@ -24,4 +28,4 @@
"endList": true,
"discontinuitySequence": 0,
"discontinuityStarts": []
}
\ No newline at end of file
}
......
......@@ -5,18 +5,22 @@
"segments": [
{
"duration": 6.64,
"timeline": 0,
"uri": "/test/ts-files/tvy7/8a5e2822668b5370f4eb1438b2564fb7ab12ffe1-hi720.ts"
},
{
"duration": 8,
"timeline": 0,
"uri": "/test/ts-files/tvy7/56be1cef869a1c0cc8e38864ad1add17d187f051-hi720.ts"
},
{
"duration": 8,
"timeline": 0,
"uri": "/test/ts-files/tvy7/549c8c77f55f049741a06596e5c1e01dacaa46d0-hi720.ts"
},
{
"duration": 8,
"timeline": 0,
"uri": "/test/ts-files/tvy7/6cfa378684ffeb1c455a64dae6c103290a1f53d4-hi720.ts"
}
],
......@@ -24,4 +28,4 @@
"endList": true,
"discontinuitySequence": 0,
"discontinuityStarts": []
}
\ No newline at end of file
}
......
{
allowCache: true,
discontinuityStarts: [],
mediaGroups: {
AUDIO: {
"audio-lo": {
"English": {
autoselect: true,
default: true,
language: "eng",
uri: "englo/prog_index.m3u8"
},
"Français": {
autoselect: true,
default: false,
language: "fre",
uri: "frelo/prog_index.m3u8"
},
"Espanol": {
autoselect: true,
default: false,
language: "sp",
uri: "splo/prog_index.m3u8"
}
},
"audio-hi": {
"English": {
autoselect: true,
default: true,
language: "eng",
uri: "eng/prog_index.m3u8"
},
"Français": {
autoselect: true,
default: false,
language: "fre",
uri: "fre/prog_index.m3u8"
},
"Espanol": {
autoselect: true,
default: false,
language: "sp",
uri: "sp/prog_index.m3u8"
}
}
},
VIDEO: {},
"CLOSED-CAPTIONS": {},
SUBTITLES: {}
},
playlists: [{
attributes: {
"PROGRAM-ID": 1,
BANDWIDTH: 195023,
CODECS: "mp4a.40.5",
AUDIO: "audio-lo",
},
timeline: 0,
uri: "lo/prog_index.m3u8"
}, {
attributes: {
"PROGRAM-ID": 1,
BANDWIDTH: 260000,
CODECS: "avc1.42e01e,mp4a.40.2",
AUDIO: "audio-lo"
},
timeline: 0,
uri: "lo2/prog_index.m3u8"
}, {
attributes: {
"PROGRAM-ID": 1,
BANDWIDTH: 591680,
CODECS: "mp4a.40.2, avc1.64001e",
AUDIO: "audio-hi"
},
timeline: 0,
uri: "hi/prog_index.m3u8"
}, {
attributes: {
"PROGRAM-ID": 1,
BANDWIDTH: 650000,
CODECS: "avc1.42e01e,mp4a.40.2",
AUDIO: "audio-hi"
},
timeline: 0,
uri: "hi2/prog_index.m3u8"
}]
}
#EXTM3U
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-lo",LANGUAGE="eng",NAME="English",AUTOSELECT=YES, DEFAULT=YES,URI="englo/prog_index.m3u8"
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-lo",LANGUAGE="fre",NAME="Français",AUTOSELECT=YES, DEFAULT=NO,URI="frelo/prog_index.m3u8"
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-lo",LANGUAGE="sp",NAME="Espanol",AUTOSELECT=YES, DEFAULT=NO,URI="splo/prog_index.m3u8"
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-hi",LANGUAGE="eng",NAME="English",AUTOSELECT=YES, DEFAULT=YES,URI="eng/prog_index.m3u8"
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-hi",LANGUAGE="fre",NAME="Français",AUTOSELECT=YES, DEFAULT=NO,URI="fre/prog_index.m3u8"
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-hi",LANGUAGE="sp",NAME="Espanol",AUTOSELECT=YES, DEFAULT=NO,URI="sp/prog_index.m3u8"
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=195023,CODECS="mp4a.40.5", AUDIO="audio-lo"
lo/prog_index.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=260000,CODECS="avc1.42e01e,mp4a.40.2", AUDIO="audio-lo"
lo2/prog_index.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=591680,CODECS="mp4a.40.2, avc1.64001e", AUDIO="audio-hi"
hi/prog_index.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=650000,CODECS="avc1.42e01e,mp4a.40.2", AUDIO="audio-hi"
hi2/prog_index.m3u8
{
allowCache: true,
discontinuityStarts: [],
mediaGroups: {
AUDIO: {
"audio-lo": {
"English": {
autoselect: true,
default: true,
language: "eng",
},
"Français": {
autoselect: true,
default: false,
language: "fre",
uri: "frelo/prog_index.m3u8"
},
"Espanol": {
autoselect: true,
default: false,
language: "sp",
uri: "splo/prog_index.m3u8"
}
},
"audio-hi": {
"English": {
autoselect: true,
default: true,
language: "eng",
uri: "eng/prog_index.m3u8"
},
"Français": {
autoselect: true,
default: false,
language: "fre",
uri: "fre/prog_index.m3u8"
},
"Espanol": {
autoselect: true,
default: false,
language: "sp",
uri: "sp/prog_index.m3u8"
}
}
},
VIDEO: {},
"CLOSED-CAPTIONS": {},
SUBTITLES: {}
},
playlists: [{
attributes: {
"PROGRAM-ID": 1,
BANDWIDTH: 195023,
CODECS: "mp4a.40.5",
AUDIO: "audio-lo",
},
timeline: 0,
uri: "lo/prog_index.m3u8"
}, {
attributes: {
"PROGRAM-ID": 1,
BANDWIDTH: 260000,
CODECS: "avc1.42e01e,mp4a.40.2",
AUDIO: "audio-lo"
},
timeline: 0,
uri: "lo2/prog_index.m3u8"
}, {
attributes: {
"PROGRAM-ID": 1,
BANDWIDTH: 591680,
CODECS: "mp4a.40.2, avc1.64001e",
AUDIO: "audio-hi"
},
timeline: 0,
uri: "hi/prog_index.m3u8"
}, {
attributes: {
"PROGRAM-ID": 1,
BANDWIDTH: 650000,
CODECS: "avc1.42e01e,mp4a.40.2",
AUDIO: "audio-hi"
},
timeline: 0,
uri: "hi2/prog_index.m3u8"
}]
}
#EXTM3U
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-lo",LANGUAGE="eng",NAME="English",AUTOSELECT=YES, DEFAULT=YES
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-lo",LANGUAGE="fre",NAME="Français",AUTOSELECT=YES, DEFAULT=NO,URI="frelo/prog_index.m3u8"
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-lo",LANGUAGE="sp",NAME="Espanol",AUTOSELECT=YES, DEFAULT=NO,URI="splo/prog_index.m3u8"
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-hi",LANGUAGE="eng",NAME="English",AUTOSELECT=YES, DEFAULT=YES,URI="eng/prog_index.m3u8"
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-hi",LANGUAGE="fre",NAME="Français",AUTOSELECT=YES, DEFAULT=NO,URI="fre/prog_index.m3u8"
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-hi",LANGUAGE="sp",NAME="Espanol",AUTOSELECT=YES, DEFAULT=NO,URI="sp/prog_index.m3u8"
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=195023,CODECS="mp4a.40.5", AUDIO="audio-lo"
lo/prog_index.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=260000,CODECS="avc1.42e01e,mp4a.40.2", AUDIO="audio-lo"
lo2/prog_index.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=591680,CODECS="mp4a.40.2, avc1.64001e", AUDIO="audio-hi"
hi/prog_index.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=650000,CODECS="avc1.42e01e,mp4a.40.2", AUDIO="audio-hi"
hi2/prog_index.m3u8
......@@ -4,21 +4,25 @@
"targetDuration": 10,
"segments": [
{
"uri": "001.ts"
"uri": "001.ts",
"timeline": 0
},
{
"uri": "002.ts",
"duration": 9
"duration": 9,
"timeline": 0
},
{
"uri": "003.ts",
"duration": 7
"duration": 7,
"timeline": 0
},
{
"uri": "004.ts",
"duration": 10
"duration": 10,
"timeline": 0
}
],
"discontinuitySequence": 0,
"discontinuityStarts": []
}
\ No newline at end of file
}
......
{
allowCache: true,
discontinuityStarts: [],
mediaGroups: {
AUDIO: {
aac: {
English: {
autoselect: true,
default: true,
language: "eng",
uri: "eng/prog_index.m3u8"
}
}
},
VIDEO: {
"200kbs": {
Angle1: {
autoselect: true,
default: true
},
Angle2: {
autoselect: true,
default: false,
uri: "Angle2/200kbs/prog_index.m3u8"
},
Angle3: {
autoselect: true,
default: false,
uri: "Angle3/200kbs/prog_index.m3u8"
}
},
"500kbs": {
Angle1: {
autoselect: true,
default: true
},
Angle2: {
autoselect: true,
default: false,
uri: "Angle2/500kbs/prog_index.m3u8"
},
Angle3: {
autoselect: true,
default: false,
uri: "Angle3/500kbs/prog_index.m3u8"
}
}
},
"CLOSED-CAPTIONS": {},
SUBTITLES: {}
},
playlists: [{
attributes: {
"PROGRAM-ID": 1,
BANDWIDTH: 300000,
CODECS: "mp4a.40.2,avc1.4d401e",
AUDIO: "aac",
VIDEO: "200kbs"
},
timeline: 0,
uri: "Angle1/200kbs/prog_index.m3u"
}, {
attributes: {
"PROGRAM-ID": 1,
BANDWIDTH: 754857,
CODECS: "mp4a.40.2,avc1.4d401e",
AUDIO: "aac",
VIDEO: "500kbs"
},
timeline: 0,
uri: "Angle1/500kbs/prog_index.m3u8"
}]
}
#EXTM3U
#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="200kbs",NAME="Angle1",AUTOSELECT=YES,DEFAULT=YES
#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="200kbs",NAME="Angle2",AUTOSELECT=YES,DEFAULT=NO,URI="Angle2/200kbs/prog_index.m3u8"
#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="200kbs",NAME="Angle3",AUTOSELECT=YES,DEFAULT=NO,URI="Angle3/200kbs/prog_index.m3u8"
#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="500kbs",NAME="Angle1",AUTOSELECT=YES,DEFAULT=YES
#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="500kbs",NAME="Angle2",AUTOSELECT=YES,DEFAULT=NO,URI="Angle2/500kbs/prog_index.m3u8"
#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="500kbs",NAME="Angle3",AUTOSELECT=YES,DEFAULT=NO,URI="Angle3/500kbs/prog_index.m3u8"
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac",LANGUAGE="eng",NAME="English",AUTOSELECT=YES,DEFAULT=YES,URI="eng/prog_index.m3u8"
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=300000,CODECS="mp4a.40.2,avc1.4d401e",VIDEO="200kbs",AUDIO="aac"
Angle1/200kbs/prog_index.m3u
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=754857,CODECS="mp4a.40.2,avc1.4d401e",VIDEO="500kbs",AUDIO="aac"
Angle1/500kbs/prog_index.m3u8
\ No newline at end of file
......@@ -5,18 +5,22 @@
"segments": [
{
"duration": 6.64,
"timeline": 0,
"uri": "/test/ts-files/tvy7/8a5e2822668b5370f4eb1438b2564fb7ab12ffe1-hi720.ts"
},
{
"duration": 6.08,
"timeline": 0,
"uri": "/test/ts-files/tvy7/56be1cef869a1c0cc8e38864ad1add17d187f051-hi720.ts"
},
{
"duration": 6.6,
"timeline": 0,
"uri": "/test/ts-files/tvy7/549c8c77f55f049741a06596e5c1e01dacaa46d0-hi720.ts"
},
{
"duration": 5,
"timeline": 0,
"uri": "/test/ts-files/tvy7/6cfa378684ffeb1c455a64dae6c103290a1f53d4-hi720.ts"
}
],
......@@ -24,4 +28,4 @@
"endList": true,
"discontinuitySequence": 0,
"discontinuityStarts": []
}
\ No newline at end of file
}
......
......@@ -9,6 +9,7 @@
"offset": 0
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -17,6 +18,7 @@
"offset": 522828
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -25,6 +27,7 @@
"offset": 1110328
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -33,6 +36,7 @@
"offset": 1823412
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -41,6 +45,7 @@
"offset": 2299992
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -49,6 +54,7 @@
"offset": 2835604
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -57,6 +63,7 @@
"offset": 3042780
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -65,6 +72,7 @@
"offset": 3498680
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -73,6 +81,7 @@
"offset": 4155928
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -81,6 +90,7 @@
"offset": 4727636
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -89,6 +99,7 @@
"offset": 5212676
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -97,6 +108,7 @@
"offset": 5921812
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -105,6 +117,7 @@
"offset": 6651816
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -113,6 +126,7 @@
"offset": 7108092
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -121,6 +135,7 @@
"offset": 7576776
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -129,6 +144,7 @@
"offset": 8021772
},
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
},
{
......@@ -137,6 +153,7 @@
"offset": 8353216
},
"duration": 1.4167,
"timeline": 0,
"uri": "hls_450k_video.ts"
}
],
......@@ -144,4 +161,4 @@
"endList": true,
"discontinuitySequence": 0,
"discontinuityStarts": []
}
\ No newline at end of file
}
......
......@@ -5,6 +5,7 @@
"segments": [
{
"duration": 6.64,
"timeline": 0,
"uri": "/test/ts-files/tvy7/8a5e2822668b5370f4eb1438b2564fb7ab12ffe1-hi720.ts"
}
],
......@@ -12,4 +13,4 @@
"endList": true,
"discontinuitySequence": 0,
"discontinuityStarts": []
}
\ No newline at end of file
}
......
......@@ -5,11 +5,19 @@
"attributes": {
"PROGRAM-ID": 1
},
"timeline": 0,
"uri": "media.m3u8"
},
{
"timeline": 0,
"uri": "media1.m3u8"
}
],
"discontinuityStarts": []
}
\ No newline at end of file
"discontinuityStarts": [],
"mediaGroups": {
"VIDEO": {},
"AUDIO": {},
"CLOSED-CAPTIONS": {},
"SUBTITLES": {}
}
}
......
......@@ -5,18 +5,22 @@
"segments": [
{
"duration": 6.64,
"timeline": 0,
"uri": "/test/ts-files/tvy7/8a5e2822668b5370f4eb1438b2564fb7ab12ffe1-hi720.ts"
},
{
"duration": 6.08,
"timeline": 0,
"uri": "/test/ts-files/tvy7/56be1cef869a1c0cc8e38864ad1add17d187f051-hi720.ts"
},
{
"duration": 6.6,
"timeline": 0,
"uri": "/test/ts-files/tvy7/549c8c77f55f049741a06596e5c1e01dacaa46d0-hi720.ts"
},
{
"duration": 5,
"timeline": 0,
"uri": "/test/ts-files/tvy7/6cfa378684ffeb1c455a64dae6c103290a1f53d4-hi720.ts"
}
],
......@@ -24,4 +28,4 @@
"endList": true,
"discontinuitySequence": 0,
"discontinuityStarts": []
}
\ No newline at end of file
}
......
......@@ -5,6 +5,7 @@
"segments": [
{
"duration": 10,
"timeline": 0,
"uri": "hls_450k_video.ts"
}
],
......@@ -12,4 +13,4 @@
"endList": true,
"discontinuitySequence": 0,
"discontinuityStarts": []
}
\ No newline at end of file
}
......
......@@ -5,18 +5,22 @@
"segments": [
{
"duration": 10,
"timeline": 0,
"uri": "http://example.com/00001.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "https://example.com/00002.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "//example.com/00003.ts"
},
{
"duration": 10,
"timeline": 0,
"uri": "http://example.com/00004.ts"
}
],
......@@ -24,4 +28,4 @@
"endList": true,
"discontinuitySequence": 0,
"discontinuityStarts": []
}
\ No newline at end of file
}
......
(function(videojs) {
var Component = videojs.getComponent('Component');
// -----------------
// AudioTrackMenuItem
// -----------------
//
var MenuItem = videojs.getComponent('MenuItem');
var AudioTrackMenuItem = videojs.extend(MenuItem, {
constructor: function(player, options) {
var track = options.track;
var tracks = player.audioTracks();
options.label = track.label || track.language || 'Unknown';
options.selected = track.enabled;
MenuItem.call(this, player, options);
this.track = track;
if (tracks) {
var changeHandler = videojs.bind(this, this.handleTracksChange);
tracks.addEventListener('change', changeHandler);
this.on('dispose', function() {
tracks.removeEventListener('change', changeHandler);
});
}
},
handleClick: function(event) {
var kind = this.track.kind;
var tracks = this.player_.audioTracks();
MenuItem.prototype.handleClick.call(this, event);
if (!tracks) return;
for (var i = 0; i < tracks.length; i++) {
var track = tracks[i];
if (track === this.track) {
track.enabled = true;
}
}
},
handleTracksChange: function(event) {
this.selected(this.track.enabled);
}
});
Component.registerComponent('AudioTrackMenuItem', AudioTrackMenuItem);
// -----------------
// AudioTrackButton
// -----------------
//
var MenuButton = videojs.getComponent('MenuButton');
var AudioTrackButton = videojs.extend(MenuButton, {
constructor: function(player, options) {
MenuButton.call(this, player, options);
this.el_.setAttribute('aria-label','Audio Menu');
var tracks = this.player_.audioTracks();
if (this.items.length <= 1) {
this.hide();
}
if (!tracks) {
return;
}
var updateHandler = videojs.bind(this, this.update);
tracks.addEventListener('removetrack', updateHandler);
tracks.addEventListener('addtrack', updateHandler);
this.player_.on('dispose', function() {
tracks.removeEventListener('removetrack', updateHandler);
tracks.removeEventListener('addtrack', updateHandler);
});
},
buildCSSClass() {
return 'vjs-subtitles-button ' + MenuButton.prototype.buildCSSClass.call(this);
},
createItems: function(items) {
items = items || [];
var tracks = this.player_.audioTracks();
if (!tracks) {
return items;
}
for (var i = 0; i < tracks.length; i++) {
var track = tracks[i];
items.push(new AudioTrackMenuItem(this.player_, {
'selectable': true,
'track': track
}));
}
return items;
}
});
Component.registerComponent('AudioTrackButton', AudioTrackButton);
})(window.videojs);
......@@ -9,28 +9,16 @@
<!-- video.js -->
<script src="../../node_modules/video.js/dist/video.js"></script>
<!-- Media Sources plugin -->
<script src="../../node_modules/videojs-contrib-media-sources/dist/videojs-media-sources.js"></script>
<!-- HLS plugin -->
<script src="../../src/videojs-hls.js"></script>
<!-- m3u8 handling -->
<script src="../../src/xhr.js"></script>
<script src="../../src/stream.js"></script>
<script src="../../src/m3u8/m3u8-parser.js"></script>
<script src="../../src/playlist.js"></script>
<script src="../../src/playlist-loader.js"></script>
<script src="../../dist/videojs-contrib-hls.js"></script>
<script src="../../node_modules/pkcs7/dist/pkcs7.unpad.js"></script>
<script src="../../src/decrypter.js"></script>
<!-- Track Selector plugin -->
<script src="audio-track-selector.js"></script>
<!-- player stats visualization -->
<link href="stats.css" rel="stylesheet">
<script src="../switcher/js/vendor/d3.min.js"></script>
<!-- debugging -->
<script src="../../src/bin-utils.js"></script>
<style>
body {
font-family: Arial, sans-serif;
......@@ -43,13 +31,12 @@
padding: 0 5px;
margin: 20px 0;
}
</style>
<script>
if (window.location.search === '?flash') {
videojs.options.techOrder = ['flash'];
input {
margin-top: 15px;
min-width: 450px;
padding: 5px;
}
</script>
</style>
</head>
<body>
......@@ -57,18 +44,50 @@
<p>The video below is an <a href="https://developer.apple.com/library/ios/documentation/networkinginternet/conceptual/streamingmediaguide/Introduction/Introduction.html#//apple_ref/doc/uid/TP40008332-CH1-SW1">HTTP Live Stream</a>. On desktop browsers other than Safari, the HLS plugin will polyfill support for the format on top of the video.js Flash tech.</p>
<p>Due to security restrictions in Flash, you will have to load this page over HTTP(S) to see the example in action.</p>
</div>
<video id="video"
class="video-js vjs-default-skin"
height="300"
width="600"
controls>
<source
src="http://solutions.brightcove.com/jwhisenant/hls/apple/bipbop/bipbopall.m3u8"
type="application/x-mpegURL">
<source
src="http://s3.amazonaws.com/_bc_dml/example-content/bipbop-id3/index.m3u8"
type="application/x-mpegURL">
</video>
<div id="fixture">
</div>
<form id="load-url">
<label>
URL to Load:
<input id="url-to-load" type="url" value="https://s3.amazonaws.com/_bc_dml/example-content/bipbop-advanced/bipbop_16x9_variant.m3u8">
</label>
<br>
<label>
Player Options:
<input id="player-options" type="text" value='{}'>
</label>
<br>
<label>
URL Content Type:
<input id="url-content-type" type="text" value="application/x-mpegURL">
</label>
<br>
<label>
Default Caption Track Label (blank for none):
<input id="caption-track" type="text" value="">
</label>
<br>
<label>
Technology Mode:
<input type="radio" name="technology-mode" value="auto" checked> Auto
<input type="radio" name="technology-mode" value="html"> HTML
<input type="radio" name="technology-mode" value="flash"> Flash
</label>
<br>
<label>
Autoplay:
<input type="radio" name="autoplay" value="on"> On
<input type="radio" name="autoplay" value="off" checked> Off
</label>
<br>
<br>
<button type="submit">Load</button>
</form>
<br>
<section class="stats">
<div class="player-stats">
<h2>Player Stats</h2>
......@@ -109,98 +128,181 @@
<script src="stats.js"></script>
<script>
videojs.options.flash.swf = '../../node_modules/videojs-swf/dist/video-js.swf';
// initialize the player
var player = videojs('video').ready(function() {
// ------------
// Player Stats
// ------------
var currentTimeStat = document.querySelector('.current-time-stat');
var bufferedStat = document.querySelector('.buffered-stat');
var seekableStartStat = document.querySelector('.seekable-start-stat');
var seekableEndStat = document.querySelector('.seekable-end-stat');
var videoBitrateState = document.querySelector('.video-bitrate-stat');
var measuredBitrateStat = document.querySelector('.measured-bitrate-stat');
player.on('timeupdate', function() {
currentTimeStat.textContent = player.currentTime().toFixed(1);
});
function getCheckedValue(name) {
var radios = document.getElementsByName(name);
var value;
for (var i = 0, length = radios.length; i < length; i++) {
if (radios[i].checked) {
value = radios[i].value;
break;
}
}
return value;
}
window.setInterval(function() {
var bufferedText = '', oldStart, oldEnd, i;
function createPlayer(cb) {
if (window.stats_timer) {
clearInterval(window.stats_timer);
}
// dispose of existing player
if(window.player) {
window.player.dispose();
}
// buffered
var buffered = player.buffered();
if (buffered.length) {
bufferedText += buffered.start(0) + ' - ' + buffered.end(0);
// create video element in the dom
var video = document.createElement('video');
video.id = 'videojs-contrib-hls-player';
video.className = 'video-js vjs-default-skin';
video.setAttribute('controls', true);
video.setAttribute('height', 300);
video.setAttribute('width', 600);
document.querySelector('#fixture').appendChild(video);
var techRadios = document.getElementsByName('technology-mode');
var techMode = getCheckedValue('technology-mode') || 'auto';
var autoplay = getCheckedValue('autoplay') || 'off';
var captionTrack = document.getElementById('caption-track').value || "";
var url = document.getElementById('url-to-load').value || "";
var options = {};
// try to parse options from the form
try {
options = JSON.parse(document.getElementById('player-options').value);
} catch(err) {
console.log("Reseting options to {}, JSON Parse Error:", err);
}
if(typeof options.techOrder === 'undefined') {
if (techMode === 'html') {
options.techOrder = ['html5'];
} else if (techMode === 'flash') {
options.techOrder = ['flash'];
}
for (i = 1; i < buffered.length; i++) {
bufferedText += ', ' + buffered.start(i) + ' - ' + buffered.end(i);
}
if(typeof options.autoplay === 'undefined') {
if (autoplay === 'on') {
options.autoplay = true;
} else if (techMode === 'off') {
options.autoplay = false;
}
bufferedStat.textContent = bufferedText;
}
var type = document.getElementById('url-content-type').value;
// use the form data to add a src to the player
try {
window.player = videojs(video.id, options);
if(captionTrack) {
// hackey way to show captions
window.player.on('loadeddata', function(event) {
textTracks = this.textTracks().tracks_;
for(var i = 0; i < textTracks.length; i++) {
if(textTracks[i].label === captionTrack) {
console.log("Found matching track, going to show " + captionTrack);
textTracks[i].mode = "showing";
break;
}
}
});
}
window.player.src({
src: url,
type: type
});
cb(player);
} catch(err) {
console.log("caught an error trying to create and add src to player:", err);
}
}
function setup(player) {
player.ready(function() {
// seekable
var seekable = player.seekable();
if (seekable && seekable.length) {
// ------------
// Audio Track Switcher
// ------------
oldStart = seekableStartStat.textContent;
if (seekable.start(0).toFixed(1) !== oldStart) {
seekableStartStat.textContent = seekable.start(0).toFixed(1);
player.controlBar.addChild('AudioTrackButton', {}, 13);
// ------------
// Player Stats
// ------------
var currentTimeStat = document.querySelector('.current-time-stat');
var bufferedStat = document.querySelector('.buffered-stat');
var seekableStartStat = document.querySelector('.seekable-start-stat');
var seekableEndStat = document.querySelector('.seekable-end-stat');
var videoBitrateState = document.querySelector('.video-bitrate-stat');
var measuredBitrateStat = document.querySelector('.measured-bitrate-stat');
player.on('timeupdate', function() {
currentTimeStat.textContent = player.currentTime().toFixed(1);
});
window.stats_timer = window.setInterval(function() {
var bufferedText = '', oldStart, oldEnd, i;
// buffered
var buffered = player.buffered();
if (buffered.length) {
bufferedText += buffered.start(0) + ' - ' + buffered.end(0);
}
oldEnd = seekableEndStat.textContent;
if (seekable.end(0).toFixed(1) !== oldEnd) {
seekableEndStat.textContent = seekable.end(0).toFixed(1);
for (i = 1; i < buffered.length; i++) {
bufferedText += ', ' + buffered.start(i) + ' - ' + buffered.end(i);
}
}
bufferedStat.textContent = bufferedText;
// bitrates
var playlist = player.tech_.hls.playlists.media();
if (playlist && playlist.attributes && playlist.attributes.BANDWIDTH) {
videoBitrateState.textContent = (playlist.attributes.BANDWIDTH / 1024).toLocaleString(undefined, {
maximumFractionDigits: 1
}) + ' kbps';
}
if (player.tech_.hls.bandwidth) {
measuredBitrateStat.textContent = (player.tech_.hls.bandwidth / 1024).toLocaleString(undefined, {
maximumFractionDigits: 1
}) + ' kbps';
}
}, 1000);
// seekable
var seekable = player.seekable();
if (seekable && seekable.length) {
var trackEventCount = function(eventName, selector) {
var count = 0, element = document.querySelector(selector);
player.on(eventName, function() {
count++;
element.innerHTML = count;
});
};
trackEventCount('play', '.play-count');
trackEventCount('playing', '.playing-count');
trackEventCount('seeking', '.seeking-count');
trackEventCount('seeked', '.seeked-count');
videojs.Hls.displayStats(document.querySelector('.switching-stats'), player);
videojs.Hls.displayCues(document.querySelector('.segment-timeline'), player);
});
// -----------
// Tech Switch
// -----------
var techSwitch = document.createElement('a');
techSwitch.className = 'tech-switch';
if (player.el().querySelector('video')) {
techSwitch.href = window.location.origin + window.location.pathname + '?flash';
techSwitch.appendChild(document.createTextNode('Switch to the Flash tech'));
} else {
techSwitch.href = window.location.origin + window.location.pathname;
techSwitch.appendChild(document.createTextNode('Stop forcing Flash'));
}
oldStart = seekableStartStat.textContent;
if (seekable.start(0).toFixed(1) !== oldStart) {
seekableStartStat.textContent = seekable.start(0).toFixed(1);
}
oldEnd = seekableEndStat.textContent;
if (seekable.end(0).toFixed(1) !== oldEnd) {
seekableEndStat.textContent = seekable.end(0).toFixed(1);
}
}
// bitrates
var playlist = player.tech_.hls.playlists.media();
if (playlist && playlist.attributes && playlist.attributes.BANDWIDTH) {
videoBitrateState.textContent = (playlist.attributes.BANDWIDTH / 1024).toLocaleString(undefined, {
maximumFractionDigits: 1
}) + ' kbps';
}
if (player.tech_.hls.bandwidth) {
measuredBitrateStat.textContent = (player.tech_.hls.bandwidth / 1024).toLocaleString(undefined, {
maximumFractionDigits: 1
}) + ' kbps';
}
}, 1000);
document.body.insertBefore(techSwitch, document.querySelector('.stats'));
var trackEventCount = function(eventName, selector) {
var count = 0, element = document.querySelector(selector);
player.on(eventName, function() {
count++;
element.innerHTML = count;
});
};
trackEventCount('play', '.play-count');
trackEventCount('playing', '.playing-count');
trackEventCount('seeking', '.seeking-count');
trackEventCount('seeked', '.seeked-count');
videojs.Hls.displayStats(document.querySelector('.switching-stats'), player);
videojs.Hls.displayCues(document.querySelector('.segment-timeline'), player);
});
}
(function(window, videojs) {
videojs.options.flash.swf = '../../node_modules/videojs-swf/dist/video-js.swf';
createPlayer(setup);
// hook up the video switcher
document.getElementById('load-url').addEventListener('submit', function(event) {
event.preventDefault();
createPlayer(setup);
return false;
});
}(window, window.videojs));
</script>
</body>
</html>
......