8718c2e2 by jrivera

Merge branch 'development'

Conflicts:
	.travis.yml
	package.json
	src/playlist.js
	src/videojs-hls.js
	test/videojs-hls_test.js
2 parents 2f10ecfc b5e60aba
Showing 181 changed files with 4200 additions and 4270 deletions
# http://editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
# OS
Thumbs.db
ehthumbs.db
Desktop.ini
.DS_Store
dist/*
/node_modules/
._*
# Editors
*~
*.iml
*.ipr
*.iws
*.swp
tmp/**.*.swo
*.tmproj
*.tmproject
*.sublime-*
.idea/
.project/
.settings/
.vscode/
# Logs
logs
*.log
npm-debug.log*
# Dependency directories
bower_components/
node_modules/
# Yeoman meta-data
.yo-rc.json
# Build-related directories
dist/
dist-test/
docs/api/
es5/
tmp
test/test-manifests.js
test/test-expected.js
......
{
"curly": true,
"eqeqeq": true,
"immed": true,
"latedef": true,
"newcap": true,
"noarg": true,
"sub": true,
"undef": true,
"unused": true,
"boss": true,
"eqnull": true,
"node": true,
"camelcase": true,
"nonew": true,
"quotmark": "single",
"trailing": true,
"maxlen": 80
}
*~
*.iml
*.swp
tmp/**
test/**
# Intentionally left blank, so that npm does not ignore anything by default,
# but relies on the package.json "files" array to explicitly define what ends
# up in the package.
......
language: node_js
sudo: false
language: node_js
addons:
firefox: "latest"
node_js:
- "stable"
install:
- npm install -g grunt-cli && npm install
notifications:
hipchat:
rooms:
......@@ -12,6 +12,7 @@ notifications:
channels:
- "chat.freenode.net#videojs"
use_notice: true
# Set up a virtual screen for Firefox.
before_script:
- export DISPLAY=:99.0
- sh -e /etc/init.d/xvfb start
......
'use strict';
var
basename = require('path').basename,
mediaSourcesPath = 'node_modules/videojs-contrib-media-sources/dist/',
mediaSourcesDebug = mediaSourcesPath + 'videojs-media-sources.js';
module.exports = function(grunt) {
var pkg = grunt.file.readJSON('package.json');
// Project configuration.
grunt.initConfig({
// Metadata.
pkg: pkg,
banner: '/*! <%= pkg.name %> - v<%= pkg.version %> - ' +
'<%= grunt.template.today("yyyy-mm-dd") %>\n' +
'* Copyright (c) <%= grunt.template.today("yyyy") %> Brightcove;' +
' Licensed <%= _.pluck(pkg.licenses, "type").join(", ") %> */\n',
// Task configuration.
clean: {
files: ['build', 'dist', 'tmp']
},
concat: {
options: {
banner: '<%= banner %>',
stripBanners: true
},
dist: {
nonull: true,
src: [
mediaSourcesDebug,
'src/videojs-hls.js',
'src/xhr.js',
'src/stream.js',
'src/m3u8/m3u8-parser.js',
'src/playlist.js',
'src/playlist-loader.js',
'node_modules/pkcs7/dist/pkcs7.unpad.js',
'src/decrypter.js'
],
dest: 'dist/videojs.hls.js'
}
},
uglify: {
options: {
banner: '<%= banner %>'
},
dist: {
src: '<%= concat.dist.dest %>',
dest: 'dist/videojs.hls.min.js'
}
},
jshint: {
gruntfile: {
options: {
jshintrc: '.jshintrc'
},
src: 'Gruntfile.js'
},
src: {
options: {
jshintrc: 'src/.jshintrc'
},
src: ['src/**/*.js']
},
test: {
options: {
jshintrc: 'test/.jshintrc'
},
src: ['test/**/*.js',
'!test/tsSegment.js',
'!test/fixtures/*.js',
'!test/manifest/**',
'!test/muxer/**',
'!test/switcher/**']
}
},
connect: {
dev: {
options: {
hostname: '*',
port: 9999,
keepalive: true
}
},
test: {
options: {
hostname: '*',
port: 9999
}
}
},
open : {
dev : {
path: 'http://127.0.0.1:<%= connect.dev.options.port %>/example.html',
app: 'Google Chrome'
}
},
watch: {
build: {
files: '<%= concat.dist.src %>',
tasks: ['clean', 'concat', 'uglify']
},
gruntfile: {
files: '<%= jshint.gruntfile.src %>',
tasks: ['jshint:gruntfile']
},
src: {
files: '<%= jshint.src.src %>',
tasks: ['jshint:src', 'test']
},
test: {
files: '<%= jshint.test.src %>',
tasks: ['jshint:test', 'test']
}
},
concurrent: {
dev: {
tasks: ['connect', 'open', 'watch'],
options: {
logConcurrentOutput: true
}
}
},
version: {
project: {
src: ['package.json']
}
},
'github-release': {
options: {
repository: 'videojs/videojs-contrib-hls',
auth: {
user: process.env.VJS_GITHUB_USER,
password: process.env.VJS_GITHUB_TOKEN
},
release: {
'tag_name': 'v' + pkg.version,
name: pkg.version,
body: require('chg').find(pkg.version).changesRaw
}
},
files: {
'dist': ['videojs.hls.min.js']
}
},
karma: {
options: {
frameworks: ['qunit']
},
saucelabs: {
configFile: 'test/karma.conf.js',
autoWatch: true
},
dev: {
browsers: ['Chrome', 'Safari', 'Firefox',
'Opera', 'IE', 'PhantomJS', 'ChromeCanary'],
configFile: 'test/localkarma.conf.js',
autoWatch: true
},
chromecanary: {
options: {
browsers: ['ChromeCanary'],
configFile: 'test/localkarma.conf.js',
autoWatch: true
}
},
phantomjs: {
options: {
browsers: ['PhantomJS'],
configFile: 'test/localkarma.conf.js',
autoWatch: true
}
},
opera: {
options: {
browsers: ['Opera'],
configFile: 'test/localkarma.conf.js',
autoWatch: true
}
},
chrome: {
options: {
browsers: ['Chrome'],
configFile: 'test/localkarma.conf.js',
autoWatch: true
}
},
safari: {
options: {
browsers: ['Safari'],
configFile: 'test/localkarma.conf.js',
autoWatch: true
}
},
firefox: {
options: {
browsers: ['Firefox'],
configFile: 'test/localkarma.conf.js',
autoWatch: true
}
},
ie: {
options: {
browsers: ['IE'],
configFile: 'test/localkarma.conf.js',
autoWatch: true
}
},
ci: {
configFile: 'test/karma.conf.js',
autoWatch: false
}
},
protractor: {
options: {
configFile: 'test/functional/protractor.config.js',
webdriverManagerUpdate: process.env.TRAVIS ? false : true
},
chrome: {
options: {
args: {
capabilities: {
browserName: 'chrome'
}
}
}
},
firefox: {
options: {
args: {
capabilities: {
browserName: 'firefox'
}
}
}
},
safari: {
options: {
args: {
capabilities: {
browserName: 'safari'
}
}
}
},
ie: {
options: {
args: {
capabilities: {
browserName: 'internet explorer'
}
}
}
},
saucelabs:{}
}
});
// These plugins provide necessary tasks.
grunt.loadNpmTasks('grunt-karma');
grunt.loadNpmTasks('grunt-contrib-clean');
grunt.loadNpmTasks('grunt-contrib-concat');
grunt.loadNpmTasks('grunt-contrib-uglify');
grunt.loadNpmTasks('grunt-contrib-jshint');
grunt.loadNpmTasks('grunt-contrib-watch');
grunt.loadNpmTasks('grunt-contrib-connect');
grunt.loadNpmTasks('grunt-open');
grunt.loadNpmTasks('grunt-concurrent');
grunt.loadNpmTasks('grunt-contrib-watch');
grunt.loadNpmTasks('grunt-github-releaser');
grunt.loadNpmTasks('grunt-version');
grunt.loadNpmTasks('grunt-protractor-runner');
grunt.loadNpmTasks('chg');
grunt.registerTask('manifests-to-js', 'Wrap the test fixtures and output' +
' so they can be loaded in a browser',
function() {
var
jsManifests = 'window.manifests = {\n',
jsExpected = 'window.expected = {\n';
grunt.file.recurse('test/manifest/',
function(abspath, root, sub, filename) {
if ((/\.m3u8$/).test(abspath)) {
// translate this manifest
jsManifests += ' \'' + basename(filename, '.m3u8') + '\': ' +
grunt.file.read(abspath)
.split(/\r\n|\n/)
// quote and concatenate
.map(function(line) {
return ' \'' + line + '\\n\' +\n';
}).join('')
// strip leading spaces and the trailing '+'
.slice(4, -3);
jsManifests += ',\n';
}
if ((/\.js$/).test(abspath)) {
// append the expected parse
jsExpected += ' "' + basename(filename, '.js') + '": ' +
grunt.file.read(abspath) + ',\n';
}
});
// clean up and close the objects
jsManifests = jsManifests.slice(0, -2);
jsManifests += '\n};\n';
jsExpected = jsExpected.slice(0, -2);
jsExpected += '\n};\n';
// write out the manifests
grunt.file.write('tmp/manifests.js', jsManifests);
grunt.file.write('tmp/expected.js', jsExpected);
});
// Launch a Development Environment
grunt.registerTask('dev', 'Launching Dev Environment', 'concurrent:dev');
grunt.registerTask('build',
['clean',
'concat',
'uglify']);
// Default task.
grunt.registerTask('default',
['test',
'build']);
// The test task will run `karma:saucelabs` when running in travis,
// otherwise, it'll default to running karma in chrome.
// You can specify which browsers to build with by using grunt-style arguments
// or separating them with a comma:
// grunt test:chrome:firefox # grunt-style
// grunt test:chrome,firefox # comma-separated
grunt.registerTask('test', function() {
var tasks = this.args;
grunt.task.run(['jshint', 'manifests-to-js']);
if (process.env.TRAVIS) {
if (process.env.TRAVIS_PULL_REQUEST === 'false') {
grunt.task.run(['karma:saucelabs']);
grunt.task.run(['connect:test', 'protractor:saucelabs']);
} else {
grunt.task.run(['karma:firefox']);
}
} else {
if (tasks.length === 0) {
tasks.push('chrome');
}
if (tasks.length === 1) {
tasks = tasks[0].split(',');
}
tasks = tasks.reduce(function(acc, el) {
acc.push('karma:' + el);
if (/chrome|firefox|safari|ie/.test(el)) {
acc.push('protractor:' + el);
}
return acc;
}, ['connect:test']);
grunt.task.run(tasks);
}
});
};
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)*
- [video.js HLS Source Handler](#videojs-hls-source-handler)
- [Getting Started](#getting-started)
- [Documentation](#documentation)
- [Options](#options)
- [withCredentials](#withcredentials)
- [Runtime Properties](#runtime-properties)
- [hls.playlists.master](#hlsplaylistsmaster)
- [hls.playlists.media](#hlsplaylistsmedia)
- [hls.segmentXhrTime](#hlssegmentxhrtime)
- [hls.bandwidth](#hlsbandwidth)
- [hls.bytesReceived](#hlsbytesreceived)
- [hls.selectPlaylist](#hlsselectplaylist)
- [Events](#events)
- [loadedmetadata](#loadedmetadata)
- [loadedplaylist](#loadedplaylist)
- [mediachange](#mediachange)
- [In-Band Metadata](#in-band-metadata)
- [Hosting Considerations](#hosting-considerations)
- [Testing](#testing)
- [Release History](#release-history)
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
# video.js HLS Source Handler
Play back HLS with video.js, even where it's not natively supported.
......
......@@ -2,31 +2,8 @@
<html>
<head>
<meta charset="utf-8">
<title>video.js HLS Plugin Example</title>
<link href="node_modules/video.js/dist/video-js.css" rel="stylesheet">
<!-- 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="node_modules/pkcs7/dist/pkcs7.unpad.js"></script>
<script src="src/decrypter.js"></script>
<script src="src/bin-utils.js"></script>
<title>videojs-contrib-hls Demo</title>
<link href="/node_modules/video.js/dist/video-js.css" rel="stylesheet">
<style>
body {
font-family: Arial, sans-serif;
......@@ -52,14 +29,8 @@
<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">
<video id="videojs-contrib-hls-player" class="video-js vjs-default-skin" controls>
<source src="http://solutions.brightcove.com/jwhisenant/hls/apple/bipbop/bipbopall.m3u8" type="application/x-mpegURL">
</video>
<form id=load-url>
......@@ -69,12 +40,16 @@
</label>
<button type=submit>Load</button>
</form>
<ul>
<li><a href="/test/">Run unit tests in browser.</a></li>
<li><a href="/docs/api/">Read generated docs.</a></li>
</ul>
<script src="/node_modules/video.js/dist/video.js"></script>
<script src="/dist/videojs-contrib-hls.js"></script>
<script>
videojs.options.flash.swf = 'node_modules/videojs-swf/dist/video-js.swf';
// initialize the player
var player = videojs('video');
(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');
......@@ -86,6 +61,7 @@
});
return false;
});
}(window, window.videojs));
</script>
</body>
</html>
......
{
"name": "videojs-contrib-hls",
"version": "1.3.9",
"description": "Play back HLS with video.js, even where it's not natively supported",
"main": "es5/videojs-contrib-hls.js",
"engines": {
"node": ">= 0.10.12"
},
......@@ -8,47 +10,121 @@
"type": "git",
"url": "git@github.com:videojs/videojs-contrib-hls.git"
},
"license": "Apache-2.0",
"scripts": {
"test": "grunt test",
"prepublish": "if [ -z \"$TRAVIS\" ]; then grunt; fi"
"prebuild": "npm run clean",
"build": "npm-run-all -p build:*",
"build:js": "npm-run-all build:js:babel build:js:browserify build:js:bannerize build:js:uglify",
"build:js:babel": "babel src -d es5",
"build:js:bannerize": "bannerize dist/videojs-contrib-hls.js --banner=scripts/banner.ejs",
"build:js:browserify": "browserify . -s videojs-contrib-hls -o dist/videojs-contrib-hls.js",
"build:js:uglify": "uglifyjs dist/videojs-contrib-hls.js --comments --mangle --compress -o dist/videojs-contrib-hls.min.js",
"build:test": "npm-run-all build:test:manifest build:test:js",
"build:test:js": "node scripts/build-test.js",
"build:test:manifest": "node -e \"var b=require('./scripts/manifest-data.js'); b.build();\"",
"clean": "npm-run-all -p clean:*",
"clean:build": "node -e \"var s=require('shelljs'),d=['dist','dist-test','es5'];s.rm('-rf',d);s.mkdir('-p',d);\"",
"clean:test": "node -e \"var b=require('./scripts/manifest-data.js'); b.clean();\"",
"docs": "npm-run-all docs:*",
"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:serve": "babel-node scripts/server.js",
"pretest": "npm-run-all lint build",
"test": "karma start test/karma/detected.js",
"test:chrome": "npm run pretest && karma start test/karma/chrome.js",
"test:firefox": "npm run pretest && karma start test/karma/firefox.js",
"test:ie": "npm run pretest && karma start test/karma/ie.js",
"test:safari": "npm run pretest && karma start test/karma/safari.js",
"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: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();\"",
"prepublish": "npm run build"
},
"keywords": [
"videojs",
"videojs-plugin"
],
"devDependencies": {
"chg": "^0.2.0",
"grunt": "^0.4.5",
"grunt-concurrent": "0.4.3",
"grunt-contrib-clean": "~0.4.0",
"grunt-contrib-concat": "~0.3.0",
"grunt-contrib-connect": "~0.6.0",
"grunt-contrib-jshint": "~0.6.0",
"grunt-contrib-uglify": "~0.2.0",
"grunt-contrib-watch": "~0.4.0",
"grunt-github-releaser": "^0.1.17",
"grunt-karma": "~0.6.2",
"grunt-open": "0.2.3",
"grunt-protractor-runner": "forbesjo/grunt-protractor-runner.git#webdriverManagerUpdate",
"grunt-shell": "0.6.1",
"grunt-version": "^1.0.0",
"karma": "~0.10.0",
"karma-chrome-launcher": "~0.1.2",
"karma-firefox-launcher": "~0.1.3",
"karma-ie-launcher": "~0.1.1",
"karma-opera-launcher": "~0.1.0",
"karma-phantomjs-launcher": "^0.1.4",
"karma-qunit": "~0.1.1",
"karma-safari-launcher": "~0.1.1",
"karma-sauce-launcher": "~0.1.8",
"qunitjs": "^1.18.0",
"sinon": "1.10.2",
"video.js": "^5.2.1"
"author": "Brightcove, Inc",
"license": "Apache-2.0",
"browserify": {
"transform": [
"browserify-shim"
]
},
"browserify-shim": {
"qunit": "global:QUnit",
"sinon": "global:sinon",
"video.js": "global:videojs"
},
"vjsstandard": {
"ignore": [
"dist",
"dist-test",
"docs",
"es5",
"test/karma",
"scripts",
"utils",
"test/test-manifests.js",
"test/test-expected.js",
"src/playlist-loader.js"
]
},
"files": [
"CONTRIBUTING.md",
"dist-test/",
"dist/",
"docs/",
"es5/",
"index.html",
"scripts/",
"src/",
"test/",
"utils/"
],
"dependencies": {
"pkcs7": "^0.2.2",
"videojs-contrib-media-sources": "^2.4.0",
"video.js": "^5.2.1",
"videojs-contrib-media-sources": "^3.0.0",
"videojs-swf": "^5.0.0"
},
"devDependencies": {
"babel": "^5.8.0",
"babelify": "^6.0.0",
"bannerize": "^1.0.0",
"browserify": "^11.0.0",
"browserify-shim": "^3.0.0",
"connect": "^3.4.0",
"cowsay": "^1.1.0",
"doctoc": "^0.15.0",
"glob": "^6.0.3",
"global": "^4.3.0",
"jsdoc": "^3.4.0",
"karma": "^0.13.0",
"karma-browserify": "^4.4.0",
"karma-chrome-launcher": "^0.2.0",
"karma-detect-browsers": "^2.0.0",
"karma-firefox-launcher": "^0.1.0",
"karma-ie-launcher": "^0.2.0",
"karma-qunit": "^0.1.9",
"karma-safari-launcher": "^0.1.0",
"lodash-compat": "^3.10.0",
"minimist": "^1.2.0",
"npm-run-all": "^1.2.0",
"portscanner": "^1.0.0",
"qunitjs": "^1.18.0",
"serve-static": "^1.10.0",
"shelljs": "^0.5.3",
"sinon": "1.10.2",
"uglify-js": "^2.5.0",
"videojs-standard": "^4.0.0",
"watchify": "^3.6.0",
"webworkify": "^1.1.0"
}
}
......
/**
* <%- pkg.name %>
* @version <%- pkg.version %>
* @copyright <%- date.getFullYear() %> <%- pkg.author %>
* @license <%- pkg.license %>
*/
var browserify = require('browserify');
var fs = require('fs');
var glob = require('glob');
glob('test/**/*.test.js', function(err, files) {
browserify(files)
.transform('babelify')
.bundle()
.pipe(fs.createWriteStream('dist-test/videojs-contrib-hls.js'));
});
var fs = require('fs');
var path = require('path');
var basePath = path.resolve(__dirname, '..');
var testDataDir = path.join(basePath,'test');
var manifestDir = path.join(basePath, 'utils', 'manifest');
var manifestFilepath = path.join(testDataDir, 'test-manifests.js');
var expectedFilepath = path.join(testDataDir, 'test-expected.js');
var build = function() {
var manifests = 'export default {\n';
var expected = 'export default {\n';
var files = fs.readdirSync(manifestDir);
while (files.length > 0) {
var file = path.resolve(manifestDir, files.shift());
var extname = path.extname(file);
if (extname === '.m3u8') {
// translate this manifest
manifests += ' \'' + path.basename(file, '.m3u8') + '\': ';
manifests += fs.readFileSync(file, 'utf8')
.split(/\r\n|\n/)
// quote and concatenate
.map(function(line) {
return ' \'' + line + '\\n\' +\n';
}).join('')
// strip leading spaces and the trailing '+'
.slice(4, -3);
manifests += ',\n';
} else if (extname === '.js') {
// append the expected parse
expected += ' "' + path.basename(file, '.js') + '": ';
expected += fs.readFileSync(file, 'utf8');
expected += ',\n';
} else {
console.log('Unknown file ' + file + ' found in manifest dir ' + manifestDir);
}
}
// clean up and close the objects
manifests = manifests.slice(0, -2);
manifests += '\n};\n';
expected = expected.slice(0, -2);
expected += '\n};\n';
fs.writeFileSync(manifestFilepath, manifests);
fs.writeFileSync(expectedFilepath, expected);
console.log('Wrote test data file ' + manifestFilepath);
console.log('Wrote test data file ' + expectedFilepath);
};
var watch = function() {
build();
fs.watch(manifestDir, function(event, filename) {
console.log('files in manifest dir were changed rebuilding manifest data');
build();
});
};
var clean = function() {
try {
fs.unlinkSync(manifestFilepath);
} catch(e) {
console.log(e);
}
try {
fs.unlinkSync(expectedFilepath);
} catch(e) {
console.log(e);
}
}
module.exports = {
build: build,
watch: watch,
clean: clean
};
import connect from 'connect';
import cowsay from 'cowsay';
import path from 'path';
import portscanner from 'portscanner';
import serveStatic from 'serve-static';
// Configuration for the server.
const PORT = 9999;
const MAX_PORT = PORT + 100;
const HOST = '127.0.0.1';
const app = connect();
const verbs = [
'Chewing the cud',
'Grazing',
'Mooing',
'Lowing',
'Churning the cream'
];
app.use(serveStatic(path.join(__dirname, '..')));
portscanner.findAPortNotInUse(PORT, MAX_PORT, HOST, (error, port) => {
if (error) {
throw error;
}
process.stdout.write(cowsay.say({
text: `${verbs[Math.floor(Math.random() * 5)]} on ${HOST}:${port}`
}) + '\n\n');
app.listen(port);
});
var browserify = require('browserify');
var fs = require('fs');
var glob = require('glob');
var watchify = require('watchify');
glob('test/**/*.test.js', function(err, files) {
var b = browserify(files, {
cache: {},
packageCache: {},
plugin: [watchify]
}).transform('babelify');
var bundle = function() {
b.bundle().pipe(fs.createWriteStream('dist-test/videojs-contrib-hls.js'));
};
b.on('log', function(msg) {
process.stdout.write(msg + '\n');
});
b.on('update', bundle);
bundle();
});
{
"curly": true,
"eqeqeq": true,
"globals": {
"console": true
},
"immed": true,
"latedef": true,
"newcap": true,
"noarg": true,
"sub": true,
"undef": true,
"unused": true,
"boss": true,
"eqnull": true,
"browser": true
}
(function(window) {
var textRange = function(range, i) {
const textRange = function(range, i) {
return range.start(i) + '-' + range.end(i);
};
var module = {
hexDump: function(data) {
var
bytes = Array.prototype.slice.call(data),
step = 16,
formatHexString = function(e, i) {
var value = e.toString(16);
return "00".substring(0, 2 - value.length) + value + (i % 2 ? ' ' : '');
},
formatAsciiString = function(e) {
};
const formatHexString = function(e, i) {
let value = e.toString(16);
return '00'.substring(0, 2 - value.length) + value + (i % 2 ? ' ' : '');
};
const formatAsciiString = function(e) {
if (e >= 0x20 && e < 0x7e) {
return String.fromCharCode(e);
}
return '.';
},
result = '',
hex,
ascii;
for (var j = 0; j < bytes.length / step; j++) {
};
const utils = {
hexDump(data) {
let bytes = Array.prototype.slice.call(data);
let step = 16;
let result = '';
let hex;
let ascii;
for (let j = 0; j < bytes.length / step; j++) {
hex = bytes.slice(j * step, j * step + step).map(formatHexString).join('');
ascii = bytes.slice(j * step, j * step + step).map(formatAsciiString).join('');
result += hex + ' ' + ascii + '\n';
}
return result;
},
tagDump: function(tag) {
return module.hexDump(tag.bytes);
tagDump(tag) {
return utils.hexDump(tag.bytes);
},
textRanges: function(ranges) {
var result = '', i;
textRanges(ranges) {
let result = '';
let i;
for (i = 0; i < ranges.length; i++) {
result += textRange(ranges, i) + ' ';
}
return result;
}
};
};
window.videojs.Hls.utils = module;
})(this);
export default utils;
......
/*
*
* This file contains an adaptation of the AES decryption algorithm
* from the Standford Javascript Cryptography Library. That work is
* covered by the following copyright and permissions notice:
*
* Copyright 2009-2010 Emily Stark, Mike Hamburg, Dan Boneh.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are
* met:
*
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above
* copyright notice, this list of conditions and the following
* disclaimer in the documentation and/or other materials provided
* with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR
* IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
* BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
* OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN
* IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* The views and conclusions contained in the software and documentation
* are those of the authors and should not be interpreted as representing
* official policies, either expressed or implied, of the authors.
*/
(function(window, videojs, unpad) {
'use strict';
var AES, AsyncStream, Decrypter, decrypt, ntoh;
/**
* Convert network-order (big-endian) bytes into their little-endian
* representation.
*/
ntoh = function(word) {
return (word << 24) |
((word & 0xff00) << 8) |
((word & 0xff0000) >> 8) |
(word >>> 24);
};
/**
* 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
* @param key {Array} The key as an array of 4, 6 or 8 words.
*/
AES = function (key) {
this._precompute();
var i, j, tmp,
encKey, decKey,
sbox = this._tables[0][4], decTable = this._tables[1],
keyLen = key.length, rcon = 1;
if (keyLen !== 4 && keyLen !== 6 && keyLen !== 8) {
throw new Error("Invalid aes key size");
}
encKey = key.slice(0);
decKey = [];
this._key = [encKey, decKey];
// schedule encryption keys
for (i = keyLen; i < 4 * keyLen + 28; i++) {
tmp = encKey[i-1];
// apply sbox
if (i%keyLen === 0 || (keyLen === 8 && i%keyLen === 4)) {
tmp = sbox[tmp>>>24]<<24 ^ sbox[tmp>>16&255]<<16 ^ sbox[tmp>>8&255]<<8 ^ sbox[tmp&255];
// shift rows and add rcon
if (i%keyLen === 0) {
tmp = tmp<<8 ^ tmp>>>24 ^ rcon<<24;
rcon = rcon<<1 ^ (rcon>>7)*283;
}
}
encKey[i] = encKey[i-keyLen] ^ tmp;
}
// schedule decryption keys
for (j = 0; i; j++, i--) {
tmp = encKey[j&3 ? i : i - 4];
if (i<=4 || j<4) {
decKey[j] = tmp;
} else {
decKey[j] = decTable[0][sbox[tmp>>>24 ]] ^
decTable[1][sbox[tmp>>16 & 255]] ^
decTable[2][sbox[tmp>>8 & 255]] ^
decTable[3][sbox[tmp & 255]];
}
}
};
AES.prototype = {
/**
* The expanded S-box and inverse S-box tables. These will be computed
* on the client so that we don't have to send them down the wire.
*
* There are two tables, _tables[0] is for encryption and
* _tables[1] is for decryption.
*
* The first 4 sub-tables are the expanded S-box with MixColumns. The
* last (_tables[01][4]) is the S-box itself.
*
* @private
*/
_tables: [[[],[],[],[],[]],[[],[],[],[],[]]],
/**
* Expand the S-box tables.
*
* @private
*/
_precompute: function () {
var encTable = this._tables[0], decTable = this._tables[1],
sbox = encTable[4], sboxInv = decTable[4],
i, x, xInv, d=[], th=[], x2, x4, x8, s, tEnc, tDec;
// Compute double and third tables
for (i = 0; i < 256; i++) {
th[( d[i] = i<<1 ^ (i>>7)*283 )^i]=i;
}
for (x = xInv = 0; !sbox[x]; x ^= x2 || 1, xInv = th[xInv] || 1) {
// Compute sbox
s = xInv ^ xInv<<1 ^ xInv<<2 ^ xInv<<3 ^ xInv<<4;
s = s>>8 ^ s&255 ^ 99;
sbox[x] = s;
sboxInv[s] = x;
// Compute MixColumns
x8 = d[x4 = d[x2 = d[x]]];
tDec = x8*0x1010101 ^ x4*0x10001 ^ x2*0x101 ^ x*0x1010100;
tEnc = d[s]*0x101 ^ s*0x1010100;
for (i = 0; i < 4; i++) {
encTable[i][x] = tEnc = tEnc<<24 ^ tEnc>>>8;
decTable[i][s] = tDec = tDec<<24 ^ tDec>>>8;
}
}
// Compactify. Considerable speedup on Firefox.
for (i = 0; i < 5; i++) {
encTable[i] = encTable[i].slice(0);
decTable[i] = decTable[i].slice(0);
}
},
/**
* 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
* into
* @param offset {number} the offset into the output array to start
* writing results
* @return {Array} The plaintext.
*/
decrypt:function (encrypted0, encrypted1, encrypted2, encrypted3, out, offset) {
var key = this._key[1],
// state variables a,b,c,d are loaded with pre-whitened data
a = encrypted0 ^ key[0],
b = encrypted3 ^ key[1],
c = encrypted2 ^ key[2],
d = encrypted1 ^ key[3],
a2, b2, c2,
nInnerRounds = key.length / 4 - 2, // key.length === 2 ?
i,
kIndex = 4,
table = this._tables[1],
// load up the tables
table0 = table[0],
table1 = table[1],
table2 = table[2],
table3 = table[3],
sbox = table[4];
// Inner rounds. Cribbed from OpenSSL.
for (i = 0; i < nInnerRounds; i++) {
a2 = table0[a>>>24] ^ table1[b>>16 & 255] ^ table2[c>>8 & 255] ^ table3[d & 255] ^ key[kIndex];
b2 = table0[b>>>24] ^ table1[c>>16 & 255] ^ table2[d>>8 & 255] ^ table3[a & 255] ^ key[kIndex + 1];
c2 = table0[c>>>24] ^ table1[d>>16 & 255] ^ table2[a>>8 & 255] ^ table3[b & 255] ^ key[kIndex + 2];
d = table0[d>>>24] ^ table1[a>>16 & 255] ^ table2[b>>8 & 255] ^ table3[c & 255] ^ key[kIndex + 3];
kIndex += 4;
a=a2; b=b2; c=c2;
}
// Last round.
for (i = 0; i < 4; i++) {
out[(3 & -i) + offset] =
sbox[a>>>24 ]<<24 ^
sbox[b>>16 & 255]<<16 ^
sbox[c>>8 & 255]<<8 ^
sbox[d & 255] ^
key[kIndex++];
a2=a; a=b; b=c; c=d; d=a2;
}
}
};
/**
* 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
* use for the first round of CBC.
* @return {Uint8Array} the decrypted bytes
*
* @see http://en.wikipedia.org/wiki/Advanced_Encryption_Standard
* @see http://en.wikipedia.org/wiki/Block_cipher_mode_of_operation#Cipher_Block_Chaining_.28CBC.29
* @see https://tools.ietf.org/html/rfc2315
*/
decrypt = function(encrypted, key, initVector) {
var
// word-level access to the encrypted bytes
encrypted32 = new Int32Array(encrypted.buffer, encrypted.byteOffset, encrypted.byteLength >> 2),
decipher = new AES(Array.prototype.slice.call(key)),
// byte and word-level access for the decrypted output
decrypted = new Uint8Array(encrypted.byteLength),
decrypted32 = new Int32Array(decrypted.buffer),
// temporary variables for working with the IV, encrypted, and
// decrypted data
init0, init1, init2, init3,
encrypted0, encrypted1, encrypted2, encrypted3,
// iteration variable
wordIx;
// pull out the words of the IV to ensure we don't modify the
// passed-in reference and easier access
init0 = initVector[0];
init1 = initVector[1];
init2 = initVector[2];
init3 = initVector[3];
// decrypt four word sequences, applying cipher-block chaining (CBC)
// to each decrypted block
for (wordIx = 0; wordIx < encrypted32.length; wordIx += 4) {
// convert big-endian (network order) words into little-endian
// (javascript order)
encrypted0 = ntoh(encrypted32[wordIx]);
encrypted1 = ntoh(encrypted32[wordIx + 1]);
encrypted2 = ntoh(encrypted32[wordIx + 2]);
encrypted3 = ntoh(encrypted32[wordIx + 3]);
// decrypt the block
decipher.decrypt(encrypted0,
encrypted1,
encrypted2,
encrypted3,
decrypted32,
wordIx);
// XOR with the IV, and restore network byte-order to obtain the
// plaintext
decrypted32[wordIx] = ntoh(decrypted32[wordIx] ^ init0);
decrypted32[wordIx + 1] = ntoh(decrypted32[wordIx + 1] ^ init1);
decrypted32[wordIx + 2] = ntoh(decrypted32[wordIx + 2] ^ init2);
decrypted32[wordIx + 3] = ntoh(decrypted32[wordIx + 3] ^ init3);
// setup the IV for the next round
init0 = encrypted0;
init1 = encrypted1;
init2 = encrypted2;
init3 = encrypted3;
}
return decrypted;
};
AsyncStream = function() {
this.jobs = [];
this.delay = 1;
this.timeout_ = null;
};
AsyncStream.prototype = new videojs.Hls.Stream();
AsyncStream.prototype.processJob_ = function() {
this.jobs.shift()();
if (this.jobs.length) {
this.timeout_ = setTimeout(this.processJob_.bind(this),
this.delay);
} else {
this.timeout_ = null;
}
};
AsyncStream.prototype.push = function(job) {
this.jobs.push(job);
if (!this.timeout_) {
this.timeout_ = setTimeout(this.processJob_.bind(this),
this.delay);
}
};
Decrypter = function(encrypted, key, initVector, done) {
var
step = Decrypter.STEP,
encrypted32 = new Int32Array(encrypted.buffer),
decrypted = new Uint8Array(encrypted.byteLength),
i = 0;
this.asyncStream_ = new AsyncStream();
// split up the encryption job and do the individual chunks asynchronously
this.asyncStream_.push(this.decryptChunk_(encrypted32.subarray(i, i + step),
key,
initVector,
decrypted));
for (i = step; i < encrypted32.length; i += step) {
initVector = new Uint32Array([
ntoh(encrypted32[i - 4]),
ntoh(encrypted32[i - 3]),
ntoh(encrypted32[i - 2]),
ntoh(encrypted32[i - 1])
]);
this.asyncStream_.push(this.decryptChunk_(encrypted32.subarray(i, i + step),
key,
initVector,
decrypted));
}
// invoke the done() callback when everything is finished
this.asyncStream_.push(function() {
// remove pkcs#7 padding from the decrypted bytes
done(null, unpad(decrypted));
});
};
Decrypter.prototype = new videojs.Hls.Stream();
Decrypter.prototype.decryptChunk_ = function(encrypted, key, initVector, decrypted) {
return function() {
var bytes = decrypt(encrypted,
key,
initVector);
decrypted.set(bytes, encrypted.byteOffset);
};
};
// the maximum number of bytes to process at one time
Decrypter.STEP = 4 * 8000;
// exports
videojs.Hls.decrypt = decrypt;
videojs.Hls.Decrypter = Decrypter;
videojs.Hls.AsyncStream = AsyncStream;
})(window, window.videojs, window.pkcs7.unpad);
/*
* aes.js
*
* This file contains an adaptation of the AES decryption algorithm
* from the Standford Javascript Cryptography Library. That work is
* covered by the following copyright and permissions notice:
*
* Copyright 2009-2010 Emily Stark, Mike Hamburg, Dan Boneh.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are
* met:
*
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above
* copyright notice, this list of conditions and the following
* disclaimer in the documentation and/or other materials provided
* with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR
* IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
* BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
* OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN
* IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* The views and conclusions contained in the software and documentation
* are those of the authors and should not be interpreted as representing
* official policies, either expressed or implied, of the authors.
*/
/**
* Expand the S-box tables.
*
* @private
*/
const precompute = function() {
let tables = [[[], [], [], [], []], [[], [], [], [], []]];
let encTable = tables[0];
let decTable = tables[1];
let sbox = encTable[4];
let sboxInv = decTable[4];
let i;
let x;
let xInv;
let d = [];
let th = [];
let x2;
let x4;
let x8;
let s;
let tEnc;
let tDec;
// Compute double and third tables
for (i = 0; i < 256; i++) {
th[(d[i] = i << 1 ^ (i >> 7) * 283) ^ i] = i;
}
for (x = xInv = 0; !sbox[x]; x ^= x2 || 1, xInv = th[xInv] || 1) {
// Compute sbox
s = xInv ^ xInv << 1 ^ xInv << 2 ^ xInv << 3 ^ xInv << 4;
s = s >> 8 ^ s & 255 ^ 99;
sbox[x] = s;
sboxInv[s] = x;
// Compute MixColumns
x8 = d[x4 = d[x2 = d[x]]];
tDec = x8 * 0x1010101 ^ x4 * 0x10001 ^ x2 * 0x101 ^ x * 0x1010100;
tEnc = d[s] * 0x101 ^ s * 0x1010100;
for (i = 0; i < 4; i++) {
encTable[i][x] = tEnc = tEnc << 24 ^ tEnc >>> 8;
decTable[i][s] = tDec = tDec << 24 ^ tDec >>> 8;
}
}
// Compactify. Considerable speedup on Firefox.
for (i = 0; i < 5; i++) {
encTable[i] = encTable[i].slice(0);
decTable[i] = decTable[i].slice(0);
}
return tables;
};
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
* @param key {Array} The key as an array of 4, 6 or 8 words.
*/
export default class AES {
constructor(key) {
/**
* The expanded S-box and inverse S-box tables. These will be computed
* on the client so that we don't have to send them down the wire.
*
* There are two tables, _tables[0] is for encryption and
* _tables[1] is for decryption.
*
* The first 4 sub-tables are the expanded S-box with MixColumns. The
* last (_tables[01][4]) is the S-box itself.
*
* @private
*/
// if we have yet to precompute the S-box tables
// do so now
if (!aesTables) {
aesTables = precompute();
}
// then make a copy of that object for use
this._tables = [[aesTables[0][0].slice(),
aesTables[0][1].slice(),
aesTables[0][2].slice(),
aesTables[0][3].slice(),
aesTables[0][4].slice()],
[aesTables[1][0].slice(),
aesTables[1][1].slice(),
aesTables[1][2].slice(),
aesTables[1][3].slice(),
aesTables[1][4].slice()]];
let i;
let j;
let tmp;
let encKey;
let decKey;
let sbox = this._tables[0][4];
let decTable = this._tables[1];
let keyLen = key.length;
let rcon = 1;
if (keyLen !== 4 && keyLen !== 6 && keyLen !== 8) {
throw new Error('Invalid aes key size');
}
encKey = key.slice(0);
decKey = [];
this._key = [encKey, decKey];
// schedule encryption keys
for (i = keyLen; i < 4 * keyLen + 28; i++) {
tmp = encKey[i - 1];
// apply sbox
if (i % keyLen === 0 || (keyLen === 8 && i % keyLen === 4)) {
tmp = sbox[tmp >>> 24] << 24 ^
sbox[tmp >> 16 & 255] << 16 ^
sbox[tmp >> 8 & 255] << 8 ^
sbox[tmp & 255];
// shift rows and add rcon
if (i % keyLen === 0) {
tmp = tmp << 8 ^ tmp >>> 24 ^ rcon << 24;
rcon = rcon << 1 ^ (rcon >> 7) * 283;
}
}
encKey[i] = encKey[i - keyLen] ^ tmp;
}
// schedule decryption keys
for (j = 0; i; j++, i--) {
tmp = encKey[j & 3 ? i : i - 4];
if (i <= 4 || j < 4) {
decKey[j] = tmp;
} else {
decKey[j] = decTable[0][sbox[tmp >>> 24 ]] ^
decTable[1][sbox[tmp >> 16 & 255]] ^
decTable[2][sbox[tmp >> 8 & 255]] ^
decTable[3][sbox[tmp & 255]];
}
}
}
/**
* 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
* into
* @param offset {number} the offset into the output array to start
* writing results
* @return {Array} The plaintext.
*/
decrypt(encrypted0, encrypted1, encrypted2, encrypted3, out, offset) {
let key = this._key[1];
// state variables a,b,c,d are loaded with pre-whitened data
let a = encrypted0 ^ key[0];
let b = encrypted3 ^ key[1];
let c = encrypted2 ^ key[2];
let d = encrypted1 ^ key[3];
let a2;
let b2;
let c2;
// key.length === 2 ?
let nInnerRounds = key.length / 4 - 2;
let i;
let kIndex = 4;
let table = this._tables[1];
// load up the tables
let table0 = table[0];
let table1 = table[1];
let table2 = table[2];
let table3 = table[3];
let sbox = table[4];
// Inner rounds. Cribbed from OpenSSL.
for (i = 0; i < nInnerRounds; i++) {
a2 = table0[a >>> 24] ^
table1[b >> 16 & 255] ^
table2[c >> 8 & 255] ^
table3[d & 255] ^
key[kIndex];
b2 = table0[b >>> 24] ^
table1[c >> 16 & 255] ^
table2[d >> 8 & 255] ^
table3[a & 255] ^
key[kIndex + 1];
c2 = table0[c >>> 24] ^
table1[d >> 16 & 255] ^
table2[a >> 8 & 255] ^
table3[b & 255] ^
key[kIndex + 2];
d = table0[d >>> 24] ^
table1[a >> 16 & 255] ^
table2[b >> 8 & 255] ^
table3[c & 255] ^
key[kIndex + 3];
kIndex += 4;
a = a2; b = b2; c = c2;
}
// Last round.
for (i = 0; i < 4; i++) {
out[(3 & -i) + offset] =
sbox[a >>> 24] << 24 ^
sbox[b >> 16 & 255] << 16 ^
sbox[c >> 8 & 255] << 8 ^
sbox[d & 255] ^
key[kIndex++];
a2 = a; a = b; b = c; c = d; d = a2;
}
}
}
import Stream from '../stream';
/**
* A wrapper around the Stream class to use setTiemout
* and run stream "jobs" Asynchronously
*/
export default class AsyncStream extends Stream {
constructor() {
super(Stream);
this.jobs = [];
this.delay = 1;
this.timeout_ = null;
}
processJob_() {
this.jobs.shift()();
if (this.jobs.length) {
this.timeout_ = setTimeout(this.processJob_.bind(this),
this.delay);
} else {
this.timeout_ = null;
}
}
push(job) {
this.jobs.push(job);
if (!this.timeout_) {
this.timeout_ = setTimeout(this.processJob_.bind(this),
this.delay);
}
}
}
/*
* decrypter.js
*
* An asynchronous implementation of AES-128 CBC decryption with
* PKCS#7 padding.
*/
import AES from './aes';
import AsyncStream from './async-stream';
import {unpad} from 'pkcs7';
/**
* Convert network-order (big-endian) bytes into their little-endian
* representation.
*/
const ntoh = function(word) {
return (word << 24) |
((word & 0xff00) << 8) |
((word & 0xff0000) >> 8) |
(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
* use for the first round of CBC.
* @return {Uint8Array} the decrypted bytes
*
* @see http://en.wikipedia.org/wiki/Advanced_Encryption_Standard
* @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,
encrypted.byteOffset,
encrypted.byteLength >> 2);
let decipher = new AES(Array.prototype.slice.call(key));
// byte and word-level access for the decrypted output
let decrypted = new Uint8Array(encrypted.byteLength);
let decrypted32 = new Int32Array(decrypted.buffer);
// temporary variables for working with the IV, encrypted, and
// decrypted data
let init0;
let init1;
let init2;
let init3;
let encrypted0;
let encrypted1;
let encrypted2;
let encrypted3;
// iteration variable
let wordIx;
// pull out the words of the IV to ensure we don't modify the
// passed-in reference and easier access
init0 = initVector[0];
init1 = initVector[1];
init2 = initVector[2];
init3 = initVector[3];
// decrypt four word sequences, applying cipher-block chaining (CBC)
// to each decrypted block
for (wordIx = 0; wordIx < encrypted32.length; wordIx += 4) {
// convert big-endian (network order) words into little-endian
// (javascript order)
encrypted0 = ntoh(encrypted32[wordIx]);
encrypted1 = ntoh(encrypted32[wordIx + 1]);
encrypted2 = ntoh(encrypted32[wordIx + 2]);
encrypted3 = ntoh(encrypted32[wordIx + 3]);
// decrypt the block
decipher.decrypt(encrypted0,
encrypted1,
encrypted2,
encrypted3,
decrypted32,
wordIx);
// XOR with the IV, and restore network byte-order to obtain the
// plaintext
decrypted32[wordIx] = ntoh(decrypted32[wordIx] ^ init0);
decrypted32[wordIx + 1] = ntoh(decrypted32[wordIx + 1] ^ init1);
decrypted32[wordIx + 2] = ntoh(decrypted32[wordIx + 2] ^ init2);
decrypted32[wordIx + 3] = ntoh(decrypted32[wordIx + 3] ^ init3);
// setup the IV for the next round
init0 = encrypted0;
init1 = encrypted1;
init2 = encrypted2;
init3 = encrypted3;
}
return decrypted;
};
/**
* The `Decrypter` class that manages decryption of AES
* data through `AsyncStream` objects and the `decrypt`
* function
*/
export class Decrypter {
constructor(encrypted, key, initVector, done) {
let step = Decrypter.STEP;
let encrypted32 = new Int32Array(encrypted.buffer);
let decrypted = new Uint8Array(encrypted.byteLength);
let i = 0;
this.asyncStream_ = new AsyncStream();
// split up the encryption job and do the individual chunks asynchronously
this.asyncStream_.push(this.decryptChunk_(encrypted32.subarray(i, i + step),
key,
initVector,
decrypted));
for (i = step; i < encrypted32.length; i += step) {
initVector = new Uint32Array([ntoh(encrypted32[i - 4]),
ntoh(encrypted32[i - 3]),
ntoh(encrypted32[i - 2]),
ntoh(encrypted32[i - 1])]);
this.asyncStream_.push(this.decryptChunk_(encrypted32.subarray(i, i + step),
key,
initVector,
decrypted));
}
// invoke the done() callback when everything is finished
this.asyncStream_.push(function() {
// remove pkcs#7 padding from the decrypted bytes
done(null, unpad(decrypted));
});
}
decryptChunk_(encrypted, key, initVector, decrypted) {
return function() {
let bytes = decrypt(encrypted, key, initVector);
decrypted.set(bytes, encrypted.byteOffset);
};
}
}
// the maximum number of bytes to process at one time
// 4 * 8000;
Decrypter.STEP = 32000;
export default {
Decrypter,
decrypt
};
/*
* index.js
*
* Index module to easily import the primary components of AES-128
* decryption. Like this:
*
* ```js
* import {Decrypter, decrypt, AsyncStream} from './src/decrypter';
* ```
*/
import {decrypt, Decrypter} from './decrypter';
import AsyncStream from './async-stream';
export default {
decrypt,
Decrypter,
AsyncStream
};
/**
* 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
* that do not assume the entirety of the manifest is ready and expose a
* ReadableStream-like interface.
*/
import LineStream from './line-stream';
import ParseStream from './parse-stream';
import Parser from './parser';
export default {
LineStream,
ParseStream,
Parser
};
import Stream from '../stream';
/**
* A stream that buffers string input and generates a `data` event for each
* line.
*/
export default class LineStream extends Stream {
constructor() {
super();
this.buffer = '';
}
/**
* Add new data to be parsed.
* @param data {string} the text to process
*/
push(data) {
let nextNewline;
this.buffer += data;
nextNewline = this.buffer.indexOf('\n');
for (; nextNewline > -1; nextNewline = this.buffer.indexOf('\n')) {
this.trigger('data', this.buffer.substring(0, nextNewline));
this.buffer = this.buffer.substring(nextNewline + 1);
}
}
}
/**
* 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
* that do not assume the entirety of the manifest is ready and expose a
* ReadableStream-like interface.
*/
(function(videojs, parseInt, isFinite, mergeOptions, undefined) {
var
noop = function() {},
import Stream from '../stream';
// "forgiving" attribute list psuedo-grammar:
// attributes -> keyvalue (',' keyvalue)*
// keyvalue -> key '=' value
// key -> [^=]*
// value -> '"' [^"]* '"' | [^,]*
attributeSeparator = (function() {
var
key = '[^=]*',
value = '"[^"]*"|[^,]*',
keyvalue = '(?:' + key + ')=(?:' + value + ')';
// "forgiving" attribute list psuedo-grammar:
// attributes -> keyvalue (',' keyvalue)*
// keyvalue -> key '=' value
// key -> [^=]*
// value -> '"' [^"]* '"' | [^,]*
const attributeSeparator = function() {
let key = '[^=]*';
let value = '"[^"]*"|[^,]*';
let keyvalue = '(?:' + key + ')=(?:' + value + ')';
return new RegExp('(?:^|,)(' + keyvalue + ')');
})(),
parseAttributes = function(attributes) {
var
};
const parseAttributes = function(attributes) {
// split the string using attributes as the separator
attrs = attributes.split(attributeSeparator),
i = attrs.length,
result = {},
attr;
let attrs = attributes.split(attributeSeparator());
let i = attrs.length;
let result = {};
let attr;
while (i--) {
// filter out unmatched portions of the string
......@@ -37,7 +27,7 @@
}
// split the key and value
attr = /([^=]*)=(.*)/.exec(attrs[i]).slice(1);
attr = (/([^=]*)=(.*)/).exec(attrs[i]).slice(1);
// trim whitespace and remove optional quotes around the value
attr[0] = attr[0].replace(/^\s+|\s+$/g, '');
attr[1] = attr[1].replace(/^\s+|\s+$/g, '');
......@@ -45,39 +35,9 @@
result[attr[0]] = attr[1];
}
return result;
},
Stream = videojs.Hls.Stream,
LineStream,
ParseStream,
Parser;
/**
* A stream that buffers string input and generates a `data` event for each
* line.
*/
LineStream = function() {
var buffer = '';
LineStream.prototype.init.call(this);
};
/**
* Add new data to be parsed.
* @param data {string} the text to process
*/
this.push = function(data) {
var nextNewline;
buffer += data;
nextNewline = buffer.indexOf('\n');
for (; nextNewline > -1; nextNewline = buffer.indexOf('\n')) {
this.trigger('data', buffer.substring(0, nextNewline));
buffer = buffer.substring(nextNewline + 1);
}
};
};
LineStream.prototype = new Stream();
/**
/**
* A line-level M3U8 parser event stream. It expects to receive input one
* line at a time and performs a context-free parse of its contents. A stream
* interpretation of a manifest can be useful if the manifest is expected to
......@@ -98,18 +58,20 @@
* tags are given the tag type `unknown` and a single additional property
* `data` with the remainder of the input.
*/
ParseStream = function() {
ParseStream.prototype.init.call(this);
};
ParseStream.prototype = new Stream();
export default class ParseStream extends Stream {
constructor() {
super();
}
/**
* Parses an additional line of input.
* @param line {string} a single line of an M3U8 file to parse
*/
ParseStream.prototype.push = function(line) {
var match, event;
push(line) {
let match;
let event;
//strip whitespace
// strip whitespace
line = line.replace(/^[\u0000\s]+|[\u0000\s]+$/g, '');
if (line.length === 0) {
// ignore empty lines
......@@ -134,12 +96,12 @@
return;
}
//strip off any carriage returns here so the regex matching
//doesn't have to account for them.
line = line.replace('\r','');
// strip off any carriage returns here so the regex matching
// doesn't have to account for them.
line = line.replace('\r', '');
// Tags
match = /^#EXTM3U/.exec(line);
match = (/^#EXTM3U/).exec(line);
if (match) {
this.trigger('data', {
type: 'tag',
......@@ -271,10 +233,9 @@
event.attributes = parseAttributes(match[1]);
if (event.attributes.RESOLUTION) {
(function() {
var
split = event.attributes.RESOLUTION.split('x'),
resolution = {};
let split = event.attributes.RESOLUTION.split('x');
let resolution = {};
if (split[0]) {
resolution.width = parseInt(split[0], 10);
}
......@@ -282,7 +243,6 @@
resolution.height = parseInt(split[1], 10);
}
event.attributes.RESOLUTION = resolution;
})();
}
if (event.attributes.BANDWIDTH) {
event.attributes.BANDWIDTH = parseInt(event.attributes.BANDWIDTH, 10);
......@@ -320,7 +280,7 @@
event.attributes = parseAttributes(match[1]);
// parse the IV string into a Uint32Array
if (event.attributes.IV) {
if (event.attributes.IV.substring(0,2) === '0x') {
if (event.attributes.IV.substring(0, 2) === '0x') {
event.attributes.IV = event.attributes.IV.substring(2);
}
......@@ -341,248 +301,5 @@
type: 'tag',
data: line.slice(4, line.length)
});
};
/**
* A parser for M3U8 files. The current interpretation of the input is
* exposed as a property `manifest` on parser objects. It's just two lines to
* create and parse a manifest once you have the contents available as a string:
*
* ```js
* var parser = new videojs.m3u8.Parser();
* parser.push(xhr.responseText);
* ```
*
* New input can later be applied to update the manifest object by calling
* `push` again.
*
* The parser attempts to create a usable manifest object even if the
* 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.
*/
Parser = function() {
var
self = this,
uris = [],
currentUri = {},
key;
Parser.prototype.init.call(this);
this.lineStream = new LineStream();
this.parseStream = new ParseStream();
this.lineStream.pipe(this.parseStream);
// the manifest is empty until the parse stream begins delivering data
this.manifest = {
allowCache: true,
discontinuityStarts: []
};
// update the manifest with the m3u8 entry from the parse stream
this.parseStream.on('data', function(entry) {
({
tag: function() {
// switch based on the tag type
(({
'allow-cache': function() {
this.manifest.allowCache = entry.allowed;
if (!('allowed' in entry)) {
this.trigger('info', {
message: 'defaulting allowCache to YES'
});
this.manifest.allowCache = true;
}
},
'byterange': function() {
var byterange = {};
if ('length' in entry) {
currentUri.byterange = byterange;
byterange.length = entry.length;
if (!('offset' in entry)) {
this.trigger('info', {
message: 'defaulting offset to zero'
});
entry.offset = 0;
}
}
if ('offset' in entry) {
currentUri.byterange = byterange;
byterange.offset = entry.offset;
}
},
'endlist': function() {
this.manifest.endList = true;
},
'inf': function() {
if (!('mediaSequence' in this.manifest)) {
this.manifest.mediaSequence = 0;
this.trigger('info', {
message: 'defaulting media sequence to zero'
});
}
if (!('discontinuitySequence' in this.manifest)) {
this.manifest.discontinuitySequence = 0;
this.trigger('info', {
message: 'defaulting discontinuity sequence to zero'
});
}
if (entry.duration >= 0) {
currentUri.duration = entry.duration;
}
this.manifest.segments = uris;
},
'key': function() {
if (!entry.attributes) {
this.trigger('warn', {
message: 'ignoring key declaration without attribute list'
});
return;
}
// clear the active encryption key
if (entry.attributes.METHOD === 'NONE') {
key = null;
return;
}
if (!entry.attributes.URI) {
this.trigger('warn', {
message: 'ignoring key declaration without URI'
});
return;
}
if (!entry.attributes.METHOD) {
this.trigger('warn', {
message: 'defaulting key method to AES-128'
});
}
// setup an encryption key for upcoming segments
key = {
method: entry.attributes.METHOD || 'AES-128',
uri: entry.attributes.URI
};
if (entry.attributes.IV !== undefined) {
key.iv = entry.attributes.IV;
}
},
'media-sequence': function() {
if (!isFinite(entry.number)) {
this.trigger('warn', {
message: 'ignoring invalid media sequence: ' + entry.number
});
return;
}
this.manifest.mediaSequence = entry.number;
},
'discontinuity-sequence': function() {
if (!isFinite(entry.number)) {
this.trigger('warn', {
message: 'ignoring invalid discontinuity sequence: ' + entry.number
});
return;
}
this.manifest.discontinuitySequence = entry.number;
},
'playlist-type': function() {
if (!(/VOD|EVENT/).test(entry.playlistType)) {
this.trigger('warn', {
message: 'ignoring unknown playlist type: ' + entry.playlist
});
return;
}
this.manifest.playlistType = entry.playlistType;
},
'stream-inf': function() {
this.manifest.playlists = uris;
if (!entry.attributes) {
this.trigger('warn', {
message: 'ignoring empty stream-inf attributes'
});
return;
}
if (!currentUri.attributes) {
currentUri.attributes = {};
}
currentUri.attributes = mergeOptions(currentUri.attributes,
entry.attributes);
},
'discontinuity': function() {
currentUri.discontinuity = true;
this.manifest.discontinuityStarts.push(uris.length);
},
'targetduration': function() {
if (!isFinite(entry.duration) || entry.duration < 0) {
this.trigger('warn', {
message: 'ignoring invalid target duration: ' + entry.duration
});
return;
}
this.manifest.targetDuration = entry.duration;
},
'totalduration': function() {
if (!isFinite(entry.duration) || entry.duration < 0) {
this.trigger('warn', {
message: 'ignoring invalid total duration: ' + entry.duration
});
return;
}
this.manifest.totalDuration = entry.duration;
}
})[entry.tagType] || noop).call(self);
},
uri: function() {
currentUri.uri = entry.uri;
uris.push(currentUri);
// if no explicit duration was declared, use the target duration
if (this.manifest.targetDuration &&
!('duration' in currentUri)) {
this.trigger('warn', {
message: 'defaulting segment duration to the target duration'
});
currentUri.duration = this.manifest.targetDuration;
}
// annotate with encryption information, if necessary
if (key) {
currentUri.key = key;
}
// prepare for the next URI
currentUri = {};
},
comment: function() {
// comments are not important for playback
}
})[entry.type].call(self);
});
};
Parser.prototype = new Stream();
/**
* Parse the input string and update the manifest object.
* @param chunk {string} a potentially incomplete portion of the manifest
*/
Parser.prototype.push = function(chunk) {
this.lineStream.push(chunk);
};
/**
* Flush any remaining input. This can be handy if the last line of an M3U8
* manifest did not contain a trailing newline but the file has been
* completely received.
*/
Parser.prototype.end = function() {
// flush any buffered input
this.lineStream.push('\n');
};
window.videojs.m3u8 = {
LineStream: LineStream,
ParseStream: ParseStream,
Parser: Parser
};
})(window.videojs, window.parseInt, window.isFinite, window.videojs.mergeOptions);
}
......
import Stream from '../stream' ;
import LineStream from './line-stream';
import ParseStream from './parse-stream';
import {mergeOptions} from 'video.js';
/**
* A parser for M3U8 files. The current interpretation of the input is
* exposed as a property `manifest` on parser objects. It's just two lines to
* create and parse a manifest once you have the contents available as a string:
*
* ```js
* var parser = new videojs.m3u8.Parser();
* parser.push(xhr.responseText);
* ```
*
* New input can later be applied to update the manifest object by calling
* `push` again.
*
* The parser attempts to create a usable manifest object even if the
* 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.
*/
export default class Parser extends Stream {
constructor() {
super();
this.lineStream = new LineStream();
this.parseStream = new ParseStream();
this.lineStream.pipe(this.parseStream);
/* eslint-disable consistent-this */
let self = this;
/* eslint-enable consistent-this */
let uris = [];
let currentUri = {};
let key;
let noop = function() {};
// the manifest is empty until the parse stream begins delivering data
this.manifest = {
allowCache: true,
discontinuityStarts: []
};
// update the manifest with the m3u8 entry from the parse stream
this.parseStream.on('data', function(entry) {
({
tag() {
// switch based on the tag type
(({
'allow-cache'() {
this.manifest.allowCache = entry.allowed;
if (!('allowed' in entry)) {
this.trigger('info', {
message: 'defaulting allowCache to YES'
});
this.manifest.allowCache = true;
}
},
byterange() {
let byterange = {};
if ('length' in entry) {
currentUri.byterange = byterange;
byterange.length = entry.length;
if (!('offset' in entry)) {
this.trigger('info', {
message: 'defaulting offset to zero'
});
entry.offset = 0;
}
}
if ('offset' in entry) {
currentUri.byterange = byterange;
byterange.offset = entry.offset;
}
},
endlist() {
this.manifest.endList = true;
},
inf() {
if (!('mediaSequence' in this.manifest)) {
this.manifest.mediaSequence = 0;
this.trigger('info', {
message: 'defaulting media sequence to zero'
});
}
if (!('discontinuitySequence' in this.manifest)) {
this.manifest.discontinuitySequence = 0;
this.trigger('info', {
message: 'defaulting discontinuity sequence to zero'
});
}
if (entry.duration >= 0) {
currentUri.duration = entry.duration;
}
this.manifest.segments = uris;
},
key() {
if (!entry.attributes) {
this.trigger('warn', {
message: 'ignoring key declaration without attribute list'
});
return;
}
// clear the active encryption key
if (entry.attributes.METHOD === 'NONE') {
key = null;
return;
}
if (!entry.attributes.URI) {
this.trigger('warn', {
message: 'ignoring key declaration without URI'
});
return;
}
if (!entry.attributes.METHOD) {
this.trigger('warn', {
message: 'defaulting key method to AES-128'
});
}
// setup an encryption key for upcoming segments
key = {
method: entry.attributes.METHOD || 'AES-128',
uri: entry.attributes.URI
};
if (typeof entry.attributes.IV !== 'undefined') {
key.iv = entry.attributes.IV;
}
},
'media-sequence'() {
if (!isFinite(entry.number)) {
this.trigger('warn', {
message: 'ignoring invalid media sequence: ' + entry.number
});
return;
}
this.manifest.mediaSequence = entry.number;
},
'discontinuity-sequence'() {
if (!isFinite(entry.number)) {
this.trigger('warn', {
message: 'ignoring invalid discontinuity sequence: ' + entry.number
});
return;
}
this.manifest.discontinuitySequence = entry.number;
},
'playlist-type'() {
if (!(/VOD|EVENT/).test(entry.playlistType)) {
this.trigger('warn', {
message: 'ignoring unknown playlist type: ' + entry.playlist
});
return;
}
this.manifest.playlistType = entry.playlistType;
},
'stream-inf'() {
this.manifest.playlists = uris;
if (!entry.attributes) {
this.trigger('warn', {
message: 'ignoring empty stream-inf attributes'
});
return;
}
if (!currentUri.attributes) {
currentUri.attributes = {};
}
currentUri.attributes = mergeOptions(currentUri.attributes,
entry.attributes);
},
discontinuity() {
currentUri.discontinuity = true;
this.manifest.discontinuityStarts.push(uris.length);
},
targetduration() {
if (!isFinite(entry.duration) || entry.duration < 0) {
this.trigger('warn', {
message: 'ignoring invalid target duration: ' + entry.duration
});
return;
}
this.manifest.targetDuration = entry.duration;
},
totalduration() {
if (!isFinite(entry.duration) || entry.duration < 0) {
this.trigger('warn', {
message: 'ignoring invalid total duration: ' + entry.duration
});
return;
}
this.manifest.totalDuration = entry.duration;
}
})[entry.tagType] || noop).call(self);
},
uri() {
currentUri.uri = entry.uri;
uris.push(currentUri);
// if no explicit duration was declared, use the target duration
if (this.manifest.targetDuration &&
!('duration' in currentUri)) {
this.trigger('warn', {
message: 'defaulting segment duration to the target duration'
});
currentUri.duration = this.manifest.targetDuration;
}
// annotate with encryption information, if necessary
if (key) {
currentUri.key = key;
}
// prepare for the next URI
currentUri = {};
},
comment() {
// comments are not important for playback
}
})[entry.type].call(self);
});
}
/**
* Parse the input string and update the manifest object.
* @param chunk {string} a potentially incomplete portion of the manifest
*/
push(chunk) {
this.lineStream.push(chunk);
}
/**
* Flush any remaining input. This can be handy if the last line of an M3U8
* manifest did not contain a trailing newline but the file has been
* completely received.
*/
end() {
// flush any buffered input
this.lineStream.push('\n');
}
}
......@@ -5,14 +5,13 @@
* M3U8 playlists.
*
*/
(function(window, videojs) {
'use strict';
var
resolveUrl = videojs.Hls.resolveUrl,
xhr = videojs.Hls.xhr,
mergeOptions = videojs.mergeOptions,
import resolveUrl from './resolve-url';
import XhrModule from './xhr';
import {mergeOptions} from 'video.js';
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
......@@ -23,14 +22,12 @@
* master playlist with the updated media playlist merged in, or
* null if the merge produced no change.
*/
updateMaster = function(master, media) {
var
changed = false,
result = mergeOptions(master, {}),
i,
playlist;
i = master.playlists.length;
const updateMaster = function(master, media) {
let changed = false;
let result = mergeOptions(master, {});
let i = master.playlists.length;
let playlist;
while (i--) {
playlist = result.playlists[i];
if (playlist.uri === media.uri) {
......@@ -51,15 +48,16 @@
if (playlist.segments) {
result.playlists[i].segments = updateSegments(playlist.segments,
media.segments,
media.mediaSequence - playlist.mediaSequence);
media.mediaSequence -
playlist.mediaSequence);
}
changed = true;
}
}
return changed ? result : null;
},
};
/**
/**
* 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.
......@@ -73,8 +71,11 @@
* playlists.
* @return a list of merged segment objects
*/
updateSegments = function(original, update, offset) {
var result = update.slice(), length, i;
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);
......@@ -82,18 +83,17 @@
result[i - offset] = mergeOptions(original[i], result[i - offset]);
}
return result;
},
PlaylistLoader = function(srcUrl, withCredentials) {
var
loader = this,
dispose,
mediaUpdateTimeout,
request,
playlistRequestError,
haveMetadata;
PlaylistLoader.prototype.init.call(this);
};
export default class PlaylistLoader extends Stream {
constructor(srcUrl, withCredentials) {
super();
let loader = this;
let dispose;
let mediaUpdateTimeout;
let request;
let playlistRequestError;
let haveMetadata;
// a flag that disables "expired time"-tracking this setting has
// no effect when not playing a live stream
......@@ -127,7 +127,9 @@
// updated playlist.
haveMetadata = function(xhr, url) {
var parser, refreshDelay, update;
let parser;
let refreshDelay;
let update;
loader.setBandwidth(request || xhr);
......@@ -135,7 +137,7 @@
request = null;
loader.state = 'HAVE_METADATA';
parser = new videojs.m3u8.Parser();
parser = new m3u8.Parser();
parser.push(xhr.responseText);
parser.end();
parser.manifest.uri = url;
......@@ -198,7 +200,8 @@
* object to switch to
*/
loader.media = function(playlist) {
var startingState = loader.state, mediaChange;
let startingState = loader.state;
let mediaChange;
// getter
if (!playlist) {
return loader.media_;
......@@ -258,9 +261,9 @@
}
// request the new playlist
request = xhr({
request = XhrModule({
uri: resolveUrl(loader.master.uri, playlist.uri),
withCredentials: withCredentials
withCredentials
}, function(error, request) {
if (error) {
return playlistRequestError(request, playlist.uri, startingState);
......@@ -295,9 +298,9 @@
}
loader.state = 'HAVE_CURRENT_METADATA';
request = xhr({
request = XhrModule({
uri: resolveUrl(loader.master.uri, loader.media().uri),
withCredentials: withCredentials
withCredentials
}, function(error, request) {
if (error) {
return playlistRequestError(request, loader.media().uri);
......@@ -307,11 +310,12 @@
});
// request the specified URL
request = xhr({
request = XhrModule({
uri: srcUrl,
withCredentials: withCredentials
withCredentials
}, function(error, req) {
var parser, i;
let parser;
let i;
// clear the loader's request reference
request = null;
......@@ -321,12 +325,13 @@
status: req.status,
message: 'HLS playlist request error at URL: ' + srcUrl,
responseText: req.responseText,
code: 2 // MEDIA_ERR_NETWORK
// MEDIA_ERR_NETWORK
code: 2
};
return loader.trigger('error');
}
parser = new videojs.m3u8.Parser();
parser = new m3u8.Parser();
parser.push(req.responseText);
parser.end();
......@@ -365,16 +370,16 @@
haveMetadata(req, srcUrl);
return loader.trigger('loadedmetadata');
});
};
PlaylistLoader.prototype = new videojs.Hls.Stream();
}
/**
* Update the PlaylistLoader state to reflect the changes in an
* update to the current media playlist.
* @param update {object} the updated media playlist object
*/
PlaylistLoader.prototype.updateMediaPlaylist_ = function(update) {
var outdated, i, segment;
updateMediaPlaylist_(update) {
let outdated;
let i;
let segment;
outdated = this.media_;
this.media_ = this.master.playlists[update.uri];
......@@ -432,7 +437,7 @@
}
this.expired_ += segment.duration;
}
};
}
/**
* Determine the index of the segment that contains a specified
......@@ -452,17 +457,16 @@
* value will be clamped to the index of the segment containing the
* closest playback position that is currently available.
*/
PlaylistLoader.prototype.getMediaIndexForTime_ = function(time) {
var
i,
segment,
originalTime = time,
numSegments = this.media_.segments.length,
lastSegment = numSegments - 1,
startIndex,
endIndex,
knownStart,
knownEnd;
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;
......@@ -558,7 +562,5 @@
// the one most likely to tell us something about the timeline
return lastSegment;
}
};
videojs.Hls.PlaylistLoader = PlaylistLoader;
})(window, window.videojs);
}
}
......
/**
* Playlist related utilities.
*/
(function(window, videojs) {
'use strict';
import {createTimeRange} from 'video.js';
var Playlist = {
let Playlist = {
/**
* The number of segments that are unsafe to start playback at in
* a live stream. Changing this value can cause playback stalls.
......@@ -12,24 +11,22 @@
* https://tools.ietf.org/html/draft-pantos-http-live-streaming-18#section-6.3.3
*/
UNSAFE_LIVE_SEGMENTS: 3
};
var duration, intervalDuration, backwardDuration, forwardDuration, seekable;
backwardDuration = function(playlist, endSequence) {
var result = 0, segment, i;
};
i = endSequence - playlist.mediaSequence;
const backwardDuration = function(playlist, endSequence) {
let result = 0;
let i = endSequence - playlist.mediaSequence;
// if a start time is available for segment immediately following
// the interval, use it
segment = playlist.segments[i];
let segment = playlist.segments[i];
// Walk backward until we find the latest segment with timeline
// information that is earlier than endSequence
if (segment) {
if (segment.start !== undefined) {
if (typeof segment.start !== 'undefined') {
return { result: segment.start, precise: true };
}
if (segment.end !== undefined) {
if (typeof segment.end !== 'undefined') {
return {
result: segment.end - segment.duration,
precise: true
......@@ -38,28 +35,29 @@
}
while (i--) {
segment = playlist.segments[i];
if (segment.end !== undefined) {
if (typeof segment.end !== 'undefined') {
return { result: result + segment.end, precise: true };
}
result += segment.duration;
if (segment.start !== undefined) {
if (typeof segment.start !== 'undefined') {
return { result: result + segment.start, precise: true };
}
}
return { result: result, precise: false };
};
forwardDuration = function(playlist, endSequence) {
var result = 0, segment, i;
return { result, precise: false };
};
i = endSequence - playlist.mediaSequence;
const forwardDuration = function(playlist, endSequence) {
let result = 0;
let segment;
let i = endSequence - playlist.mediaSequence;
// Walk forward until we find the earliest segment with timeline
// information
for (; i < playlist.segments.length; i++) {
segment = playlist.segments[i];
if (segment.start !== undefined) {
if (typeof segment.start !== 'undefined') {
return {
result: segment.start - result,
precise: true
......@@ -68,7 +66,7 @@
result += segment.duration;
if (segment.end !== undefined) {
if (typeof segment.end !== 'undefined') {
return {
result: segment.end - result,
precise: true
......@@ -78,9 +76,9 @@
}
// indicate we didn't find a useful duration estimate
return { result: -1, precise: false };
};
};
/**
/**
* Calculate the media duration from the segments associated with a
* playlist. The duration of a subinterval of the available segments
* may be calculated by specifying an end index.
......@@ -91,10 +89,11 @@
* @return {number} the duration between the first available segment
* and end index.
*/
intervalDuration = function(playlist, endSequence) {
var backward, forward;
const intervalDuration = function(playlist, endSequence) {
let backward;
let forward;
if (endSequence === undefined) {
if (typeof endSequence === 'undefined') {
endSequence = playlist.mediaSequence + playlist.segments.length;
}
......@@ -122,9 +121,9 @@
// return the less-precise, playlist-based duration estimate
return backward.result;
};
};
/**
/**
* Calculates the duration of a playlist. If a start and end index
* are specified, the duration will be for the subset of the media
* timeline between those two indices. The total duration for live
......@@ -139,18 +138,18 @@
* @return {number} the duration between the start index and end
* index.
*/
duration = function(playlist, endSequence, includeTrailingTime) {
export const duration = function(playlist, endSequence, includeTrailingTime) {
if (!playlist) {
return 0;
}
if (includeTrailingTime === undefined) {
if (typeof includeTrailingTime === 'undefined') {
includeTrailingTime = true;
}
// if a slice of the total duration is not requested, use
// playlist-level duration indicators when they're present
if (endSequence === undefined) {
if (typeof endSequence === 'undefined') {
// if present, use the duration specified in the playlist
if (playlist.totalDuration) {
return playlist.totalDuration;
......@@ -166,9 +165,9 @@
return intervalDuration(playlist,
endSequence,
includeTrailingTime);
};
};
/**
/**
* Calculates the interval of time that is currently seekable in a
* playlist. The returned time ranges are relative to the earliest
* moment in the specified playlist that is still available. A full
......@@ -179,16 +178,17 @@
* @return {TimeRanges} the periods of time that are valid targets
* for seeking
*/
seekable = function(playlist) {
var start, end;
export const seekable = function(playlist) {
let start;
let end;
// without segments, there are no seekable ranges
if (!playlist.segments) {
return videojs.createTimeRange();
return createTimeRange();
}
// when the playlist is complete, the entire duration is seekable
if (playlist.endList) {
return videojs.createTimeRange(0, duration(playlist));
return createTimeRange(0, duration(playlist));
}
// live playlists should not expose three segment durations worth
......@@ -198,12 +198,11 @@
end = intervalDuration(playlist,
playlist.mediaSequence +
Math.max(0, playlist.segments.length - Playlist.UNSAFE_LIVE_SEGMENTS));
return videojs.createTimeRange(start, end);
};
return createTimeRange(start, end);
};
// exports
Playlist.duration = duration;
Playlist.seekable = seekable;
videojs.Hls.Playlist = Playlist;
Playlist.duration = duration;
Playlist.seekable = seekable;
})(window, window.videojs);
// exports
export default Playlist;
......
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
*/
/* 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');
let docHead = document.querySelector('head');
let a = document.createElement('a');
let base = oldBase;
let oldHref;
let result;
// prep the document
if (oldBase) {
oldHref = oldBase.href;
} else {
base = docHead.appendChild(document.createElement('base'));
}
base.href = basePath;
a.href = path;
result = a.href;
// clean up
if (oldBase) {
oldBase.href = oldHref;
} else {
docHead.removeChild(base);
}
return result;
};
export default resolveUrl;
/**
* A lightweight readable stream implemention that handles event dispatching.
* Objects that inherit from streams should call init in their constructors.
*/
(function(videojs, undefined) {
var Stream = function() {
this.init = function() {
var listeners = {};
export default class Stream {
constructor() {
this.listeners = {};
}
/**
* 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
* the specified type occurs
*/
this.on = function(type, listener) {
if (!listeners[type]) {
listeners[type] = [];
on(type, listener) {
if (!this.listeners[type]) {
this.listeners[type] = [];
}
listeners[type].push(listener);
};
this.listeners[type].push(listener);
}
/**
* Remove a listener for a specified event type.
* @param type {string} the event name
* @param listener {function} a function previously registered for this
* type of event through `on`
*/
this.off = function(type, listener) {
var index;
if (!listeners[type]) {
off(type, listener) {
let index;
if (!this.listeners[type]) {
return false;
}
index = listeners[type].indexOf(listener);
listeners[type].splice(index, 1);
index = this.listeners[type].indexOf(listener);
this.listeners[type].splice(index, 1);
return index > -1;
};
}
/**
* 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
*/
this.trigger = function(type) {
var callbacks, i, length, args;
callbacks = listeners[type];
trigger(type) {
let callbacks;
let i;
let length;
let args;
callbacks = this.listeners[type];
if (!callbacks) {
return;
}
......@@ -60,15 +67,14 @@
callbacks[i].apply(this, args);
}
}
};
}
/**
* Destroys the stream and cleans up.
*/
this.dispose = function() {
listeners = {};
};
};
};
dispose() {
this.listeners = {};
}
/**
* Forwards all `data` events on this stream to the destination stream. The
* destination stream should provide a method `push` to receive the data
......@@ -76,11 +82,9 @@
* @param destination {stream} the stream that will receive all `data` events
* @see http://nodejs.org/api/stream.html#stream_readable_pipe_destination_options
*/
Stream.prototype.pipe = function(destination) {
pipe(destination) {
this.on('data', function(data) {
destination.push(data);
});
};
videojs.Hls.Stream = Stream;
})(window.videojs);
}
}
......
/*
/**
* videojs-hls
* The main file for the HLS project.
* License: https://github.com/videojs/videojs-contrib-hls/blob/master/LICENSE
*/
(function(window, videojs, document, undefined) {
'use strict';
import PlaylistLoader from './playlist-loader';
import Playlist from './playlist';
import xhr from './xhr';
import {Decrypter, AsyncStream, decrypt} from './decrypter';
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';
const Hls = {
PlaylistLoader,
Playlist,
Decrypter,
AsyncStream,
decrypt,
utils,
xhr
};
// 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;
var
// A fudge factor to apply to advertised playlist bitrates to account for
// temporary flucations in client bandwidth
bandwidthVariance = 1.2,
blacklistDuration = 5 * 60 * 1000, // 5 minute blacklist
TIME_FUDGE_FACTOR = 1 / 30, // Fudge factor to account for TimeRanges rounding
Component = videojs.getComponent('Component'),
// native HLS is definitely not supported if HTML5 video isn't
if (!videojs.getComponent('Html5').isSupported()) {
return false;
}
// The amount of time to wait between checking the state of the buffer
bufferCheckInterval = 500,
xMpegUrl = video.canPlayType('application/x-mpegURL');
vndMpeg = video.canPlayType('application/vnd.apple.mpegURL');
return (/probably|maybe/).test(xMpegUrl) ||
(/probably|maybe/).test(vndMpeg);
}());
safeGetComputedStyle,
keyFailed,
resolveUrl;
// 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
keyFailed = function(key) {
const keyFailed = function(key) {
return key.retries && key.retries >= 2;
};
videojs.Hls = {};
videojs.HlsHandler = videojs.extend(Component, {
constructor: function(tech, options) {
var self = this, _player;
/**
* Returns the CSS value for the specified property on an element
* using `getComputedStyle`. Firefox has a long-standing issue where
* getComputedStyle() may return null when running in an iframe with
* `display: none`.
* @see https://bugzilla.mozilla.org/show_bug.cgi?id=548397
*/
const safeGetComputedStyle = function(el, property) {
let result;
if (!el) {
return '';
}
result = getComputedStyle(el);
if (!result) {
return '';
}
return result[property];
};
/**
* Updates segment with information about its end-point in time and, optionally,
* the segment duration if we have enough information to determine a segment duration
* accurately.
* @param playlist {object} a media playlist object
* @param segmentIndex {number} the index of segment we last appended
* @param segmentEnd {number} the known of the segment referenced by segmentIndex
*/
const updateSegmentMetadata = function(playlist, segmentIndex, segmentEnd) {
if (!playlist) {
return;
}
let segment = playlist.segments[segmentIndex];
let previousSegment = playlist.segments[segmentIndex - 1];
if (segmentEnd && segment) {
segment.end = segmentEnd;
// fix up segment durations based on segment end data
if (!previousSegment) {
// first segment is always has a start time of 0 making its duration
// equal to the segment end
segment.duration = segment.end;
} else if (previousSegment.end) {
segment.duration = segment.end - previousSegment.end;
}
}
};
/**
* Determines if we should call endOfStream on the media source based on the state
* of the buffer or if appened segment was the final segment in the playlist.
* @param playlist {object} a media playlist object
* @param mediaSource {object} the MediaSource object
* @param segmentIndex {number} the index of segment we last appended
* @param currentBuffered {object} the buffered region that currentTime resides in
* @return {boolean} whether the calling function should call endOfStream on the MediaSource
*/
const detectEndOfStream = function(playlist, mediaSource, segmentIndex, currentBuffered) {
if (!playlist) {
return false;
}
let segments = playlist.segments;
// determine a few boolean values to help make the branch below easier
// to read
let appendedLastSegment = (segmentIndex === segments.length - 1);
let bufferedToEnd = (currentBuffered.length &&
segments[segments.length - 1].end <= currentBuffered.end(0));
// if we've buffered to the end of the video, we need to call endOfStream
// so that MediaSources can trigger the `ended` event when it runs out of
// buffered data instead of waiting for me
return playlist.endList &&
mediaSource.readyState === 'open' &&
(appendedLastSegment || bufferedToEnd);
};
const parseCodecs = function(codecs) {
let result = {
codecCount: 0,
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;
};
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();
Component.call(this, tech);
if (typeof time === 'undefined') {
time = tech.currentTime();
}
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)]);
}
}
}
return videojs.createTimeRanges(ranges);
};
};
export default class HlsHandler extends Component {
constructor(tech, options) {
super(tech);
let _player;
// tech.player() is deprecated but setup a reference to HLS for
// backwards-compatibility
......@@ -39,9 +341,9 @@ videojs.HlsHandler = videojs.extend(Component, {
_player = videojs(tech.options_.playerId);
if (!_player.hls) {
Object.defineProperty(_player, 'hls', {
get: function() {
get: () => {
videojs.log.warn('player.hls is deprecated. Use player.tech.hls instead.');
return self;
return this;
}
});
}
......@@ -55,7 +357,8 @@ videojs.HlsHandler = videojs.extend(Component, {
// start playlist selection at a reasonable bandwidth for
// broadband internet
this.bandwidth = options.bandwidth || 4194304; // 0.5 Mbps
// 0.5 Mbps
this.bandwidth = options.bandwidth || 4194304;
this.bytesReceived = 0;
// loadingState_ tracks how far along the buffering process we
......@@ -82,71 +385,8 @@ videojs.HlsHandler = videojs.extend(Component, {
this.on(this.tech_, 'play', this.play);
}
});
// HLS is a source handler, not a tech. Make sure attempts to use it
// as one do not cause exceptions.
videojs.Hls.canPlaySource = function() {
return videojs.log.warn('HLS is no longer a tech. Please remove it from ' +
'your player\'s techOrder.');
};
/**
* 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
* the browser it is running in. It is not necessary to use or modify
* this object in normal usage.
*/
videojs.HlsSourceHandler = function(mode) {
return {
canHandleSource: function(srcObj) {
return videojs.HlsSourceHandler.canPlayType(srcObj.type);
},
handleSource: function(source, tech) {
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
tech.setTimeout(function() {
tech.trigger('loadstart');
}, 1);
}
tech.hls = new videojs.HlsHandler(tech, {
source: source,
mode: mode
});
tech.hls.src(source.src);
return tech.hls;
},
canPlayType: function(type) {
return videojs.HlsSourceHandler.canPlayType(type);
}
};
};
videojs.HlsSourceHandler.canPlayType = function(type) {
var mpegurlRE = /^application\/(?:x-|vnd\.apple\.)mpegurl/i;
// favor native HLS support if it's available
if (videojs.Hls.supportsNativeHls) {
return false;
}
return mpegurlRE.test(type);
};
// register source handlers with the appropriate techs
if (videojs.MediaSource.supportsNativeMediaSources()) {
videojs.getComponent('Html5').registerSourceHandler(videojs.HlsSourceHandler('html5'));
}
if (window.Uint8Array) {
videojs.getComponent('Flash').registerSourceHandler(videojs.HlsSourceHandler('flash'));
}
// the desired length of video to maintain in the buffer, in seconds
videojs.Hls.GOAL_BUFFER_LENGTH = 30;
videojs.HlsHandler.prototype.src = function(src) {
var oldMediaPlaylist;
src(src) {
let oldMediaPlaylist;
// do nothing if the src is falsey
if (!src) {
......@@ -159,16 +399,17 @@ videojs.HlsHandler.prototype.src = function(src) {
this.mediaSource.addEventListener('sourceopen', this.handleSourceOpen.bind(this));
this.options_ = {};
if (this.source_.withCredentials !== undefined) {
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 videojs.Hls.PlaylistLoader(this.source_.src, this.options_.withCredentials);
this.playlists = new Hls.PlaylistLoader(this.source_.src,
this.options_.withCredentials);
this.tech_.one('canplay', this.setupFirstPlay.bind(this));
this.playlists.on('loadedmetadata', function() {
this.playlists.on('loadedmetadata', () => {
oldMediaPlaylist = this.playlists.media();
// if this isn't a live video and preload permits, start
......@@ -183,14 +424,15 @@ videojs.HlsHandler.prototype.src = function(src) {
this.setupFirstPlay();
this.fillBuffer();
this.tech_.trigger('loadedmetadata');
}.bind(this));
});
this.playlists.on('error', function() {
this.playlists.on('error', () => {
this.blacklistCurrentPlaylist_(this.playlists.error);
}.bind(this));
});
this.playlists.on('loadedplaylist', function() {
var updatedPlaylist = this.playlists.media(), seekable;
this.playlists.on('loadedplaylist', () => {
let updatedPlaylist = this.playlists.media();
let seekable;
if (!updatedPlaylist) {
// select the initial variant
......@@ -208,14 +450,14 @@ videojs.HlsHandler.prototype.src = function(src) {
}
oldMediaPlaylist = updatedPlaylist;
}.bind(this));
});
this.playlists.on('mediachange', function() {
this.playlists.on('mediachange', () => {
this.tech_.trigger({
type: 'mediachange',
bubbles: true
});
}.bind(this));
});
// do nothing if the tech has been disposed already
// this can occur if someone sets the src in player.ready(), for instance
......@@ -224,9 +466,8 @@ videojs.HlsHandler.prototype.src = function(src) {
}
this.tech_.src(videojs.URL.createObjectURL(this.mediaSource));
};
videojs.HlsHandler.prototype.handleSourceOpen = function() {
}
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)
......@@ -243,149 +484,9 @@ videojs.HlsHandler.prototype.handleSourceOpen = function() {
if (this.tech_.autoplay()) {
this.play();
}
};
// 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.
videojs.Hls.findSoleUncommonTimeRangesEnd_ = function(original, update) {
var
i, start, end,
result = [],
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`
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];
};
/**
* 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
*/
videojs.HlsHandler.prototype.updateSegmentMetadata_ = function(playlist, segmentIndex, segmentEnd) {
var
segment,
previousSegment;
if (!playlist) {
return;
}
segment = playlist.segments[segmentIndex];
previousSegment = playlist.segments[segmentIndex - 1];
if (segmentEnd && segment) {
segment.end = segmentEnd;
// fix up segment durations based on segment end data
if (!previousSegment) {
// first segment is always has a start time of 0 making its duration
// equal to the segment end
segment.duration = segment.end;
} else if (previousSegment.end) {
segment.duration = segment.end - previousSegment.end;
}
}
};
/**
* Determines if we should call endOfStream on the media source based on the state
* of the buffer or if appened segment was the final segment in the playlist.
* @param playlist {object} a media playlist object
* @param 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
*/
videojs.HlsHandler.prototype.isEndOfStream_ = function(playlist, segmentIndex, currentBuffered) {
var
segments = playlist.segments,
appendedLastSegment,
bufferedToEnd;
if (!playlist) {
return false;
}
// determine a few boolean values to help make the branch below easier
// to read
appendedLastSegment = (segmentIndex === segments.length - 1);
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 &&
this.mediaSource.readyState === 'open' &&
(appendedLastSegment || bufferedToEnd);
};
var parseCodecs = function(codecs) {
var 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;
};
/**
/**
* 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
......@@ -398,13 +499,12 @@ var parseCodecs = function(codecs) {
* will be excluded from the default playlist selection algorithm
* indefinitely.
*/
videojs.HlsHandler.prototype.excludeIncompatibleVariants_ = function(media) {
var
master = this.playlists.master,
codecCount = 2,
videoCodec = null,
audioProfile = null,
codecs;
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);
......@@ -413,7 +513,7 @@ videojs.HlsHandler.prototype.excludeIncompatibleVariants_ = function(media) {
codecCount = codecs.codecCount;
}
master.playlists.forEach(function(variant) {
var variantCodecs = {
let variantCodecs = {
codecCount: 2,
videoCodec: null,
audioProfile: null
......@@ -441,10 +541,11 @@ videojs.HlsHandler.prototype.excludeIncompatibleVariants_ = function(media) {
variant.excludeUntil = Infinity;
}
});
};
}
videojs.HlsHandler.prototype.setupSourceBuffer_ = function() {
var media = this.playlists.media(), mimeType;
setupSourceBuffer_() {
let media = this.playlists.media();
let mimeType;
// wait until a media playlist is available and the Media Source is
// attached
......@@ -467,16 +568,15 @@ videojs.HlsHandler.prototype.setupSourceBuffer_ = function() {
// transition the sourcebuffer to the ended state if we've hit the end of
// the playlist
this.sourceBuffer.addEventListener('updateend', this.updateEndHandler_.bind(this));
};
}
/**
/**
* Seek to the latest media position if this is a live video and the
* player and video are loaded and initialized.
*/
videojs.HlsHandler.prototype.setupFirstPlay = function() {
var seekable, media;
media = this.playlists.media();
setupFirstPlay() {
let seekable;
let media = this.playlists.media();
// check that everything is ready to begin buffering
......@@ -506,12 +606,12 @@ videojs.HlsHandler.prototype.setupFirstPlay = function() {
this.tech_.setCurrentTime(seekable.end(0));
}
}
};
}
/**
/**
* Begin playing the video.
*/
videojs.HlsHandler.prototype.play = function() {
play() {
this.loadingState_ = 'segments';
if (this.tech_.ended()) {
......@@ -529,11 +629,10 @@ videojs.HlsHandler.prototype.play = function() {
this.tech_.setCurrentTime(this.seekable().start(0));
}
}
};
}
videojs.HlsHandler.prototype.setCurrentTime = function(currentTime) {
var
buffered = this.findBufferedRange_();
setCurrentTime(currentTime) {
let buffered = this.findBufferedRange_();
if (!(this.playlists && this.playlists.media())) {
// return immediately if the metadata is not ready yet
......@@ -570,11 +669,10 @@ videojs.HlsHandler.prototype.setCurrentTime = function(currentTime) {
// begin filling the buffer at the new position
this.fillBuffer(this.playlists.getMediaIndexForTime_(currentTime));
};
}
videojs.HlsHandler.prototype.duration = function() {
var
playlists = this.playlists;
duration() {
let playlists = this.playlists;
if (!playlists) {
return 0;
......@@ -584,11 +682,12 @@ videojs.HlsHandler.prototype.duration = function() {
return this.mediaSource.duration;
}
return videojs.Hls.Playlist.duration(playlists.media());
};
return Hls.Playlist.duration(playlists.media());
}
videojs.HlsHandler.prototype.seekable = function() {
var media, seekable;
seekable() {
let media;
let seekable;
if (!this.playlists) {
return videojs.createTimeRanges();
......@@ -598,7 +697,7 @@ videojs.HlsHandler.prototype.seekable = function() {
return videojs.createTimeRanges();
}
seekable = videojs.Hls.Playlist.seekable(media);
seekable = Hls.Playlist.seekable(media);
if (seekable.length === 0) {
return seekable;
}
......@@ -608,30 +707,28 @@ videojs.HlsHandler.prototype.seekable = function() {
// 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)
]]);
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
*/
videojs.HlsHandler.prototype.updateDuration = function(playlist) {
var oldDuration = this.mediaSource.duration,
newDuration = videojs.Hls.Playlist.duration(playlist),
buffered = this.tech_.buffered(),
setDuration = function() {
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);
}.bind(this);
};
if (buffered.length > 0) {
newDuration = Math.max(newDuration, buffered.end(buffered.length - 1));
......@@ -647,31 +744,31 @@ videojs.HlsHandler.prototype.updateDuration = function(playlist) {
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.
*/
videojs.HlsHandler.prototype.resetSrc_ = function() {
resetSrc_() {
this.cancelSegmentXhr();
this.cancelKeyXhr();
if (this.sourceBuffer && this.mediaSource.readyState === 'open') {
this.sourceBuffer.abort();
}
};
}
videojs.HlsHandler.prototype.cancelKeyXhr = function() {
cancelKeyXhr() {
if (this.keyXhr_) {
this.keyXhr_.onreadystatechange = null;
this.keyXhr_.abort();
this.keyXhr_ = null;
}
};
}
videojs.HlsHandler.prototype.cancelSegmentXhr = function() {
cancelSegmentXhr() {
if (this.segmentXhr_) {
// Prevent error handler from running.
this.segmentXhr_.onreadystatechange = null;
......@@ -681,33 +778,12 @@ videojs.HlsHandler.prototype.cancelSegmentXhr = function() {
// clear out the segment being processed
this.pendingSegment_ = null;
};
/**
* 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
*/
safeGetComputedStyle = function(el, property) {
var result;
if (!el) {
return '';
}
result = getComputedStyle(el);
if (!result) {
return '';
}
return result[property];
};
/**
/**
* Abort all outstanding work and cleanup.
*/
videojs.HlsHandler.prototype.dispose = function() {
dispose() {
this.stopCheckingBuffer_();
if (this.playlists) {
......@@ -715,36 +791,36 @@ videojs.HlsHandler.prototype.dispose = function() {
}
this.resetSrc_();
Component.prototype.dispose.call(this);
};
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
*/
videojs.HlsHandler.prototype.selectPlaylist = function () {
var
effectiveBitrate,
sortedPlaylists = this.playlists.master.playlists.slice(),
bandwidthPlaylists = [],
now = +new Date(),
i,
variant,
bandwidthBestVariant,
resolutionPlusOne,
resolutionBestVariant,
width,
height;
sortedPlaylists.sort(videojs.Hls.comparePlaylistBandwidth);
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(function(variant) {
if (variant.excludeUntil !== undefined) {
return now >= variant.excludeUntil;
sortedPlaylists = sortedPlaylists.filter((localVariant) => {
if (typeof localVariant.excludeUntil !== 'undefined') {
return now >= localVariant.excludeUntil;
}
return true;
});
......@@ -776,9 +852,10 @@ videojs.HlsHandler.prototype.selectPlaylist = function () {
i = bandwidthPlaylists.length;
// sort variants by resolution
bandwidthPlaylists.sort(videojs.Hls.comparePlaylistResolution);
bandwidthPlaylists.sort(Hls.comparePlaylistResolution);
// forget our old variant from above, or we might choose that in high-bandwidth scenarios
// 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;
......@@ -801,20 +878,22 @@ videojs.HlsHandler.prototype.selectPlaylist = function () {
// since the playlists are sorted, the first variant that has
// dimensions less than or equal to the player size is the best
if (variant.attributes.RESOLUTION.width === width &&
variant.attributes.RESOLUTION.height === height) {
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 (variant.attributes.RESOLUTION.width < width &&
variant.attributes.RESOLUTION.height < height) {
} 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 ||
(variant.attributes.RESOLUTION.width < resolutionPlusOne.attributes.RESOLUTION.width &&
variant.attributes.RESOLUTION.height < resolutionPlusOne.attributes.RESOLUTION.height)) {
(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
......@@ -824,17 +903,21 @@ videojs.HlsHandler.prototype.selectPlaylist = function () {
// 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];
};
return resolutionPlusOne ||
resolutionBestVariant ||
bandwidthBestVariant ||
sortedPlaylists[0];
}
/**
/**
* Periodically request new segments and append video data.
*/
videojs.HlsHandler.prototype.checkBuffer_ = function() {
checkBuffer_() {
// calling this method directly resets any outstanding buffer checks
if (this.checkBufferTimeout_) {
window.clearTimeout(this.checkBufferTimeout_);
......@@ -847,101 +930,44 @@ videojs.HlsHandler.prototype.checkBuffer_ = function() {
// 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.
*/
videojs.HlsHandler.prototype.startCheckingBuffer_ = function() {
startCheckingBuffer_() {
this.checkBuffer_();
};
}
/**
/**
* Stop the periodic task requesting new segments and feeding the
* SourceBuffer.
*/
videojs.HlsHandler.prototype.stopCheckingBuffer_ = function() {
stopCheckingBuffer_() {
if (this.checkBufferTimeout_) {
window.clearTimeout(this.checkBufferTimeout_);
this.checkBufferTimeout_ = null;
}
};
var filterBufferedRanges = function(predicate) {
return function(time) {
var
i,
ranges = [],
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.
buffered = tech.buffered();
if (time === undefined) {
time = tech.currentTime();
}
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)]);
}
}
}
return videojs.createTimeRanges(ranges);
};
};
/**
* 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.
*/
videojs.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.
*/
videojs.HlsHandler.prototype.findNextBufferedRange_ = filterBufferedRanges(function(start, end, time) {
return start - TIME_FUDGE_FACTOR >= time;
});
/**
/**
* 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
*/
videojs.HlsHandler.prototype.fillBuffer = function(mediaIndex) {
var
tech = this.tech_,
currentTime = tech.currentTime(),
hasBufferedContent = (this.tech_.buffered().length !== 0),
currentBuffered = this.findBufferedRange_(),
outsideBufferedRanges = !(currentBuffered && currentBuffered.length),
currentBufferedEnd = 0,
bufferedTime = 0,
segment,
segmentInfo,
segmentTimestampOffset;
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') {
......@@ -964,7 +990,7 @@ videojs.HlsHandler.prototype.fillBuffer = function(mediaIndex) {
}
// if no segments are available, do nothing
if (this.playlists.state === "HAVE_NOTHING" ||
if (this.playlists.state === 'HAVE_NOTHING' ||
!this.playlists.media() ||
!this.playlists.media().segments) {
return;
......@@ -975,7 +1001,7 @@ videojs.HlsHandler.prototype.fillBuffer = function(mediaIndex) {
return;
}
if (mediaIndex === undefined) {
if (typeof mediaIndex === 'undefined') {
if (currentBuffered && currentBuffered.length) {
currentBufferedEnd = currentBuffered.end(0);
mediaIndex = this.playlists.getMediaIndexForTime_(currentBufferedEnd);
......@@ -983,7 +1009,7 @@ videojs.HlsHandler.prototype.fillBuffer = function(mediaIndex) {
// if there is plenty of content in the buffer and we're not
// seeking, relax for awhile
if (bufferedTime >= videojs.Hls.GOAL_BUFFER_LENGTH) {
if (bufferedTime >= Hls.GOAL_BUFFER_LENGTH) {
return;
}
} else {
......@@ -1010,12 +1036,12 @@ videojs.HlsHandler.prototype.fillBuffer = function(mediaIndex) {
// 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: mediaIndex,
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: currentBufferedEnd,
currentBufferedEnd,
// unencrypted bytes of the segment
bytes: null,
// when a key is defined for this segment, the encrypted bytes
......@@ -1032,7 +1058,7 @@ videojs.HlsHandler.prototype.fillBuffer = function(mediaIndex) {
};
if (mediaIndex > 0) {
segmentTimestampOffset = videojs.Hls.Playlist.duration(segmentInfo.playlist,
segmentTimestampOffset = Hls.Playlist.duration(segmentInfo.playlist,
segmentInfo.playlist.mediaSequence + mediaIndex) + this.playlists.expired_;
}
......@@ -1055,43 +1081,50 @@ videojs.HlsHandler.prototype.fillBuffer = function(mediaIndex) {
}
this.loadSegment(segmentInfo);
};
}
playlistUriToUrl(segmentRelativeUrl) {
let playListUrl;
videojs.HlsHandler.prototype.playlistUriToUrl = function(segmentRelativeUrl) {
var 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);
playListUrl =
resolveUrl(resolveUrl(this.source_.src, this.playlists.media().uri || ''),
segmentRelativeUrl);
}
return playListUrl;
};
}
/* Turns segment byterange into a string suitable for use in
/*
* Turns segment byterange into a string suitable for use in
* HTTP Range requests
*/
videojs.HlsHandler.prototype.byterangeStr_ = function(byterange) {
var byterangeStart, byterangeEnd;
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;
};
return 'bytes=' + byterangeStart + '-' + byterangeEnd;
}
/* Defines headers for use in the xhr request for a particular segment.
/*
* Defines headers for use in the xhr request for a particular segment.
*/
videojs.HlsHandler.prototype.segmentXhrHeaders_ = function(segment) {
var headers = {};
segmentXhrHeaders_(segment) {
let headers = {};
if ('byterange' in segment) {
headers['Range'] = this.byterangeStr_(segment.byterange);
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
......@@ -1099,22 +1132,23 @@ videojs.HlsHandler.prototype.segmentXhrHeaders_ = function(segment) {
* * `bytesReceived` - amount of bytes downloaded
* `bandwidth` is the only required property.
*/
videojs.HlsHandler.prototype.setBandwidth = function(xhr) {
setBandwidth(localXhr) {
// calculate the download bandwidth
this.segmentXhrTime = xhr.roundTripTime;
this.bandwidth = xhr.bandwidth;
this.bytesReceived += xhr.bytesReceived || 0;
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.
*/
videojs.HlsHandler.prototype.blacklistCurrentPlaylist_ = function(error) {
var currentPlaylist, nextPlaylist;
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
......@@ -1136,27 +1170,28 @@ videojs.HlsHandler.prototype.blacklistCurrentPlaylist_ = function(error) {
nextPlaylist = this.selectPlaylist();
if (nextPlaylist) {
videojs.log.warn('Problem encountered with the current HLS playlist. Switching to another playlist.');
videojs.log.warn('Problem encountered with the current ' +
'HLS playlist. Switching to another playlist.');
return this.playlists.media(nextPlaylist);
} else {
videojs.log.warn('Problem encountered with the current HLS playlist. No suitable alternatives found.');
}
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');
}
};
videojs.HlsHandler.prototype.loadSegment = function(segmentInfo) {
var
self = this,
segment = segmentInfo.playlist.segments[segmentInfo.mediaIndex],
removeToTime = 0,
seekable = this.seekable(),
currentTime = this.tech_.currentTime();
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
// 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
......@@ -1178,7 +1213,7 @@ videojs.HlsHandler.prototype.loadSegment = function(segmentInfo) {
}
// request the next segment
this.segmentXhr_ = videojs.Hls.xhr({
this.segmentXhr_ = Hls.xhr({
uri: segmentInfo.uri,
responseType: 'arraybuffer',
withCredentials: this.source_.withCredentials,
......@@ -1187,25 +1222,25 @@ videojs.HlsHandler.prototype.loadSegment = function(segmentInfo) {
// decrease in network performance or a server issue.
timeout: (segment.duration * 1.5) * 1000,
headers: this.segmentXhrHeaders_(segment)
}, function(error, request) {
}, (error, request) => {
// This is a timeout of a previously aborted segment request
// so simply ignore it
if (!self.segmentXhr_ || request !== self.segmentXhr_) {
if (!this.segmentXhr_ || request !== this.segmentXhr_) {
return;
}
// the segment request is no longer outstanding
self.segmentXhr_ = null;
this.segmentXhr_ = null;
// if a segment request times out, we may have better luck with another playlist
if (request.timedout) {
self.bandwidth = 1;
return self.playlists.media(self.selectPlaylist());
this.bandwidth = 1;
return this.playlists.media(this.selectPlaylist());
}
// otherwise, trigger a network error
if (!request.aborted && error) {
return self.blacklistCurrentPlaylist_({
return this.blacklistCurrentPlaylist_({
status: request.status,
message: 'HLS segment request error at URL: ' + segmentInfo.uri,
code: (request.status >= 500) ? 4 : 2
......@@ -1217,8 +1252,8 @@ videojs.HlsHandler.prototype.loadSegment = function(segmentInfo) {
return;
}
self.lastSegmentLoaded_ = segment;
self.setBandwidth(request);
this.lastSegmentLoaded_ = segment;
this.setBandwidth(request);
if (segment.key) {
segmentInfo.encryptedBytes = new Uint8Array(request.response);
......@@ -1226,28 +1261,26 @@ videojs.HlsHandler.prototype.loadSegment = function(segmentInfo) {
segmentInfo.bytes = new Uint8Array(request.response);
}
self.pendingSegment_ = segmentInfo;
this.pendingSegment_ = segmentInfo;
self.tech_.trigger('progress');
self.drainBuffer();
this.tech_.trigger('progress');
this.drainBuffer();
// figure out what stream the next segment should be downloaded from
// with the updated bandwidth information
self.playlists.media(self.selectPlaylist());
this.playlists.media(this.selectPlaylist());
});
};
}
videojs.HlsHandler.prototype.drainBuffer = function() {
var
segmentInfo,
mediaIndex,
playlist,
offset,
bytes,
segment,
decrypter,
segIv;
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
......@@ -1270,7 +1303,6 @@ videojs.HlsHandler.prototype.drainBuffer = function() {
segmentInfo = this.pendingSegment_;
mediaIndex = segmentInfo.mediaIndex;
playlist = segmentInfo.playlist;
offset = segmentInfo.offset;
bytes = segmentInfo.bytes;
segment = playlist.segments[mediaIndex];
......@@ -1284,30 +1316,30 @@ videojs.HlsHandler.prototype.drainBuffer = function() {
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;
} else {
}
// 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]);
segIv = segment.key.iv ||
new Uint32Array([0, 0, 0, mediaIndex + playlist.mediaSequence]);
// create a decrypter to incrementally decrypt the segment
decrypter = new videojs.Hls.Decrypter(segmentInfo.encryptedBytes,
decrypter = new Hls.Decrypter(segmentInfo.encryptedBytes,
segment.key.bytes,
segIv,
function(err, bytes) {
segmentInfo.bytes = bytes;
function(error, localBytes) {
if (error) {
videojs.log.warn(error);
}
segmentInfo.bytes = localBytes;
});
segmentInfo.decrypter = decrypter;
return;
}
}
this.pendingSegment_.buffered = this.tech_.buffered();
......@@ -1317,22 +1349,20 @@ videojs.HlsHandler.prototype.drainBuffer = function() {
// the segment is asynchronously added to the current buffered data
this.sourceBuffer.appendBuffer(bytes);
};
}
videojs.HlsHandler.prototype.updateEndHandler_ = function () {
var
segmentInfo = this.pendingSegment_,
segment,
segments,
playlist,
currentMediaIndex,
currentBuffered,
seekable,
timelineUpdate,
isEndOfStream;
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;
}
......@@ -1346,10 +1376,10 @@ videojs.HlsHandler.prototype.updateEndHandler_ = function () {
this.pendingSegment_ = null;
playlist = segmentInfo.playlist;
segments = playlist.segments;
currentMediaIndex = segmentInfo.mediaIndex + (segmentInfo.mediaSequence - playlist.mediaSequence);
currentMediaIndex = segmentInfo.mediaIndex +
(segmentInfo.mediaSequence - playlist.mediaSequence);
currentBuffered = this.findBufferedRange_();
isEndOfStream = this.isEndOfStream_(playlist, currentMediaIndex, currentBuffered);
isEndOfStream = detectEndOfStream(playlist, this.mediaSource, currentMediaIndex, currentBuffered);
// if we switched renditions don't try to add segment timeline
// information to the playlist
......@@ -1360,10 +1390,6 @@ videojs.HlsHandler.prototype.updateEndHandler_ = function () {
return this.fillBuffer();
}
// annotate the segment with any start and end time information
// added by the media processing
segment = playlist.segments[currentMediaIndex];
// when seeking to the beginning of the seekable range, it's
// possible that imprecise timing information may cause the seek to
// end up earlier than the start of the range
......@@ -1373,19 +1399,21 @@ videojs.HlsHandler.prototype.updateEndHandler_ = function () {
currentBuffered.length === 0) {
if (seekable.length &&
this.tech_.currentTime() < seekable.start(0)) {
var next = this.findNextBufferedRange_();
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));
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 = videojs.Hls.findSoleUncommonTimeRangesEnd_(segmentInfo.buffered,
timelineUpdate = Hls.findSoleUncommonTimeRangesEnd_(segmentInfo.buffered,
this.tech_.buffered());
// Update segment meta-data (duration and end-point) based on timeline
this.updateSegmentMetadata_(playlist, currentMediaIndex, timelineUpdate);
updateSegmentMetadata(playlist, currentMediaIndex, timelineUpdate);
// If we decide to signal the end of stream, then we can return instead
// of trying to fetch more segments
......@@ -1407,42 +1435,44 @@ videojs.HlsHandler.prototype.updateEndHandler_ = function () {
// improves subsequent media index calculations.
this.fillBuffer(currentMediaIndex + 1);
return;
};
}
/**
/**
* Attempt to retrieve the key for a particular media segment.
*/
videojs.HlsHandler.prototype.fetchKey_ = function(segment) {
var key, self, settings, receiveKey;
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;
}
self = this;
settings = this.options_;
/**
* Handle a key XHR response.
*/
receiveKey = function(key) {
return function(error, request) {
var view;
self.keyXhr_ = null;
receiveKey = (keyRecieved) => {
return (error, request) => {
let view;
this.keyXhr_ = null;
if (error || !request.response || request.response.byteLength !== 16) {
key.retries = key.retries || 0;
key.retries++;
keyRecieved.retries = keyRecieved.retries || 0;
keyRecieved.retries++;
if (!request.aborted) {
// try fetching again
self.fetchKey_(segment);
this.fetchKey_(segment);
}
return;
}
view = new DataView(request.response);
key.bytes = new Uint32Array([
keyRecieved.bytes = new Uint32Array([
view.getUint32(0),
view.getUint32(4),
view.getUint32(8),
......@@ -1450,7 +1480,7 @@ videojs.HlsHandler.prototype.fetchKey_ = function(segment) {
]);
// check to see if this allows us to make progress buffering now
self.checkBuffer_();
this.checkBuffer_();
};
};
......@@ -1463,135 +1493,105 @@ videojs.HlsHandler.prototype.fetchKey_ = function(segment) {
// request the key if the retry limit hasn't been reached
if (!key.bytes && !keyFailed(key)) {
this.keyXhr_ = videojs.Hls.xhr({
this.keyXhr_ = Hls.xhr({
uri: this.playlistUriToUrl(key.uri),
responseType: 'arraybuffer',
withCredentials: settings.withCredentials
}, receiveKey(key));
return;
}
};
}
}
/**
* Whether the browser has built-in HLS support.
* 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.
*/
videojs.Hls.supportsNativeHls = (function() {
var
video = document.createElement('video'),
xMpegUrl,
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.
videojs.Hls.isSupported = function() {
return videojs.log.warn('HLS is no longer a tech. Please remove it from ' +
'your player\'s techOrder.');
};
HlsHandler.prototype.findBufferedRange_ =
filterBufferedRanges(function(start, end, time) {
return start - TIME_FUDGE_FACTOR <= time &&
end + TIME_FUDGE_FACTOR >= time;
});
/**
* 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.
* 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.
*/
videojs.Hls.comparePlaylistBandwidth = function(left, right) {
var leftBandwidth, 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;
};
HlsHandler.prototype.findNextBufferedRange_ =
filterBufferedRanges(function(start, end, time) {
return start - TIME_FUDGE_FACTOR >= time;
});
/**
* 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.
* 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
* the browser it is running in. It is not necessary to use or modify
* this object in normal usage.
*/
videojs.Hls.comparePlaylistResolution = function(left, right) {
var leftWidth, rightWidth;
if (left.attributes && left.attributes.RESOLUTION && left.attributes.RESOLUTION.width) {
leftWidth = left.attributes.RESOLUTION.width;
const HlsSourceHandler = function(mode) {
return {
canHandleSource(srcObj) {
return HlsSourceHandler.canPlayType(srcObj.type);
},
handleSource(source, tech) {
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
tech.setTimeout(function() {
tech.trigger('loadstart');
}, 1);
}
leftWidth = leftWidth || window.Number.MAX_VALUE;
if (right.attributes && right.attributes.RESOLUTION && right.attributes.RESOLUTION.width) {
rightWidth = right.attributes.RESOLUTION.width;
tech.hls = new HlsHandler(tech, {
source,
mode
});
tech.hls.src(source.src);
return tech.hls;
},
canPlayType(type) {
return HlsSourceHandler.canPlayType(type);
}
};
};
rightWidth = rightWidth || window.Number.MAX_VALUE;
HlsSourceHandler.canPlayType = function(type) {
let mpegurlRE = /^application\/(?:x-|vnd\.apple\.)mpegurl/i;
// 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;
} else {
return leftWidth - rightWidth;
// favor native HLS support if it's available
if (Hls.supportsNativeHls) {
return false;
}
return mpegurlRE.test(type);
};
/**
* 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
*/
resolveUrl = videojs.Hls.resolveUrl = function(basePath, path) {
// use the base element to get the browser to handle URI resolution
var
oldBase = document.querySelector('base'),
docHead = document.querySelector('head'),
a = document.createElement('a'),
base = oldBase,
oldHref,
result;
// prep the document
if (oldBase) {
oldHref = oldBase.href;
} else {
base = docHead.appendChild(document.createElement('base'));
}
if (typeof videojs.MediaSource === 'undefined' ||
typeof videojs.URL === 'undefined') {
videojs.MediaSource = MediaSource;
videojs.URL = URL;
}
// register source handlers with the appropriate techs
if (MediaSource.supportsNativeMediaSources()) {
videojs.getComponent('Html5').registerSourceHandler(HlsSourceHandler('html5'));
}
if (window.Uint8Array) {
videojs.getComponent('Flash').registerSourceHandler(HlsSourceHandler('flash'));
}
base.href = basePath;
a.href = path;
result = a.href;
videojs.HlsHandler = HlsHandler;
videojs.HlsSourceHandler = HlsSourceHandler;
videojs.Hls = Hls;
videojs.m3u8 = m3u8;
// clean up
if (oldBase) {
oldBase.href = oldHref;
} else {
docHead.removeChild(base);
}
return result;
export default {
Hls,
HlsHandler,
HlsSourceHandler
};
})(window, window.videojs, document);
......
(function(videojs) {
'use strict';
/**
/**
* A wrapper for videojs.xhr that tracks bandwidth.
*/
videojs.Hls.xhr = function(options, callback) {
import {xhr as videojsXHR, mergeOptions} from 'video.js';
const xhr = function(options, callback) {
// Add a default timeout for all hls requests
options = videojs.mergeOptions({
options = mergeOptions({
timeout: 45e3
}, options);
var request = videojs.xhr(options, function(error, response) {
let request = videojsXHR(options, function(error, response) {
if (!error && request.response) {
request.responseTime = (new Date()).getTime();
request.roundTripTime = request.responseTime - request.requestTime;
request.bytesReceived = request.response.byteLength || request.response.length;
if (!request.bandwidth) {
request.bandwidth = Math.floor((request.bytesReceived / request.roundTripTime) * 8 * 1000);
request.bandwidth =
Math.floor((request.bytesReceived / request.roundTripTime) * 8 * 1000);
}
}
// videojs.xhr now uses a specific code on the error object to signal that a request has
// videojs.xhr now uses a specific code
// on the error object to signal that a request has
// timed out errors of setting a boolean on the request object
if (error || request.timedout) {
request.timedout = request.timedout || (error.code === 'ETIMEDOUT');
......@@ -44,5 +44,6 @@
request.requestTime = (new Date()).getTime();
return request;
};
})(window.videojs);
};
export default xhr;
......
{
"curly": true,
"eqeqeq": true,
"immed": true,
"latedef": true,
"newcap": true,
"noarg": true,
"sub": true,
"undef": true,
"unused": true,
"boss": true,
"eqnull": true,
"browser": true,
"node": true,
"predef": [
"QUnit",
"module",
"test",
"asyncTest",
"expect",
"start",
"stop",
"ok",
"equal",
"notEqual",
"deepEqual",
"notDeepEqual",
"strictEqual",
"notStrictEqual",
"throws",
"sinon",
"process"
]
}
// see docs/hlse.md for instructions on how test data was generated
import QUnit from 'qunit';
import {unpad} from 'pkcs7';
import sinon from 'sinon';
import {decrypt, Decrypter, AsyncStream} from '../src/decrypter';
// see docs/hlse.md for instructions on how test data was generated
const stringFromBytes = function(bytes) {
let result = '';
for (let i = 0; i < bytes.length; i++) {
result += String.fromCharCode(bytes[i]);
}
return result;
};
QUnit.module('Decryption');
QUnit.test('decrypts a single AES-128 with PKCS7 block', function() {
let key = new Uint32Array([0, 0, 0, 0]);
let initVector = key;
// the string "howdy folks" encrypted
let encrypted = new Uint8Array([
0xce, 0x90, 0x97, 0xd0,
0x08, 0x46, 0x4d, 0x18,
0x4f, 0xae, 0x01, 0x1c,
0x82, 0xa8, 0xf0, 0x67
]);
QUnit.deepEqual('howdy folks',
stringFromBytes(unpad(decrypt(encrypted, key, initVector))),
'decrypted with a byte array key'
);
});
QUnit.test('decrypts multiple AES-128 blocks with CBC', function() {
let key = new Uint32Array([0, 0, 0, 0]);
let initVector = key;
// the string "0123456789abcdef01234" encrypted
let encrypted = new Uint8Array([
0x14, 0xf5, 0xfe, 0x74,
0x69, 0x66, 0xf2, 0x92,
0x65, 0x1c, 0x22, 0x88,
0xbb, 0xff, 0x46, 0x09,
0x0b, 0xde, 0x5e, 0x71,
0x77, 0x87, 0xeb, 0x84,
0xa9, 0x54, 0xc2, 0x45,
0xe9, 0x4e, 0x29, 0xb3
]);
QUnit.deepEqual('0123456789abcdef01234',
stringFromBytes(unpad(decrypt(encrypted, key, initVector))),
'decrypted multiple blocks');
});
QUnit.test(
'verify that the deepcopy works by doing two decrypts in the same test',
function() {
let key = new Uint32Array([0, 0, 0, 0]);
let initVector = key;
// the string "howdy folks" encrypted
let pkcs7Block = new Uint8Array([
0xce, 0x90, 0x97, 0xd0,
0x08, 0x46, 0x4d, 0x18,
0x4f, 0xae, 0x01, 0x1c,
0x82, 0xa8, 0xf0, 0x67
]);
QUnit.deepEqual('howdy folks',
stringFromBytes(unpad(decrypt(pkcs7Block, key, initVector))),
'decrypted with a byte array key'
);
// the string "0123456789abcdef01234" encrypted
let cbcBlocks = new Uint8Array([
0x14, 0xf5, 0xfe, 0x74,
0x69, 0x66, 0xf2, 0x92,
0x65, 0x1c, 0x22, 0x88,
0xbb, 0xff, 0x46, 0x09,
0x0b, 0xde, 0x5e, 0x71,
0x77, 0x87, 0xeb, 0x84,
0xa9, 0x54, 0xc2, 0x45,
0xe9, 0x4e, 0x29, 0xb3
]);
QUnit.deepEqual('0123456789abcdef01234',
stringFromBytes(unpad(decrypt(cbcBlocks, key, initVector))),
'decrypted multiple blocks');
});
QUnit.module('Incremental Processing', {
beforeEach() {
this.clock = sinon.useFakeTimers();
},
afterEach() {
this.clock.restore();
}
});
QUnit.test('executes a callback after a timeout', function() {
let asyncStream = new AsyncStream();
let calls = '';
asyncStream.push(function() {
calls += 'a';
});
this.clock.tick(asyncStream.delay);
QUnit.equal(calls, 'a', 'invoked the callback once');
this.clock.tick(asyncStream.delay);
QUnit.equal(calls, 'a', 'only invoked the callback once');
});
QUnit.test('executes callback in series', function() {
let asyncStream = new AsyncStream();
let calls = '';
asyncStream.push(function() {
calls += 'a';
});
asyncStream.push(function() {
calls += 'b';
});
this.clock.tick(asyncStream.delay);
QUnit.equal(calls, 'a', 'invoked the first callback');
this.clock.tick(asyncStream.delay);
QUnit.equal(calls, 'ab', 'invoked the second');
});
QUnit.module('Incremental Decryption', {
beforeEach() {
this.clock = sinon.useFakeTimers();
},
afterEach() {
this.clock.restore();
}
});
QUnit.test('asynchronously decrypts a 4-word block', function() {
let key = new Uint32Array([0, 0, 0, 0]);
let initVector = key;
// the string "howdy folks" encrypted
let encrypted = new Uint8Array([0xce, 0x90, 0x97, 0xd0,
0x08, 0x46, 0x4d, 0x18,
0x4f, 0xae, 0x01, 0x1c,
0x82, 0xa8, 0xf0, 0x67]);
let decrypted;
let decrypter = new Decrypter(encrypted,
key,
initVector,
function(error, result) {
if (error) {
throw new Error(error);
}
decrypted = result;
});
QUnit.ok(!decrypted, 'asynchronously decrypts');
this.clock.tick(decrypter.asyncStream_.delay * 2);
QUnit.ok(decrypted, 'completed decryption');
QUnit.deepEqual('howdy folks',
stringFromBytes(decrypted),
'decrypts and unpads the result');
});
QUnit.test('breaks up input greater than the step value', function() {
let encrypted = new Int32Array(Decrypter.STEP + 4);
let done = false;
let decrypter = new Decrypter(encrypted,
new Uint32Array(4),
new Uint32Array(4),
function() {
done = true;
});
this.clock.tick(decrypter.asyncStream_.delay * 2);
QUnit.ok(!done, 'not finished after two ticks');
this.clock.tick(decrypter.asyncStream_.delay);
QUnit.ok(done, 'finished after the last chunk is decrypted');
});
(function(window, videojs, unpad, undefined) {
'use strict';
/*
======== A Handy Little QUnit Reference ========
http://api.qunitjs.com/
Test methods:
module(name, {[setup][ ,teardown]})
test(name, callback)
expect(numberOfAssertions)
stop(increment)
start(decrement)
Test assertions:
ok(value, [message])
equal(actual, expected, [message])
notEqual(actual, expected, [message])
deepEqual(actual, expected, [message])
notDeepEqual(actual, expected, [message])
strictEqual(actual, expected, [message])
notStrictEqual(actual, expected, [message])
throws(block, [expected], [message])
*/
// see docs/hlse.md for instructions on how test data was generated
var stringFromBytes = function(bytes) {
var result = '', i;
for (i = 0; i < bytes.length; i++) {
result += String.fromCharCode(bytes[i]);
}
return result;
};
module('Decryption');
test('decrypts a single AES-128 with PKCS7 block', function() {
var
key = new Uint32Array([0, 0, 0, 0]),
initVector = key,
// the string "howdy folks" encrypted
encrypted = new Uint8Array([
0xce, 0x90, 0x97, 0xd0,
0x08, 0x46, 0x4d, 0x18,
0x4f, 0xae, 0x01, 0x1c,
0x82, 0xa8, 0xf0, 0x67]);
deepEqual('howdy folks',
stringFromBytes(unpad(videojs.Hls.decrypt(encrypted, key, initVector))),
'decrypted with a byte array key');
});
test('decrypts multiple AES-128 blocks with CBC', function() {
var
key = new Uint32Array([0, 0, 0, 0]),
initVector = key,
// the string "0123456789abcdef01234" encrypted
encrypted = new Uint8Array([
0x14, 0xf5, 0xfe, 0x74,
0x69, 0x66, 0xf2, 0x92,
0x65, 0x1c, 0x22, 0x88,
0xbb, 0xff, 0x46, 0x09,
0x0b, 0xde, 0x5e, 0x71,
0x77, 0x87, 0xeb, 0x84,
0xa9, 0x54, 0xc2, 0x45,
0xe9, 0x4e, 0x29, 0xb3
]);
deepEqual('0123456789abcdef01234',
stringFromBytes(unpad(videojs.Hls.decrypt(encrypted, key, initVector))),
'decrypted multiple blocks');
});
var clock;
module('Incremental Processing', {
setup: function() {
clock = sinon.useFakeTimers();
},
teardown: function() {
clock.restore();
}
});
test('executes a callback after a timeout', function() {
var asyncStream = new videojs.Hls.AsyncStream(),
calls = '';
asyncStream.push(function() {
calls += 'a';
});
clock.tick(asyncStream.delay);
equal(calls, 'a', 'invoked the callback once');
clock.tick(asyncStream.delay);
equal(calls, 'a', 'only invoked the callback once');
});
test('executes callback in series', function() {
var asyncStream = new videojs.Hls.AsyncStream(),
calls = '';
asyncStream.push(function() {
calls += 'a';
});
asyncStream.push(function() {
calls += 'b';
});
clock.tick(asyncStream.delay);
equal(calls, 'a', 'invoked the first callback');
clock.tick(asyncStream.delay);
equal(calls, 'ab', 'invoked the second');
});
var decrypter;
module('Incremental Decryption', {
setup: function() {
clock = sinon.useFakeTimers();
},
teardown: function() {
clock.restore();
}
});
test('asynchronously decrypts a 4-word block', function() {
var
key = new Uint32Array([0, 0, 0, 0]),
initVector = key,
// the string "howdy folks" encrypted
encrypted = new Uint8Array([
0xce, 0x90, 0x97, 0xd0,
0x08, 0x46, 0x4d, 0x18,
0x4f, 0xae, 0x01, 0x1c,
0x82, 0xa8, 0xf0, 0x67]),
decrypted;
decrypter = new videojs.Hls.Decrypter(encrypted, key, initVector, function(error, result) {
decrypted = result;
});
ok(!decrypted, 'asynchronously decrypts');
clock.tick(decrypter.asyncStream_.delay * 2);
ok(decrypted, 'completed decryption');
deepEqual('howdy folks',
stringFromBytes(decrypted),
'decrypts and unpads the result');
});
test('breaks up input greater than the step value', function() {
var encrypted = new Int32Array(videojs.Hls.Decrypter.STEP + 4),
done = false,
decrypter = new videojs.Hls.Decrypter(encrypted,
new Uint32Array(4),
new Uint32Array(4),
function() {
done = true;
});
clock.tick(decrypter.asyncStream_.delay * 2);
ok(!done, 'not finished after two ticks');
clock.tick(decrypter.asyncStream_.delay);
ok(done, 'finished after the last chunk is decrypted');
});
})(window, window.videojs, window.pkcs7.unpad);
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>video.js HLS Plugin Test Suite</title>
<link rel="stylesheet" href="/node_modules/qunitjs/qunit/qunit.css" media="screen">
<link rel="stylesheet" href="/node_modules/video.js/dist/video-js.css" media="screen">
</head>
<body>
<div id="qunit"></div>
<div id="qunit-fixture"></div>
<!-- NOTE in order for test to pass we require sinon 1.10.2 exactly -->
<script src="/node_modules/sinon/pkg/sinon.js"></script>
<script src="/node_modules/qunitjs/qunit/qunit.js"></script>
<script src="/node_modules/video.js/dist/video.js"></script>
<script src="/dist-test/videojs-contrib-hls.js"></script>
</body>
</html>
var fixture = document.createElement('div');
fixture.id = 'qunit-fixture';
document.body.appendChild(fixture);
// Karma example configuration file
// NOTE: To configure Karma tests, do the following:
// 1. Copy this file and rename the copy with a .conf.js extension, for example: karma.conf.js
// 2. Configure the properties below in your conf.js copy
// 3. Run your tests
module.exports = function(config) {
var customLaunchers = {
chrome_sl: {
singleRun: true,
base: 'SauceLabs',
browserName: 'chrome',
platform: 'Windows 7'
},
firefox_sl: {
singleRun: true,
base: 'SauceLabs',
browserName: 'firefox',
platform: 'Windows 8'
},
safari_sl: {
singleRun: true,
base: 'SauceLabs',
browserName: 'safari',
platform: 'OS X 10.8'
},
ipad_sl: {
singleRun: true,
base: 'SauceLabs',
browserName: 'ipad',
platform:'OS X 10.9',
version: '7.1'
},
android_sl: {
singleRun: true,
base: 'SauceLabs',
browserName: 'android',
platform:'Linux'
}
};
config.set({
// base path, that will be used to resolve files and exclude
basePath: '',
frameworks: ['qunit'],
// Set autoWatch to true if you plan to run `grunt karma` continuously, to automatically test changes as you make them.
autoWatch: false,
// Setting singleRun to true here will start up your specified browsers, run tests, and then shut down the browsers. Helpful to have in a CI environment, where you don't want to leave browsers running continuously.
singleRun: true,
// custom launchers for sauce labs
//define SL browsers
customLaunchers: customLaunchers,
// Start these browsers
browsers: ['chrome_sl'], //Object.keys(customLaunchers),
// List of files / patterns to load in the browser
// Add any new src files to this list.
// If you add new unit tests, they will be picked up automatically by Karma,
// unless you've added them to a nested directory, in which case you should
// add their paths to this list.
files: [
'../node_modules/sinon/pkg/sinon.js',
'../node_modules/video.js/dist/video-js.css',
'../node_modules/video.js/dist/video.js',
'../node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js',
'../node_modules/pkcs7/dist/pkcs7.unpad.js',
'../test/karma-qunit-shim.js',
'../src/videojs-hls.js',
'../src/stream.js',
'../src/m3u8/m3u8-parser.js',
'../src/xhr.js',
'../src/playlist.js',
'../src/playlist-loader.js',
'../src/decrypter.js',
'../tmp/manifests.js',
'../tmp/expected.js',
'tsSegment-bc.js',
'../src/bin-utils.js',
'../test/*.js',
],
plugins: [
'karma-qunit',
'karma-chrome-launcher',
'karma-firefox-launcher',
'karma-ie-launcher',
'karma-opera-launcher',
'karma-phantomjs-launcher',
'karma-safari-launcher',
'karma-sauce-launcher'
],
// test results reporter to use
// possible values: 'dots', 'progress', 'junit'
reporters: ['dots', 'progress'],
// web server port
port: 9876,
// cli runner port
runnerPort: 9100,
// enable / disable colors in the output (reporters and logs)
colors: true,
// level of logging
// possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
//logLevel: config.LOG_INFO,
// If browser does not capture in given timeout [ms], kill it
captureTimeout: 60000,
// global config for SauceLabs
sauceLabs: {
startConnect: false,
tunnelIdentifier: process.env.TRAVIS_JOB_NUMBER,
build: process.env.TRAVIS_BUILD_NUMBER,
testName: process.env.TRAVIS_BUILD_NUMBER + process.env.TRAVIS_BRANCH,
recordScreenshots: false
}
});
};
var common = require('./common');
module.exports = function(config) {
config.set(common({
plugins: ['karma-chrome-launcher'],
browsers: ['Chrome']
}));
};
var merge = require('lodash-compat/object/merge');
var DEFAULTS = {
basePath: '../..',
frameworks: ['browserify', 'qunit'],
files: [
'node_modules/sinon/pkg/sinon.js',
'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/**'
],
plugins: [
'karma-browserify',
'karma-qunit'
],
preprocessors: {
'test/**/*.test.js': ['browserify']
},
reporters: ['dots'],
port: 9876,
colors: true,
autoWatch: false,
singleRun: true,
concurrency: Infinity,
browserify: {
debug: true,
transform: [
'babelify',
'browserify-shim'
],
noParse: [
'test/data/**',
]
}
};
/**
* Customizes target/source merging with lodash merge.
*
* @param {Mixed} target
* @param {Mixed} source
* @return {Mixed}
*/
var customizer = function(target, source) {
if (Array.isArray(target)) {
return target.concat(source);
}
};
/**
* Generates a new Karma config with a common set of base configuration.
*
* @param {Object} custom
* Configuration that will be deep-merged. Arrays will be
* concatenated.
* @return {Object}
*/
module.exports = function(custom) {
return merge({}, custom, DEFAULTS, customizer);
};
var common = require('./common');
// Runs default testing configuration in multiple environments.
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']
}))
} else {
config.set(common({
frameworks: ['detectBrowsers'],
plugins: [
'karma-chrome-launcher',
'karma-detect-browsers',
'karma-firefox-launcher',
'karma-ie-launcher',
'karma-safari-launcher'
],
detectBrowsers: {
// disable safari as it was not previously supported and causes test failures
postDetection: function(availableBrowsers) {
var safariIndex = availableBrowsers.indexOf('Safari');
if(safariIndex !== -1) {
console.log("Not running safari it is/was broken");
availableBrowsers.splice(safariIndex, 1);
}
return availableBrowsers;
},
usePhantomJS: false
}
}));
}
};
var common = require('./common');
module.exports = function(config) {
config.set(common({
plugins: ['karma-firefox-launcher'],
browsers: ['Firefox']
}));
};
var common = require('./common');
module.exports = function(config) {
config.set(common({
plugins: ['karma-ie-launcher'],
browsers: ['IE']
}));
};
var common = require('./common');
module.exports = function(config) {
config.set(common({
plugins: ['karma-safari-launcher'],
browsers: ['Safari']
}));
};
// Karma example configuration file
// NOTE: To configure Karma tests, do the following:
// 1. Copy this file and rename the copy with a .conf.js extension, for example: karma.conf.js
// 2. Configure the properties below in your conf.js copy
// 3. Run your tests
module.exports = function(config) {
config.set({
// base path, that will be used to resolve files and exclude
basePath: '',
frameworks: ['qunit'],
// Set autoWatch to true if you plan to run `grunt karma` continuously, to automatically test changes as you make them.
autoWatch: false,
// Setting singleRun to true here will start up your specified browsers, run tests, and then shut down the browsers. Helpful to have in a CI environment, where you don't want to leave browsers running continuously.
singleRun: true,
// Start these browsers, currently available:
// - Chrome
// - ChromeCanary
// - Firefox
// - Opera
// - Safari (only Mac)
// - PhantomJS
// - IE (only Windows)
// Example usage:
// browsers: [],
// List of files / patterns to load in the browser
// Add any new src files to this list.
// If you add new unit tests, they will be picked up automatically by Karma,
// unless you've added them to a nested directory, in which case you should
// add their paths to this list.
files: [
'../node_modules/sinon/pkg/sinon.js',
'../node_modules/video.js/dist/video-js.css',
'../node_modules/video.js/dist/video.js',
'../node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js',
'../node_modules/pkcs7/dist/pkcs7.unpad.js',
'../test/karma-qunit-shim.js',
'../src/videojs-hls.js',
'../src/stream.js',
'../src/m3u8/m3u8-parser.js',
'../src/xhr.js',
'../src/playlist.js',
'../src/playlist-loader.js',
'../src/decrypter.js',
'../tmp/manifests.js',
'../tmp/expected.js',
'tsSegment-bc.js',
'../src/bin-utils.js',
'../test/*.js',
],
plugins: [
'karma-qunit',
'karma-chrome-launcher',
'karma-firefox-launcher',
'karma-ie-launcher',
'karma-opera-launcher',
'karma-phantomjs-launcher',
'karma-safari-launcher'
],
// list of files to exclude
exclude: [
],
// test results reporter to use
// possible values: 'dots', 'progress', 'junit'
reporters: ['progress'],
// web server port
port: 9876,
// cli runner port
runnerPort: 9100,
// enable / disable colors in the output (reporters and logs)
colors: true,
// level of logging
// possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
logLevel: config.LOG_DISABLE,
// If browser does not capture in given timeout [ms], kill it
captureTimeout: 60000
});
};
import {ParseStream, LineStream, Parser} from '../src/m3u8';
import QUnit from 'qunit';
import testDataExpected from './test-expected.js';
import testDataManifests from './test-manifests.js';
QUnit.module('LineStream', {
beforeEach() {
this.lineStream = new LineStream();
}
});
QUnit.test('empty inputs produce no tokens', function() {
let data = false;
this.lineStream.on('data', function() {
data = true;
});
this.lineStream.push('');
QUnit.ok(!data, 'no tokens were produced');
});
QUnit.test('splits on newlines', function() {
let lines = [];
this.lineStream.on('data', function(line) {
lines.push(line);
});
this.lineStream.push('#EXTM3U\nmovie.ts\n');
QUnit.strictEqual(2, lines.length, 'two lines are ready');
QUnit.strictEqual('#EXTM3U', lines.shift(), 'the first line is the first token');
QUnit.strictEqual('movie.ts', lines.shift(), 'the second line is the second token');
});
QUnit.test('empty lines become empty strings', function() {
let lines = [];
this.lineStream.on('data', function(line) {
lines.push(line);
});
this.lineStream.push('\n\n');
QUnit.strictEqual(2, lines.length, 'two lines are ready');
QUnit.strictEqual('', lines.shift(), 'the first line is empty');
QUnit.strictEqual('', lines.shift(), 'the second line is empty');
});
QUnit.test('handles lines broken across appends', function() {
let lines = [];
this.lineStream.on('data', function(line) {
lines.push(line);
});
this.lineStream.push('#EXTM');
QUnit.strictEqual(0, lines.length, 'no lines are ready');
this.lineStream.push('3U\nmovie.ts\n');
QUnit.strictEqual(2, lines.length, 'two lines are ready');
QUnit.strictEqual('#EXTM3U', lines.shift(), 'the first line is the first token');
QUnit.strictEqual('movie.ts', lines.shift(), 'the second line is the second token');
});
QUnit.test('stops sending events after deregistering', function() {
let temporaryLines = [];
let temporary = function(line) {
temporaryLines.push(line);
};
let permanentLines = [];
let permanent = function(line) {
permanentLines.push(line);
};
this.lineStream.on('data', temporary);
this.lineStream.on('data', permanent);
this.lineStream.push('line one\n');
QUnit.strictEqual(temporaryLines.length,
permanentLines.length,
'both callbacks receive the event');
QUnit.ok(this.lineStream.off('data', temporary), 'a listener was removed');
this.lineStream.push('line two\n');
QUnit.strictEqual(1, temporaryLines.length, 'no new events are received');
QUnit.strictEqual(2, permanentLines.length, 'new events are still received');
});
QUnit.module('ParseStream', {
beforeEach() {
this.lineStream = new LineStream();
this.parseStream = new ParseStream();
this.lineStream.pipe(this.parseStream);
}
});
QUnit.test('parses comment lines', function() {
let manifest = '# a line that starts with a hash mark without "EXT" is a comment\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'comment', 'the type is comment');
QUnit.strictEqual(element.text,
manifest.slice(1, manifest.length - 1),
'the comment text is parsed');
});
QUnit.test('parses uri lines', function() {
let manifest = 'any non-blank line that does not start with a hash-mark is a URI\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'uri', 'the type is uri');
QUnit.strictEqual(element.uri,
manifest.substring(0, manifest.length - 1),
'the uri text is parsed');
});
QUnit.test('parses unknown tag types', function() {
let manifest = '#EXT-X-EXAMPLE-TAG:some,additional,stuff\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the type is tag');
QUnit.strictEqual(element.data,
manifest.slice(4, manifest.length - 1),
'unknown tag data is preserved');
});
// #EXTM3U
QUnit.test('parses #EXTM3U tags', function() {
let manifest = '#EXTM3U\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'm3u', 'the tag type is m3u');
});
// #EXTINF
QUnit.test('parses minimal #EXTINF tags', function() {
let manifest = '#EXTINF\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'inf', 'the tag type is inf');
});
QUnit.test('parses #EXTINF tags with durations', function() {
let manifest = '#EXTINF:15\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'inf', 'the tag type is inf');
QUnit.strictEqual(element.duration, 15, 'the duration is parsed');
QUnit.ok(!('title' in element), 'no title is parsed');
manifest = '#EXTINF:21,\n';
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'inf', 'the tag type is inf');
QUnit.strictEqual(element.duration, 21, 'the duration is parsed');
QUnit.ok(!('title' in element), 'no title is parsed');
});
QUnit.test('parses #EXTINF tags with a duration and title', function() {
let manifest = '#EXTINF:13,Does anyone really use the title attribute?\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'inf', 'the tag type is inf');
QUnit.strictEqual(element.duration, 13, 'the duration is parsed');
QUnit.strictEqual(element.title,
manifest.substring(manifest.indexOf(',') + 1, manifest.length - 1),
'the title is parsed');
});
QUnit.test('parses #EXTINF tags with carriage returns', function() {
let manifest = '#EXTINF:13,Does anyone really use the title attribute?\r\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'inf', 'the tag type is inf');
QUnit.strictEqual(element.duration, 13, 'the duration is parsed');
QUnit.strictEqual(element.title,
manifest.substring(manifest.indexOf(',') + 1, manifest.length - 2),
'the title is parsed');
});
// #EXT-X-TARGETDURATION
QUnit.test('parses minimal #EXT-X-TARGETDURATION tags', function() {
let manifest = '#EXT-X-TARGETDURATION\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'targetduration', 'the tag type is targetduration');
QUnit.ok(!('duration' in element), 'no duration is parsed');
});
QUnit.test('parses #EXT-X-TARGETDURATION with duration', function() {
let manifest = '#EXT-X-TARGETDURATION:47\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'targetduration', 'the tag type is targetduration');
QUnit.strictEqual(element.duration, 47, 'the duration is parsed');
});
// #EXT-X-VERSION
QUnit.test('parses minimal #EXT-X-VERSION tags', function() {
let manifest = '#EXT-X-VERSION:\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'version', 'the tag type is version');
QUnit.ok(!('version' in element), 'no version is present');
});
QUnit.test('parses #EXT-X-VERSION with a version', function() {
let manifest = '#EXT-X-VERSION:99\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'version', 'the tag type is version');
QUnit.strictEqual(element.version, 99, 'the version is parsed');
});
// #EXT-X-MEDIA-SEQUENCE
QUnit.test('parses minimal #EXT-X-MEDIA-SEQUENCE tags', function() {
let manifest = '#EXT-X-MEDIA-SEQUENCE\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'media-sequence', 'the tag type is media-sequence');
QUnit.ok(!('number' in element), 'no number is present');
});
QUnit.test('parses #EXT-X-MEDIA-SEQUENCE with sequence numbers', function() {
let manifest = '#EXT-X-MEDIA-SEQUENCE:109\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'media-sequence', 'the tag type is media-sequence');
QUnit.ok(element.number, 109, 'the number is parsed');
});
// #EXT-X-PLAYLIST-TYPE
QUnit.test('parses minimal #EXT-X-PLAYLIST-TYPE tags', function() {
let manifest = '#EXT-X-PLAYLIST-TYPE:\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'playlist-type', 'the tag type is playlist-type');
QUnit.ok(!('playlistType' in element), 'no playlist type is present');
});
QUnit.test('parses #EXT-X-PLAYLIST-TYPE with mutability info', function() {
let manifest = '#EXT-X-PLAYLIST-TYPE:EVENT\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'playlist-type', 'the tag type is playlist-type');
QUnit.strictEqual(element.playlistType, 'EVENT', 'the playlist type is EVENT');
manifest = '#EXT-X-PLAYLIST-TYPE:VOD\n';
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'playlist-type', 'the tag type is playlist-type');
QUnit.strictEqual(element.playlistType, 'VOD', 'the playlist type is VOD');
manifest = '#EXT-X-PLAYLIST-TYPE:nonsense\n';
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'playlist-type', 'the tag type is playlist-type');
QUnit.strictEqual(element.playlistType, 'nonsense', 'the playlist type is parsed');
});
// #EXT-X-BYTERANGE
QUnit.test('parses minimal #EXT-X-BYTERANGE tags', function() {
let manifest = '#EXT-X-BYTERANGE\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'byterange', 'the tag type is byterange');
QUnit.ok(!('length' in element), 'no length is present');
QUnit.ok(!('offset' in element), 'no offset is present');
});
QUnit.test('parses #EXT-X-BYTERANGE with length and offset', function() {
let manifest = '#EXT-X-BYTERANGE:45\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'byterange', 'the tag type is byterange');
QUnit.strictEqual(element.length, 45, 'length is parsed');
QUnit.ok(!('offset' in element), 'no offset is present');
manifest = '#EXT-X-BYTERANGE:108@16\n';
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'byterange', 'the tag type is byterange');
QUnit.strictEqual(element.length, 108, 'length is parsed');
QUnit.strictEqual(element.offset, 16, 'offset is parsed');
});
// #EXT-X-ALLOW-CACHE
QUnit.test('parses minimal #EXT-X-ALLOW-CACHE tags', function() {
let manifest = '#EXT-X-ALLOW-CACHE:\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'allow-cache', 'the tag type is allow-cache');
QUnit.ok(!('allowed' in element), 'no allowed is present');
});
QUnit.test('parses valid #EXT-X-ALLOW-CACHE tags', function() {
let manifest = '#EXT-X-ALLOW-CACHE:YES\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'allow-cache', 'the tag type is allow-cache');
QUnit.ok(element.allowed, 'allowed is parsed');
manifest = '#EXT-X-ALLOW-CACHE:NO\n';
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'allow-cache', 'the tag type is allow-cache');
QUnit.ok(!element.allowed, 'allowed is parsed');
});
// #EXT-X-STREAM-INF
QUnit.test('parses minimal #EXT-X-STREAM-INF tags', function() {
let manifest = '#EXT-X-STREAM-INF\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'stream-inf', 'the tag type is stream-inf');
QUnit.ok(!('attributes' in element), 'no attributes are present');
});
QUnit.test('parses #EXT-X-STREAM-INF with common attributes', function() {
let manifest = '#EXT-X-STREAM-INF:BANDWIDTH=14400\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'stream-inf', 'the tag type is stream-inf');
QUnit.strictEqual(element.attributes.BANDWIDTH, 14400, 'bandwidth is parsed');
manifest = '#EXT-X-STREAM-INF:PROGRAM-ID=7\n';
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'stream-inf', 'the tag type is stream-inf');
QUnit.strictEqual(element.attributes['PROGRAM-ID'], 7, 'program-id is parsed');
manifest = '#EXT-X-STREAM-INF:RESOLUTION=396x224\n';
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'stream-inf', 'the tag type is stream-inf');
QUnit.strictEqual(element.attributes.RESOLUTION.width, 396, 'width is parsed');
QUnit.strictEqual(element.attributes.RESOLUTION.height, 224, 'heigth is parsed');
manifest = '#EXT-X-STREAM-INF:CODECS="avc1.4d400d, mp4a.40.2"\n';
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'stream-inf', 'the tag type is stream-inf');
QUnit.strictEqual(element.attributes.CODECS,
'avc1.4d400d, mp4a.40.2',
'codecs are parsed');
});
QUnit.test('parses #EXT-X-STREAM-INF with arbitrary attributes', function() {
let manifest = '#EXT-X-STREAM-INF:NUMERIC=24,ALPHA=Value,MIXED=123abc\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'stream-inf', 'the tag type is stream-inf');
QUnit.strictEqual(element.attributes.NUMERIC, '24', 'numeric attributes are parsed');
QUnit.strictEqual(element.attributes.ALPHA,
'Value',
'alphabetic attributes are parsed');
QUnit.strictEqual(element.attributes.MIXED, '123abc', 'mixed attributes are parsed');
});
// #EXT-X-ENDLIST
QUnit.test('parses #EXT-X-ENDLIST tags', function() {
let manifest = '#EXT-X-ENDLIST\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.strictEqual(element.type, 'tag', 'the line type is tag');
QUnit.strictEqual(element.tagType, 'endlist', 'the tag type is stream-inf');
});
// #EXT-X-KEY
QUnit.test('parses valid #EXT-X-KEY tags', function() {
let manifest =
'#EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=52"\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.deepEqual(element, {
type: 'tag',
tagType: 'key',
attributes: {
METHOD: 'AES-128',
URI: 'https://priv.example.com/key.php?r=52'
}
}, 'parsed a valid key');
manifest = '#EXT-X-KEY:URI="https://example.com/key#1",METHOD=FutureType-1024\n';
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.deepEqual(element, {
type: 'tag',
tagType: 'key',
attributes: {
METHOD: 'FutureType-1024',
URI: 'https://example.com/key#1'
}
}, 'parsed the attribute list independent of order');
manifest = '#EXT-X-KEY:IV=1234567890abcdef1234567890abcdef\n';
this.lineStream.push(manifest);
QUnit.ok(element.attributes.IV, 'detected an IV attribute');
QUnit.deepEqual(element.attributes.IV, new Uint32Array([
0x12345678,
0x90abcdef,
0x12345678,
0x90abcdef
]), 'parsed an IV value');
});
QUnit.test('parses minimal #EXT-X-KEY tags', function() {
let manifest = '#EXT-X-KEY:\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.ok(element, 'an event was triggered');
QUnit.deepEqual(element, {
type: 'tag',
tagType: 'key'
}, 'parsed a minimal key tag');
});
QUnit.test('parses lightly-broken #EXT-X-KEY tags', function() {
let manifest = '#EXT-X-KEY:URI=\'https://example.com/single-quote\',METHOD=AES-128\n';
let element;
this.parseStream.on('data', function(elem) {
element = elem;
});
this.lineStream.push(manifest);
QUnit.strictEqual(element.attributes.URI,
'https://example.com/single-quote',
'parsed a single-quoted uri');
element = null;
manifest = '#EXT-X-KEYURI="https://example.com/key",METHOD=AES-128\n';
this.lineStream.push(manifest);
QUnit.strictEqual(element.tagType, 'key', 'parsed the tag type');
QUnit.strictEqual(element.attributes.URI,
'https://example.com/key',
'inferred a colon after the tag type');
element = null;
manifest = '#EXT-X-KEY: URI = "https://example.com/key",METHOD=AES-128\n';
this.lineStream.push(manifest);
QUnit.strictEqual(element.attributes.URI,
'https://example.com/key',
'trims and removes quotes around the URI');
});
QUnit.test('ignores empty lines', function() {
let manifest = '\n';
let event = false;
this.parseStream.on('data', function() {
event = true;
});
this.lineStream.push(manifest);
QUnit.ok(!event, 'no event is triggered');
});
QUnit.module('m3u8 parser');
QUnit.test('can be constructed', function() {
QUnit.notStrictEqual(typeof new Parser(), 'undefined', 'parser is defined');
});
QUnit.module('m3u8s');
QUnit.test('parses static manifests as expected', function() {
let key;
for (key in testDataManifests) {
if (testDataExpected[key]) {
let parser = new Parser();
parser.push(testDataManifests[key]);
QUnit.deepEqual(parser.manifest,
testDataExpected[key],
key + '.m3u8 was parsed correctly'
);
}
}
});
(function(window, undefined) {
var
//manifestController = this.manifestController,
m3u8 = window.videojs.m3u8,
ParseStream = m3u8.ParseStream,
parseStream,
LineStream = m3u8.LineStream,
lineStream,
Parser = m3u8.Parser,
parser;
/*
M3U8 Test Suite
*/
module('LineStream', {
setup: function() {
lineStream = new LineStream();
}
});
test('empty inputs produce no tokens', function() {
var data = false;
lineStream.on('data', function() {
data = true;
});
lineStream.push('');
ok(!data, 'no tokens were produced');
});
test('splits on newlines', function() {
var lines = [];
lineStream.on('data', function(line) {
lines.push(line);
});
lineStream.push('#EXTM3U\nmovie.ts\n');
strictEqual(2, lines.length, 'two lines are ready');
strictEqual('#EXTM3U', lines.shift(), 'the first line is the first token');
strictEqual('movie.ts', lines.shift(), 'the second line is the second token');
});
test('empty lines become empty strings', function() {
var lines = [];
lineStream.on('data', function(line) {
lines.push(line);
});
lineStream.push('\n\n');
strictEqual(2, lines.length, 'two lines are ready');
strictEqual('', lines.shift(), 'the first line is empty');
strictEqual('', lines.shift(), 'the second line is empty');
});
test('handles lines broken across appends', function() {
var lines = [];
lineStream.on('data', function(line) {
lines.push(line);
});
lineStream.push('#EXTM');
strictEqual(0, lines.length, 'no lines are ready');
lineStream.push('3U\nmovie.ts\n');
strictEqual(2, lines.length, 'two lines are ready');
strictEqual('#EXTM3U', lines.shift(), 'the first line is the first token');
strictEqual('movie.ts', lines.shift(), 'the second line is the second token');
});
test('stops sending events after deregistering', function() {
var
temporaryLines = [],
temporary = function(line) {
temporaryLines.push(line);
},
permanentLines = [],
permanent = function(line) {
permanentLines.push(line);
};
lineStream.on('data', temporary);
lineStream.on('data', permanent);
lineStream.push('line one\n');
strictEqual(temporaryLines.length, permanentLines.length, 'both callbacks receive the event');
ok(lineStream.off('data', temporary), 'a listener was removed');
lineStream.push('line two\n');
strictEqual(1, temporaryLines.length, 'no new events are received');
strictEqual(2, permanentLines.length, 'new events are still received');
});
module('ParseStream', {
setup: function() {
lineStream = new LineStream();
parseStream = new ParseStream();
lineStream.pipe(parseStream);
}
});
test('parses comment lines', function() {
var
manifest = '# a line that starts with a hash mark without "EXT" is a comment\n',
element;
parseStream.on('data', function(elem) {
element = elem;
});
lineStream.push(manifest);
ok(element, 'an event was triggered');
strictEqual(element.type, 'comment', 'the type is comment');
strictEqual(element.text,
manifest.slice(1, manifest.length - 1),
'the comment text is parsed');
});
test('parses uri lines', function() {
var
manifest = 'any non-blank line that does not start with a hash-mark is a URI\n',
element;
parseStream.on('data', function(elem) {
element = elem;
});
lineStream.push(manifest);
ok(element, 'an event was triggered');
strictEqual(element.type, 'uri', 'the type is uri');
strictEqual(element.uri,
manifest.substring(0, manifest.length - 1),
'the uri text is parsed');
});
test('parses unknown tag types', function() {
var
manifest = '#EXT-X-EXAMPLE-TAG:some,additional,stuff\n',
element;
parseStream.on('data', function(elem) {
element = elem;
});
lineStream.push(manifest);
ok(element, 'an event was triggered');
strictEqual(element.type, 'tag', 'the type is tag');
strictEqual(element.data,
manifest.slice(4, manifest.length - 1),
'unknown tag data is preserved');
});
// #EXTM3U
test('parses #EXTM3U tags', function() {
var
manifest = '#EXTM3U\n',
element;
parseStream.on('data', function(elem) {
element = elem;
});
lineStream.push(manifest);
ok(element, 'an event was triggered');
strictEqual(element.type, 'tag', 'the line type is tag');
strictEqual(element.tagType, 'm3u', 'the tag type is m3u');
});
// #EXTINF
test('parses minimal #EXTINF tags', function() {
var
manifest = '#EXTINF\n',
element;
parseStream.on('data', function(elem) {
element = elem;
});
lineStream.push(manifest);
ok(element, 'an event was triggered');
strictEqual(element.type, 'tag', 'the line type is tag');
strictEqual(element.tagType, 'inf', 'the tag type is inf');
});
test('parses #EXTINF tags with durations', function() {
var
manifest = '#EXTINF:15\n',
element;
parseStream.on('data', function(elem) {
element = elem;
});
lineStream.push(manifest);
ok(element, 'an event was triggered');
strictEqual(element.type, 'tag', 'the line type is tag');
strictEqual(element.tagType, 'inf', 'the tag type is inf');
strictEqual(element.duration, 15, 'the duration is parsed');
ok(!('title' in element), 'no title is parsed');
manifest = '#EXTINF:21,\n';
lineStream.push(manifest);
ok(element, 'an event was triggered');
strictEqual(element.type, 'tag', 'the line type is tag');
strictEqual(element.tagType, 'inf', 'the tag type is inf');
strictEqual(element.duration, 21, 'the duration is parsed');
ok(!('title' in element), 'no title is parsed');
});
test('parses #EXTINF tags with a duration and title', function() {
var
manifest = '#EXTINF:13,Does anyone really use the title attribute?\n',
element;
parseStream.on('data', function(elem) {
element = elem;
});
lineStream.push(manifest);
ok(element, 'an event was triggered');
strictEqual(element.type, 'tag', 'the line type is tag');
strictEqual(element.tagType, 'inf', 'the tag type is inf');
strictEqual(element.duration, 13, 'the duration is parsed');
strictEqual(element.title,
manifest.substring(manifest.indexOf(',') + 1, manifest.length - 1),
'the title is parsed');
});
test('parses #EXTINF tags with carriage returns', function() {
var
manifest = '#EXTINF:13,Does anyone really use the title attribute?\r\n',
element;
parseStream.on('data', function(elem) {
element = elem;
});
lineStream.push(manifest);
ok(element, 'an event was triggered');
strictEqual(element.type, 'tag', 'the line type is tag');
strictEqual(element.tagType, 'inf', 'the tag type is inf');
strictEqual(element.duration, 13, 'the duration is parsed');
strictEqual(element.title,
manifest.substring(manifest.indexOf(',') + 1, manifest.length - 2),
'the title is parsed');
});
// #EXT-X-TARGETDURATION
test('parses minimal #EXT-X-TARGETDURATION tags', function() {
var
manifest = '#EXT-X-TARGETDURATION\n',
element;
parseStream.on('data', function(elem) {
element = elem;
});
lineStream.push(manifest);
ok(element, 'an event was triggered');
strictEqual(element.type, 'tag', 'the line type is tag');
strictEqual(element.tagType, 'targetduration', 'the tag type is targetduration');
ok(!('duration' in element), 'no duration is parsed');
});
test('parses #EXT-X-TARGETDURATION with duration', function() {
var
manifest = '#EXT-X-TARGETDURATION:47\n',
element;
parseStream.on('data', function(elem) {
element = elem;
});
lineStream.push(manifest);
ok(element, 'an event was triggered');
strictEqual(element.type, 'tag', 'the line type is tag');
strictEqual(element.tagType, 'targetduration', 'the tag type is targetduration');
strictEqual(element.duration, 47, 'the duration is parsed');
});
// #EXT-X-VERSION
test('parses minimal #EXT-X-VERSION tags', function() {
var
manifest = '#EXT-X-VERSION:\n',
element;
parseStream.on('data', function(elem) {
element = elem;
});
lineStream.push(manifest);
ok(element, 'an event was triggered');
strictEqual(element.type, 'tag', 'the line type is tag');
strictEqual(element.tagType, 'version', 'the tag type is version');
ok(!('version' in element), 'no version is present');
});
test('parses #EXT-X-VERSION with a version', function() {
var
manifest = '#EXT-X-VERSION:99\n',
element;
parseStream.on('data', function(elem) {
element = elem;
});
lineStream.push(manifest);
ok(element, 'an event was triggered');
strictEqual(element.type, 'tag', 'the line type is tag');
strictEqual(element.tagType, 'version', 'the tag type is version');
strictEqual(element.version, 99, 'the version is parsed');
});
// #EXT-X-MEDIA-SEQUENCE
test('parses minimal #EXT-X-MEDIA-SEQUENCE tags', function() {
var
manifest = '#EXT-X-MEDIA-SEQUENCE\n',
element;
parseStream.on('data', function(elem) {
element = elem;
});
lineStream.push(manifest);
ok(element, 'an event was triggered');
strictEqual(element.type, 'tag', 'the line type is tag');
strictEqual(element.tagType, 'media-sequence', 'the tag type is media-sequence');
ok(!('number' in element), 'no number is present');
});
test('parses #EXT-X-MEDIA-SEQUENCE with sequence numbers', function() {
var
manifest = '#EXT-X-MEDIA-SEQUENCE:109\n',
element;
parseStream.on('data', function(elem) {
element = elem;
});
lineStream.push(manifest);
ok(element, 'an event was triggered');
strictEqual(element.type, 'tag', 'the line type is tag');
strictEqual(element.tagType, 'media-sequence', 'the tag type is media-sequence');
ok(element.number, 109, 'the number is parsed');
});
// #EXT-X-PLAYLIST-TYPE
test('parses minimal #EXT-X-PLAYLIST-TYPE tags', function() {
var
manifest = '#EXT-X-PLAYLIST-TYPE:\n',
element;
parseStream.on('data', function(elem) {
element = elem;
});
lineStream.push(manifest);
ok(element, 'an event was triggered');
strictEqual(element.type, 'tag', 'the line type is tag');
strictEqual(element.tagType, 'playlist-type', 'the tag type is playlist-type');
ok(!('playlistType' in element), 'no playlist type is present');
});
test('parses #EXT-X-PLAYLIST-TYPE with mutability info', function() {
var
manifest = '#EXT-X-PLAYLIST-TYPE:EVENT\n',
element;
parseStream.on('data', function(elem) {
element = elem;
});
lineStream.push(manifest);
ok(element, 'an event was triggered');
strictEqual(element.type, 'tag', 'the line type is tag');
strictEqual(element.tagType, 'playlist-type', 'the tag type is playlist-type');
strictEqual(element.playlistType, 'EVENT', 'the playlist type is EVENT');
manifest = '#EXT-X-PLAYLIST-TYPE:VOD\n';
lineStream.push(manifest);
ok(element, 'an event was triggered');
strictEqual(element.type, 'tag', 'the line type is tag');
strictEqual(element.tagType, 'playlist-type', 'the tag type is playlist-type');
strictEqual(element.playlistType, 'VOD', 'the playlist type is VOD');
manifest = '#EXT-X-PLAYLIST-TYPE:nonsense\n';
lineStream.push(manifest);
ok(element, 'an event was triggered');
strictEqual(element.type, 'tag', 'the line type is tag');
strictEqual(element.tagType, 'playlist-type', 'the tag type is playlist-type');
strictEqual(element.playlistType, 'nonsense', 'the playlist type is parsed');
});
// #EXT-X-BYTERANGE
test('parses minimal #EXT-X-BYTERANGE tags', function() {
var
manifest = '#EXT-X-BYTERANGE\n',
element;
parseStream.on('data', function(elem) {
element = elem;
});
lineStream.push(manifest);
ok(element, 'an event was triggered');
strictEqual(element.type, 'tag', 'the line type is tag');
strictEqual(element.tagType, 'byterange', 'the tag type is byterange');
ok(!('length' in element), 'no length is present');
ok(!('offset' in element), 'no offset is present');
});
test('parses #EXT-X-BYTERANGE with length and offset', function() {
var
manifest = '#EXT-X-BYTERANGE:45\n',
element;
parseStream.on('data', function(elem) {
element = elem;
});
lineStream.push(manifest);
ok(element, 'an event was triggered');
strictEqual(element.type, 'tag', 'the line type is tag');
strictEqual(element.tagType, 'byterange', 'the tag type is byterange');
strictEqual(element.length, 45, 'length is parsed');
ok(!('offset' in element), 'no offset is present');
manifest = '#EXT-X-BYTERANGE:108@16\n';
lineStream.push(manifest);
ok(element, 'an event was triggered');
strictEqual(element.type, 'tag', 'the line type is tag');
strictEqual(element.tagType, 'byterange', 'the tag type is byterange');
strictEqual(element.length, 108, 'length is parsed');
strictEqual(element.offset, 16, 'offset is parsed');
});
// #EXT-X-ALLOW-CACHE
test('parses minimal #EXT-X-ALLOW-CACHE tags', function() {
var
manifest = '#EXT-X-ALLOW-CACHE:\n',
element;
parseStream.on('data', function(elem) {
element = elem;
});
lineStream.push(manifest);
ok(element, 'an event was triggered');
strictEqual(element.type, 'tag', 'the line type is tag');
strictEqual(element.tagType, 'allow-cache', 'the tag type is allow-cache');
ok(!('allowed' in element), 'no allowed is present');
});
test('parses valid #EXT-X-ALLOW-CACHE tags', function() {
var
manifest = '#EXT-X-ALLOW-CACHE:YES\n',
element;
parseStream.on('data', function(elem) {
element = elem;
});
lineStream.push(manifest);
ok(element, 'an event was triggered');
strictEqual(element.type, 'tag', 'the line type is tag');
strictEqual(element.tagType, 'allow-cache', 'the tag type is allow-cache');
ok(element.allowed, 'allowed is parsed');
manifest = '#EXT-X-ALLOW-CACHE:NO\n';
lineStream.push(manifest);
ok(element, 'an event was triggered');
strictEqual(element.type, 'tag', 'the line type is tag');
strictEqual(element.tagType, 'allow-cache', 'the tag type is allow-cache');
ok(!element.allowed, 'allowed is parsed');
});
// #EXT-X-STREAM-INF
test('parses minimal #EXT-X-STREAM-INF tags', function() {
var
manifest = '#EXT-X-STREAM-INF\n',
element;
parseStream.on('data', function(elem) {
element = elem;
});
lineStream.push(manifest);
ok(element, 'an event was triggered');
strictEqual(element.type, 'tag', 'the line type is tag');
strictEqual(element.tagType, 'stream-inf', 'the tag type is stream-inf');
ok(!('attributes' in element), 'no attributes are present');
});
test('parses #EXT-X-STREAM-INF with common attributes', function() {
var
manifest = '#EXT-X-STREAM-INF:BANDWIDTH=14400\n',
element;
parseStream.on('data', function(elem) {
element = elem;
});
lineStream.push(manifest);
ok(element, 'an event was triggered');
strictEqual(element.type, 'tag', 'the line type is tag');
strictEqual(element.tagType, 'stream-inf', 'the tag type is stream-inf');
strictEqual(element.attributes.BANDWIDTH, 14400, 'bandwidth is parsed');
manifest = '#EXT-X-STREAM-INF:PROGRAM-ID=7\n';
lineStream.push(manifest);
ok(element, 'an event was triggered');
strictEqual(element.type, 'tag', 'the line type is tag');
strictEqual(element.tagType, 'stream-inf', 'the tag type is stream-inf');
strictEqual(element.attributes['PROGRAM-ID'], 7, 'program-id is parsed');
manifest = '#EXT-X-STREAM-INF:RESOLUTION=396x224\n';
lineStream.push(manifest);
ok(element, 'an event was triggered');
strictEqual(element.type, 'tag', 'the line type is tag');
strictEqual(element.tagType, 'stream-inf', 'the tag type is stream-inf');
strictEqual(element.attributes.RESOLUTION.width, 396, 'width is parsed');
strictEqual(element.attributes.RESOLUTION.height, 224, 'heigth is parsed');
manifest = '#EXT-X-STREAM-INF:CODECS="avc1.4d400d, mp4a.40.2"\n';
lineStream.push(manifest);
ok(element, 'an event was triggered');
strictEqual(element.type, 'tag', 'the line type is tag');
strictEqual(element.tagType, 'stream-inf', 'the tag type is stream-inf');
strictEqual(element.attributes.CODECS,
'avc1.4d400d, mp4a.40.2',
'codecs are parsed');
});
test('parses #EXT-X-STREAM-INF with arbitrary attributes', function() {
var
manifest = '#EXT-X-STREAM-INF:NUMERIC=24,ALPHA=Value,MIXED=123abc\n',
element;
parseStream.on('data', function(elem) {
element = elem;
});
lineStream.push(manifest);
ok(element, 'an event was triggered');
strictEqual(element.type, 'tag', 'the line type is tag');
strictEqual(element.tagType, 'stream-inf', 'the tag type is stream-inf');
strictEqual(element.attributes.NUMERIC, '24', 'numeric attributes are parsed');
strictEqual(element.attributes.ALPHA, 'Value', 'alphabetic attributes are parsed');
strictEqual(element.attributes.MIXED, '123abc', 'mixed attributes are parsed');
});
// #EXT-X-ENDLIST
test('parses #EXT-X-ENDLIST tags', function() {
var
manifest = '#EXT-X-ENDLIST\n',
element;
parseStream.on('data', function(elem) {
element = elem;
});
lineStream.push(manifest);
ok(element, 'an event was triggered');
strictEqual(element.type, 'tag', 'the line type is tag');
strictEqual(element.tagType, 'endlist', 'the tag type is stream-inf');
});
// #EXT-X-KEY
test('parses valid #EXT-X-KEY tags', function() {
var
manifest = '#EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=52"\n',
element;
parseStream.on('data', function(elem) {
element = elem;
});
lineStream.push(manifest);
ok(element, 'an event was triggered');
deepEqual(element, {
type: 'tag',
tagType: 'key',
attributes: {
METHOD: 'AES-128',
URI: 'https://priv.example.com/key.php?r=52'
}
}, 'parsed a valid key');
manifest = '#EXT-X-KEY:URI="https://example.com/key#1",METHOD=FutureType-1024\n';
lineStream.push(manifest);
ok(element, 'an event was triggered');
deepEqual(element, {
type: 'tag',
tagType: 'key',
attributes: {
METHOD: 'FutureType-1024',
URI: 'https://example.com/key#1'
}
}, 'parsed the attribute list independent of order');
manifest = '#EXT-X-KEY:IV=1234567890abcdef1234567890abcdef\n';
lineStream.push(manifest);
ok(element.attributes.IV, 'detected an IV attribute');
deepEqual(element.attributes.IV, new Uint32Array([
0x12345678,
0x90abcdef,
0x12345678,
0x90abcdef
]), 'parsed an IV value');
});
test('parses minimal #EXT-X-KEY tags', function() {
var
manifest = '#EXT-X-KEY:\n',
element;
parseStream.on('data', function(elem) {
element = elem;
});
lineStream.push(manifest);
ok(element, 'an event was triggered');
deepEqual(element, {
type: 'tag',
tagType: 'key'
}, 'parsed a minimal key tag');
});
test('parses lightly-broken #EXT-X-KEY tags', function() {
var
manifest = '#EXT-X-KEY:URI=\'https://example.com/single-quote\',METHOD=AES-128\n',
element;
parseStream.on('data', function(elem) {
element = elem;
});
lineStream.push(manifest);
strictEqual(element.attributes.URI,
'https://example.com/single-quote',
'parsed a single-quoted uri');
element = null;
manifest = '#EXT-X-KEYURI="https://example.com/key",METHOD=AES-128\n';
lineStream.push(manifest);
strictEqual(element.tagType, 'key', 'parsed the tag type');
strictEqual(element.attributes.URI,
'https://example.com/key',
'inferred a colon after the tag type');
element = null;
manifest = '#EXT-X-KEY: URI = "https://example.com/key",METHOD=AES-128\n';
lineStream.push(manifest);
strictEqual(element.attributes.URI,
'https://example.com/key',
'trims and removes quotes around the URI');
});
test('ignores empty lines', function() {
var
manifest = '\n',
event = false;
parseStream.on('data', function() {
event = true;
});
lineStream.push(manifest);
ok(!event, 'no event is triggered');
});
module('m3u8 parser');
test('can be constructed', function() {
notStrictEqual(new Parser(), undefined, 'parser is defined');
});
module('m3u8s');
test('parses static manifests as expected', function() {
var key;
for (key in window.manifests) {
if (window.expected[key]) {
parser = new Parser();
parser.push(window.manifests[key]);
deepEqual(parser.manifest,
window.expected[key],
key + '.m3u8 was parsed correctly');
}
}
});
})(window, window.console);
<!doctype html>
<html>
<head>
<title>MPEG-TS Parser Performance Workbench</title>
<!-- video.js -->
<script src="../node_modules/video.js/video.dev.js"></script>
<!-- HLS plugin -->
<script src="../src/video-js-hls.js"></script>
<script src="../src/flv-tag.js"></script>
<script src="../src/exp-golomb.js"></script>
<script src="../src/h264-stream.js"></script>
<script src="../src/aac-stream.js"></script>
<script src="../src/segment-parser.js"></script>
<!-- MPEG-TS segment -->
<script src="tsSegment-bc.js"></script>
<style>
.desc {
background-color: #ddd;
border: thin solid #333;
padding: 8px;
}
</style>
</head>
<body>
<p class="desc">Select your number of iterations and then press "Run" to begin parsing MPEG-TS packets into FLV tags. This page can be handy for identifying segment parser performance bottlenecks.</p>
<form>
<input name="iterations" min="1" type="number" value="1">
<button type="sumbit">Run</button>
</form>
<table>
<thead>
<th>Iterations</th><th>Time</th><th>MB/second</th>
</thead>
<tbody class="results"></tbody>
</table>
<script>
var
button = document.querySelector('button'),
input = document.querySelector('input'),
results = document.querySelector('.results'),
reportResults = function(count, elapsed) {
var
row = document.createElement('tr'),
countCell = document.createElement('td'),
elapsedCell = document.createElement('td'),
throughputCell = document.createElement('td');
countCell.innerText = count;
elapsedCell.innerText = elapsed;
throughputCell.innerText = (((bcSegment.byteLength * count * 1000) / elapsed) / (Math.pow(2, 20))).toFixed(3);
row.appendChild(countCell);
row.appendChild(elapsedCell);
row.appendChild(throughputCell);
results.insertBefore(row, results.firstChild);
};
button.addEventListener('click', function(event) {
var
iterations = input.value,
parser = new window.videojs.hls.SegmentParser(),
start;
// setup
start = +new Date();
while (iterations--) {
// parse the segment
parser.parseSegmentBinaryData(window.bcSegment);
// finalize all the FLV tags
while (parser.tagsAvailable()) {
parser.getNextTag();
}
}
// report
reportResults(input.value, (+new Date()) - start);
// don't actually submit the form
event.preventDefault();
}, false);
</script>
</body>
</html>
import sinon from 'sinon';
import QUnit from 'qunit';
import PlaylistLoader from '../src/playlist-loader';
import videojs from 'video.js';
// Attempts to produce an absolute URL to a given relative path
// based on window.location.href
const urlTo = function(path) {
return window.location.href
.split('/')
.slice(0, -1)
.concat([path])
.join('/');
};
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;
},
afterEach() {
this.sinonXhr.restore();
this.clock.restore();
videojs.xhr.XMLHttpRequest = this.oldXHR;
}
});
QUnit.test('throws if the playlist url is empty or undefined', function() {
QUnit.throws(function() {
PlaylistLoader();
}, 'requires an argument');
QUnit.throws(function() {
PlaylistLoader('');
}, 'does not accept the empty string');
});
QUnit.test('starts without any metadata', function() {
let loader = new PlaylistLoader('master.m3u8');
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.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n');
QUnit.equal(loader.expired_,
0,
'zero seconds expired');
});
QUnit.test('requests the initial playlist immediately', function() {
/* eslint-disable no-unused-vars */
let loader = new PlaylistLoader('master.m3u8');
/* eslint-enable no-unused-vars */
QUnit.strictEqual(this.requests.length, 1, 'made a request');
QUnit.strictEqual(this.requests[0].url,
'master.m3u8',
'requested the initial playlist');
});
QUnit.test('moves to HAVE_MASTER after loading a master playlist', function() {
let loader = new PlaylistLoader('master.m3u8');
let state;
loader.on('loadedplaylist', function() {
state = loader.state;
});
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:\n' +
'media.m3u8\n');
QUnit.ok(loader.master, 'the master playlist is available');
QUnit.strictEqual(state, 'HAVE_MASTER', 'the state at loadedplaylist correct');
});
QUnit.test('jumps to HAVE_METADATA when initialized with a media playlist', function() {
let loadedmetadatas = 0;
let loader = new PlaylistLoader('media.m3u8');
loader.on('loadedmetadata', function() {
loadedmetadatas++;
});
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n' +
'#EXT-X-ENDLIST\n');
QUnit.ok(loader.master, 'infers a master playlist');
QUnit.ok(loader.media(), 'sets the media playlist');
QUnit.ok(loader.media().uri, 'sets the media playlist URI');
QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
QUnit.strictEqual(this.requests.length, 0, 'no more requests are made');
QUnit.strictEqual(loadedmetadatas, 1, 'fired one loadedmetadata');
});
QUnit.test('jumps to HAVE_METADATA when initialized with a live media playlist',
function() {
let loader = new PlaylistLoader('media.m3u8');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n');
QUnit.ok(loader.master, 'infers a master playlist');
QUnit.ok(loader.media(), 'sets the media playlist');
QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
});
QUnit.test('moves to HAVE_METADATA after loading a media playlist', function() {
let loadedPlaylist = 0;
let loadedMetadata = 0;
let loader = new PlaylistLoader('master.m3u8');
loader.on('loadedplaylist', function() {
loadedPlaylist++;
});
loader.on('loadedmetadata', function() {
loadedMetadata++;
});
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:\n' +
'media.m3u8\n' +
'alt.m3u8\n');
QUnit.strictEqual(loadedPlaylist, 1, 'fired loadedplaylist once');
QUnit.strictEqual(loadedMetadata, 0, 'did not fire loadedmetadata');
QUnit.strictEqual(this.requests.length, 1, 'requests the media playlist');
QUnit.strictEqual(this.requests[0].method, 'GET', 'GETs the media playlist');
QUnit.strictEqual(this.requests[0].url,
urlTo('media.m3u8'),
'requests the first playlist');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n');
QUnit.ok(loader.master, 'sets the master playlist');
QUnit.ok(loader.media(), 'sets the media playlist');
QUnit.strictEqual(loadedPlaylist, 2, 'fired loadedplaylist twice');
QUnit.strictEqual(loadedMetadata, 1, 'fired loadedmetadata once');
QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
});
QUnit.test('moves to HAVE_CURRENT_METADATA when refreshing the playlist', function() {
let loader = new PlaylistLoader('live.m3u8');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n');
// 10s, one target duration
this.clock.tick(10 * 1000);
QUnit.strictEqual(loader.state, 'HAVE_CURRENT_METADATA', 'the state is correct');
QUnit.strictEqual(this.requests.length, 1, 'requested playlist');
QUnit.strictEqual(this.requests[0].url,
urlTo('live.m3u8'),
'refreshes the media playlist');
});
QUnit.test('returns to HAVE_METADATA after refreshing the playlist', function() {
let loader = new PlaylistLoader('live.m3u8');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n');
// 10s, one target duration
this.clock.tick(10 * 1000);
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'1.ts\n');
QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
});
QUnit.test('does not increment expired seconds before firstplay is triggered',
function() {
let loader = new PlaylistLoader('live.m3u8');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'0.ts\n' +
'#EXTINF:10,\n' +
'1.ts\n' +
'#EXTINF:10,\n' +
'2.ts\n' +
'#EXTINF:10,\n' +
'3.ts\n');
// 10s, one target duration
this.clock.tick(10 * 1000);
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1\n' +
'#EXTINF:10,\n' +
'1.ts\n' +
'#EXTINF:10,\n' +
'2.ts\n' +
'#EXTINF:10,\n' +
'3.ts\n' +
'#EXTINF:10,\n' +
'4.ts\n');
QUnit.equal(loader.expired_, 0, 'expired one segment');
});
QUnit.test('increments expired seconds after a segment is removed', function() {
let loader = new PlaylistLoader('live.m3u8');
loader.trigger('firstplay');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'0.ts\n' +
'#EXTINF:10,\n' +
'1.ts\n' +
'#EXTINF:10,\n' +
'2.ts\n' +
'#EXTINF:10,\n' +
'3.ts\n');
// 10s, one target duration
this.clock.tick(10 * 1000);
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1\n' +
'#EXTINF:10,\n' +
'1.ts\n' +
'#EXTINF:10,\n' +
'2.ts\n' +
'#EXTINF:10,\n' +
'3.ts\n' +
'#EXTINF:10,\n' +
'4.ts\n');
QUnit.equal(loader.expired_, 10, 'expired one segment');
});
QUnit.test('increments expired seconds after a discontinuity', function() {
let loader = new PlaylistLoader('live.m3u8');
loader.trigger('firstplay');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'0.ts\n' +
'#EXTINF:3,\n' +
'1.ts\n' +
'#EXT-X-DISCONTINUITY\n' +
'#EXTINF:4,\n' +
'2.ts\n');
// 10s, one target duration
this.clock.tick(10 * 1000);
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1\n' +
'#EXTINF:3,\n' +
'1.ts\n' +
'#EXT-X-DISCONTINUITY\n' +
'#EXTINF:4,\n' +
'2.ts\n');
QUnit.equal(loader.expired_, 10, 'expired one segment');
// 10s, one target duration
this.clock.tick(10 * 1000);
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:2\n' +
'#EXT-X-DISCONTINUITY\n' +
'#EXTINF:4,\n' +
'2.ts\n');
QUnit.equal(loader.expired_, 13, 'no expirations after the discontinuity yet');
// 10s, one target duration
this.clock.tick(10 * 1000);
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:3\n' +
'#EXT-X-DISCONTINUITY-SEQUENCE:1\n' +
'#EXTINF:10,\n' +
'3.ts\n');
QUnit.equal(loader.expired_, 17, 'tracked expiration across the discontinuity');
});
QUnit.test('tracks expired seconds properly when two discontinuities expire at once',
function() {
let loader = new PlaylistLoader('live.m3u8');
loader.trigger('firstplay');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:4,\n' +
'0.ts\n' +
'#EXT-X-DISCONTINUITY\n' +
'#EXTINF:5,\n' +
'1.ts\n' +
'#EXT-X-DISCONTINUITY\n' +
'#EXTINF:6,\n' +
'2.ts\n' +
'#EXTINF:7,\n' +
'3.ts\n');
this.clock.tick(10 * 1000);
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:3\n' +
'#EXT-X-DISCONTINUITY-SEQUENCE:2\n' +
'#EXTINF:7,\n' +
'3.ts\n');
QUnit.equal(loader.expired_, 4 + 5 + 6, 'tracked multiple expiring discontinuities');
});
QUnit.test('estimates expired if an entire window elapses between live playlist updates',
function() {
let loader = new PlaylistLoader('live.m3u8');
loader.trigger('firstplay');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:4,\n' +
'0.ts\n' +
'#EXTINF:5,\n' +
'1.ts\n');
this.clock.tick(10 * 1000);
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:4\n' +
'#EXTINF:6,\n' +
'4.ts\n' +
'#EXTINF:7,\n' +
'5.ts\n');
QUnit.equal(loader.expired_,
4 + 5 + (2 * 10),
'made a very rough estimate of expired time');
});
QUnit.test('emits an error when an initial playlist request fails', function() {
let errors = [];
let loader = new PlaylistLoader('master.m3u8');
loader.on('error', function() {
errors.push(loader.error);
});
this.requests.pop().respond(500);
QUnit.strictEqual(errors.length, 1, 'emitted one error');
QUnit.strictEqual(errors[0].status, 500, 'http status is captured');
});
QUnit.test('errors when an initial media playlist request fails', function() {
let errors = [];
let loader = new PlaylistLoader('master.m3u8');
loader.on('error', function() {
errors.push(loader.error);
});
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:\n' +
'media.m3u8\n');
QUnit.strictEqual(errors.length, 0, 'emitted no errors');
this.requests.pop().respond(500);
QUnit.strictEqual(errors.length, 1, 'emitted one error');
QUnit.strictEqual(errors[0].status, 500, 'http status is captured');
});
// 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');
/* eslint-enable no-unused-vars */
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'0.ts\n');
// trigger a refresh
this.clock.tick(10 * 1000);
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'0.ts\n');
// half the default target-duration
this.clock.tick(5 * 1000);
QUnit.strictEqual(this.requests.length, 1, 'sent a request');
QUnit.strictEqual(this.requests[0].url,
urlTo('live.m3u8'),
'requested the media playlist');
});
QUnit.test('preserves segment metadata across playlist refreshes', function() {
let loader = new PlaylistLoader('live.m3u8');
let segment;
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'0.ts\n' +
'#EXTINF:10,\n' +
'1.ts\n' +
'#EXTINF:10,\n' +
'2.ts\n');
// add PTS info to 1.ts
segment = loader.media().segments[1];
segment.minVideoPts = 14;
segment.maxAudioPts = 27;
segment.preciseDuration = 10.045;
// trigger a refresh
this.clock.tick(10 * 1000);
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1\n' +
'#EXTINF:10,\n' +
'1.ts\n' +
'#EXTINF:10,\n' +
'2.ts\n');
QUnit.deepEqual(loader.media().segments[0], segment, 'preserved segment attributes');
});
QUnit.test('clears the update timeout when switching quality', function() {
let loader = new PlaylistLoader('live-master.m3u8');
let refreshes = 0;
// track the number of playlist refreshes triggered
loader.on('mediaupdatetimeout', function() {
refreshes++;
});
// deliver the master
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'live-low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'live-high.m3u8\n');
// deliver the low quality playlist
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n');
// change to a higher quality playlist
loader.media('live-high.m3u8');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'high-0.ts\n');
// trigger a refresh
this.clock.tick(10 * 1000);
QUnit.equal(1, refreshes, 'only one refresh was triggered');
});
QUnit.test('media-sequence updates are considered a playlist change', function() {
/* eslint-disable no-unused-vars */
let loader = new PlaylistLoader('live.m3u8');
/* eslint-enable no-unused-vars */
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'0.ts\n');
// trigger a refresh
this.clock.tick(10 * 1000);
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1\n' +
'#EXTINF:10,\n' +
'0.ts\n');
// half the default target-duration
this.clock.tick(5 * 1000);
QUnit.strictEqual(this.requests.length, 0, 'no request is sent');
});
QUnit.test('emits an error if a media refresh fails', function() {
let errors = 0;
let errorResponseText = 'custom error message';
let loader = new PlaylistLoader('live.m3u8');
loader.on('error', function() {
errors++;
});
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'0.ts\n');
// trigger a refresh
this.clock.tick(10 * 1000);
this.requests.pop().respond(500, null, errorResponseText);
QUnit.strictEqual(errors, 1, 'emitted an error');
QUnit.strictEqual(loader.error.status, 500, 'captured the status code');
QUnit.strictEqual(loader.error.responseText,
errorResponseText,
'captured the responseText');
});
QUnit.test('switches media playlists when requested', function() {
let loader = new PlaylistLoader('master.m3u8');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n');
loader.media(loader.master.playlists[1]);
QUnit.strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'high-0.ts\n');
QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'switched active media');
QUnit.strictEqual(loader.media(),
loader.master.playlists[1],
'updated the active media');
});
QUnit.test('can switch playlists immediately after the master is downloaded', function() {
let loader = new PlaylistLoader('master.m3u8');
loader.on('loadedplaylist', function() {
loader.media('high.m3u8');
});
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
QUnit.equal(this.requests[0].url, urlTo('high.m3u8'), 'switched variants immediately');
});
QUnit.test('can switch media playlists based on URI', function() {
let loader = new PlaylistLoader('master.m3u8');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n');
loader.media('high.m3u8');
QUnit.strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'high-0.ts\n');
QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'switched active media');
QUnit.strictEqual(loader.media(),
loader.master.playlists[1],
'updated the active media');
});
QUnit.test('aborts in-flight playlist refreshes when switching', function() {
let loader = new PlaylistLoader('master.m3u8');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n');
this.clock.tick(10 * 1000);
loader.media('high.m3u8');
QUnit.strictEqual(this.requests[0].aborted, true, 'aborted refresh request');
QUnit.ok(!this.requests[0].onreadystatechange,
'onreadystatechange handlers should be removed on abort');
QUnit.strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state');
});
QUnit.test('switching to the active playlist is a no-op', function() {
let loader = new PlaylistLoader('master.m3u8');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n' +
'#EXT-X-ENDLIST\n');
loader.media('low.m3u8');
QUnit.strictEqual(this.requests.length, 0, 'no requests are sent');
});
QUnit.test('switching to the active live playlist is a no-op', function() {
let loader = new PlaylistLoader('master.m3u8');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n');
loader.media('low.m3u8');
QUnit.strictEqual(this.requests.length, 0, 'no requests are sent');
});
QUnit.test('switches back to loaded playlists without re-requesting them', function() {
let loader = new PlaylistLoader('master.m3u8');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n' +
'#EXT-X-ENDLIST\n');
loader.media('high.m3u8');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'high-0.ts\n' +
'#EXT-X-ENDLIST\n');
loader.media('low.m3u8');
QUnit.strictEqual(this.requests.length, 0, 'no outstanding requests');
QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'returned to loaded playlist');
});
QUnit.test('aborts outstanding requests if switching back to an already loaded playlist',
function() {
let loader = new PlaylistLoader('master.m3u8');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n' +
'#EXT-X-ENDLIST\n');
loader.media('high.m3u8');
loader.media('low.m3u8');
QUnit.strictEqual(this.requests.length,
1,
'requested high playlist');
QUnit.ok(this.requests[0].aborted,
'aborted playlist request');
QUnit.ok(!this.requests[0].onreadystatechange,
'onreadystatechange handlers should be removed on abort');
QUnit.strictEqual(loader.state,
'HAVE_METADATA',
'returned to loaded playlist');
QUnit.strictEqual(loader.media(),
loader.master.playlists[0],
'switched to loaded playlist');
});
QUnit.test('does not abort requests when the same playlist is re-requested',
function() {
let loader = new PlaylistLoader('master.m3u8');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n' +
'#EXT-X-ENDLIST\n');
loader.media('high.m3u8');
loader.media('high.m3u8');
QUnit.strictEqual(this.requests.length, 1, 'made only one request');
QUnit.ok(!this.requests[0].aborted, 'request not aborted');
});
QUnit.test('throws an error if a media switch is initiated too early', function() {
let loader = new PlaylistLoader('master.m3u8');
QUnit.throws(function() {
loader.media('high.m3u8');
}, 'threw an error from HAVE_NOTHING');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
});
QUnit.test('throws an error if a switch to an unrecognized playlist is requested',
function() {
let loader = new PlaylistLoader('master.m3u8');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'media.m3u8\n');
QUnit.throws(function() {
loader.media('unrecognized.m3u8');
}, 'throws an error');
});
QUnit.test('dispose cancels the refresh timeout', function() {
let loader = new PlaylistLoader('live.m3u8');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'0.ts\n');
loader.dispose();
// a lot of time passes...
this.clock.tick(15 * 1000);
QUnit.strictEqual(this.requests.length, 0, 'no refresh request was made');
});
QUnit.test('dispose aborts pending refresh requests', function() {
let loader = new PlaylistLoader('live.m3u8');
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'0.ts\n');
this.clock.tick(10 * 1000);
loader.dispose();
QUnit.ok(this.requests[0].aborted, 'refresh request aborted');
QUnit.ok(!this.requests[0].onreadystatechange,
'onreadystatechange handler should not exist after dispose called'
);
});
QUnit.test('errors if requests take longer than 45s', function() {
let loader = new PlaylistLoader('media.m3u8');
let errors = 0;
loader.on('error', function() {
errors++;
});
this.clock.tick(45 * 1000);
QUnit.strictEqual(errors, 1, 'fired one error');
QUnit.strictEqual(loader.error.code, 2, 'fired a network error');
});
QUnit.test('triggers an event when the active media changes', function() {
let loader = new PlaylistLoader('master.m3u8');
let mediaChanges = 0;
loader.on('mediachange', function() {
mediaChanges++;
});
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n' +
'#EXT-X-ENDLIST\n');
QUnit.strictEqual(mediaChanges, 0, 'initial selection is not a media change');
loader.media('high.m3u8');
QUnit.strictEqual(mediaChanges, 0, 'mediachange does not fire immediately');
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'high-0.ts\n' +
'#EXT-X-ENDLIST\n');
QUnit.strictEqual(mediaChanges, 1, 'fired a mediachange');
// switch back to an already loaded playlist
loader.media('low.m3u8');
QUnit.strictEqual(mediaChanges, 2, 'fired a mediachange');
// trigger a no-op switch
loader.media('low.m3u8');
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.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.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.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');
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.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');
// no newline
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n' +
'#EXT-X-ENDLIST');
QUnit.ok(loader.media().endList, 'flushed the final line of input');
});
(function(window) {
'use strict';
var
sinonXhr,
clock,
requests,
videojs = window.videojs,
// Attempts to produce an absolute URL to a given relative path
// based on window.location.href
urlTo = function(path) {
return window.location.href
.split('/')
.slice(0, -1)
.concat([path])
.join('/');
};
module('Playlist Loader', {
setup: function() {
// fake XHRs
sinonXhr = sinon.useFakeXMLHttpRequest();
videojs.xhr.XMLHttpRequest = sinonXhr;
requests = [];
sinonXhr.onCreate = function(xhr) {
// force the XHR2 timeout polyfill
xhr.timeout = undefined;
requests.push(xhr);
};
// fake timers
clock = sinon.useFakeTimers();
},
teardown: function() {
sinonXhr.restore();
videojs.xhr.XMLHttpRequest = window.XMLHttpRequest;
clock.restore();
}
});
test('throws if the playlist url is empty or undefined', function() {
throws(function() {
videojs.Hls.PlaylistLoader();
}, 'requires an argument');
throws(function() {
videojs.Hls.PlaylistLoader('');
}, 'does not accept the empty string');
});
test('starts without any metadata', function() {
var loader = new videojs.Hls.PlaylistLoader('master.m3u8');
strictEqual(loader.state, 'HAVE_NOTHING', 'no metadata has loaded yet');
});
test('starts with no expired time', function() {
var loader = new videojs.Hls.PlaylistLoader('media.m3u8');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n');
equal(loader.expired_,
0,
'zero seconds expired');
});
test('requests the initial playlist immediately', function() {
new videojs.Hls.PlaylistLoader('master.m3u8');
strictEqual(requests.length, 1, 'made a request');
strictEqual(requests[0].url, 'master.m3u8', 'requested the initial playlist');
});
test('moves to HAVE_MASTER after loading a master playlist', function() {
var loader = new videojs.Hls.PlaylistLoader('master.m3u8'), state;
loader.on('loadedplaylist', function() {
state = loader.state;
});
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:\n' +
'media.m3u8\n');
ok(loader.master, 'the master playlist is available');
strictEqual(state, 'HAVE_MASTER', 'the state at loadedplaylist correct');
});
test('jumps to HAVE_METADATA when initialized with a media playlist', function() {
var
loadedmetadatas = 0,
loader = new videojs.Hls.PlaylistLoader('media.m3u8');
loader.on('loadedmetadata', function() {
loadedmetadatas++;
});
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n' +
'#EXT-X-ENDLIST\n');
ok(loader.master, 'infers a master playlist');
ok(loader.media(), 'sets the media playlist');
ok(loader.media().uri, 'sets the media playlist URI');
strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
strictEqual(requests.length, 0, 'no more requests are made');
strictEqual(loadedmetadatas, 1, 'fired one loadedmetadata');
});
test('jumps to HAVE_METADATA when initialized with a live media playlist', function() {
var loader = new videojs.Hls.PlaylistLoader('media.m3u8');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n');
ok(loader.master, 'infers a master playlist');
ok(loader.media(), 'sets the media playlist');
strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
});
test('moves to HAVE_METADATA after loading a media playlist', function() {
var
loadedPlaylist = 0,
loadedMetadata = 0,
loader = new videojs.Hls.PlaylistLoader('master.m3u8');
loader.on('loadedplaylist', function() {
loadedPlaylist++;
});
loader.on('loadedmetadata', function() {
loadedMetadata++;
});
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:\n' +
'media.m3u8\n' +
'alt.m3u8\n');
strictEqual(loadedPlaylist, 1, 'fired loadedplaylist once');
strictEqual(loadedMetadata, 0, 'did not fire loadedmetadata');
strictEqual(requests.length, 1, 'requests the media playlist');
strictEqual(requests[0].method, 'GET', 'GETs the media playlist');
strictEqual(requests[0].url,
urlTo('media.m3u8'),
'requests the first playlist');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n');
ok(loader.master, 'sets the master playlist');
ok(loader.media(), 'sets the media playlist');
strictEqual(loadedPlaylist, 2, 'fired loadedplaylist twice');
strictEqual(loadedMetadata, 1, 'fired loadedmetadata once');
strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
});
test('moves to HAVE_CURRENT_METADATA when refreshing the playlist', function() {
var loader = new videojs.Hls.PlaylistLoader('live.m3u8');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n');
clock.tick(10 * 1000); // 10s, one target duration
strictEqual(loader.state, 'HAVE_CURRENT_METADATA', 'the state is correct');
strictEqual(requests.length, 1, 'requested playlist');
strictEqual(requests[0].url,
urlTo('live.m3u8'),
'refreshes the media playlist');
});
test('returns to HAVE_METADATA after refreshing the playlist', function() {
var loader = new videojs.Hls.PlaylistLoader('live.m3u8');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n');
clock.tick(10 * 1000); // 10s, one target duration
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'1.ts\n');
strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
});
test('does not increment expired seconds before firstplay is triggered', function() {
var loader = new videojs.Hls.PlaylistLoader('live.m3u8');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'0.ts\n' +
'#EXTINF:10,\n' +
'1.ts\n' +
'#EXTINF:10,\n' +
'2.ts\n' +
'#EXTINF:10,\n' +
'3.ts\n');
clock.tick(10 * 1000); // 10s, one target duration
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1\n' +
'#EXTINF:10,\n' +
'1.ts\n' +
'#EXTINF:10,\n' +
'2.ts\n' +
'#EXTINF:10,\n' +
'3.ts\n' +
'#EXTINF:10,\n' +
'4.ts\n');
equal(loader.expired_, 0, 'expired one segment');
});
test('increments expired seconds after a segment is removed', function() {
var loader = new videojs.Hls.PlaylistLoader('live.m3u8');
loader.trigger('firstplay');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'0.ts\n' +
'#EXTINF:10,\n' +
'1.ts\n' +
'#EXTINF:10,\n' +
'2.ts\n' +
'#EXTINF:10,\n' +
'3.ts\n');
clock.tick(10 * 1000); // 10s, one target duration
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1\n' +
'#EXTINF:10,\n' +
'1.ts\n' +
'#EXTINF:10,\n' +
'2.ts\n' +
'#EXTINF:10,\n' +
'3.ts\n' +
'#EXTINF:10,\n' +
'4.ts\n');
equal(loader.expired_, 10, 'expired one segment');
});
test('increments expired seconds after a discontinuity', function() {
var loader = new videojs.Hls.PlaylistLoader('live.m3u8');
loader.trigger('firstplay');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'0.ts\n' +
'#EXTINF:3,\n' +
'1.ts\n' +
'#EXT-X-DISCONTINUITY\n' +
'#EXTINF:4,\n' +
'2.ts\n');
clock.tick(10 * 1000); // 10s, one target duration
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1\n' +
'#EXTINF:3,\n' +
'1.ts\n' +
'#EXT-X-DISCONTINUITY\n' +
'#EXTINF:4,\n' +
'2.ts\n');
equal(loader.expired_, 10, 'expired one segment');
clock.tick(10 * 1000); // 10s, one target duration
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:2\n' +
'#EXT-X-DISCONTINUITY\n' +
'#EXTINF:4,\n' +
'2.ts\n');
equal(loader.expired_, 13, 'no expirations after the discontinuity yet');
clock.tick(10 * 1000); // 10s, one target duration
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:3\n' +
'#EXT-X-DISCONTINUITY-SEQUENCE:1\n' +
'#EXTINF:10,\n' +
'3.ts\n');
equal(loader.expired_, 17, 'tracked expiration across the discontinuity');
});
test('tracks expired seconds properly when two discontinuities expire at once', function() {
var loader = new videojs.Hls.PlaylistLoader('live.m3u8');
loader.trigger('firstplay');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:4,\n' +
'0.ts\n' +
'#EXT-X-DISCONTINUITY\n' +
'#EXTINF:5,\n' +
'1.ts\n' +
'#EXT-X-DISCONTINUITY\n' +
'#EXTINF:6,\n' +
'2.ts\n' +
'#EXTINF:7,\n' +
'3.ts\n');
clock.tick(10 * 1000);
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:3\n' +
'#EXT-X-DISCONTINUITY-SEQUENCE:2\n' +
'#EXTINF:7,\n' +
'3.ts\n');
equal(loader.expired_, 4 + 5 + 6, 'tracked multiple expiring discontinuities');
});
test('estimates expired if an entire window elapses between live playlist updates', function() {
var loader = new videojs.Hls.PlaylistLoader('live.m3u8');
loader.trigger('firstplay');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:4,\n' +
'0.ts\n' +
'#EXTINF:5,\n' +
'1.ts\n');
clock.tick(10 * 1000);
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:4\n' +
'#EXTINF:6,\n' +
'4.ts\n' +
'#EXTINF:7,\n' +
'5.ts\n');
equal(loader.expired_,
4 + 5 + (2 * 10),
'made a very rough estimate of expired time');
});
test('emits an error when an initial playlist request fails', function() {
var
errors = [],
loader = new videojs.Hls.PlaylistLoader('master.m3u8');
loader.on('error', function() {
errors.push(loader.error);
});
requests.pop().respond(500);
strictEqual(errors.length, 1, 'emitted one error');
strictEqual(errors[0].status, 500, 'http status is captured');
});
test('errors when an initial media playlist request fails', function() {
var
errors = [],
loader = new videojs.Hls.PlaylistLoader('master.m3u8');
loader.on('error', function() {
errors.push(loader.error);
});
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:\n' +
'media.m3u8\n');
strictEqual(errors.length, 0, 'emitted no errors');
requests.pop().respond(500);
strictEqual(errors.length, 1, 'emitted one error');
strictEqual(errors[0].status, 500, 'http status is captured');
});
// http://tools.ietf.org/html/draft-pantos-http-live-streaming-12#section-6.3.4
test('halves the refresh timeout if a playlist is unchanged' +
'since the last reload', function() {
new videojs.Hls.PlaylistLoader('live.m3u8');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'0.ts\n');
clock.tick(10 * 1000); // trigger a refresh
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'0.ts\n');
clock.tick(5 * 1000); // half the default target-duration
strictEqual(requests.length, 1, 'sent a request');
strictEqual(requests[0].url,
urlTo('live.m3u8'),
'requested the media playlist');
});
test('preserves segment metadata across playlist refreshes', function() {
var loader = new videojs.Hls.PlaylistLoader('live.m3u8'), segment;
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'0.ts\n' +
'#EXTINF:10,\n' +
'1.ts\n' +
'#EXTINF:10,\n' +
'2.ts\n');
// add PTS info to 1.ts
segment = loader.media().segments[1];
segment.minVideoPts = 14;
segment.maxAudioPts = 27;
segment.preciseDuration = 10.045;
clock.tick(10 * 1000); // trigger a refresh
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1\n' +
'#EXTINF:10,\n' +
'1.ts\n' +
'#EXTINF:10,\n' +
'2.ts\n');
deepEqual(loader.media().segments[0], segment, 'preserved segment attributes');
});
test('clears the update timeout when switching quality', function() {
var loader = new videojs.Hls.PlaylistLoader('live-master.m3u8'), refreshes = 0;
// track the number of playlist refreshes triggered
loader.on('mediaupdatetimeout', function() {
refreshes++;
});
// deliver the master
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'live-low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'live-high.m3u8\n');
// deliver the low quality playlist
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n');
// change to a higher quality playlist
loader.media('live-high.m3u8');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'high-0.ts\n');
clock.tick(10 * 1000); // trigger a refresh
equal(1, refreshes, 'only one refresh was triggered');
});
test('media-sequence updates are considered a playlist change', function() {
new videojs.Hls.PlaylistLoader('live.m3u8');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'0.ts\n');
clock.tick(10 * 1000); // trigger a refresh
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1\n' +
'#EXTINF:10,\n' +
'0.ts\n');
clock.tick(5 * 1000); // half the default target-duration
strictEqual(requests.length, 0, 'no request is sent');
});
test('emits an error if a media refresh fails', function() {
var
errors = 0,
errorResponseText = 'custom error message',
loader = new videojs.Hls.PlaylistLoader('live.m3u8');
loader.on('error', function() {
errors++;
});
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'0.ts\n');
clock.tick(10 * 1000); // trigger a refresh
requests.pop().respond(500, null, errorResponseText);
strictEqual(errors, 1, 'emitted an error');
strictEqual(loader.error.status, 500, 'captured the status code');
strictEqual(loader.error.responseText, errorResponseText, 'captured the responseText');
});
test('switches media playlists when requested', function() {
var loader = new videojs.Hls.PlaylistLoader('master.m3u8');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n');
loader.media(loader.master.playlists[1]);
strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'high-0.ts\n');
strictEqual(loader.state, 'HAVE_METADATA', 'switched active media');
strictEqual(loader.media(),
loader.master.playlists[1],
'updated the active media');
});
test('can switch playlists immediately after the master is downloaded', function() {
var loader = new videojs.Hls.PlaylistLoader('master.m3u8');
loader.on('loadedplaylist', function() {
loader.media('high.m3u8');
});
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
equal(requests[0].url, urlTo('high.m3u8'), 'switched variants immediately');
});
test('can switch media playlists based on URI', function() {
var loader = new videojs.Hls.PlaylistLoader('master.m3u8');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n');
loader.media('high.m3u8');
strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'high-0.ts\n');
strictEqual(loader.state, 'HAVE_METADATA', 'switched active media');
strictEqual(loader.media(),
loader.master.playlists[1],
'updated the active media');
});
test('aborts in-flight playlist refreshes when switching', function() {
var loader = new videojs.Hls.PlaylistLoader('master.m3u8');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n');
clock.tick(10 * 1000);
loader.media('high.m3u8');
strictEqual(requests[0].aborted, true, 'aborted refresh request');
ok(!requests[0].onreadystatechange, 'onreadystatechange handlers should be removed on abort');
strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state');
});
test('switching to the active playlist is a no-op', function() {
var loader = new videojs.Hls.PlaylistLoader('master.m3u8');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n' +
'#EXT-X-ENDLIST\n');
loader.media('low.m3u8');
strictEqual(requests.length, 0, 'no requests are sent');
});
test('switching to the active live playlist is a no-op', function() {
var loader = new videojs.Hls.PlaylistLoader('master.m3u8');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n');
loader.media('low.m3u8');
strictEqual(requests.length, 0, 'no requests are sent');
});
test('switches back to loaded playlists without re-requesting them', function() {
var loader = new videojs.Hls.PlaylistLoader('master.m3u8');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n' +
'#EXT-X-ENDLIST\n');
loader.media('high.m3u8');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'high-0.ts\n' +
'#EXT-X-ENDLIST\n');
loader.media('low.m3u8');
strictEqual(requests.length, 0, 'no outstanding requests');
strictEqual(loader.state, 'HAVE_METADATA', 'returned to loaded playlist');
});
test('aborts outstanding requests if switching back to an already loaded playlist', function() {
var loader = new videojs.Hls.PlaylistLoader('master.m3u8');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n' +
'#EXT-X-ENDLIST\n');
loader.media('high.m3u8');
loader.media('low.m3u8');
strictEqual(requests.length, 1, 'requested high playlist');
ok(requests[0].aborted, 'aborted playlist request');
ok(!requests[0].onreadystatechange, 'onreadystatechange handlers should be removed on abort');
strictEqual(loader.state, 'HAVE_METADATA', 'returned to loaded playlist');
strictEqual(loader.media(), loader.master.playlists[0], 'switched to loaded playlist');
});
test('does not abort requests when the same playlist is re-requested', function() {
var loader = new videojs.Hls.PlaylistLoader('master.m3u8');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n' +
'#EXT-X-ENDLIST\n');
loader.media('high.m3u8');
loader.media('high.m3u8');
strictEqual(requests.length, 1, 'made only one request');
ok(!requests[0].aborted, 'request not aborted');
});
test('throws an error if a media switch is initiated too early', function() {
var loader = new videojs.Hls.PlaylistLoader('master.m3u8');
throws(function() {
loader.media('high.m3u8');
}, 'threw an error from HAVE_NOTHING');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
});
test('throws an error if a switch to an unrecognized playlist is requested', function() {
var loader = new videojs.Hls.PlaylistLoader('master.m3u8');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'media.m3u8\n');
throws(function() {
loader.media('unrecognized.m3u8');
}, 'throws an error');
});
test('dispose cancels the refresh timeout', function() {
var loader = new videojs.Hls.PlaylistLoader('live.m3u8');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'0.ts\n');
loader.dispose();
// a lot of time passes...
clock.tick(15 * 1000);
strictEqual(requests.length, 0, 'no refresh request was made');
});
test('dispose aborts pending refresh requests', function() {
var loader = new videojs.Hls.PlaylistLoader('live.m3u8');
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'0.ts\n');
clock.tick(10 * 1000);
loader.dispose();
ok(requests[0].aborted, 'refresh request aborted');
ok(!requests[0].onreadystatechange, 'onreadystatechange handler should not exist after dispose called');
});
test('errors if requests take longer than 45s', function() {
var
loader = new videojs.Hls.PlaylistLoader('media.m3u8'),
errors = 0;
loader.on('error', function() {
errors++;
});
clock.tick(45 * 1000);
strictEqual(errors, 1, 'fired one error');
strictEqual(loader.error.code, 2, 'fired a network error');
});
test('triggers an event when the active media changes', function() {
var
loader = new videojs.Hls.PlaylistLoader('master.m3u8'),
mediaChanges = 0;
loader.on('mediachange', function() {
mediaChanges++;
});
requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
'low.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
'high.m3u8\n');
requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n' +
'#EXT-X-ENDLIST\n');
strictEqual(mediaChanges, 0, 'initial selection is not a media change');
loader.media('high.m3u8');
strictEqual(mediaChanges, 0, 'mediachange does not fire immediately');
requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'high-0.ts\n' +
'#EXT-X-ENDLIST\n');
strictEqual(mediaChanges, 1, 'fired a mediachange');
// switch back to an already loaded playlist
loader.media('low.m3u8');
strictEqual(mediaChanges, 2, 'fired a mediachange');
// trigger a no-op switch
loader.media('low.m3u8');
strictEqual(mediaChanges, 2, 'ignored a no-op media change');
});
test('can get media index by playback position for non-live videos', function() {
var loader = new videojs.Hls.PlaylistLoader('media.m3u8');
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');
equal(loader.getMediaIndexForTime_(-1),
0,
'the index is never less than zero');
equal(loader.getMediaIndexForTime_(0), 0, 'time zero is index zero');
equal(loader.getMediaIndexForTime_(3), 0, 'time three is index zero');
equal(loader.getMediaIndexForTime_(10), 2, 'time 10 is index 2');
equal(loader.getMediaIndexForTime_(22),
2,
'time greater than the length is index 2');
});
test('returns the lower index when calculating for a segment boundary', function() {
var loader = new videojs.Hls.PlaylistLoader('media.m3u8');
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');
equal(loader.getMediaIndexForTime_(4), 1, 'rounds up exact matches');
equal(loader.getMediaIndexForTime_(3.7), 0, 'rounds down');
equal(loader.getMediaIndexForTime_(4.5), 1, 'rounds up at 0.5');
});
test('accounts for non-zero starting segment time when calculating media index', function() {
var loader = new videojs.Hls.PlaylistLoader('media.m3u8');
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;
equal(loader.getMediaIndexForTime_(0), -1, 'the lowest returned value is negative one');
equal(loader.getMediaIndexForTime_(45), -1, 'expired content returns negative one');
equal(loader.getMediaIndexForTime_(75), -1, 'expired content returns negative one');
equal(loader.getMediaIndexForTime_(50 + 100), 0, 'calculates the earliest available position');
equal(loader.getMediaIndexForTime_(50 + 100 + 2), 0, 'calculates within the first segment');
equal(loader.getMediaIndexForTime_(50 + 100 + 2), 0, 'calculates within the first segment');
equal(loader.getMediaIndexForTime_(50 + 100 + 4), 1, 'calculates within the second segment');
equal(loader.getMediaIndexForTime_(50 + 100 + 4.5), 1, 'calculates within the second segment');
equal(loader.getMediaIndexForTime_(50 + 100 + 6), 1, 'calculates within the second segment');
});
test('prefers precise segment timing when tracking expired time', function() {
var loader = new videojs.Hls.PlaylistLoader('media.m3u8');
loader.trigger('firstplay');
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;
equal(loader.getMediaIndexForTime_(149), 0, 'prefers the value on the first segment');
clock.tick(10 * 1000); // trigger a playlist refresh
requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:1002\n' +
'#EXTINF:5,\n' +
'1002.ts\n');
equal(loader.getMediaIndexForTime_(150 + 4 + 1), 0, 'tracks precise expired times');
});
test('accounts for expired time when calculating media index', function() {
var loader = new videojs.Hls.PlaylistLoader('media.m3u8');
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;
equal(loader.getMediaIndexForTime_(0), -1, 'expired content returns a negative index');
equal(loader.getMediaIndexForTime_(75), -1, 'expired content returns a negative index');
equal(loader.getMediaIndexForTime_(50 + 100), 0, 'calculates the earliest available position');
equal(loader.getMediaIndexForTime_(50 + 100 + 2), 0, 'calculates within the first segment');
equal(loader.getMediaIndexForTime_(50 + 100 + 2), 0, 'calculates within the first segment');
equal(loader.getMediaIndexForTime_(50 + 100 + 4.5), 1, 'calculates within the second segment');
equal(loader.getMediaIndexForTime_(50 + 100 + 6), 1, 'calculates within the second segment');
});
test('does not misintrepret playlists missing newlines at the end', function() {
var loader = new videojs.Hls.PlaylistLoader('media.m3u8');
requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MEDIA-SEQUENCE:0\n' +
'#EXTINF:10,\n' +
'low-0.ts\n' +
'#EXT-X-ENDLIST'); // no newline
ok(loader.media().endList, 'flushed the final line of input');
});
})(window);
/* Tests for the playlist utilities */
(function(window, videojs) {
'use strict';
var Playlist = videojs.Hls.Playlist;
import Playlist from '../src/playlist';
import QUnit from 'qunit';
QUnit.module('Playlist Duration');
module('Playlist Duration');
test('total duration for live playlists is Infinity', function() {
var duration = Playlist.duration({
QUnit.test('total duration for live playlists is Infinity', function() {
let duration = Playlist.duration({
segments: [{
duration: 4,
uri: '0.ts'
}]
});
equal(duration, Infinity, 'duration is infinity');
});
QUnit.equal(duration, Infinity, 'duration is infinity');
});
module('Playlist Interval Duration');
QUnit.module('Playlist Interval Duration');
test('accounts for non-zero starting VOD media sequences', function() {
var duration = Playlist.duration({
QUnit.test('accounts for non-zero starting VOD media sequences', function() {
let duration = Playlist.duration({
mediaSequence: 10,
endList: true,
segments: [{
......@@ -37,11 +34,11 @@
}]
});
equal(duration, 4 * 10, 'includes only listed segments');
});
QUnit.equal(duration, 4 * 10, 'includes only listed segments');
});
test('uses timeline values when available', function() {
var duration = Playlist.duration({
QUnit.test('uses timeline values when available', function() {
let duration = Playlist.duration({
mediaSequence: 0,
endList: true,
segments: [{
......@@ -62,11 +59,11 @@
}]
}, 4);
equal(duration, 4 * 10 + 2, 'used timeline values');
});
QUnit.equal(duration, 4 * 10 + 2, 'used timeline values');
});
test('works when partial timeline information is available', function() {
var duration = Playlist.duration({
QUnit.test('works when partial timeline information is available', function() {
let duration = Playlist.duration({
mediaSequence: 0,
endList: true,
segments: [{
......@@ -90,11 +87,11 @@
}]
}, 5);
equal(duration, 50.0002, 'calculated with mixed intervals');
});
QUnit.equal(duration, 50.0002, 'calculated with mixed intervals');
});
test('uses timeline values for the expired duration of live playlists', function() {
var playlist = {
QUnit.test('uses timeline values for the expired duration of live playlists', function() {
let playlist = {
mediaSequence: 12,
segments: [{
duration: 10,
......@@ -104,18 +101,20 @@
duration: 9,
uri: '1.ts'
}]
}, duration;
};
let duration;
duration = Playlist.duration(playlist, playlist.mediaSequence);
equal(duration, 110.5, 'used segment end time');
QUnit.equal(duration, 110.5, 'used segment end time');
duration = Playlist.duration(playlist, playlist.mediaSequence + 1);
equal(duration, 120.5, 'used segment end time');
QUnit.equal(duration, 120.5, 'used segment end time');
duration = Playlist.duration(playlist, playlist.mediaSequence + 2);
equal(duration, 120.5 + 9, 'used segment end time');
});
QUnit.equal(duration, 120.5 + 9, 'used segment end time');
});
test('looks outside the queried interval for live playlist timeline values', function() {
var playlist = {
QUnit.test('looks outside the queried interval for live playlist timeline values',
function() {
let playlist = {
mediaSequence: 12,
segments: [{
duration: 10,
......@@ -125,14 +124,15 @@
end: 120.5,
uri: '1.ts'
}]
}, duration;
};
let duration;
duration = Playlist.duration(playlist, playlist.mediaSequence);
equal(duration, 120.5 - 9 - 10, 'used segment end time');
});
QUnit.equal(duration, 120.5 - 9 - 10, 'used segment end time');
});
test('ignores discontinuity sequences later than the end', function() {
var duration = Playlist.duration({
QUnit.test('ignores discontinuity sequences later than the end', function() {
let duration = Playlist.duration({
mediaSequence: 0,
discontinuityStarts: [1, 3],
segments: [{
......@@ -152,12 +152,12 @@
}]
}, 2);
equal(duration, 19, 'excluded the later segments');
});
QUnit.equal(duration, 19, 'excluded the later segments');
});
test('handles trailing segments without timeline information', function() {
var playlist, duration;
playlist = {
QUnit.test('handles trailing segments without timeline information', function() {
let duration;
let playlist = {
mediaSequence: 0,
endList: true,
segments: [{
......@@ -178,15 +178,15 @@
};
duration = Playlist.duration(playlist, 3);
equal(duration, 29.45, 'calculated duration');
QUnit.equal(duration, 29.45, 'calculated duration');
duration = Playlist.duration(playlist, 2);
equal(duration, 19.5, 'calculated duration');
});
QUnit.equal(duration, 19.5, 'calculated duration');
});
test('uses timeline intervals when segments have them', function() {
var playlist, duration;
playlist = {
QUnit.test('uses timeline intervals when segments have them', function() {
let duration;
let playlist = {
mediaSequence: 0,
segments: [{
start: 0,
......@@ -195,23 +195,24 @@
}, {
duration: 9,
uri: '1.ts'
},{
}, {
start: 20.1,
end: 30.1,
duration: 10,
uri: '2.ts'
}]
};
duration = Playlist.duration(playlist, 2);
equal(duration, 20.1, 'used the timeline-based interval');
duration = Playlist.duration(playlist, 2);
QUnit.equal(duration, 20.1, 'used the timeline-based interval');
duration = Playlist.duration(playlist, 3);
equal(duration, 30.1, 'used the timeline-based interval');
});
QUnit.equal(duration, 30.1, 'used the timeline-based interval');
});
test('counts the time between segments as part of the earlier segment\'s duration', function() {
var duration = Playlist.duration({
QUnit.test('counts the time between segments as part of the earlier segment\'s duration',
function() {
let duration = Playlist.duration({
mediaSequence: 0,
endList: true,
segments: [{
......@@ -226,11 +227,11 @@
}]
}, 1);
equal(duration, 10.1, 'included the segment gap');
});
QUnit.equal(duration, 10.1, 'included the segment gap');
});
test('accounts for discontinuities', function() {
var duration = Playlist.duration({
QUnit.test('accounts for discontinuities', function() {
let duration = Playlist.duration({
mediaSequence: 0,
endList: true,
discontinuityStarts: [1],
......@@ -244,11 +245,11 @@
}]
}, 2);
equal(duration, 10 + 10, 'handles discontinuities');
});
QUnit.equal(duration, 10 + 10, 'handles discontinuities');
});
test('a non-positive length interval has zero duration', function() {
var playlist = {
QUnit.test('a non-positive length interval has zero duration', function() {
let playlist = {
mediaSequence: 0,
discontinuityStarts: [1],
segments: [{
......@@ -261,15 +262,15 @@
}]
};
equal(Playlist.duration(playlist, 0), 0, 'zero-length duration is zero');
equal(Playlist.duration(playlist, 0, false), 0, 'zero-length duration is zero');
equal(Playlist.duration(playlist, -1), 0, 'negative length duration is zero');
});
QUnit.equal(Playlist.duration(playlist, 0), 0, 'zero-length duration is zero');
QUnit.equal(Playlist.duration(playlist, 0, false), 0, 'zero-length duration is zero');
QUnit.equal(Playlist.duration(playlist, -1), 0, 'negative length duration is zero');
});
module('Playlist Seekable');
QUnit.module('Playlist Seekable');
test('calculates seekable time ranges from the available segments', function() {
var playlist = {
QUnit.test('calculates seekable time ranges from the available segments', function() {
let playlist = {
mediaSequence: 0,
segments: [{
duration: 10,
......@@ -279,26 +280,29 @@
uri: '1.ts'
}],
endList: true
}, seekable = Playlist.seekable(playlist);
};
let seekable = Playlist.seekable(playlist);
equal(seekable.length, 1, 'there are seekable ranges');
equal(seekable.start(0), 0, 'starts at zero');
equal(seekable.end(0), Playlist.duration(playlist), 'ends at the duration');
});
QUnit.equal(seekable.length, 1, 'there are seekable ranges');
QUnit.equal(seekable.start(0), 0, 'starts at zero');
QUnit.equal(seekable.end(0), Playlist.duration(playlist), 'ends at the duration');
});
test('master playlists have empty seekable ranges', function() {
var seekable = Playlist.seekable({
QUnit.test('master playlists have empty seekable ranges', function() {
let seekable = Playlist.seekable({
playlists: [{
uri: 'low.m3u8'
}, {
uri: 'high.m3u8'
}]
});
equal(seekable.length, 0, 'no seekable ranges from a master playlist');
});
test('seekable end is three target durations from the actual end of live playlists', function() {
var seekable = Playlist.seekable({
QUnit.equal(seekable.length, 0, 'no seekable ranges from a master playlist');
});
QUnit.test('seekable end is three target durations from the actual end of live playlists',
function() {
let seekable = Playlist.seekable({
mediaSequence: 0,
segments: [{
duration: 7,
......@@ -314,13 +318,14 @@
uri: '3.ts'
}]
});
equal(seekable.length, 1, 'there are seekable ranges');
equal(seekable.start(0), 0, 'starts at zero');
equal(seekable.end(0), 7, 'ends three target durations from the last segment');
});
test('only considers available segments', function() {
var seekable = Playlist.seekable({
QUnit.equal(seekable.length, 1, 'there are seekable ranges');
QUnit.equal(seekable.start(0), 0, 'starts at zero');
QUnit.equal(seekable.end(0), 7, 'ends three target durations from the last segment');
});
QUnit.test('only considers available segments', function() {
let seekable = Playlist.seekable({
mediaSequence: 7,
segments: [{
uri: '8.ts',
......@@ -336,13 +341,16 @@
duration: 10
}]
});
equal(seekable.length, 1, 'there are seekable ranges');
equal(seekable.start(0), 0, 'starts at the earliest available segment');
equal(seekable.end(0), 10, 'ends three target durations from the last available segment');
});
test('seekable end accounts for non-standard target durations', function() {
var seekable = Playlist.seekable({
QUnit.equal(seekable.length, 1, 'there are seekable ranges');
QUnit.equal(seekable.start(0), 0, 'starts at the earliest available segment');
QUnit.equal(seekable.end(0),
10,
'ends three target durations from the last available segment');
});
QUnit.test('seekable end accounts for non-standard target durations', function() {
let seekable = Playlist.seekable({
targetDuration: 2,
mediaSequence: 0,
segments: [{
......@@ -362,10 +370,9 @@
uri: '4.ts'
}]
});
equal(seekable.start(0), 0, 'starts at the earliest available segment');
equal(seekable.end(0),
QUnit.equal(seekable.start(0), 0, 'starts at the earliest available segment');
QUnit.equal(seekable.end(0),
9 - (2 + 2 + 1),
'allows seeking no further than three segments from the end');
});
})(window, window.videojs);
});
......
import document from 'global/document';
import QUnit from 'qunit';
import sinon from 'sinon';
import videojs from 'video.js';
QUnit.module('videojs-contrib-hls - sanity', {
beforeEach() {
this.fixture = document.getElementById('qunit-fixture');
this.video = document.createElement('video');
this.fixture.appendChild(this.video);
this.player = videojs(this.video);
// Mock the environment's timers because certain things - particularly
// player readiness - are asynchronous in video.js 5.
this.clock = sinon.useFakeTimers();
},
afterEach() {
// The clock _must_ be restored before disposing the player; otherwise,
// certain timeout listeners that happen inside video.js may throw errors.
this.clock.restore();
this.player.dispose();
}
});
QUnit.test('the environment is sane', function(assert) {
assert.strictEqual(typeof Array.isArray, 'function', 'es5 exists');
assert.strictEqual(typeof sinon, 'object', 'sinon exists');
assert.strictEqual(typeof videojs, 'function', 'videojs exists');
assert.strictEqual(typeof videojs.MediaSource, 'function', 'MediaSource is an object');
assert.strictEqual(typeof videojs.URL, 'object', 'URL is an object');
assert.strictEqual(typeof videojs.Hls, 'object', 'Hls is an object');
assert.strictEqual(typeof videojs.HlsSourceHandler,
'function',
'HlsSourceHandler is a function');
assert.strictEqual(typeof videojs.HlsHandler, 'function', 'HlsHandler is a function');
});
This diff could not be displayed because it is too large.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>video.js HLS Plugin Test Suite</title>
<!-- Load sinon server for fakeXHR -->
<script src="../node_modules/sinon/pkg/sinon.js"></script>
<!-- Load local QUnit. -->
<link rel="stylesheet" href="../node_modules/qunitjs/qunit/qunit.css" media="screen">
<script src="../node_modules/qunitjs/qunit/qunit.js"></script>
<!-- video.js -->
<script src="../node_modules/video.js/dist/video.js"></script>
<link rel="stylesheet" href="../node_modules/video.js/dist/video-js.css" media="screen">
<script src="../node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js"></script>
<!-- HLS plugin -->
<script src="../src/videojs-hls.js"></script>
<script src="../src/xhr.js"></script>
<script src="../src/stream.js"></script>
<!-- M3U8 -->
<script src="../src/m3u8/m3u8-parser.js"></script>
<script src="../src/playlist.js"></script>
<script src="../src/playlist-loader.js"></script>
<script src="../node_modules/pkcs7/dist/pkcs7.unpad.js"></script>
<script src="../src/decrypter.js"></script>
<!-- M3U8 TEST DATA -->
<script src="../tmp/manifests.js"></script>
<script src="../tmp/expected.js"></script>
<!-- M3U8 -->
<!-- SEGMENT -->
<script src="tsSegment-bc.js"></script>
<script src="../src/bin-utils.js"></script>
<!-- Test cases -->
<script>
module('environment');
test('is sane', function() {
expect(1);
ok(true);
});
</script>
<script src="videojs-hls_test.js"></script>
<script src="m3u8_test.js"></script>
<script src="playlist_test.js"></script>
<script src="playlist-loader_test.js"></script>
<script src="decrypter_test.js"></script>
</head>
<body>
<div id="qunit"></div>
<div id="qunit-fixture">
<span>test markup</span>
</div>
</body>
</html>
This diff could not be displayed because it is too large.
......@@ -20,7 +20,7 @@ if (process.env.SAUCE_USERNAME) {
config.maxDuration = 300;
}
config.baseUrl = 'http://127.0.0.1:9999/example.html';
config.baseUrl = 'http://127.0.0.1:9999/';
config.specs = ['spec.js'];
config.framework = 'jasmine2';
......
......@@ -12,7 +12,7 @@
<link rel="stylesheet" href="css/normalize.min.css">
<link rel="stylesheet" href="css/main.css">
<link rel="stylesheet" href="../../node_modules/video.js/dist/video-js/video-js.css">
<link rel="stylesheet" href="../../node_modules/video.js/dist/video-js.css">
<script src="js/vendor/modernizr-2.6.2.min.js"></script>
</head>
......@@ -120,13 +120,17 @@
<script src="../../node_modules/sinon/lib/sinon/util/fake_timers.js"></script>
<script src="js/vendor/d3.min.js"></script>
<script src="../../node_modules/video.js/dist/video-js/video.js"></script>
<script src="../../node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js"></script>
<script src="../../src/videojs-hls.js"></script>
<script src="../../src/xhr.js"></script>
<script src="../../src/stream.js"></script>
<script src="../../src/m3u8/m3u8-parser.js"></script>
<script src="../../src/playlist-loader.js"></script>
<script src="/node_modules/video.js/dist/video.js"></script>
<script src="/node_modules/videojs-contrib-media-sources/dist/videojs-media-sources.js"></script>
<script src="/node_modules/pkcs7/dist/pkcs7.unpad.js"></script>
<script src="/src/videojs-hls.js"></script>
<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="/src/decrypter.js"></script>
<script src="/src/bin-utils.js"></script>
<script src="js/switcher.js"></script>
</body>
......
......@@ -142,6 +142,7 @@
// mock out the environment
clock = sinon.useFakeTimers();
fakeXhr = sinon.useFakeXMLHttpRequest();
videojs.xhr.XMLHttpRequest = fakeXhr;
requests = [];
fakeXhr.onCreate = function(xhr) {
xhr.startTime = +new Date();
......@@ -156,7 +157,6 @@
video.controls = true;
fixture.appendChild(video);
player = videojs(video, {
techOrder: ['hls'],
sources: [{
src: 'http://example.com/master.m3u8',
type: 'application/x-mpegurl'
......@@ -295,6 +295,8 @@
done(null, results);
}, 0);
});
/// trigger the ready function through set timeout
clock.tick(1);
};
runButton = document.getElementById('run-simulation');
runButton.addEventListener('click', function() {
......