Merge branch 'development'
Showing
182 changed files
with
5639 additions
and
5698 deletions
.editorconfig
0 → 100644
1 | # OS | ||
2 | Thumbs.db | ||
3 | ehthumbs.db | ||
4 | Desktop.ini | ||
1 | .DS_Store | 5 | .DS_Store |
2 | dist/* | 6 | ._* |
3 | /node_modules/ | 7 | |
8 | # Editors | ||
4 | *~ | 9 | *~ |
5 | *.iml | ||
6 | *.ipr | ||
7 | *.iws | ||
8 | *.swp | 10 | *.swp |
9 | tmp/**.*.swo | 11 | *.tmproj |
12 | *.tmproject | ||
13 | *.sublime-* | ||
14 | .idea/ | ||
15 | .project/ | ||
16 | .settings/ | ||
17 | .vscode/ | ||
18 | |||
19 | # Logs | ||
20 | logs | ||
21 | *.log | ||
22 | npm-debug.log* | ||
23 | |||
24 | # Dependency directories | ||
25 | bower_components/ | ||
26 | node_modules/ | ||
27 | |||
28 | # Yeoman meta-data | ||
29 | .yo-rc.json | ||
30 | |||
31 | # Build-related directories | ||
32 | dist/ | ||
33 | dist-test/ | ||
34 | docs/api/ | ||
35 | es5/ | ||
36 | tmp | ||
37 | test/test-manifests.js | ||
38 | test/test-expected.js | ... | ... |
.jshintrc
deleted
100644 → 0
1 | { | ||
2 | "curly": true, | ||
3 | "eqeqeq": true, | ||
4 | "immed": true, | ||
5 | "latedef": true, | ||
6 | "newcap": true, | ||
7 | "noarg": true, | ||
8 | "sub": true, | ||
9 | "undef": true, | ||
10 | "unused": true, | ||
11 | "boss": true, | ||
12 | "eqnull": true, | ||
13 | "node": true, | ||
14 | |||
15 | "camelcase": true, | ||
16 | "nonew": true, | ||
17 | "quotmark": "single", | ||
18 | "trailing": true, | ||
19 | "maxlen": 80 | ||
20 | } |
1 | language: node_js | ||
2 | sudo: false | 1 | sudo: false |
2 | language: node_js | ||
3 | addons: | ||
4 | firefox: "latest" | ||
3 | node_js: | 5 | node_js: |
4 | - "stable" | 6 | - "stable" |
5 | install: | ||
6 | - npm install -g grunt-cli && npm install | ||
7 | notifications: | 7 | notifications: |
8 | hipchat: | 8 | hipchat: |
9 | rooms: | 9 | rooms: |
... | @@ -12,6 +12,7 @@ notifications: | ... | @@ -12,6 +12,7 @@ notifications: |
12 | channels: | 12 | channels: |
13 | - "chat.freenode.net#videojs" | 13 | - "chat.freenode.net#videojs" |
14 | use_notice: true | 14 | use_notice: true |
15 | # Set up a virtual screen for Firefox. | ||
15 | before_script: | 16 | before_script: |
16 | - export DISPLAY=:99.0 | 17 | - export DISPLAY=:99.0 |
17 | - sh -e /etc/init.d/xvfb start | 18 | - sh -e /etc/init.d/xvfb start | ... | ... |
1 | CHANGELOG | 1 | CHANGELOG |
2 | ========= | 2 | ========= |
3 | 3 | ||
4 | ## HEAD (Unreleased) | 4 | -------------------- |
5 | * buffer at the current time range end instead of incrementing a variable. ([view](https://github.com/videojs/videojs-contrib-hls/pull/423)) | 5 | |
6 | ## 2.0.1 (2016-03-11) | ||
7 | * First release of the ES6 version of the SourceHandler | ||
8 | * All new lint/build/test setup via the [generator-videojs-plugin](https://github.com/videojs/generator-videojs-plugin) project | ||
9 | |||
10 | -------------------- | ||
11 | |||
12 | ## 1.13.1 (2016-03-04) | ||
13 | * Converted from a Tech to a SourceHandler for Video.js 5.x compatibility | ||
14 | * Implemented a Media Source Extensions-based playback engine with a Flash-based fallback | ||
15 | * Rewrote the Transmuxer and moved it into it's own project [mux.js](https://github.com/videojs/mux.js) | ||
16 | * Added support for 608/708 captions | ||
6 | 17 | ||
7 | -------------------- | 18 | -------------------- |
8 | 19 | ... | ... |
Gruntfile.js
deleted
100644 → 0
1 | 'use strict'; | ||
2 | |||
3 | var | ||
4 | basename = require('path').basename, | ||
5 | mediaSourcesPath = 'node_modules/videojs-contrib-media-sources/dist/', | ||
6 | mediaSourcesDebug = mediaSourcesPath + 'videojs-media-sources.js'; | ||
7 | |||
8 | module.exports = function(grunt) { | ||
9 | var pkg = grunt.file.readJSON('package.json'); | ||
10 | |||
11 | // Project configuration. | ||
12 | grunt.initConfig({ | ||
13 | // Metadata. | ||
14 | pkg: pkg, | ||
15 | banner: '/*! <%= pkg.name %> - v<%= pkg.version %> - ' + | ||
16 | '<%= grunt.template.today("yyyy-mm-dd") %>\n' + | ||
17 | '* Copyright (c) <%= grunt.template.today("yyyy") %> Brightcove;' + | ||
18 | ' Licensed <%= _.pluck(pkg.licenses, "type").join(", ") %> */\n', | ||
19 | // Task configuration. | ||
20 | clean: { | ||
21 | files: ['build', 'dist', 'tmp'] | ||
22 | }, | ||
23 | concat: { | ||
24 | options: { | ||
25 | banner: '<%= banner %>', | ||
26 | stripBanners: true | ||
27 | }, | ||
28 | dist: { | ||
29 | nonull: true, | ||
30 | src: [ | ||
31 | mediaSourcesDebug, | ||
32 | 'src/videojs-hls.js', | ||
33 | 'src/xhr.js', | ||
34 | 'src/stream.js', | ||
35 | 'src/m3u8/m3u8-parser.js', | ||
36 | 'src/playlist.js', | ||
37 | 'src/playlist-loader.js', | ||
38 | 'node_modules/pkcs7/dist/pkcs7.unpad.js', | ||
39 | 'src/decrypter.js' | ||
40 | ], | ||
41 | dest: 'dist/videojs.hls.js' | ||
42 | } | ||
43 | }, | ||
44 | uglify: { | ||
45 | options: { | ||
46 | banner: '<%= banner %>' | ||
47 | }, | ||
48 | dist: { | ||
49 | src: '<%= concat.dist.dest %>', | ||
50 | dest: 'dist/videojs.hls.min.js' | ||
51 | } | ||
52 | }, | ||
53 | jshint: { | ||
54 | gruntfile: { | ||
55 | options: { | ||
56 | jshintrc: '.jshintrc' | ||
57 | }, | ||
58 | src: 'Gruntfile.js' | ||
59 | }, | ||
60 | src: { | ||
61 | options: { | ||
62 | jshintrc: 'src/.jshintrc' | ||
63 | }, | ||
64 | src: ['src/**/*.js'] | ||
65 | }, | ||
66 | test: { | ||
67 | options: { | ||
68 | jshintrc: 'test/.jshintrc' | ||
69 | }, | ||
70 | src: ['test/**/*.js', | ||
71 | '!test/tsSegment.js', | ||
72 | '!test/fixtures/*.js', | ||
73 | '!test/manifest/**', | ||
74 | '!test/muxer/**', | ||
75 | '!test/switcher/**'] | ||
76 | } | ||
77 | }, | ||
78 | connect: { | ||
79 | dev: { | ||
80 | options: { | ||
81 | hostname: '*', | ||
82 | port: 9999, | ||
83 | keepalive: true | ||
84 | } | ||
85 | }, | ||
86 | test: { | ||
87 | options: { | ||
88 | hostname: '*', | ||
89 | port: 9999 | ||
90 | } | ||
91 | } | ||
92 | }, | ||
93 | open : { | ||
94 | dev : { | ||
95 | path: 'http://127.0.0.1:<%= connect.dev.options.port %>/example.html', | ||
96 | app: 'Google Chrome' | ||
97 | } | ||
98 | }, | ||
99 | watch: { | ||
100 | build: { | ||
101 | files: '<%= concat.dist.src %>', | ||
102 | tasks: ['clean', 'concat', 'uglify'] | ||
103 | }, | ||
104 | gruntfile: { | ||
105 | files: '<%= jshint.gruntfile.src %>', | ||
106 | tasks: ['jshint:gruntfile'] | ||
107 | }, | ||
108 | src: { | ||
109 | files: '<%= jshint.src.src %>', | ||
110 | tasks: ['jshint:src', 'test'] | ||
111 | }, | ||
112 | test: { | ||
113 | files: '<%= jshint.test.src %>', | ||
114 | tasks: ['jshint:test', 'test'] | ||
115 | } | ||
116 | }, | ||
117 | concurrent: { | ||
118 | dev: { | ||
119 | tasks: ['connect', 'open', 'watch'], | ||
120 | options: { | ||
121 | logConcurrentOutput: true | ||
122 | } | ||
123 | } | ||
124 | }, | ||
125 | version: { | ||
126 | project: { | ||
127 | src: ['package.json'] | ||
128 | } | ||
129 | }, | ||
130 | 'github-release': { | ||
131 | options: { | ||
132 | repository: 'videojs/videojs-contrib-hls', | ||
133 | auth: { | ||
134 | user: process.env.VJS_GITHUB_USER, | ||
135 | password: process.env.VJS_GITHUB_TOKEN | ||
136 | }, | ||
137 | release: { | ||
138 | 'tag_name': 'v' + pkg.version, | ||
139 | name: pkg.version, | ||
140 | body: require('chg').find(pkg.version).changesRaw | ||
141 | } | ||
142 | }, | ||
143 | files: { | ||
144 | 'dist': ['videojs.hls.min.js'] | ||
145 | } | ||
146 | }, | ||
147 | karma: { | ||
148 | options: { | ||
149 | frameworks: ['qunit'] | ||
150 | }, | ||
151 | |||
152 | saucelabs: { | ||
153 | configFile: 'test/karma.conf.js', | ||
154 | autoWatch: true | ||
155 | }, | ||
156 | |||
157 | dev: { | ||
158 | browsers: ['Chrome', 'Safari', 'Firefox', | ||
159 | 'Opera', 'IE', 'PhantomJS', 'ChromeCanary'], | ||
160 | configFile: 'test/localkarma.conf.js', | ||
161 | autoWatch: true | ||
162 | }, | ||
163 | |||
164 | chromecanary: { | ||
165 | options: { | ||
166 | browsers: ['ChromeCanary'], | ||
167 | configFile: 'test/localkarma.conf.js', | ||
168 | autoWatch: true | ||
169 | } | ||
170 | }, | ||
171 | |||
172 | phantomjs: { | ||
173 | options: { | ||
174 | browsers: ['PhantomJS'], | ||
175 | configFile: 'test/localkarma.conf.js', | ||
176 | autoWatch: true | ||
177 | } | ||
178 | }, | ||
179 | |||
180 | opera: { | ||
181 | options: { | ||
182 | browsers: ['Opera'], | ||
183 | configFile: 'test/localkarma.conf.js', | ||
184 | autoWatch: true | ||
185 | } | ||
186 | }, | ||
187 | |||
188 | chrome: { | ||
189 | options: { | ||
190 | browsers: ['Chrome'], | ||
191 | configFile: 'test/localkarma.conf.js', | ||
192 | autoWatch: true | ||
193 | } | ||
194 | }, | ||
195 | |||
196 | safari: { | ||
197 | options: { | ||
198 | browsers: ['Safari'], | ||
199 | configFile: 'test/localkarma.conf.js', | ||
200 | autoWatch: true | ||
201 | } | ||
202 | }, | ||
203 | |||
204 | firefox: { | ||
205 | options: { | ||
206 | browsers: ['Firefox'], | ||
207 | configFile: 'test/localkarma.conf.js', | ||
208 | autoWatch: true | ||
209 | } | ||
210 | }, | ||
211 | |||
212 | ie: { | ||
213 | options: { | ||
214 | browsers: ['IE'], | ||
215 | configFile: 'test/localkarma.conf.js', | ||
216 | autoWatch: true | ||
217 | } | ||
218 | }, | ||
219 | |||
220 | ci: { | ||
221 | configFile: 'test/karma.conf.js', | ||
222 | autoWatch: false | ||
223 | } | ||
224 | }, | ||
225 | protractor: { | ||
226 | options: { | ||
227 | configFile: 'test/functional/protractor.config.js', | ||
228 | webdriverManagerUpdate: process.env.TRAVIS ? false : true | ||
229 | }, | ||
230 | |||
231 | chrome: { | ||
232 | options: { | ||
233 | args: { | ||
234 | capabilities: { | ||
235 | browserName: 'chrome' | ||
236 | } | ||
237 | } | ||
238 | } | ||
239 | }, | ||
240 | |||
241 | firefox: { | ||
242 | options: { | ||
243 | args: { | ||
244 | capabilities: { | ||
245 | browserName: 'firefox' | ||
246 | } | ||
247 | } | ||
248 | } | ||
249 | }, | ||
250 | |||
251 | safari: { | ||
252 | options: { | ||
253 | args: { | ||
254 | capabilities: { | ||
255 | browserName: 'safari' | ||
256 | } | ||
257 | } | ||
258 | } | ||
259 | }, | ||
260 | |||
261 | ie: { | ||
262 | options: { | ||
263 | args: { | ||
264 | capabilities: { | ||
265 | browserName: 'internet explorer' | ||
266 | } | ||
267 | } | ||
268 | } | ||
269 | }, | ||
270 | |||
271 | saucelabs:{} | ||
272 | } | ||
273 | }); | ||
274 | |||
275 | // These plugins provide necessary tasks. | ||
276 | grunt.loadNpmTasks('grunt-karma'); | ||
277 | grunt.loadNpmTasks('grunt-contrib-clean'); | ||
278 | grunt.loadNpmTasks('grunt-contrib-concat'); | ||
279 | grunt.loadNpmTasks('grunt-contrib-uglify'); | ||
280 | grunt.loadNpmTasks('grunt-contrib-jshint'); | ||
281 | grunt.loadNpmTasks('grunt-contrib-watch'); | ||
282 | grunt.loadNpmTasks('grunt-contrib-connect'); | ||
283 | grunt.loadNpmTasks('grunt-open'); | ||
284 | grunt.loadNpmTasks('grunt-concurrent'); | ||
285 | grunt.loadNpmTasks('grunt-contrib-watch'); | ||
286 | grunt.loadNpmTasks('grunt-github-releaser'); | ||
287 | grunt.loadNpmTasks('grunt-version'); | ||
288 | grunt.loadNpmTasks('grunt-protractor-runner'); | ||
289 | grunt.loadNpmTasks('chg'); | ||
290 | |||
291 | |||
292 | grunt.registerTask('manifests-to-js', 'Wrap the test fixtures and output' + | ||
293 | ' so they can be loaded in a browser', | ||
294 | function() { | ||
295 | var | ||
296 | jsManifests = 'window.manifests = {\n', | ||
297 | jsExpected = 'window.expected = {\n'; | ||
298 | grunt.file.recurse('test/manifest/', | ||
299 | function(abspath, root, sub, filename) { | ||
300 | if ((/\.m3u8$/).test(abspath)) { | ||
301 | |||
302 | // translate this manifest | ||
303 | jsManifests += ' \'' + basename(filename, '.m3u8') + '\': ' + | ||
304 | grunt.file.read(abspath) | ||
305 | .split(/\r\n|\n/) | ||
306 | |||
307 | // quote and concatenate | ||
308 | .map(function(line) { | ||
309 | return ' \'' + line + '\\n\' +\n'; | ||
310 | }).join('') | ||
311 | |||
312 | // strip leading spaces and the trailing '+' | ||
313 | .slice(4, -3); | ||
314 | jsManifests += ',\n'; | ||
315 | } | ||
316 | |||
317 | if ((/\.js$/).test(abspath)) { | ||
318 | |||
319 | // append the expected parse | ||
320 | jsExpected += ' "' + basename(filename, '.js') + '": ' + | ||
321 | grunt.file.read(abspath) + ',\n'; | ||
322 | } | ||
323 | }); | ||
324 | |||
325 | // clean up and close the objects | ||
326 | jsManifests = jsManifests.slice(0, -2); | ||
327 | jsManifests += '\n};\n'; | ||
328 | jsExpected = jsExpected.slice(0, -2); | ||
329 | jsExpected += '\n};\n'; | ||
330 | |||
331 | // write out the manifests | ||
332 | grunt.file.write('tmp/manifests.js', jsManifests); | ||
333 | grunt.file.write('tmp/expected.js', jsExpected); | ||
334 | }); | ||
335 | |||
336 | // Launch a Development Environment | ||
337 | grunt.registerTask('dev', 'Launching Dev Environment', 'concurrent:dev'); | ||
338 | |||
339 | grunt.registerTask('build', | ||
340 | ['clean', | ||
341 | 'concat', | ||
342 | 'uglify']); | ||
343 | |||
344 | // Default task. | ||
345 | grunt.registerTask('default', | ||
346 | ['test', | ||
347 | 'build']); | ||
348 | |||
349 | // The test task will run `karma:saucelabs` when running in travis, | ||
350 | // otherwise, it'll default to running karma in chrome. | ||
351 | // You can specify which browsers to build with by using grunt-style arguments | ||
352 | // or separating them with a comma: | ||
353 | // grunt test:chrome:firefox # grunt-style | ||
354 | // grunt test:chrome,firefox # comma-separated | ||
355 | grunt.registerTask('test', function() { | ||
356 | var tasks = this.args; | ||
357 | |||
358 | grunt.task.run(['jshint', 'manifests-to-js']); | ||
359 | |||
360 | if (process.env.TRAVIS) { | ||
361 | if (process.env.TRAVIS_PULL_REQUEST === 'false') { | ||
362 | grunt.task.run(['karma:saucelabs']); | ||
363 | grunt.task.run(['connect:test', 'protractor:saucelabs']); | ||
364 | } else { | ||
365 | grunt.task.run(['karma:firefox']); | ||
366 | } | ||
367 | } else { | ||
368 | if (tasks.length === 0) { | ||
369 | tasks.push('chrome'); | ||
370 | } | ||
371 | if (tasks.length === 1) { | ||
372 | tasks = tasks[0].split(','); | ||
373 | } | ||
374 | tasks = tasks.reduce(function(acc, el) { | ||
375 | acc.push('karma:' + el); | ||
376 | if (/chrome|firefox|safari|ie/.test(el)) { | ||
377 | acc.push('protractor:' + el); | ||
378 | } | ||
379 | return acc; | ||
380 | }, ['connect:test']); | ||
381 | |||
382 | grunt.task.run(tasks); | ||
383 | } | ||
384 | }); | ||
385 | }; |
... | @@ -4,6 +4,33 @@ Play back HLS with video.js, even where it's not natively supported. | ... | @@ -4,6 +4,33 @@ Play back HLS with video.js, even where it's not natively supported. |
4 | 4 | ||
5 | [![Build Status](https://travis-ci.org/videojs/videojs-contrib-hls.svg?branch=master)](https://travis-ci.org/videojs/videojs-contrib-hls) | 5 | [![Build Status](https://travis-ci.org/videojs/videojs-contrib-hls.svg?branch=master)](https://travis-ci.org/videojs/videojs-contrib-hls) |
6 | 6 | ||
7 | <!-- START doctoc generated TOC please keep comment here to allow auto update --> | ||
8 | <!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE --> | ||
9 | **Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* | ||
10 | |||
11 | - [Getting Started](#getting-started) | ||
12 | - [Documentation](#documentation) | ||
13 | - [Options](#options) | ||
14 | - [withCredentials](#withcredentials) | ||
15 | - [Runtime Properties](#runtime-properties) | ||
16 | - [hls.playlists.master](#hlsplaylistsmaster) | ||
17 | - [hls.playlists.media](#hlsplaylistsmedia) | ||
18 | - [hls.segmentXhrTime](#hlssegmentxhrtime) | ||
19 | - [hls.bandwidth](#hlsbandwidth) | ||
20 | - [hls.bytesReceived](#hlsbytesreceived) | ||
21 | - [hls.selectPlaylist](#hlsselectplaylist) | ||
22 | - [Events](#events) | ||
23 | - [loadedmetadata](#loadedmetadata) | ||
24 | - [loadedplaylist](#loadedplaylist) | ||
25 | - [mediachange](#mediachange) | ||
26 | - [In-Band Metadata](#in-band-metadata) | ||
27 | - [Hosting Considerations](#hosting-considerations) | ||
28 | - [Testing](#testing) | ||
29 | - [Release History](#release-history) | ||
30 | |||
31 | <!-- END doctoc generated TOC please keep comment here to allow auto update --> | ||
32 | |||
33 | |||
7 | ## Getting Started | 34 | ## Getting Started |
8 | Download | 35 | Download |
9 | [videojs-contrib-hls](https://github.com/videojs/videojs-contrib-hls/releases) | 36 | [videojs-contrib-hls](https://github.com/videojs/videojs-contrib-hls/releases) | ... | ... |
... | @@ -2,31 +2,8 @@ | ... | @@ -2,31 +2,8 @@ |
2 | <html> | 2 | <html> |
3 | <head> | 3 | <head> |
4 | <meta charset="utf-8"> | 4 | <meta charset="utf-8"> |
5 | <title>video.js HLS Plugin Example</title> | 5 | <title>videojs-contrib-hls Demo</title> |
6 | 6 | <link href="/node_modules/video.js/dist/video-js.css" rel="stylesheet"> | |
7 | <link href="node_modules/video.js/dist/video-js.css" rel="stylesheet"> | ||
8 | |||
9 | <!-- video.js --> | ||
10 | <script src="node_modules/video.js/dist/video.js"></script> | ||
11 | |||
12 | <!-- Media Sources plugin --> | ||
13 | <script src="node_modules/videojs-contrib-media-sources/dist/videojs-media-sources.js"></script> | ||
14 | |||
15 | <!-- HLS plugin --> | ||
16 | <script src="src/videojs-hls.js"></script> | ||
17 | |||
18 | <!-- m3u8 handling --> | ||
19 | <script src="src/xhr.js"></script> | ||
20 | <script src="src/stream.js"></script> | ||
21 | <script src="src/m3u8/m3u8-parser.js"></script> | ||
22 | <script src="src/playlist.js"></script> | ||
23 | <script src="src/playlist-loader.js"></script> | ||
24 | |||
25 | <script src="node_modules/pkcs7/dist/pkcs7.unpad.js"></script> | ||
26 | <script src="src/decrypter.js"></script> | ||
27 | |||
28 | <script src="src/bin-utils.js"></script> | ||
29 | |||
30 | <style> | 7 | <style> |
31 | body { | 8 | body { |
32 | font-family: Arial, sans-serif; | 9 | font-family: Arial, sans-serif; |
... | @@ -52,14 +29,8 @@ | ... | @@ -52,14 +29,8 @@ |
52 | <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> | 29 | <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> |
53 | <p>Due to security restrictions in Flash, you will have to load this page over HTTP(S) to see the example in action.</p> | 30 | <p>Due to security restrictions in Flash, you will have to load this page over HTTP(S) to see the example in action.</p> |
54 | </div> | 31 | </div> |
55 | <video id="video" | 32 | <video id="videojs-contrib-hls-player" class="video-js vjs-default-skin" controls> |
56 | class="video-js vjs-default-skin" | 33 | <source src="http://solutions.brightcove.com/jwhisenant/hls/apple/bipbop/bipbopall.m3u8" type="application/x-mpegURL"> |
57 | height="300" | ||
58 | width="600" | ||
59 | controls> | ||
60 | <source | ||
61 | src="http://solutions.brightcove.com/jwhisenant/hls/apple/bipbop/bipbopall.m3u8" | ||
62 | type="application/x-mpegURL"> | ||
63 | </video> | 34 | </video> |
64 | 35 | ||
65 | <form id=load-url> | 36 | <form id=load-url> |
... | @@ -69,23 +40,28 @@ | ... | @@ -69,23 +40,28 @@ |
69 | </label> | 40 | </label> |
70 | <button type=submit>Load</button> | 41 | <button type=submit>Load</button> |
71 | </form> | 42 | </form> |
43 | <ul> | ||
44 | <li><a href="/test/">Run unit tests in browser.</a></li> | ||
45 | <li><a href="/docs/api/">Read generated docs.</a></li> | ||
46 | </ul> | ||
72 | 47 | ||
48 | <script src="/node_modules/video.js/dist/video.js"></script> | ||
49 | <script src="/dist/videojs-contrib-hls.js"></script> | ||
73 | <script> | 50 | <script> |
74 | videojs.options.flash.swf = 'node_modules/videojs-swf/dist/video-js.swf'; | 51 | (function(window, videojs) { |
75 | // initialize the player | 52 | var player = window.player = videojs('videojs-contrib-hls-player'); |
76 | var player = videojs('video'); | 53 | // hook up the video switcher |
77 | 54 | var loadUrl = document.getElementById('load-url'); | |
78 | // hook up the video switcher | 55 | var url = document.getElementById('url'); |
79 | var loadUrl = document.getElementById('load-url'); | 56 | loadUrl.addEventListener('submit', function(event) { |
80 | var url = document.getElementById('url'); | 57 | event.preventDefault(); |
81 | loadUrl.addEventListener('submit', function(event) { | 58 | player.src({ |
82 | event.preventDefault(); | 59 | src: url.value, |
83 | player.src({ | 60 | type: 'application/x-mpegURL' |
84 | src: url.value, | 61 | }); |
85 | type: 'application/x-mpegURL' | 62 | return false; |
86 | }); | 63 | }); |
87 | return false; | 64 | }(window, window.videojs)); |
88 | }); | ||
89 | </script> | 65 | </script> |
90 | </body> | 66 | </body> |
91 | </html> | 67 | </html> | ... | ... |
1 | { | 1 | { |
2 | "name": "videojs-contrib-hls", | 2 | "name": "videojs-contrib-hls", |
3 | "version": "1.3.11", | 3 | "version": "2.0.1", |
4 | "description": "Play back HLS with video.js, even where it's not natively supported", | ||
5 | "main": "es5/videojs-contrib-hls.js", | ||
4 | "engines": { | 6 | "engines": { |
5 | "node": ">= 0.10.12" | 7 | "node": ">= 0.10.12" |
6 | }, | 8 | }, |
... | @@ -8,47 +10,120 @@ | ... | @@ -8,47 +10,120 @@ |
8 | "type": "git", | 10 | "type": "git", |
9 | "url": "git@github.com:videojs/videojs-contrib-hls.git" | 11 | "url": "git@github.com:videojs/videojs-contrib-hls.git" |
10 | }, | 12 | }, |
11 | "license": "Apache-2.0", | ||
12 | "scripts": { | 13 | "scripts": { |
13 | "test": "grunt test", | 14 | "prebuild": "npm run clean", |
14 | "prepublish": "if [ -z \"$TRAVIS\" ]; then grunt; fi" | 15 | "build": "npm-run-all -p build:*", |
16 | "build:js": "npm-run-all build:js:babel build:js:browserify build:js:bannerize build:js:uglify", | ||
17 | "build:js:babel": "babel src -d es5", | ||
18 | "build:js:bannerize": "bannerize dist/videojs-contrib-hls.js --banner=scripts/banner.ejs", | ||
19 | "build:js:browserify": "browserify . -s videojs-contrib-hls -o dist/videojs-contrib-hls.js", | ||
20 | "build:js:uglify": "uglifyjs dist/videojs-contrib-hls.js --comments --mangle --compress -o dist/videojs-contrib-hls.min.js", | ||
21 | "build:test": "npm-run-all build:test:manifest build:test:js", | ||
22 | "build:test:js": "node scripts/build-test.js", | ||
23 | "build:test:manifest": "node -e \"var b=require('./scripts/manifest-data.js'); b.build();\"", | ||
24 | "clean": "npm-run-all -p clean:*", | ||
25 | "clean:build": "node -e \"var s=require('shelljs'),d=['dist','dist-test','es5'];s.rm('-rf',d);s.mkdir('-p',d);\"", | ||
26 | "clean:test": "node -e \"var b=require('./scripts/manifest-data.js'); b.clean();\"", | ||
27 | "docs": "npm-run-all docs:*", | ||
28 | "docs:api": "jsdoc src -r -d docs/api", | ||
29 | "docs:toc": "doctoc README.md", | ||
30 | "lint": "vjsstandard", | ||
31 | "prestart": "npm-run-all docs build", | ||
32 | "start": "npm-run-all -p start:* watch:*", | ||
33 | "start:serve": "babel-node scripts/server.js", | ||
34 | "pretest": "npm-run-all lint build", | ||
35 | "test": "karma start test/karma/detected.js", | ||
36 | "test:chrome": "npm run pretest && karma start test/karma/chrome.js", | ||
37 | "test:firefox": "npm run pretest && karma start test/karma/firefox.js", | ||
38 | "test:ie": "npm run pretest && karma start test/karma/ie.js", | ||
39 | "test:safari": "npm run pretest && karma start test/karma/safari.js", | ||
40 | "preversion": "npm test", | ||
41 | "version": "npm run build", | ||
42 | "watch": "npm-run-all -p watch:*", | ||
43 | "watch:js": "watchify src/videojs-contrib-hls.js -t babelify -v -o dist/videojs-contrib-hls.js", | ||
44 | "watch:test": "npm-run-all -p watch:test:*", | ||
45 | "watch:test:js": "node scripts/watch-test.js", | ||
46 | "watch:test:manifest": "node -e \"var b=require('./scripts/manifest-data.js'); b.watch();\"", | ||
47 | "prepublish": "npm run build" | ||
15 | }, | 48 | }, |
16 | "keywords": [ | 49 | "keywords": [ |
17 | "videojs", | 50 | "videojs", |
18 | "videojs-plugin" | 51 | "videojs-plugin" |
19 | ], | 52 | ], |
20 | "devDependencies": { | 53 | "author": "Brightcove, Inc", |
21 | "chg": "^0.2.0", | 54 | "license": "Apache-2.0", |
22 | "grunt": "^0.4.5", | 55 | "browserify": { |
23 | "grunt-concurrent": "0.4.3", | 56 | "transform": [ |
24 | "grunt-contrib-clean": "~0.4.0", | 57 | "browserify-shim" |
25 | "grunt-contrib-concat": "~0.3.0", | 58 | ] |
26 | "grunt-contrib-connect": "~0.6.0", | 59 | }, |
27 | "grunt-contrib-jshint": "~0.6.0", | 60 | "browserify-shim": { |
28 | "grunt-contrib-uglify": "~0.2.0", | 61 | "qunit": "global:QUnit", |
29 | "grunt-contrib-watch": "~0.4.0", | 62 | "sinon": "global:sinon", |
30 | "grunt-github-releaser": "^0.1.17", | 63 | "video.js": "global:videojs" |
31 | "grunt-karma": "~0.6.2", | ||
32 | "grunt-open": "0.2.3", | ||
33 | "grunt-protractor-runner": "forbesjo/grunt-protractor-runner.git#webdriverManagerUpdate", | ||
34 | "grunt-shell": "0.6.1", | ||
35 | "grunt-version": "^1.0.0", | ||
36 | "karma": "~0.10.0", | ||
37 | "karma-chrome-launcher": "~0.1.2", | ||
38 | "karma-firefox-launcher": "~0.1.3", | ||
39 | "karma-ie-launcher": "~0.1.1", | ||
40 | "karma-opera-launcher": "~0.1.0", | ||
41 | "karma-phantomjs-launcher": "^0.1.4", | ||
42 | "karma-qunit": "~0.1.1", | ||
43 | "karma-safari-launcher": "~0.1.1", | ||
44 | "karma-sauce-launcher": "~0.1.8", | ||
45 | "qunitjs": "^1.18.0", | ||
46 | "sinon": "1.10.2", | ||
47 | "video.js": "^5.2.1" | ||
48 | }, | 64 | }, |
65 | "vjsstandard": { | ||
66 | "ignore": [ | ||
67 | "dist", | ||
68 | "dist-test", | ||
69 | "docs", | ||
70 | "es5", | ||
71 | "test/karma", | ||
72 | "scripts", | ||
73 | "utils", | ||
74 | "test/test-manifests.js", | ||
75 | "test/test-expected.js", | ||
76 | "src/playlist-loader.js" | ||
77 | ] | ||
78 | }, | ||
79 | "files": [ | ||
80 | "CONTRIBUTING.md", | ||
81 | "dist-test/", | ||
82 | "dist/", | ||
83 | "docs/", | ||
84 | "es5/", | ||
85 | "index.html", | ||
86 | "scripts/", | ||
87 | "src/", | ||
88 | "test/", | ||
89 | "utils/" | ||
90 | ], | ||
49 | "dependencies": { | 91 | "dependencies": { |
50 | "pkcs7": "^0.2.2", | 92 | "pkcs7": "^0.2.2", |
51 | "videojs-contrib-media-sources": "^2.5.0", | 93 | "video.js": "^5.2.1", |
94 | "videojs-contrib-media-sources": "^3.0.0", | ||
52 | "videojs-swf": "^5.0.0" | 95 | "videojs-swf": "^5.0.0" |
96 | }, | ||
97 | "devDependencies": { | ||
98 | "babel": "^5.8.0", | ||
99 | "babelify": "^6.0.0", | ||
100 | "bannerize": "^1.0.0", | ||
101 | "browserify": "^11.0.0", | ||
102 | "browserify-shim": "^3.0.0", | ||
103 | "connect": "^3.4.0", | ||
104 | "cowsay": "^1.1.0", | ||
105 | "doctoc": "^0.15.0", | ||
106 | "glob": "^6.0.3", | ||
107 | "global": "^4.3.0", | ||
108 | "jsdoc": "^3.4.0", | ||
109 | "karma": "^0.13.0", | ||
110 | "karma-browserify": "^4.4.0", | ||
111 | "karma-chrome-launcher": "^0.2.0", | ||
112 | "karma-detect-browsers": "^2.0.0", | ||
113 | "karma-firefox-launcher": "^0.1.0", | ||
114 | "karma-ie-launcher": "^0.2.0", | ||
115 | "karma-qunit": "^0.1.9", | ||
116 | "karma-safari-launcher": "^0.1.0", | ||
117 | "lodash-compat": "^3.10.0", | ||
118 | "minimist": "^1.2.0", | ||
119 | "npm-run-all": "^1.2.0", | ||
120 | "portscanner": "^1.0.0", | ||
121 | "qunitjs": "^1.18.0", | ||
122 | "serve-static": "^1.10.0", | ||
123 | "shelljs": "^0.5.3", | ||
124 | "sinon": "1.10.2", | ||
125 | "uglify-js": "^2.5.0", | ||
126 | "videojs-standard": "^4.0.0", | ||
127 | "watchify": "^3.6.0" | ||
53 | } | 128 | } |
54 | } | 129 | } | ... | ... |
scripts/banner.ejs
0 → 100644
scripts/build-test.js
0 → 100644
scripts/manifest-data.js
0 → 100644
1 | var fs = require('fs'); | ||
2 | var path = require('path'); | ||
3 | |||
4 | var basePath = path.resolve(__dirname, '..'); | ||
5 | var testDataDir = path.join(basePath,'test'); | ||
6 | var manifestDir = path.join(basePath, 'utils', 'manifest'); | ||
7 | var manifestFilepath = path.join(testDataDir, 'test-manifests.js'); | ||
8 | var expectedFilepath = path.join(testDataDir, 'test-expected.js'); | ||
9 | |||
10 | var build = function() { | ||
11 | var manifests = 'export default {\n'; | ||
12 | var expected = 'export default {\n'; | ||
13 | |||
14 | var files = fs.readdirSync(manifestDir); | ||
15 | while (files.length > 0) { | ||
16 | var file = path.resolve(manifestDir, files.shift()); | ||
17 | var extname = path.extname(file); | ||
18 | |||
19 | if (extname === '.m3u8') { | ||
20 | // translate this manifest | ||
21 | manifests += ' \'' + path.basename(file, '.m3u8') + '\': '; | ||
22 | manifests += fs.readFileSync(file, 'utf8') | ||
23 | .split(/\r\n|\n/) | ||
24 | // quote and concatenate | ||
25 | .map(function(line) { | ||
26 | return ' \'' + line + '\\n\' +\n'; | ||
27 | }).join('') | ||
28 | // strip leading spaces and the trailing '+' | ||
29 | .slice(4, -3); | ||
30 | manifests += ',\n'; | ||
31 | } else if (extname === '.js') { | ||
32 | // append the expected parse | ||
33 | expected += ' "' + path.basename(file, '.js') + '": '; | ||
34 | expected += fs.readFileSync(file, 'utf8'); | ||
35 | expected += ',\n'; | ||
36 | } else { | ||
37 | console.log('Unknown file ' + file + ' found in manifest dir ' + manifestDir); | ||
38 | } | ||
39 | |||
40 | } | ||
41 | |||
42 | // clean up and close the objects | ||
43 | manifests = manifests.slice(0, -2); | ||
44 | manifests += '\n};\n'; | ||
45 | expected = expected.slice(0, -2); | ||
46 | expected += '\n};\n'; | ||
47 | |||
48 | fs.writeFileSync(manifestFilepath, manifests); | ||
49 | fs.writeFileSync(expectedFilepath, expected); | ||
50 | console.log('Wrote test data file ' + manifestFilepath); | ||
51 | console.log('Wrote test data file ' + expectedFilepath); | ||
52 | }; | ||
53 | |||
54 | var watch = function() { | ||
55 | build(); | ||
56 | fs.watch(manifestDir, function(event, filename) { | ||
57 | console.log('files in manifest dir were changed rebuilding manifest data'); | ||
58 | build(); | ||
59 | }); | ||
60 | }; | ||
61 | |||
62 | var clean = function() { | ||
63 | try { | ||
64 | fs.unlinkSync(manifestFilepath); | ||
65 | } catch(e) { | ||
66 | console.log(e); | ||
67 | } | ||
68 | try { | ||
69 | fs.unlinkSync(expectedFilepath); | ||
70 | } catch(e) { | ||
71 | console.log(e); | ||
72 | } | ||
73 | } | ||
74 | |||
75 | module.exports = { | ||
76 | build: build, | ||
77 | watch: watch, | ||
78 | clean: clean | ||
79 | }; |
scripts/server.js
0 → 100644
1 | import connect from 'connect'; | ||
2 | import cowsay from 'cowsay'; | ||
3 | import path from 'path'; | ||
4 | import portscanner from 'portscanner'; | ||
5 | import serveStatic from 'serve-static'; | ||
6 | |||
7 | // Configuration for the server. | ||
8 | const PORT = 9999; | ||
9 | const MAX_PORT = PORT + 100; | ||
10 | const HOST = '127.0.0.1'; | ||
11 | |||
12 | const app = connect(); | ||
13 | |||
14 | const verbs = [ | ||
15 | 'Chewing the cud', | ||
16 | 'Grazing', | ||
17 | 'Mooing', | ||
18 | 'Lowing', | ||
19 | 'Churning the cream' | ||
20 | ]; | ||
21 | |||
22 | app.use(serveStatic(path.join(__dirname, '..'))); | ||
23 | |||
24 | portscanner.findAPortNotInUse(PORT, MAX_PORT, HOST, (error, port) => { | ||
25 | if (error) { | ||
26 | throw error; | ||
27 | } | ||
28 | |||
29 | process.stdout.write(cowsay.say({ | ||
30 | text: `${verbs[Math.floor(Math.random() * 5)]} on ${HOST}:${port}` | ||
31 | }) + '\n\n'); | ||
32 | |||
33 | app.listen(port); | ||
34 | }); |
scripts/watch-test.js
0 → 100644
1 | var browserify = require('browserify'); | ||
2 | var fs = require('fs'); | ||
3 | var glob = require('glob'); | ||
4 | var watchify = require('watchify'); | ||
5 | |||
6 | glob('test/**/*.test.js', function(err, files) { | ||
7 | var b = browserify(files, { | ||
8 | cache: {}, | ||
9 | packageCache: {}, | ||
10 | plugin: [watchify] | ||
11 | }).transform('babelify'); | ||
12 | |||
13 | var bundle = function() { | ||
14 | b.bundle().pipe(fs.createWriteStream('dist-test/videojs-contrib-hls.js')); | ||
15 | }; | ||
16 | |||
17 | b.on('log', function(msg) { | ||
18 | process.stdout.write(msg + '\n'); | ||
19 | }); | ||
20 | |||
21 | b.on('update', bundle); | ||
22 | bundle(); | ||
23 | }); |
src/.jshintrc
deleted
100644 → 0
1 | (function(window) { | 1 | const textRange = function(range, i) { |
2 | var textRange = function(range, i) { | 2 | return range.start(i) + '-' + range.end(i); |
3 | return range.start(i) + '-' + range.end(i); | 3 | }; |
4 | }; | 4 | |
5 | var module = { | 5 | const formatHexString = function(e, i) { |
6 | hexDump: function(data) { | 6 | let value = e.toString(16); |
7 | var | 7 | |
8 | bytes = Array.prototype.slice.call(data), | 8 | return '00'.substring(0, 2 - value.length) + value + (i % 2 ? ' ' : ''); |
9 | step = 16, | 9 | }; |
10 | formatHexString = function(e, i) { | 10 | const formatAsciiString = function(e) { |
11 | var value = e.toString(16); | 11 | if (e >= 0x20 && e < 0x7e) { |
12 | return "00".substring(0, 2 - value.length) + value + (i % 2 ? ' ' : ''); | 12 | return String.fromCharCode(e); |
13 | }, | 13 | } |
14 | formatAsciiString = function(e) { | 14 | return '.'; |
15 | if (e >= 0x20 && e < 0x7e) { | 15 | }; |
16 | return String.fromCharCode(e); | 16 | |
17 | } | 17 | const utils = { |
18 | return '.'; | 18 | hexDump(data) { |
19 | }, | 19 | let bytes = Array.prototype.slice.call(data); |
20 | result = '', | 20 | let step = 16; |
21 | hex, | 21 | let result = ''; |
22 | ascii; | 22 | let hex; |
23 | for (var j = 0; j < bytes.length / step; j++) { | 23 | let ascii; |
24 | hex = bytes.slice(j * step, j * step + step).map(formatHexString).join(''); | 24 | |
25 | ascii = bytes.slice(j * step, j * step + step).map(formatAsciiString).join(''); | 25 | for (let j = 0; j < bytes.length / step; j++) { |
26 | result += hex + ' ' + ascii + '\n'; | 26 | hex = bytes.slice(j * step, j * step + step).map(formatHexString).join(''); |
27 | } | 27 | ascii = bytes.slice(j * step, j * step + step).map(formatAsciiString).join(''); |
28 | return result; | 28 | result += hex + ' ' + ascii + '\n'; |
29 | }, | 29 | } |
30 | tagDump: function(tag) { | 30 | return result; |
31 | return module.hexDump(tag.bytes); | 31 | }, |
32 | }, | 32 | tagDump(tag) { |
33 | textRanges: function(ranges) { | 33 | return utils.hexDump(tag.bytes); |
34 | var result = '', i; | 34 | }, |
35 | for (i = 0; i < ranges.length; i++) { | 35 | textRanges(ranges) { |
36 | result += textRange(ranges, i) + ' '; | 36 | let result = ''; |
37 | } | 37 | let i; |
38 | return result; | 38 | |
39 | for (i = 0; i < ranges.length; i++) { | ||
40 | result += textRange(ranges, i) + ' '; | ||
39 | } | 41 | } |
40 | }; | 42 | return result; |
43 | } | ||
44 | }; | ||
41 | 45 | ||
42 | window.videojs.Hls.utils = module; | 46 | export default utils; |
43 | })(this); | ... | ... |
src/decrypter.js
deleted
100644 → 0
1 | /* | ||
2 | * | ||
3 | * This file contains an adaptation of the AES decryption algorithm | ||
4 | * from the Standford Javascript Cryptography Library. That work is | ||
5 | * covered by the following copyright and permissions notice: | ||
6 | * | ||
7 | * Copyright 2009-2010 Emily Stark, Mike Hamburg, Dan Boneh. | ||
8 | * All rights reserved. | ||
9 | * | ||
10 | * Redistribution and use in source and binary forms, with or without | ||
11 | * modification, are permitted provided that the following conditions are | ||
12 | * met: | ||
13 | * | ||
14 | * 1. Redistributions of source code must retain the above copyright | ||
15 | * notice, this list of conditions and the following disclaimer. | ||
16 | * | ||
17 | * 2. Redistributions in binary form must reproduce the above | ||
18 | * copyright notice, this list of conditions and the following | ||
19 | * disclaimer in the documentation and/or other materials provided | ||
20 | * with the distribution. | ||
21 | * | ||
22 | * THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR | ||
23 | * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED | ||
24 | * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE | ||
25 | * DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> OR CONTRIBUTORS BE | ||
26 | * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR | ||
27 | * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF | ||
28 | * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR | ||
29 | * BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, | ||
30 | * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE | ||
31 | * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN | ||
32 | * IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | ||
33 | * | ||
34 | * The views and conclusions contained in the software and documentation | ||
35 | * are those of the authors and should not be interpreted as representing | ||
36 | * official policies, either expressed or implied, of the authors. | ||
37 | */ | ||
38 | (function(window, videojs, unpad) { | ||
39 | 'use strict'; | ||
40 | |||
41 | var AES, AsyncStream, Decrypter, decrypt, ntoh; | ||
42 | |||
43 | /** | ||
44 | * Convert network-order (big-endian) bytes into their little-endian | ||
45 | * representation. | ||
46 | */ | ||
47 | ntoh = function(word) { | ||
48 | return (word << 24) | | ||
49 | ((word & 0xff00) << 8) | | ||
50 | ((word & 0xff0000) >> 8) | | ||
51 | (word >>> 24); | ||
52 | }; | ||
53 | |||
54 | /** | ||
55 | * Schedule out an AES key for both encryption and decryption. This | ||
56 | * is a low-level class. Use a cipher mode to do bulk encryption. | ||
57 | * | ||
58 | * @constructor | ||
59 | * @param key {Array} The key as an array of 4, 6 or 8 words. | ||
60 | */ | ||
61 | AES = function (key) { | ||
62 | this._precompute(); | ||
63 | |||
64 | var i, j, tmp, | ||
65 | encKey, decKey, | ||
66 | sbox = this._tables[0][4], decTable = this._tables[1], | ||
67 | keyLen = key.length, rcon = 1; | ||
68 | |||
69 | if (keyLen !== 4 && keyLen !== 6 && keyLen !== 8) { | ||
70 | throw new Error("Invalid aes key size"); | ||
71 | } | ||
72 | |||
73 | encKey = key.slice(0); | ||
74 | decKey = []; | ||
75 | this._key = [encKey, decKey]; | ||
76 | |||
77 | // schedule encryption keys | ||
78 | for (i = keyLen; i < 4 * keyLen + 28; i++) { | ||
79 | tmp = encKey[i-1]; | ||
80 | |||
81 | // apply sbox | ||
82 | if (i%keyLen === 0 || (keyLen === 8 && i%keyLen === 4)) { | ||
83 | tmp = sbox[tmp>>>24]<<24 ^ sbox[tmp>>16&255]<<16 ^ sbox[tmp>>8&255]<<8 ^ sbox[tmp&255]; | ||
84 | |||
85 | // shift rows and add rcon | ||
86 | if (i%keyLen === 0) { | ||
87 | tmp = tmp<<8 ^ tmp>>>24 ^ rcon<<24; | ||
88 | rcon = rcon<<1 ^ (rcon>>7)*283; | ||
89 | } | ||
90 | } | ||
91 | |||
92 | encKey[i] = encKey[i-keyLen] ^ tmp; | ||
93 | } | ||
94 | |||
95 | // schedule decryption keys | ||
96 | for (j = 0; i; j++, i--) { | ||
97 | tmp = encKey[j&3 ? i : i - 4]; | ||
98 | if (i<=4 || j<4) { | ||
99 | decKey[j] = tmp; | ||
100 | } else { | ||
101 | decKey[j] = decTable[0][sbox[tmp>>>24 ]] ^ | ||
102 | decTable[1][sbox[tmp>>16 & 255]] ^ | ||
103 | decTable[2][sbox[tmp>>8 & 255]] ^ | ||
104 | decTable[3][sbox[tmp & 255]]; | ||
105 | } | ||
106 | } | ||
107 | }; | ||
108 | |||
109 | AES.prototype = { | ||
110 | /** | ||
111 | * The expanded S-box and inverse S-box tables. These will be computed | ||
112 | * on the client so that we don't have to send them down the wire. | ||
113 | * | ||
114 | * There are two tables, _tables[0] is for encryption and | ||
115 | * _tables[1] is for decryption. | ||
116 | * | ||
117 | * The first 4 sub-tables are the expanded S-box with MixColumns. The | ||
118 | * last (_tables[01][4]) is the S-box itself. | ||
119 | * | ||
120 | * @private | ||
121 | */ | ||
122 | _tables: [[[],[],[],[],[]],[[],[],[],[],[]]], | ||
123 | |||
124 | /** | ||
125 | * Expand the S-box tables. | ||
126 | * | ||
127 | * @private | ||
128 | */ | ||
129 | _precompute: function () { | ||
130 | var encTable = this._tables[0], decTable = this._tables[1], | ||
131 | sbox = encTable[4], sboxInv = decTable[4], | ||
132 | i, x, xInv, d=[], th=[], x2, x4, x8, s, tEnc, tDec; | ||
133 | |||
134 | // Compute double and third tables | ||
135 | for (i = 0; i < 256; i++) { | ||
136 | th[( d[i] = i<<1 ^ (i>>7)*283 )^i]=i; | ||
137 | } | ||
138 | |||
139 | for (x = xInv = 0; !sbox[x]; x ^= x2 || 1, xInv = th[xInv] || 1) { | ||
140 | // Compute sbox | ||
141 | s = xInv ^ xInv<<1 ^ xInv<<2 ^ xInv<<3 ^ xInv<<4; | ||
142 | s = s>>8 ^ s&255 ^ 99; | ||
143 | sbox[x] = s; | ||
144 | sboxInv[s] = x; | ||
145 | |||
146 | // Compute MixColumns | ||
147 | x8 = d[x4 = d[x2 = d[x]]]; | ||
148 | tDec = x8*0x1010101 ^ x4*0x10001 ^ x2*0x101 ^ x*0x1010100; | ||
149 | tEnc = d[s]*0x101 ^ s*0x1010100; | ||
150 | |||
151 | for (i = 0; i < 4; i++) { | ||
152 | encTable[i][x] = tEnc = tEnc<<24 ^ tEnc>>>8; | ||
153 | decTable[i][s] = tDec = tDec<<24 ^ tDec>>>8; | ||
154 | } | ||
155 | } | ||
156 | |||
157 | // Compactify. Considerable speedup on Firefox. | ||
158 | for (i = 0; i < 5; i++) { | ||
159 | encTable[i] = encTable[i].slice(0); | ||
160 | decTable[i] = decTable[i].slice(0); | ||
161 | } | ||
162 | }, | ||
163 | |||
164 | /** | ||
165 | * Decrypt 16 bytes, specified as four 32-bit words. | ||
166 | * @param encrypted0 {number} the first word to decrypt | ||
167 | * @param encrypted1 {number} the second word to decrypt | ||
168 | * @param encrypted2 {number} the third word to decrypt | ||
169 | * @param encrypted3 {number} the fourth word to decrypt | ||
170 | * @param out {Int32Array} the array to write the decrypted words | ||
171 | * into | ||
172 | * @param offset {number} the offset into the output array to start | ||
173 | * writing results | ||
174 | * @return {Array} The plaintext. | ||
175 | */ | ||
176 | decrypt:function (encrypted0, encrypted1, encrypted2, encrypted3, out, offset) { | ||
177 | var key = this._key[1], | ||
178 | // state variables a,b,c,d are loaded with pre-whitened data | ||
179 | a = encrypted0 ^ key[0], | ||
180 | b = encrypted3 ^ key[1], | ||
181 | c = encrypted2 ^ key[2], | ||
182 | d = encrypted1 ^ key[3], | ||
183 | a2, b2, c2, | ||
184 | |||
185 | nInnerRounds = key.length / 4 - 2, // key.length === 2 ? | ||
186 | i, | ||
187 | kIndex = 4, | ||
188 | table = this._tables[1], | ||
189 | |||
190 | // load up the tables | ||
191 | table0 = table[0], | ||
192 | table1 = table[1], | ||
193 | table2 = table[2], | ||
194 | table3 = table[3], | ||
195 | sbox = table[4]; | ||
196 | |||
197 | // Inner rounds. Cribbed from OpenSSL. | ||
198 | for (i = 0; i < nInnerRounds; i++) { | ||
199 | a2 = table0[a>>>24] ^ table1[b>>16 & 255] ^ table2[c>>8 & 255] ^ table3[d & 255] ^ key[kIndex]; | ||
200 | b2 = table0[b>>>24] ^ table1[c>>16 & 255] ^ table2[d>>8 & 255] ^ table3[a & 255] ^ key[kIndex + 1]; | ||
201 | c2 = table0[c>>>24] ^ table1[d>>16 & 255] ^ table2[a>>8 & 255] ^ table3[b & 255] ^ key[kIndex + 2]; | ||
202 | d = table0[d>>>24] ^ table1[a>>16 & 255] ^ table2[b>>8 & 255] ^ table3[c & 255] ^ key[kIndex + 3]; | ||
203 | kIndex += 4; | ||
204 | a=a2; b=b2; c=c2; | ||
205 | } | ||
206 | |||
207 | // Last round. | ||
208 | for (i = 0; i < 4; i++) { | ||
209 | out[(3 & -i) + offset] = | ||
210 | sbox[a>>>24 ]<<24 ^ | ||
211 | sbox[b>>16 & 255]<<16 ^ | ||
212 | sbox[c>>8 & 255]<<8 ^ | ||
213 | sbox[d & 255] ^ | ||
214 | key[kIndex++]; | ||
215 | a2=a; a=b; b=c; c=d; d=a2; | ||
216 | } | ||
217 | } | ||
218 | }; | ||
219 | |||
220 | /** | ||
221 | * Decrypt bytes using AES-128 with CBC and PKCS#7 padding. | ||
222 | * @param encrypted {Uint8Array} the encrypted bytes | ||
223 | * @param key {Uint32Array} the bytes of the decryption key | ||
224 | * @param initVector {Uint32Array} the initialization vector (IV) to | ||
225 | * use for the first round of CBC. | ||
226 | * @return {Uint8Array} the decrypted bytes | ||
227 | * | ||
228 | * @see http://en.wikipedia.org/wiki/Advanced_Encryption_Standard | ||
229 | * @see http://en.wikipedia.org/wiki/Block_cipher_mode_of_operation#Cipher_Block_Chaining_.28CBC.29 | ||
230 | * @see https://tools.ietf.org/html/rfc2315 | ||
231 | */ | ||
232 | decrypt = function(encrypted, key, initVector) { | ||
233 | var | ||
234 | // word-level access to the encrypted bytes | ||
235 | encrypted32 = new Int32Array(encrypted.buffer, encrypted.byteOffset, encrypted.byteLength >> 2), | ||
236 | |||
237 | decipher = new AES(Array.prototype.slice.call(key)), | ||
238 | |||
239 | // byte and word-level access for the decrypted output | ||
240 | decrypted = new Uint8Array(encrypted.byteLength), | ||
241 | decrypted32 = new Int32Array(decrypted.buffer), | ||
242 | |||
243 | // temporary variables for working with the IV, encrypted, and | ||
244 | // decrypted data | ||
245 | init0, init1, init2, init3, | ||
246 | encrypted0, encrypted1, encrypted2, encrypted3, | ||
247 | |||
248 | // iteration variable | ||
249 | wordIx; | ||
250 | |||
251 | // pull out the words of the IV to ensure we don't modify the | ||
252 | // passed-in reference and easier access | ||
253 | init0 = initVector[0]; | ||
254 | init1 = initVector[1]; | ||
255 | init2 = initVector[2]; | ||
256 | init3 = initVector[3]; | ||
257 | |||
258 | // decrypt four word sequences, applying cipher-block chaining (CBC) | ||
259 | // to each decrypted block | ||
260 | for (wordIx = 0; wordIx < encrypted32.length; wordIx += 4) { | ||
261 | // convert big-endian (network order) words into little-endian | ||
262 | // (javascript order) | ||
263 | encrypted0 = ntoh(encrypted32[wordIx]); | ||
264 | encrypted1 = ntoh(encrypted32[wordIx + 1]); | ||
265 | encrypted2 = ntoh(encrypted32[wordIx + 2]); | ||
266 | encrypted3 = ntoh(encrypted32[wordIx + 3]); | ||
267 | |||
268 | // decrypt the block | ||
269 | decipher.decrypt(encrypted0, | ||
270 | encrypted1, | ||
271 | encrypted2, | ||
272 | encrypted3, | ||
273 | decrypted32, | ||
274 | wordIx); | ||
275 | |||
276 | // XOR with the IV, and restore network byte-order to obtain the | ||
277 | // plaintext | ||
278 | decrypted32[wordIx] = ntoh(decrypted32[wordIx] ^ init0); | ||
279 | decrypted32[wordIx + 1] = ntoh(decrypted32[wordIx + 1] ^ init1); | ||
280 | decrypted32[wordIx + 2] = ntoh(decrypted32[wordIx + 2] ^ init2); | ||
281 | decrypted32[wordIx + 3] = ntoh(decrypted32[wordIx + 3] ^ init3); | ||
282 | |||
283 | // setup the IV for the next round | ||
284 | init0 = encrypted0; | ||
285 | init1 = encrypted1; | ||
286 | init2 = encrypted2; | ||
287 | init3 = encrypted3; | ||
288 | } | ||
289 | |||
290 | return decrypted; | ||
291 | }; | ||
292 | |||
293 | AsyncStream = function() { | ||
294 | this.jobs = []; | ||
295 | this.delay = 1; | ||
296 | this.timeout_ = null; | ||
297 | }; | ||
298 | AsyncStream.prototype = new videojs.Hls.Stream(); | ||
299 | AsyncStream.prototype.processJob_ = function() { | ||
300 | this.jobs.shift()(); | ||
301 | if (this.jobs.length) { | ||
302 | this.timeout_ = setTimeout(this.processJob_.bind(this), | ||
303 | this.delay); | ||
304 | } else { | ||
305 | this.timeout_ = null; | ||
306 | } | ||
307 | }; | ||
308 | AsyncStream.prototype.push = function(job) { | ||
309 | this.jobs.push(job); | ||
310 | if (!this.timeout_) { | ||
311 | this.timeout_ = setTimeout(this.processJob_.bind(this), | ||
312 | this.delay); | ||
313 | } | ||
314 | }; | ||
315 | |||
316 | Decrypter = function(encrypted, key, initVector, done) { | ||
317 | var | ||
318 | step = Decrypter.STEP, | ||
319 | encrypted32 = new Int32Array(encrypted.buffer), | ||
320 | decrypted = new Uint8Array(encrypted.byteLength), | ||
321 | i = 0; | ||
322 | this.asyncStream_ = new AsyncStream(); | ||
323 | |||
324 | // split up the encryption job and do the individual chunks asynchronously | ||
325 | this.asyncStream_.push(this.decryptChunk_(encrypted32.subarray(i, i + step), | ||
326 | key, | ||
327 | initVector, | ||
328 | decrypted)); | ||
329 | for (i = step; i < encrypted32.length; i += step) { | ||
330 | initVector = new Uint32Array([ | ||
331 | ntoh(encrypted32[i - 4]), | ||
332 | ntoh(encrypted32[i - 3]), | ||
333 | ntoh(encrypted32[i - 2]), | ||
334 | ntoh(encrypted32[i - 1]) | ||
335 | ]); | ||
336 | this.asyncStream_.push(this.decryptChunk_(encrypted32.subarray(i, i + step), | ||
337 | key, | ||
338 | initVector, | ||
339 | decrypted)); | ||
340 | } | ||
341 | // invoke the done() callback when everything is finished | ||
342 | this.asyncStream_.push(function() { | ||
343 | // remove pkcs#7 padding from the decrypted bytes | ||
344 | done(null, unpad(decrypted)); | ||
345 | }); | ||
346 | }; | ||
347 | Decrypter.prototype = new videojs.Hls.Stream(); | ||
348 | Decrypter.prototype.decryptChunk_ = function(encrypted, key, initVector, decrypted) { | ||
349 | return function() { | ||
350 | var bytes = decrypt(encrypted, | ||
351 | key, | ||
352 | initVector); | ||
353 | decrypted.set(bytes, encrypted.byteOffset); | ||
354 | }; | ||
355 | }; | ||
356 | // the maximum number of bytes to process at one time | ||
357 | Decrypter.STEP = 4 * 8000; | ||
358 | |||
359 | // exports | ||
360 | videojs.Hls.decrypt = decrypt; | ||
361 | videojs.Hls.Decrypter = Decrypter; | ||
362 | videojs.Hls.AsyncStream = AsyncStream; | ||
363 | |||
364 | })(window, window.videojs, window.pkcs7.unpad); |
src/decrypter/aes.js
0 → 100644
1 | /* | ||
2 | * aes.js | ||
3 | * | ||
4 | * This file contains an adaptation of the AES decryption algorithm | ||
5 | * from the Standford Javascript Cryptography Library. That work is | ||
6 | * covered by the following copyright and permissions notice: | ||
7 | * | ||
8 | * Copyright 2009-2010 Emily Stark, Mike Hamburg, Dan Boneh. | ||
9 | * All rights reserved. | ||
10 | * | ||
11 | * Redistribution and use in source and binary forms, with or without | ||
12 | * modification, are permitted provided that the following conditions are | ||
13 | * met: | ||
14 | * | ||
15 | * 1. Redistributions of source code must retain the above copyright | ||
16 | * notice, this list of conditions and the following disclaimer. | ||
17 | * | ||
18 | * 2. Redistributions in binary form must reproduce the above | ||
19 | * copyright notice, this list of conditions and the following | ||
20 | * disclaimer in the documentation and/or other materials provided | ||
21 | * with the distribution. | ||
22 | * | ||
23 | * THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR | ||
24 | * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED | ||
25 | * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE | ||
26 | * DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> OR CONTRIBUTORS BE | ||
27 | * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR | ||
28 | * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF | ||
29 | * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR | ||
30 | * BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, | ||
31 | * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE | ||
32 | * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN | ||
33 | * IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | ||
34 | * | ||
35 | * The views and conclusions contained in the software and documentation | ||
36 | * are those of the authors and should not be interpreted as representing | ||
37 | * official policies, either expressed or implied, of the authors. | ||
38 | */ | ||
39 | |||
40 | /** | ||
41 | * Expand the S-box tables. | ||
42 | * | ||
43 | * @private | ||
44 | */ | ||
45 | const precompute = function() { | ||
46 | let tables = [[[], [], [], [], []], [[], [], [], [], []]]; | ||
47 | let encTable = tables[0]; | ||
48 | let decTable = tables[1]; | ||
49 | let sbox = encTable[4]; | ||
50 | let sboxInv = decTable[4]; | ||
51 | let i; | ||
52 | let x; | ||
53 | let xInv; | ||
54 | let d = []; | ||
55 | let th = []; | ||
56 | let x2; | ||
57 | let x4; | ||
58 | let x8; | ||
59 | let s; | ||
60 | let tEnc; | ||
61 | let tDec; | ||
62 | |||
63 | // Compute double and third tables | ||
64 | for (i = 0; i < 256; i++) { | ||
65 | th[(d[i] = i << 1 ^ (i >> 7) * 283) ^ i] = i; | ||
66 | } | ||
67 | |||
68 | for (x = xInv = 0; !sbox[x]; x ^= x2 || 1, xInv = th[xInv] || 1) { | ||
69 | // Compute sbox | ||
70 | s = xInv ^ xInv << 1 ^ xInv << 2 ^ xInv << 3 ^ xInv << 4; | ||
71 | s = s >> 8 ^ s & 255 ^ 99; | ||
72 | sbox[x] = s; | ||
73 | sboxInv[s] = x; | ||
74 | |||
75 | // Compute MixColumns | ||
76 | x8 = d[x4 = d[x2 = d[x]]]; | ||
77 | tDec = x8 * 0x1010101 ^ x4 * 0x10001 ^ x2 * 0x101 ^ x * 0x1010100; | ||
78 | tEnc = d[s] * 0x101 ^ s * 0x1010100; | ||
79 | |||
80 | for (i = 0; i < 4; i++) { | ||
81 | encTable[i][x] = tEnc = tEnc << 24 ^ tEnc >>> 8; | ||
82 | decTable[i][s] = tDec = tDec << 24 ^ tDec >>> 8; | ||
83 | } | ||
84 | } | ||
85 | |||
86 | // Compactify. Considerable speedup on Firefox. | ||
87 | for (i = 0; i < 5; i++) { | ||
88 | encTable[i] = encTable[i].slice(0); | ||
89 | decTable[i] = decTable[i].slice(0); | ||
90 | } | ||
91 | return tables; | ||
92 | }; | ||
93 | let aesTables = null; | ||
94 | |||
95 | /** | ||
96 | * Schedule out an AES key for both encryption and decryption. This | ||
97 | * is a low-level class. Use a cipher mode to do bulk encryption. | ||
98 | * | ||
99 | * @constructor | ||
100 | * @param key {Array} The key as an array of 4, 6 or 8 words. | ||
101 | */ | ||
102 | export default class AES { | ||
103 | constructor(key) { | ||
104 | /** | ||
105 | * The expanded S-box and inverse S-box tables. These will be computed | ||
106 | * on the client so that we don't have to send them down the wire. | ||
107 | * | ||
108 | * There are two tables, _tables[0] is for encryption and | ||
109 | * _tables[1] is for decryption. | ||
110 | * | ||
111 | * The first 4 sub-tables are the expanded S-box with MixColumns. The | ||
112 | * last (_tables[01][4]) is the S-box itself. | ||
113 | * | ||
114 | * @private | ||
115 | */ | ||
116 | // if we have yet to precompute the S-box tables | ||
117 | // do so now | ||
118 | if (!aesTables) { | ||
119 | aesTables = precompute(); | ||
120 | } | ||
121 | // then make a copy of that object for use | ||
122 | this._tables = [[aesTables[0][0].slice(), | ||
123 | aesTables[0][1].slice(), | ||
124 | aesTables[0][2].slice(), | ||
125 | aesTables[0][3].slice(), | ||
126 | aesTables[0][4].slice()], | ||
127 | [aesTables[1][0].slice(), | ||
128 | aesTables[1][1].slice(), | ||
129 | aesTables[1][2].slice(), | ||
130 | aesTables[1][3].slice(), | ||
131 | aesTables[1][4].slice()]]; | ||
132 | let i; | ||
133 | let j; | ||
134 | let tmp; | ||
135 | let encKey; | ||
136 | let decKey; | ||
137 | let sbox = this._tables[0][4]; | ||
138 | let decTable = this._tables[1]; | ||
139 | let keyLen = key.length; | ||
140 | let rcon = 1; | ||
141 | |||
142 | if (keyLen !== 4 && keyLen !== 6 && keyLen !== 8) { | ||
143 | throw new Error('Invalid aes key size'); | ||
144 | } | ||
145 | |||
146 | encKey = key.slice(0); | ||
147 | decKey = []; | ||
148 | this._key = [encKey, decKey]; | ||
149 | |||
150 | // schedule encryption keys | ||
151 | for (i = keyLen; i < 4 * keyLen + 28; i++) { | ||
152 | tmp = encKey[i - 1]; | ||
153 | |||
154 | // apply sbox | ||
155 | if (i % keyLen === 0 || (keyLen === 8 && i % keyLen === 4)) { | ||
156 | tmp = sbox[tmp >>> 24] << 24 ^ | ||
157 | sbox[tmp >> 16 & 255] << 16 ^ | ||
158 | sbox[tmp >> 8 & 255] << 8 ^ | ||
159 | sbox[tmp & 255]; | ||
160 | |||
161 | // shift rows and add rcon | ||
162 | if (i % keyLen === 0) { | ||
163 | tmp = tmp << 8 ^ tmp >>> 24 ^ rcon << 24; | ||
164 | rcon = rcon << 1 ^ (rcon >> 7) * 283; | ||
165 | } | ||
166 | } | ||
167 | |||
168 | encKey[i] = encKey[i - keyLen] ^ tmp; | ||
169 | } | ||
170 | |||
171 | // schedule decryption keys | ||
172 | for (j = 0; i; j++, i--) { | ||
173 | tmp = encKey[j & 3 ? i : i - 4]; | ||
174 | if (i <= 4 || j < 4) { | ||
175 | decKey[j] = tmp; | ||
176 | } else { | ||
177 | decKey[j] = decTable[0][sbox[tmp >>> 24 ]] ^ | ||
178 | decTable[1][sbox[tmp >> 16 & 255]] ^ | ||
179 | decTable[2][sbox[tmp >> 8 & 255]] ^ | ||
180 | decTable[3][sbox[tmp & 255]]; | ||
181 | } | ||
182 | } | ||
183 | } | ||
184 | |||
185 | /** | ||
186 | * Decrypt 16 bytes, specified as four 32-bit words. | ||
187 | * @param encrypted0 {number} the first word to decrypt | ||
188 | * @param encrypted1 {number} the second word to decrypt | ||
189 | * @param encrypted2 {number} the third word to decrypt | ||
190 | * @param encrypted3 {number} the fourth word to decrypt | ||
191 | * @param out {Int32Array} the array to write the decrypted words | ||
192 | * into | ||
193 | * @param offset {number} the offset into the output array to start | ||
194 | * writing results | ||
195 | * @return {Array} The plaintext. | ||
196 | */ | ||
197 | decrypt(encrypted0, encrypted1, encrypted2, encrypted3, out, offset) { | ||
198 | let key = this._key[1]; | ||
199 | // state variables a,b,c,d are loaded with pre-whitened data | ||
200 | let a = encrypted0 ^ key[0]; | ||
201 | let b = encrypted3 ^ key[1]; | ||
202 | let c = encrypted2 ^ key[2]; | ||
203 | let d = encrypted1 ^ key[3]; | ||
204 | let a2; | ||
205 | let b2; | ||
206 | let c2; | ||
207 | |||
208 | // key.length === 2 ? | ||
209 | let nInnerRounds = key.length / 4 - 2; | ||
210 | let i; | ||
211 | let kIndex = 4; | ||
212 | let table = this._tables[1]; | ||
213 | |||
214 | // load up the tables | ||
215 | let table0 = table[0]; | ||
216 | let table1 = table[1]; | ||
217 | let table2 = table[2]; | ||
218 | let table3 = table[3]; | ||
219 | let sbox = table[4]; | ||
220 | |||
221 | // Inner rounds. Cribbed from OpenSSL. | ||
222 | for (i = 0; i < nInnerRounds; i++) { | ||
223 | a2 = table0[a >>> 24] ^ | ||
224 | table1[b >> 16 & 255] ^ | ||
225 | table2[c >> 8 & 255] ^ | ||
226 | table3[d & 255] ^ | ||
227 | key[kIndex]; | ||
228 | b2 = table0[b >>> 24] ^ | ||
229 | table1[c >> 16 & 255] ^ | ||
230 | table2[d >> 8 & 255] ^ | ||
231 | table3[a & 255] ^ | ||
232 | key[kIndex + 1]; | ||
233 | c2 = table0[c >>> 24] ^ | ||
234 | table1[d >> 16 & 255] ^ | ||
235 | table2[a >> 8 & 255] ^ | ||
236 | table3[b & 255] ^ | ||
237 | key[kIndex + 2]; | ||
238 | d = table0[d >>> 24] ^ | ||
239 | table1[a >> 16 & 255] ^ | ||
240 | table2[b >> 8 & 255] ^ | ||
241 | table3[c & 255] ^ | ||
242 | key[kIndex + 3]; | ||
243 | kIndex += 4; | ||
244 | a = a2; b = b2; c = c2; | ||
245 | } | ||
246 | |||
247 | // Last round. | ||
248 | for (i = 0; i < 4; i++) { | ||
249 | out[(3 & -i) + offset] = | ||
250 | sbox[a >>> 24] << 24 ^ | ||
251 | sbox[b >> 16 & 255] << 16 ^ | ||
252 | sbox[c >> 8 & 255] << 8 ^ | ||
253 | sbox[d & 255] ^ | ||
254 | key[kIndex++]; | ||
255 | a2 = a; a = b; b = c; c = d; d = a2; | ||
256 | } | ||
257 | } | ||
258 | } |
src/decrypter/async-stream.js
0 → 100644
1 | import Stream from '../stream'; | ||
2 | |||
3 | /** | ||
4 | * A wrapper around the Stream class to use setTiemout | ||
5 | * and run stream "jobs" Asynchronously | ||
6 | */ | ||
7 | export default class AsyncStream extends Stream { | ||
8 | constructor() { | ||
9 | super(Stream); | ||
10 | this.jobs = []; | ||
11 | this.delay = 1; | ||
12 | this.timeout_ = null; | ||
13 | } | ||
14 | processJob_() { | ||
15 | this.jobs.shift()(); | ||
16 | if (this.jobs.length) { | ||
17 | this.timeout_ = setTimeout(this.processJob_.bind(this), | ||
18 | this.delay); | ||
19 | } else { | ||
20 | this.timeout_ = null; | ||
21 | } | ||
22 | } | ||
23 | push(job) { | ||
24 | this.jobs.push(job); | ||
25 | if (!this.timeout_) { | ||
26 | this.timeout_ = setTimeout(this.processJob_.bind(this), | ||
27 | this.delay); | ||
28 | } | ||
29 | } | ||
30 | } | ||
31 |
src/decrypter/decrypter.js
0 → 100644
1 | /* | ||
2 | * decrypter.js | ||
3 | * | ||
4 | * An asynchronous implementation of AES-128 CBC decryption with | ||
5 | * PKCS#7 padding. | ||
6 | */ | ||
7 | |||
8 | import AES from './aes'; | ||
9 | import AsyncStream from './async-stream'; | ||
10 | import {unpad} from 'pkcs7'; | ||
11 | |||
12 | /** | ||
13 | * Convert network-order (big-endian) bytes into their little-endian | ||
14 | * representation. | ||
15 | */ | ||
16 | const ntoh = function(word) { | ||
17 | return (word << 24) | | ||
18 | ((word & 0xff00) << 8) | | ||
19 | ((word & 0xff0000) >> 8) | | ||
20 | (word >>> 24); | ||
21 | }; | ||
22 | |||
23 | /* eslint-disable max-len */ | ||
24 | /** | ||
25 | * Decrypt bytes using AES-128 with CBC and PKCS#7 padding. | ||
26 | * @param encrypted {Uint8Array} the encrypted bytes | ||
27 | * @param key {Uint32Array} the bytes of the decryption key | ||
28 | * @param initVector {Uint32Array} the initialization vector (IV) to | ||
29 | * use for the first round of CBC. | ||
30 | * @return {Uint8Array} the decrypted bytes | ||
31 | * | ||
32 | * @see http://en.wikipedia.org/wiki/Advanced_Encryption_Standard | ||
33 | * @see http://en.wikipedia.org/wiki/Block_cipher_mode_of_operation#Cipher_Block_Chaining_.28CBC.29 | ||
34 | * @see https://tools.ietf.org/html/rfc2315 | ||
35 | */ | ||
36 | /* eslint-enable max-len */ | ||
37 | export const decrypt = function(encrypted, key, initVector) { | ||
38 | // word-level access to the encrypted bytes | ||
39 | let encrypted32 = new Int32Array(encrypted.buffer, | ||
40 | encrypted.byteOffset, | ||
41 | encrypted.byteLength >> 2); | ||
42 | |||
43 | let decipher = new AES(Array.prototype.slice.call(key)); | ||
44 | |||
45 | // byte and word-level access for the decrypted output | ||
46 | let decrypted = new Uint8Array(encrypted.byteLength); | ||
47 | let decrypted32 = new Int32Array(decrypted.buffer); | ||
48 | |||
49 | // temporary variables for working with the IV, encrypted, and | ||
50 | // decrypted data | ||
51 | let init0; | ||
52 | let init1; | ||
53 | let init2; | ||
54 | let init3; | ||
55 | let encrypted0; | ||
56 | let encrypted1; | ||
57 | let encrypted2; | ||
58 | let encrypted3; | ||
59 | |||
60 | // iteration variable | ||
61 | let wordIx; | ||
62 | |||
63 | // pull out the words of the IV to ensure we don't modify the | ||
64 | // passed-in reference and easier access | ||
65 | init0 = initVector[0]; | ||
66 | init1 = initVector[1]; | ||
67 | init2 = initVector[2]; | ||
68 | init3 = initVector[3]; | ||
69 | |||
70 | // decrypt four word sequences, applying cipher-block chaining (CBC) | ||
71 | // to each decrypted block | ||
72 | for (wordIx = 0; wordIx < encrypted32.length; wordIx += 4) { | ||
73 | // convert big-endian (network order) words into little-endian | ||
74 | // (javascript order) | ||
75 | encrypted0 = ntoh(encrypted32[wordIx]); | ||
76 | encrypted1 = ntoh(encrypted32[wordIx + 1]); | ||
77 | encrypted2 = ntoh(encrypted32[wordIx + 2]); | ||
78 | encrypted3 = ntoh(encrypted32[wordIx + 3]); | ||
79 | |||
80 | // decrypt the block | ||
81 | decipher.decrypt(encrypted0, | ||
82 | encrypted1, | ||
83 | encrypted2, | ||
84 | encrypted3, | ||
85 | decrypted32, | ||
86 | wordIx); | ||
87 | |||
88 | // XOR with the IV, and restore network byte-order to obtain the | ||
89 | // plaintext | ||
90 | decrypted32[wordIx] = ntoh(decrypted32[wordIx] ^ init0); | ||
91 | decrypted32[wordIx + 1] = ntoh(decrypted32[wordIx + 1] ^ init1); | ||
92 | decrypted32[wordIx + 2] = ntoh(decrypted32[wordIx + 2] ^ init2); | ||
93 | decrypted32[wordIx + 3] = ntoh(decrypted32[wordIx + 3] ^ init3); | ||
94 | |||
95 | // setup the IV for the next round | ||
96 | init0 = encrypted0; | ||
97 | init1 = encrypted1; | ||
98 | init2 = encrypted2; | ||
99 | init3 = encrypted3; | ||
100 | } | ||
101 | |||
102 | return decrypted; | ||
103 | }; | ||
104 | |||
105 | /** | ||
106 | * The `Decrypter` class that manages decryption of AES | ||
107 | * data through `AsyncStream` objects and the `decrypt` | ||
108 | * function | ||
109 | */ | ||
110 | export class Decrypter { | ||
111 | constructor(encrypted, key, initVector, done) { | ||
112 | let step = Decrypter.STEP; | ||
113 | let encrypted32 = new Int32Array(encrypted.buffer); | ||
114 | let decrypted = new Uint8Array(encrypted.byteLength); | ||
115 | let i = 0; | ||
116 | |||
117 | this.asyncStream_ = new AsyncStream(); | ||
118 | |||
119 | // split up the encryption job and do the individual chunks asynchronously | ||
120 | this.asyncStream_.push(this.decryptChunk_(encrypted32.subarray(i, i + step), | ||
121 | key, | ||
122 | initVector, | ||
123 | decrypted)); | ||
124 | for (i = step; i < encrypted32.length; i += step) { | ||
125 | initVector = new Uint32Array([ntoh(encrypted32[i - 4]), | ||
126 | ntoh(encrypted32[i - 3]), | ||
127 | ntoh(encrypted32[i - 2]), | ||
128 | ntoh(encrypted32[i - 1])]); | ||
129 | this.asyncStream_.push(this.decryptChunk_(encrypted32.subarray(i, i + step), | ||
130 | key, | ||
131 | initVector, | ||
132 | decrypted)); | ||
133 | } | ||
134 | // invoke the done() callback when everything is finished | ||
135 | this.asyncStream_.push(function() { | ||
136 | // remove pkcs#7 padding from the decrypted bytes | ||
137 | done(null, unpad(decrypted)); | ||
138 | }); | ||
139 | } | ||
140 | decryptChunk_(encrypted, key, initVector, decrypted) { | ||
141 | return function() { | ||
142 | let bytes = decrypt(encrypted, key, initVector); | ||
143 | |||
144 | decrypted.set(bytes, encrypted.byteOffset); | ||
145 | }; | ||
146 | } | ||
147 | } | ||
148 | |||
149 | // the maximum number of bytes to process at one time | ||
150 | // 4 * 8000; | ||
151 | Decrypter.STEP = 32000; | ||
152 | |||
153 | export default { | ||
154 | Decrypter, | ||
155 | decrypt | ||
156 | }; |
src/decrypter/index.js
0 → 100644
1 | /* | ||
2 | * index.js | ||
3 | * | ||
4 | * Index module to easily import the primary components of AES-128 | ||
5 | * decryption. Like this: | ||
6 | * | ||
7 | * ```js | ||
8 | * import {Decrypter, decrypt, AsyncStream} from './src/decrypter'; | ||
9 | * ``` | ||
10 | */ | ||
11 | import {decrypt, Decrypter} from './decrypter'; | ||
12 | import AsyncStream from './async-stream'; | ||
13 | |||
14 | export default { | ||
15 | decrypt, | ||
16 | Decrypter, | ||
17 | AsyncStream | ||
18 | }; |
src/m3u8/index.js
0 → 100644
1 | /** | ||
2 | * Utilities for parsing M3U8 files. If the entire manifest is available, | ||
3 | * `Parser` will create an object representation with enough detail for managing | ||
4 | * playback. `ParseStream` and `LineStream` are lower-level parsing primitives | ||
5 | * that do not assume the entirety of the manifest is ready and expose a | ||
6 | * ReadableStream-like interface. | ||
7 | */ | ||
8 | |||
9 | import LineStream from './line-stream'; | ||
10 | import ParseStream from './parse-stream'; | ||
11 | import Parser from './parser'; | ||
12 | |||
13 | export default { | ||
14 | LineStream, | ||
15 | ParseStream, | ||
16 | Parser | ||
17 | }; |
src/m3u8/line-stream.js
0 → 100644
1 | import Stream from '../stream'; | ||
2 | /** | ||
3 | * A stream that buffers string input and generates a `data` event for each | ||
4 | * line. | ||
5 | */ | ||
6 | export default class LineStream extends Stream { | ||
7 | constructor() { | ||
8 | super(); | ||
9 | this.buffer = ''; | ||
10 | } | ||
11 | |||
12 | /** | ||
13 | * Add new data to be parsed. | ||
14 | * @param data {string} the text to process | ||
15 | */ | ||
16 | push(data) { | ||
17 | let nextNewline; | ||
18 | |||
19 | this.buffer += data; | ||
20 | nextNewline = this.buffer.indexOf('\n'); | ||
21 | |||
22 | for (; nextNewline > -1; nextNewline = this.buffer.indexOf('\n')) { | ||
23 | this.trigger('data', this.buffer.substring(0, nextNewline)); | ||
24 | this.buffer = this.buffer.substring(nextNewline + 1); | ||
25 | } | ||
26 | } | ||
27 | } |
1 | /** | 1 | import Stream from '../stream'; |
2 | * Utilities for parsing M3U8 files. If the entire manifest is available, | ||
3 | * `Parser` will create an object representation with enough detail for managing | ||
4 | * playback. `ParseStream` and `LineStream` are lower-level parsing primitives | ||
5 | * that do not assume the entirety of the manifest is ready and expose a | ||
6 | * ReadableStream-like interface. | ||
7 | */ | ||
8 | (function(videojs, parseInt, isFinite, mergeOptions, undefined) { | ||
9 | var | ||
10 | noop = function() {}, | ||
11 | |||
12 | // "forgiving" attribute list psuedo-grammar: | ||
13 | // attributes -> keyvalue (',' keyvalue)* | ||
14 | // keyvalue -> key '=' value | ||
15 | // key -> [^=]* | ||
16 | // value -> '"' [^"]* '"' | [^,]* | ||
17 | attributeSeparator = (function() { | ||
18 | var | ||
19 | key = '[^=]*', | ||
20 | value = '"[^"]*"|[^,]*', | ||
21 | keyvalue = '(?:' + key + ')=(?:' + value + ')'; | ||
22 | 2 | ||
23 | return new RegExp('(?:^|,)(' + keyvalue + ')'); | 3 | // "forgiving" attribute list psuedo-grammar: |
24 | })(), | 4 | // attributes -> keyvalue (',' keyvalue)* |
25 | parseAttributes = function(attributes) { | 5 | // keyvalue -> key '=' value |
26 | var | 6 | // key -> [^=]* |
27 | // split the string using attributes as the separator | 7 | // value -> '"' [^"]* '"' | [^,]* |
28 | attrs = attributes.split(attributeSeparator), | 8 | const attributeSeparator = function() { |
29 | i = attrs.length, | 9 | let key = '[^=]*'; |
30 | result = {}, | 10 | let value = '"[^"]*"|[^,]*'; |
31 | attr; | 11 | let keyvalue = '(?:' + key + ')=(?:' + value + ')'; |
32 | 12 | ||
33 | while (i--) { | 13 | return new RegExp('(?:^|,)(' + keyvalue + ')'); |
34 | // filter out unmatched portions of the string | 14 | }; |
35 | if (attrs[i] === '') { | ||
36 | continue; | ||
37 | } | ||
38 | 15 | ||
39 | // split the key and value | 16 | const parseAttributes = function(attributes) { |
40 | attr = /([^=]*)=(.*)/.exec(attrs[i]).slice(1); | 17 | // split the string using attributes as the separator |
41 | // trim whitespace and remove optional quotes around the value | 18 | let attrs = attributes.split(attributeSeparator()); |
42 | attr[0] = attr[0].replace(/^\s+|\s+$/g, ''); | 19 | let i = attrs.length; |
43 | attr[1] = attr[1].replace(/^\s+|\s+$/g, ''); | 20 | let result = {}; |
44 | attr[1] = attr[1].replace(/^['"](.*)['"]$/g, '$1'); | 21 | let attr; |
45 | result[attr[0]] = attr[1]; | ||
46 | } | ||
47 | return result; | ||
48 | }, | ||
49 | Stream = videojs.Hls.Stream, | ||
50 | LineStream, | ||
51 | ParseStream, | ||
52 | Parser; | ||
53 | 22 | ||
54 | /** | 23 | while (i--) { |
55 | * A stream that buffers string input and generates a `data` event for each | 24 | // filter out unmatched portions of the string |
56 | * line. | 25 | if (attrs[i] === '') { |
57 | */ | 26 | continue; |
58 | LineStream = function() { | 27 | } |
59 | var buffer = ''; | ||
60 | LineStream.prototype.init.call(this); | ||
61 | |||
62 | /** | ||
63 | * Add new data to be parsed. | ||
64 | * @param data {string} the text to process | ||
65 | */ | ||
66 | this.push = function(data) { | ||
67 | var nextNewline; | ||
68 | 28 | ||
69 | buffer += data; | 29 | // split the key and value |
70 | nextNewline = buffer.indexOf('\n'); | 30 | attr = (/([^=]*)=(.*)/).exec(attrs[i]).slice(1); |
31 | // trim whitespace and remove optional quotes around the value | ||
32 | attr[0] = attr[0].replace(/^\s+|\s+$/g, ''); | ||
33 | attr[1] = attr[1].replace(/^\s+|\s+$/g, ''); | ||
34 | attr[1] = attr[1].replace(/^['"](.*)['"]$/g, '$1'); | ||
35 | result[attr[0]] = attr[1]; | ||
36 | } | ||
37 | return result; | ||
38 | }; | ||
71 | 39 | ||
72 | for (; nextNewline > -1; nextNewline = buffer.indexOf('\n')) { | 40 | /** |
73 | this.trigger('data', buffer.substring(0, nextNewline)); | 41 | * A line-level M3U8 parser event stream. It expects to receive input one |
74 | buffer = buffer.substring(nextNewline + 1); | 42 | * line at a time and performs a context-free parse of its contents. A stream |
75 | } | 43 | * interpretation of a manifest can be useful if the manifest is expected to |
76 | }; | 44 | * be too large to fit comfortably into memory or the entirety of the input |
77 | }; | 45 | * is not immediately available. Otherwise, it's probably much easier to work |
78 | LineStream.prototype = new Stream(); | 46 | * with a regular `Parser` object. |
47 | * | ||
48 | * Produces `data` events with an object that captures the parser's | ||
49 | * interpretation of the input. That object has a property `tag` that is one | ||
50 | * of `uri`, `comment`, or `tag`. URIs only have a single additional | ||
51 | * property, `line`, which captures the entirety of the input without | ||
52 | * interpretation. Comments similarly have a single additional property | ||
53 | * `text` which is the input without the leading `#`. | ||
54 | * | ||
55 | * Tags always have a property `tagType` which is the lower-cased version of | ||
56 | * the M3U8 directive without the `#EXT` or `#EXT-X-` prefix. For instance, | ||
57 | * `#EXT-X-MEDIA-SEQUENCE` becomes `media-sequence` when parsed. Unrecognized | ||
58 | * tags are given the tag type `unknown` and a single additional property | ||
59 | * `data` with the remainder of the input. | ||
60 | */ | ||
61 | export default class ParseStream extends Stream { | ||
62 | constructor() { | ||
63 | super(); | ||
64 | } | ||
79 | 65 | ||
80 | /** | 66 | /** |
81 | * A line-level M3U8 parser event stream. It expects to receive input one | ||
82 | * line at a time and performs a context-free parse of its contents. A stream | ||
83 | * interpretation of a manifest can be useful if the manifest is expected to | ||
84 | * be too large to fit comfortably into memory or the entirety of the input | ||
85 | * is not immediately available. Otherwise, it's probably much easier to work | ||
86 | * with a regular `Parser` object. | ||
87 | * | ||
88 | * Produces `data` events with an object that captures the parser's | ||
89 | * interpretation of the input. That object has a property `tag` that is one | ||
90 | * of `uri`, `comment`, or `tag`. URIs only have a single additional | ||
91 | * property, `line`, which captures the entirety of the input without | ||
92 | * interpretation. Comments similarly have a single additional property | ||
93 | * `text` which is the input without the leading `#`. | ||
94 | * | ||
95 | * Tags always have a property `tagType` which is the lower-cased version of | ||
96 | * the M3U8 directive without the `#EXT` or `#EXT-X-` prefix. For instance, | ||
97 | * `#EXT-X-MEDIA-SEQUENCE` becomes `media-sequence` when parsed. Unrecognized | ||
98 | * tags are given the tag type `unknown` and a single additional property | ||
99 | * `data` with the remainder of the input. | ||
100 | */ | ||
101 | ParseStream = function() { | ||
102 | ParseStream.prototype.init.call(this); | ||
103 | }; | ||
104 | ParseStream.prototype = new Stream(); | ||
105 | /** | ||
106 | * Parses an additional line of input. | 67 | * Parses an additional line of input. |
107 | * @param line {string} a single line of an M3U8 file to parse | 68 | * @param line {string} a single line of an M3U8 file to parse |
108 | */ | 69 | */ |
109 | ParseStream.prototype.push = function(line) { | 70 | push(line) { |
110 | var match, event; | 71 | let match; |
72 | let event; | ||
111 | 73 | ||
112 | //strip whitespace | 74 | // strip whitespace |
113 | line = line.replace(/^[\u0000\s]+|[\u0000\s]+$/g, ''); | 75 | line = line.replace(/^[\u0000\s]+|[\u0000\s]+$/g, ''); |
114 | if (line.length === 0) { | 76 | if (line.length === 0) { |
115 | // ignore empty lines | 77 | // ignore empty lines |
... | @@ -134,12 +96,12 @@ | ... | @@ -134,12 +96,12 @@ |
134 | return; | 96 | return; |
135 | } | 97 | } |
136 | 98 | ||
137 | //strip off any carriage returns here so the regex matching | 99 | // strip off any carriage returns here so the regex matching |
138 | //doesn't have to account for them. | 100 | // doesn't have to account for them. |
139 | line = line.replace('\r',''); | 101 | line = line.replace('\r', ''); |
140 | 102 | ||
141 | // Tags | 103 | // Tags |
142 | match = /^#EXTM3U/.exec(line); | 104 | match = (/^#EXTM3U/).exec(line); |
143 | if (match) { | 105 | if (match) { |
144 | this.trigger('data', { | 106 | this.trigger('data', { |
145 | type: 'tag', | 107 | type: 'tag', |
... | @@ -271,18 +233,16 @@ | ... | @@ -271,18 +233,16 @@ |
271 | event.attributes = parseAttributes(match[1]); | 233 | event.attributes = parseAttributes(match[1]); |
272 | 234 | ||
273 | if (event.attributes.RESOLUTION) { | 235 | if (event.attributes.RESOLUTION) { |
274 | (function() { | 236 | let split = event.attributes.RESOLUTION.split('x'); |
275 | var | 237 | let resolution = {}; |
276 | split = event.attributes.RESOLUTION.split('x'), | 238 | |
277 | resolution = {}; | 239 | if (split[0]) { |
278 | if (split[0]) { | 240 | resolution.width = parseInt(split[0], 10); |
279 | resolution.width = parseInt(split[0], 10); | 241 | } |
280 | } | 242 | if (split[1]) { |
281 | if (split[1]) { | 243 | resolution.height = parseInt(split[1], 10); |
282 | resolution.height = parseInt(split[1], 10); | 244 | } |
283 | } | 245 | event.attributes.RESOLUTION = resolution; |
284 | event.attributes.RESOLUTION = resolution; | ||
285 | })(); | ||
286 | } | 246 | } |
287 | if (event.attributes.BANDWIDTH) { | 247 | if (event.attributes.BANDWIDTH) { |
288 | event.attributes.BANDWIDTH = parseInt(event.attributes.BANDWIDTH, 10); | 248 | event.attributes.BANDWIDTH = parseInt(event.attributes.BANDWIDTH, 10); |
... | @@ -320,7 +280,7 @@ | ... | @@ -320,7 +280,7 @@ |
320 | event.attributes = parseAttributes(match[1]); | 280 | event.attributes = parseAttributes(match[1]); |
321 | // parse the IV string into a Uint32Array | 281 | // parse the IV string into a Uint32Array |
322 | if (event.attributes.IV) { | 282 | if (event.attributes.IV) { |
323 | if (event.attributes.IV.substring(0,2) === '0x') { | 283 | if (event.attributes.IV.substring(0, 2) === '0x') { |
324 | event.attributes.IV = event.attributes.IV.substring(2); | 284 | event.attributes.IV = event.attributes.IV.substring(2); |
325 | } | 285 | } |
326 | 286 | ||
... | @@ -341,248 +301,5 @@ | ... | @@ -341,248 +301,5 @@ |
341 | type: 'tag', | 301 | type: 'tag', |
342 | data: line.slice(4, line.length) | 302 | data: line.slice(4, line.length) |
343 | }); | 303 | }); |
344 | }; | 304 | } |
345 | 305 | } | |
346 | /** | ||
347 | * A parser for M3U8 files. The current interpretation of the input is | ||
348 | * exposed as a property `manifest` on parser objects. It's just two lines to | ||
349 | * create and parse a manifest once you have the contents available as a string: | ||
350 | * | ||
351 | * ```js | ||
352 | * var parser = new videojs.m3u8.Parser(); | ||
353 | * parser.push(xhr.responseText); | ||
354 | * ``` | ||
355 | * | ||
356 | * New input can later be applied to update the manifest object by calling | ||
357 | * `push` again. | ||
358 | * | ||
359 | * The parser attempts to create a usable manifest object even if the | ||
360 | * underlying input is somewhat nonsensical. It emits `info` and `warning` | ||
361 | * events during the parse if it encounters input that seems invalid or | ||
362 | * requires some property of the manifest object to be defaulted. | ||
363 | */ | ||
364 | Parser = function() { | ||
365 | var | ||
366 | self = this, | ||
367 | uris = [], | ||
368 | currentUri = {}, | ||
369 | key; | ||
370 | Parser.prototype.init.call(this); | ||
371 | |||
372 | this.lineStream = new LineStream(); | ||
373 | this.parseStream = new ParseStream(); | ||
374 | this.lineStream.pipe(this.parseStream); | ||
375 | |||
376 | // the manifest is empty until the parse stream begins delivering data | ||
377 | this.manifest = { | ||
378 | allowCache: true, | ||
379 | discontinuityStarts: [] | ||
380 | }; | ||
381 | |||
382 | // update the manifest with the m3u8 entry from the parse stream | ||
383 | this.parseStream.on('data', function(entry) { | ||
384 | ({ | ||
385 | tag: function() { | ||
386 | // switch based on the tag type | ||
387 | (({ | ||
388 | 'allow-cache': function() { | ||
389 | this.manifest.allowCache = entry.allowed; | ||
390 | if (!('allowed' in entry)) { | ||
391 | this.trigger('info', { | ||
392 | message: 'defaulting allowCache to YES' | ||
393 | }); | ||
394 | this.manifest.allowCache = true; | ||
395 | } | ||
396 | }, | ||
397 | 'byterange': function() { | ||
398 | var byterange = {}; | ||
399 | if ('length' in entry) { | ||
400 | currentUri.byterange = byterange; | ||
401 | byterange.length = entry.length; | ||
402 | |||
403 | if (!('offset' in entry)) { | ||
404 | this.trigger('info', { | ||
405 | message: 'defaulting offset to zero' | ||
406 | }); | ||
407 | entry.offset = 0; | ||
408 | } | ||
409 | } | ||
410 | if ('offset' in entry) { | ||
411 | currentUri.byterange = byterange; | ||
412 | byterange.offset = entry.offset; | ||
413 | } | ||
414 | }, | ||
415 | 'endlist': function() { | ||
416 | this.manifest.endList = true; | ||
417 | }, | ||
418 | 'inf': function() { | ||
419 | if (!('mediaSequence' in this.manifest)) { | ||
420 | this.manifest.mediaSequence = 0; | ||
421 | this.trigger('info', { | ||
422 | message: 'defaulting media sequence to zero' | ||
423 | }); | ||
424 | } | ||
425 | if (!('discontinuitySequence' in this.manifest)) { | ||
426 | this.manifest.discontinuitySequence = 0; | ||
427 | this.trigger('info', { | ||
428 | message: 'defaulting discontinuity sequence to zero' | ||
429 | }); | ||
430 | } | ||
431 | if (entry.duration >= 0) { | ||
432 | currentUri.duration = entry.duration; | ||
433 | } | ||
434 | |||
435 | this.manifest.segments = uris; | ||
436 | |||
437 | }, | ||
438 | 'key': function() { | ||
439 | if (!entry.attributes) { | ||
440 | this.trigger('warn', { | ||
441 | message: 'ignoring key declaration without attribute list' | ||
442 | }); | ||
443 | return; | ||
444 | } | ||
445 | // clear the active encryption key | ||
446 | if (entry.attributes.METHOD === 'NONE') { | ||
447 | key = null; | ||
448 | return; | ||
449 | } | ||
450 | if (!entry.attributes.URI) { | ||
451 | this.trigger('warn', { | ||
452 | message: 'ignoring key declaration without URI' | ||
453 | }); | ||
454 | return; | ||
455 | } | ||
456 | if (!entry.attributes.METHOD) { | ||
457 | this.trigger('warn', { | ||
458 | message: 'defaulting key method to AES-128' | ||
459 | }); | ||
460 | } | ||
461 | |||
462 | // setup an encryption key for upcoming segments | ||
463 | key = { | ||
464 | method: entry.attributes.METHOD || 'AES-128', | ||
465 | uri: entry.attributes.URI | ||
466 | }; | ||
467 | |||
468 | if (entry.attributes.IV !== undefined) { | ||
469 | key.iv = entry.attributes.IV; | ||
470 | } | ||
471 | }, | ||
472 | 'media-sequence': function() { | ||
473 | if (!isFinite(entry.number)) { | ||
474 | this.trigger('warn', { | ||
475 | message: 'ignoring invalid media sequence: ' + entry.number | ||
476 | }); | ||
477 | return; | ||
478 | } | ||
479 | this.manifest.mediaSequence = entry.number; | ||
480 | }, | ||
481 | 'discontinuity-sequence': function() { | ||
482 | if (!isFinite(entry.number)) { | ||
483 | this.trigger('warn', { | ||
484 | message: 'ignoring invalid discontinuity sequence: ' + entry.number | ||
485 | }); | ||
486 | return; | ||
487 | } | ||
488 | this.manifest.discontinuitySequence = entry.number; | ||
489 | }, | ||
490 | 'playlist-type': function() { | ||
491 | if (!(/VOD|EVENT/).test(entry.playlistType)) { | ||
492 | this.trigger('warn', { | ||
493 | message: 'ignoring unknown playlist type: ' + entry.playlist | ||
494 | }); | ||
495 | return; | ||
496 | } | ||
497 | this.manifest.playlistType = entry.playlistType; | ||
498 | }, | ||
499 | 'stream-inf': function() { | ||
500 | this.manifest.playlists = uris; | ||
501 | |||
502 | if (!entry.attributes) { | ||
503 | this.trigger('warn', { | ||
504 | message: 'ignoring empty stream-inf attributes' | ||
505 | }); | ||
506 | return; | ||
507 | } | ||
508 | |||
509 | if (!currentUri.attributes) { | ||
510 | currentUri.attributes = {}; | ||
511 | } | ||
512 | currentUri.attributes = mergeOptions(currentUri.attributes, | ||
513 | entry.attributes); | ||
514 | }, | ||
515 | 'discontinuity': function() { | ||
516 | currentUri.discontinuity = true; | ||
517 | this.manifest.discontinuityStarts.push(uris.length); | ||
518 | }, | ||
519 | 'targetduration': function() { | ||
520 | if (!isFinite(entry.duration) || entry.duration < 0) { | ||
521 | this.trigger('warn', { | ||
522 | message: 'ignoring invalid target duration: ' + entry.duration | ||
523 | }); | ||
524 | return; | ||
525 | } | ||
526 | this.manifest.targetDuration = entry.duration; | ||
527 | }, | ||
528 | 'totalduration': function() { | ||
529 | if (!isFinite(entry.duration) || entry.duration < 0) { | ||
530 | this.trigger('warn', { | ||
531 | message: 'ignoring invalid total duration: ' + entry.duration | ||
532 | }); | ||
533 | return; | ||
534 | } | ||
535 | this.manifest.totalDuration = entry.duration; | ||
536 | } | ||
537 | })[entry.tagType] || noop).call(self); | ||
538 | }, | ||
539 | uri: function() { | ||
540 | currentUri.uri = entry.uri; | ||
541 | uris.push(currentUri); | ||
542 | |||
543 | // if no explicit duration was declared, use the target duration | ||
544 | if (this.manifest.targetDuration && | ||
545 | !('duration' in currentUri)) { | ||
546 | this.trigger('warn', { | ||
547 | message: 'defaulting segment duration to the target duration' | ||
548 | }); | ||
549 | currentUri.duration = this.manifest.targetDuration; | ||
550 | } | ||
551 | // annotate with encryption information, if necessary | ||
552 | if (key) { | ||
553 | currentUri.key = key; | ||
554 | } | ||
555 | |||
556 | // prepare for the next URI | ||
557 | currentUri = {}; | ||
558 | }, | ||
559 | comment: function() { | ||
560 | // comments are not important for playback | ||
561 | } | ||
562 | })[entry.type].call(self); | ||
563 | }); | ||
564 | }; | ||
565 | Parser.prototype = new Stream(); | ||
566 | /** | ||
567 | * Parse the input string and update the manifest object. | ||
568 | * @param chunk {string} a potentially incomplete portion of the manifest | ||
569 | */ | ||
570 | Parser.prototype.push = function(chunk) { | ||
571 | this.lineStream.push(chunk); | ||
572 | }; | ||
573 | /** | ||
574 | * Flush any remaining input. This can be handy if the last line of an M3U8 | ||
575 | * manifest did not contain a trailing newline but the file has been | ||
576 | * completely received. | ||
577 | */ | ||
578 | Parser.prototype.end = function() { | ||
579 | // flush any buffered input | ||
580 | this.lineStream.push('\n'); | ||
581 | }; | ||
582 | |||
583 | window.videojs.m3u8 = { | ||
584 | LineStream: LineStream, | ||
585 | ParseStream: ParseStream, | ||
586 | Parser: Parser | ||
587 | }; | ||
588 | })(window.videojs, window.parseInt, window.isFinite, window.videojs.mergeOptions); | ... | ... |
src/m3u8/parser.js
0 → 100644
1 | import Stream from '../stream' ; | ||
2 | import LineStream from './line-stream'; | ||
3 | import ParseStream from './parse-stream'; | ||
4 | import {mergeOptions} from 'video.js'; | ||
5 | |||
6 | /** | ||
7 | * A parser for M3U8 files. The current interpretation of the input is | ||
8 | * exposed as a property `manifest` on parser objects. It's just two lines to | ||
9 | * create and parse a manifest once you have the contents available as a string: | ||
10 | * | ||
11 | * ```js | ||
12 | * var parser = new videojs.m3u8.Parser(); | ||
13 | * parser.push(xhr.responseText); | ||
14 | * ``` | ||
15 | * | ||
16 | * New input can later be applied to update the manifest object by calling | ||
17 | * `push` again. | ||
18 | * | ||
19 | * The parser attempts to create a usable manifest object even if the | ||
20 | * underlying input is somewhat nonsensical. It emits `info` and `warning` | ||
21 | * events during the parse if it encounters input that seems invalid or | ||
22 | * requires some property of the manifest object to be defaulted. | ||
23 | */ | ||
24 | export default class Parser extends Stream { | ||
25 | constructor() { | ||
26 | super(); | ||
27 | this.lineStream = new LineStream(); | ||
28 | this.parseStream = new ParseStream(); | ||
29 | this.lineStream.pipe(this.parseStream); | ||
30 | /* eslint-disable consistent-this */ | ||
31 | let self = this; | ||
32 | /* eslint-enable consistent-this */ | ||
33 | let uris = []; | ||
34 | let currentUri = {}; | ||
35 | let key; | ||
36 | let noop = function() {}; | ||
37 | |||
38 | // the manifest is empty until the parse stream begins delivering data | ||
39 | this.manifest = { | ||
40 | allowCache: true, | ||
41 | discontinuityStarts: [] | ||
42 | }; | ||
43 | |||
44 | // update the manifest with the m3u8 entry from the parse stream | ||
45 | this.parseStream.on('data', function(entry) { | ||
46 | ({ | ||
47 | tag() { | ||
48 | // switch based on the tag type | ||
49 | (({ | ||
50 | 'allow-cache'() { | ||
51 | this.manifest.allowCache = entry.allowed; | ||
52 | if (!('allowed' in entry)) { | ||
53 | this.trigger('info', { | ||
54 | message: 'defaulting allowCache to YES' | ||
55 | }); | ||
56 | this.manifest.allowCache = true; | ||
57 | } | ||
58 | }, | ||
59 | byterange() { | ||
60 | let byterange = {}; | ||
61 | |||
62 | if ('length' in entry) { | ||
63 | currentUri.byterange = byterange; | ||
64 | byterange.length = entry.length; | ||
65 | |||
66 | if (!('offset' in entry)) { | ||
67 | this.trigger('info', { | ||
68 | message: 'defaulting offset to zero' | ||
69 | }); | ||
70 | entry.offset = 0; | ||
71 | } | ||
72 | } | ||
73 | if ('offset' in entry) { | ||
74 | currentUri.byterange = byterange; | ||
75 | byterange.offset = entry.offset; | ||
76 | } | ||
77 | }, | ||
78 | endlist() { | ||
79 | this.manifest.endList = true; | ||
80 | }, | ||
81 | inf() { | ||
82 | if (!('mediaSequence' in this.manifest)) { | ||
83 | this.manifest.mediaSequence = 0; | ||
84 | this.trigger('info', { | ||
85 | message: 'defaulting media sequence to zero' | ||
86 | }); | ||
87 | } | ||
88 | if (!('discontinuitySequence' in this.manifest)) { | ||
89 | this.manifest.discontinuitySequence = 0; | ||
90 | this.trigger('info', { | ||
91 | message: 'defaulting discontinuity sequence to zero' | ||
92 | }); | ||
93 | } | ||
94 | if (entry.duration >= 0) { | ||
95 | currentUri.duration = entry.duration; | ||
96 | } | ||
97 | |||
98 | this.manifest.segments = uris; | ||
99 | |||
100 | }, | ||
101 | key() { | ||
102 | if (!entry.attributes) { | ||
103 | this.trigger('warn', { | ||
104 | message: 'ignoring key declaration without attribute list' | ||
105 | }); | ||
106 | return; | ||
107 | } | ||
108 | // clear the active encryption key | ||
109 | if (entry.attributes.METHOD === 'NONE') { | ||
110 | key = null; | ||
111 | return; | ||
112 | } | ||
113 | if (!entry.attributes.URI) { | ||
114 | this.trigger('warn', { | ||
115 | message: 'ignoring key declaration without URI' | ||
116 | }); | ||
117 | return; | ||
118 | } | ||
119 | if (!entry.attributes.METHOD) { | ||
120 | this.trigger('warn', { | ||
121 | message: 'defaulting key method to AES-128' | ||
122 | }); | ||
123 | } | ||
124 | |||
125 | // setup an encryption key for upcoming segments | ||
126 | key = { | ||
127 | method: entry.attributes.METHOD || 'AES-128', | ||
128 | uri: entry.attributes.URI | ||
129 | }; | ||
130 | |||
131 | if (typeof entry.attributes.IV !== 'undefined') { | ||
132 | key.iv = entry.attributes.IV; | ||
133 | } | ||
134 | }, | ||
135 | 'media-sequence'() { | ||
136 | if (!isFinite(entry.number)) { | ||
137 | this.trigger('warn', { | ||
138 | message: 'ignoring invalid media sequence: ' + entry.number | ||
139 | }); | ||
140 | return; | ||
141 | } | ||
142 | this.manifest.mediaSequence = entry.number; | ||
143 | }, | ||
144 | 'discontinuity-sequence'() { | ||
145 | if (!isFinite(entry.number)) { | ||
146 | this.trigger('warn', { | ||
147 | message: 'ignoring invalid discontinuity sequence: ' + entry.number | ||
148 | }); | ||
149 | return; | ||
150 | } | ||
151 | this.manifest.discontinuitySequence = entry.number; | ||
152 | }, | ||
153 | 'playlist-type'() { | ||
154 | if (!(/VOD|EVENT/).test(entry.playlistType)) { | ||
155 | this.trigger('warn', { | ||
156 | message: 'ignoring unknown playlist type: ' + entry.playlist | ||
157 | }); | ||
158 | return; | ||
159 | } | ||
160 | this.manifest.playlistType = entry.playlistType; | ||
161 | }, | ||
162 | 'stream-inf'() { | ||
163 | this.manifest.playlists = uris; | ||
164 | |||
165 | if (!entry.attributes) { | ||
166 | this.trigger('warn', { | ||
167 | message: 'ignoring empty stream-inf attributes' | ||
168 | }); | ||
169 | return; | ||
170 | } | ||
171 | |||
172 | if (!currentUri.attributes) { | ||
173 | currentUri.attributes = {}; | ||
174 | } | ||
175 | currentUri.attributes = mergeOptions(currentUri.attributes, | ||
176 | entry.attributes); | ||
177 | }, | ||
178 | discontinuity() { | ||
179 | currentUri.discontinuity = true; | ||
180 | this.manifest.discontinuityStarts.push(uris.length); | ||
181 | }, | ||
182 | targetduration() { | ||
183 | if (!isFinite(entry.duration) || entry.duration < 0) { | ||
184 | this.trigger('warn', { | ||
185 | message: 'ignoring invalid target duration: ' + entry.duration | ||
186 | }); | ||
187 | return; | ||
188 | } | ||
189 | this.manifest.targetDuration = entry.duration; | ||
190 | }, | ||
191 | totalduration() { | ||
192 | if (!isFinite(entry.duration) || entry.duration < 0) { | ||
193 | this.trigger('warn', { | ||
194 | message: 'ignoring invalid total duration: ' + entry.duration | ||
195 | }); | ||
196 | return; | ||
197 | } | ||
198 | this.manifest.totalDuration = entry.duration; | ||
199 | } | ||
200 | })[entry.tagType] || noop).call(self); | ||
201 | }, | ||
202 | uri() { | ||
203 | currentUri.uri = entry.uri; | ||
204 | uris.push(currentUri); | ||
205 | |||
206 | // if no explicit duration was declared, use the target duration | ||
207 | if (this.manifest.targetDuration && | ||
208 | !('duration' in currentUri)) { | ||
209 | this.trigger('warn', { | ||
210 | message: 'defaulting segment duration to the target duration' | ||
211 | }); | ||
212 | currentUri.duration = this.manifest.targetDuration; | ||
213 | } | ||
214 | // annotate with encryption information, if necessary | ||
215 | if (key) { | ||
216 | currentUri.key = key; | ||
217 | } | ||
218 | |||
219 | // prepare for the next URI | ||
220 | currentUri = {}; | ||
221 | }, | ||
222 | comment() { | ||
223 | // comments are not important for playback | ||
224 | } | ||
225 | })[entry.type].call(self); | ||
226 | }); | ||
227 | |||
228 | } | ||
229 | |||
230 | /** | ||
231 | * Parse the input string and update the manifest object. | ||
232 | * @param chunk {string} a potentially incomplete portion of the manifest | ||
233 | */ | ||
234 | push(chunk) { | ||
235 | this.lineStream.push(chunk); | ||
236 | } | ||
237 | |||
238 | /** | ||
239 | * Flush any remaining input. This can be handy if the last line of an M3U8 | ||
240 | * manifest did not contain a trailing newline but the file has been | ||
241 | * completely received. | ||
242 | */ | ||
243 | end() { | ||
244 | // flush any buffered input | ||
245 | this.lineStream.push('\n'); | ||
246 | } | ||
247 | |||
248 | } | ||
249 |
... | @@ -5,376 +5,381 @@ | ... | @@ -5,376 +5,381 @@ |
5 | * M3U8 playlists. | 5 | * M3U8 playlists. |
6 | * | 6 | * |
7 | */ | 7 | */ |
8 | (function(window, videojs) { | 8 | import resolveUrl from './resolve-url'; |
9 | 'use strict'; | 9 | import XhrModule from './xhr'; |
10 | var | 10 | import {mergeOptions} from 'video.js'; |
11 | resolveUrl = videojs.Hls.resolveUrl, | 11 | import Stream from './stream'; |
12 | xhr = videojs.Hls.xhr, | 12 | import m3u8 from './m3u8'; |
13 | mergeOptions = videojs.mergeOptions, | ||
14 | 13 | ||
15 | /** | 14 | /** |
16 | * Returns a new master playlist that is the result of merging an | 15 | * Returns a new master playlist that is the result of merging an |
17 | * updated media playlist into the original version. If the | 16 | * updated media playlist into the original version. If the |
18 | * updated media playlist does not match any of the playlist | 17 | * updated media playlist does not match any of the playlist |
19 | * entries in the original master playlist, null is returned. | 18 | * entries in the original master playlist, null is returned. |
20 | * @param master {object} a parsed master M3U8 object | 19 | * @param master {object} a parsed master M3U8 object |
21 | * @param media {object} a parsed media M3U8 object | 20 | * @param media {object} a parsed media M3U8 object |
22 | * @return {object} a new object that represents the original | 21 | * @return {object} a new object that represents the original |
23 | * master playlist with the updated media playlist merged in, or | 22 | * master playlist with the updated media playlist merged in, or |
24 | * null if the merge produced no change. | 23 | * null if the merge produced no change. |
25 | */ | 24 | */ |
26 | updateMaster = function(master, media) { | 25 | const updateMaster = function(master, media) { |
27 | var | 26 | let changed = false; |
28 | changed = false, | 27 | let result = mergeOptions(master, {}); |
29 | result = mergeOptions(master, {}), | 28 | let i = master.playlists.length; |
30 | i, | 29 | let playlist; |
31 | playlist; | 30 | |
32 | 31 | while (i--) { | |
33 | i = master.playlists.length; | 32 | playlist = result.playlists[i]; |
34 | while (i--) { | 33 | if (playlist.uri === media.uri) { |
35 | playlist = result.playlists[i]; | 34 | // consider the playlist unchanged if the number of segments |
36 | if (playlist.uri === media.uri) { | 35 | // are equal and the media sequence number is unchanged |
37 | // consider the playlist unchanged if the number of segments | 36 | if (playlist.segments && |
38 | // are equal and the media sequence number is unchanged | 37 | media.segments && |
39 | if (playlist.segments && | 38 | playlist.segments.length === media.segments.length && |
40 | media.segments && | 39 | playlist.mediaSequence === media.mediaSequence) { |
41 | playlist.segments.length === media.segments.length && | 40 | continue; |
42 | playlist.mediaSequence === media.mediaSequence) { | ||
43 | continue; | ||
44 | } | ||
45 | |||
46 | result.playlists[i] = mergeOptions(playlist, media); | ||
47 | result.playlists[media.uri] = result.playlists[i]; | ||
48 | |||
49 | // if the update could overlap existing segment information, | ||
50 | // merge the two lists | ||
51 | if (playlist.segments) { | ||
52 | result.playlists[i].segments = updateSegments(playlist.segments, | ||
53 | media.segments, | ||
54 | media.mediaSequence - playlist.mediaSequence); | ||
55 | } | ||
56 | changed = true; | ||
57 | } | ||
58 | } | 41 | } |
59 | return changed ? result : null; | ||
60 | }, | ||
61 | 42 | ||
62 | /** | 43 | result.playlists[i] = mergeOptions(playlist, media); |
63 | * Returns a new array of segments that is the result of merging | 44 | result.playlists[media.uri] = result.playlists[i]; |
64 | * properties from an older list of segments onto an updated | 45 | |
65 | * list. No properties on the updated playlist will be overridden. | 46 | // if the update could overlap existing segment information, |
66 | * @param original {array} the outdated list of segments | 47 | // merge the two lists |
67 | * @param update {array} the updated list of segments | 48 | if (playlist.segments) { |
68 | * @param offset {number} (optional) the index of the first update | 49 | result.playlists[i].segments = updateSegments(playlist.segments, |
69 | * segment in the original segment list. For non-live playlists, | 50 | media.segments, |
70 | * this should always be zero and does not need to be | 51 | media.mediaSequence - |
71 | * specified. For live playlists, it should be the difference | 52 | playlist.mediaSequence); |
72 | * between the media sequence numbers in the original and updated | ||
73 | * playlists. | ||
74 | * @return a list of merged segment objects | ||
75 | */ | ||
76 | updateSegments = function(original, update, offset) { | ||
77 | var result = update.slice(), length, i; | ||
78 | offset = offset || 0; | ||
79 | length = Math.min(original.length, update.length + offset); | ||
80 | |||
81 | for (i = offset; i < length; i++) { | ||
82 | result[i - offset] = mergeOptions(original[i], result[i - offset]); | ||
83 | } | ||
84 | return result; | ||
85 | }, | ||
86 | |||
87 | PlaylistLoader = function(srcUrl, withCredentials) { | ||
88 | var | ||
89 | loader = this, | ||
90 | dispose, | ||
91 | mediaUpdateTimeout, | ||
92 | request, | ||
93 | playlistRequestError, | ||
94 | haveMetadata; | ||
95 | |||
96 | PlaylistLoader.prototype.init.call(this); | ||
97 | |||
98 | // a flag that disables "expired time"-tracking this setting has | ||
99 | // no effect when not playing a live stream | ||
100 | this.trackExpiredTime_ = false; | ||
101 | |||
102 | if (!srcUrl) { | ||
103 | throw new Error('A non-empty playlist URL is required'); | ||
104 | } | 53 | } |
54 | changed = true; | ||
55 | } | ||
56 | } | ||
57 | return changed ? result : null; | ||
58 | }; | ||
105 | 59 | ||
106 | playlistRequestError = function(xhr, url, startingState) { | 60 | /** |
107 | loader.setBandwidth(request || xhr); | 61 | * Returns a new array of segments that is the result of merging |
62 | * properties from an older list of segments onto an updated | ||
63 | * list. No properties on the updated playlist will be overridden. | ||
64 | * @param original {array} the outdated list of segments | ||
65 | * @param update {array} the updated list of segments | ||
66 | * @param offset {number} (optional) the index of the first update | ||
67 | * segment in the original segment list. For non-live playlists, | ||
68 | * this should always be zero and does not need to be | ||
69 | * specified. For live playlists, it should be the difference | ||
70 | * between the media sequence numbers in the original and updated | ||
71 | * playlists. | ||
72 | * @return a list of merged segment objects | ||
73 | */ | ||
74 | const updateSegments = function(original, update, offset) { | ||
75 | let result = update.slice(); | ||
76 | let length; | ||
77 | let i; | ||
78 | |||
79 | offset = offset || 0; | ||
80 | length = Math.min(original.length, update.length + offset); | ||
81 | |||
82 | for (i = offset; i < length; i++) { | ||
83 | result[i - offset] = mergeOptions(original[i], result[i - offset]); | ||
84 | } | ||
85 | return result; | ||
86 | }; | ||
87 | |||
88 | export default class PlaylistLoader extends Stream { | ||
89 | constructor(srcUrl, withCredentials) { | ||
90 | super(); | ||
91 | let loader = this; | ||
92 | let dispose; | ||
93 | let mediaUpdateTimeout; | ||
94 | let request; | ||
95 | let playlistRequestError; | ||
96 | let haveMetadata; | ||
97 | |||
98 | // a flag that disables "expired time"-tracking this setting has | ||
99 | // no effect when not playing a live stream | ||
100 | this.trackExpiredTime_ = false; | ||
101 | |||
102 | if (!srcUrl) { | ||
103 | throw new Error('A non-empty playlist URL is required'); | ||
104 | } | ||
108 | 105 | ||
109 | // any in-flight request is now finished | 106 | playlistRequestError = function(xhr, url, startingState) { |
110 | request = null; | 107 | loader.setBandwidth(request || xhr); |
111 | |||
112 | if (startingState) { | ||
113 | loader.state = startingState; | ||
114 | } | ||
115 | 108 | ||
116 | loader.error = { | 109 | // any in-flight request is now finished |
117 | playlist: loader.master.playlists[url], | 110 | request = null; |
118 | status: xhr.status, | ||
119 | message: 'HLS playlist request error at URL: ' + url, | ||
120 | responseText: xhr.responseText, | ||
121 | code: (xhr.status >= 500) ? 4 : 2 | ||
122 | }; | ||
123 | loader.trigger('error'); | ||
124 | }; | ||
125 | 111 | ||
126 | // update the playlist loader's state in response to a new or | 112 | if (startingState) { |
127 | // updated playlist. | 113 | loader.state = startingState; |
114 | } | ||
128 | 115 | ||
129 | haveMetadata = function(xhr, url) { | 116 | loader.error = { |
130 | var parser, refreshDelay, update; | 117 | playlist: loader.master.playlists[url], |
118 | status: xhr.status, | ||
119 | message: 'HLS playlist request error at URL: ' + url, | ||
120 | responseText: xhr.responseText, | ||
121 | code: (xhr.status >= 500) ? 4 : 2 | ||
122 | }; | ||
123 | loader.trigger('error'); | ||
124 | }; | ||
131 | 125 | ||
132 | loader.setBandwidth(request || xhr); | 126 | // update the playlist loader's state in response to a new or |
127 | // updated playlist. | ||
133 | 128 | ||
134 | // any in-flight request is now finished | 129 | haveMetadata = function(xhr, url) { |
135 | request = null; | 130 | let parser; |
136 | loader.state = 'HAVE_METADATA'; | 131 | let refreshDelay; |
132 | let update; | ||
137 | 133 | ||
138 | parser = new videojs.m3u8.Parser(); | 134 | loader.setBandwidth(request || xhr); |
139 | parser.push(xhr.responseText); | ||
140 | parser.end(); | ||
141 | parser.manifest.uri = url; | ||
142 | |||
143 | // merge this playlist into the master | ||
144 | update = updateMaster(loader.master, parser.manifest); | ||
145 | refreshDelay = (parser.manifest.targetDuration || 10) * 1000; | ||
146 | if (update) { | ||
147 | loader.master = update; | ||
148 | loader.updateMediaPlaylist_(parser.manifest); | ||
149 | } else { | ||
150 | // if the playlist is unchanged since the last reload, | ||
151 | // try again after half the target duration | ||
152 | refreshDelay /= 2; | ||
153 | } | ||
154 | 135 | ||
155 | // refresh live playlists after a target duration passes | 136 | // any in-flight request is now finished |
156 | if (!loader.media().endList) { | 137 | request = null; |
157 | window.clearTimeout(mediaUpdateTimeout); | 138 | loader.state = 'HAVE_METADATA'; |
158 | mediaUpdateTimeout = window.setTimeout(function() { | ||
159 | loader.trigger('mediaupdatetimeout'); | ||
160 | }, refreshDelay); | ||
161 | } | ||
162 | 139 | ||
163 | loader.trigger('loadedplaylist'); | 140 | parser = new m3u8.Parser(); |
164 | }; | 141 | parser.push(xhr.responseText); |
142 | parser.end(); | ||
143 | parser.manifest.uri = url; | ||
165 | 144 | ||
166 | // initialize the loader state | 145 | // merge this playlist into the master |
167 | loader.state = 'HAVE_NOTHING'; | 146 | update = updateMaster(loader.master, parser.manifest); |
147 | refreshDelay = (parser.manifest.targetDuration || 10) * 1000; | ||
148 | if (update) { | ||
149 | loader.master = update; | ||
150 | loader.updateMediaPlaylist_(parser.manifest); | ||
151 | } else { | ||
152 | // if the playlist is unchanged since the last reload, | ||
153 | // try again after half the target duration | ||
154 | refreshDelay /= 2; | ||
155 | } | ||
168 | 156 | ||
169 | // track the time that has expired from the live window | 157 | // refresh live playlists after a target duration passes |
170 | // this allows the seekable start range to be calculated even if | 158 | if (!loader.media().endList) { |
171 | // all segments with timing information have expired | 159 | window.clearTimeout(mediaUpdateTimeout); |
172 | this.expired_ = 0; | 160 | mediaUpdateTimeout = window.setTimeout(function() { |
161 | loader.trigger('mediaupdatetimeout'); | ||
162 | }, refreshDelay); | ||
163 | } | ||
173 | 164 | ||
174 | // capture the prototype dispose function | 165 | loader.trigger('loadedplaylist'); |
175 | dispose = this.dispose; | 166 | }; |
176 | 167 | ||
177 | /** | 168 | // initialize the loader state |
178 | * Abort any outstanding work and clean up. | 169 | loader.state = 'HAVE_NOTHING'; |
179 | */ | ||
180 | loader.dispose = function() { | ||
181 | if (request) { | ||
182 | request.onreadystatechange = null; | ||
183 | request.abort(); | ||
184 | request = null; | ||
185 | } | ||
186 | window.clearTimeout(mediaUpdateTimeout); | ||
187 | dispose.call(this); | ||
188 | }; | ||
189 | 170 | ||
190 | /** | 171 | // track the time that has expired from the live window |
191 | * When called without any arguments, returns the currently | 172 | // this allows the seekable start range to be calculated even if |
192 | * active media playlist. When called with a single argument, | 173 | // all segments with timing information have expired |
193 | * triggers the playlist loader to asynchronously switch to the | 174 | this.expired_ = 0; |
194 | * specified media playlist. Calling this method while the | ||
195 | * loader is in the HAVE_NOTHING causes an error to be emitted | ||
196 | * but otherwise has no effect. | ||
197 | * @param playlist (optional) {object} the parsed media playlist | ||
198 | * object to switch to | ||
199 | */ | ||
200 | loader.media = function(playlist) { | ||
201 | var startingState = loader.state, mediaChange; | ||
202 | // getter | ||
203 | if (!playlist) { | ||
204 | return loader.media_; | ||
205 | } | ||
206 | 175 | ||
207 | // setter | 176 | // capture the prototype dispose function |
208 | if (loader.state === 'HAVE_NOTHING') { | 177 | dispose = this.dispose; |
209 | throw new Error('Cannot switch media playlist from ' + loader.state); | ||
210 | } | ||
211 | 178 | ||
212 | // find the playlist object if the target playlist has been | 179 | /** |
213 | // specified by URI | 180 | * Abort any outstanding work and clean up. |
214 | if (typeof playlist === 'string') { | 181 | */ |
215 | if (!loader.master.playlists[playlist]) { | 182 | loader.dispose = function() { |
216 | throw new Error('Unknown playlist URI: ' + playlist); | 183 | if (request) { |
217 | } | 184 | request.onreadystatechange = null; |
218 | playlist = loader.master.playlists[playlist]; | 185 | request.abort(); |
219 | } | 186 | request = null; |
187 | } | ||
188 | window.clearTimeout(mediaUpdateTimeout); | ||
189 | dispose.call(this); | ||
190 | }; | ||
220 | 191 | ||
221 | mediaChange = !loader.media_ || playlist.uri !== loader.media_.uri; | 192 | /** |
222 | 193 | * When called without any arguments, returns the currently | |
223 | // switch to fully loaded playlists immediately | 194 | * active media playlist. When called with a single argument, |
224 | if (loader.master.playlists[playlist.uri].endList) { | 195 | * triggers the playlist loader to asynchronously switch to the |
225 | // abort outstanding playlist requests | 196 | * specified media playlist. Calling this method while the |
226 | if (request) { | 197 | * loader is in the HAVE_NOTHING causes an error to be emitted |
227 | request.onreadystatechange = null; | 198 | * but otherwise has no effect. |
228 | request.abort(); | 199 | * @param playlist (optional) {object} the parsed media playlist |
229 | request = null; | 200 | * object to switch to |
230 | } | 201 | */ |
231 | loader.state = 'HAVE_METADATA'; | 202 | loader.media = function(playlist) { |
232 | loader.media_ = playlist; | 203 | let startingState = loader.state; |
233 | 204 | let mediaChange; | |
234 | // trigger media change if the active media has been updated | 205 | // getter |
235 | if (mediaChange) { | 206 | if (!playlist) { |
236 | loader.trigger('mediachange'); | 207 | return loader.media_; |
237 | } | 208 | } |
238 | return; | ||
239 | } | ||
240 | 209 | ||
241 | // switching to the active playlist is a no-op | 210 | // setter |
242 | if (!mediaChange) { | 211 | if (loader.state === 'HAVE_NOTHING') { |
243 | return; | 212 | throw new Error('Cannot switch media playlist from ' + loader.state); |
213 | } | ||
214 | |||
215 | // find the playlist object if the target playlist has been | ||
216 | // specified by URI | ||
217 | if (typeof playlist === 'string') { | ||
218 | if (!loader.master.playlists[playlist]) { | ||
219 | throw new Error('Unknown playlist URI: ' + playlist); | ||
244 | } | 220 | } |
221 | playlist = loader.master.playlists[playlist]; | ||
222 | } | ||
245 | 223 | ||
246 | loader.state = 'SWITCHING_MEDIA'; | 224 | mediaChange = !loader.media_ || playlist.uri !== loader.media_.uri; |
247 | 225 | ||
248 | // there is already an outstanding playlist request | 226 | // switch to fully loaded playlists immediately |
227 | if (loader.master.playlists[playlist.uri].endList) { | ||
228 | // abort outstanding playlist requests | ||
249 | if (request) { | 229 | if (request) { |
250 | if (resolveUrl(loader.master.uri, playlist.uri) === request.url) { | ||
251 | // requesting to switch to the same playlist multiple times | ||
252 | // has no effect after the first | ||
253 | return; | ||
254 | } | ||
255 | request.onreadystatechange = null; | 230 | request.onreadystatechange = null; |
256 | request.abort(); | 231 | request.abort(); |
257 | request = null; | 232 | request = null; |
258 | } | 233 | } |
234 | loader.state = 'HAVE_METADATA'; | ||
235 | loader.media_ = playlist; | ||
259 | 236 | ||
260 | // request the new playlist | 237 | // trigger media change if the active media has been updated |
261 | request = xhr({ | 238 | if (mediaChange) { |
262 | uri: resolveUrl(loader.master.uri, playlist.uri), | 239 | loader.trigger('mediachange'); |
263 | withCredentials: withCredentials | 240 | } |
264 | }, function(error, request) { | 241 | return; |
265 | if (error) { | 242 | } |
266 | return playlistRequestError(request, playlist.uri, startingState); | ||
267 | } | ||
268 | |||
269 | haveMetadata(request, playlist.uri); | ||
270 | |||
271 | // fire loadedmetadata the first time a media playlist is loaded | ||
272 | if (startingState === 'HAVE_MASTER') { | ||
273 | loader.trigger('loadedmetadata'); | ||
274 | } else { | ||
275 | loader.trigger('mediachange'); | ||
276 | } | ||
277 | }); | ||
278 | }; | ||
279 | 243 | ||
280 | loader.setBandwidth = function(xhr) { | 244 | // switching to the active playlist is a no-op |
281 | loader.bandwidth = xhr.bandwidth; | 245 | if (!mediaChange) { |
282 | }; | 246 | return; |
247 | } | ||
283 | 248 | ||
284 | // In a live list, don't keep track of the expired time until | 249 | loader.state = 'SWITCHING_MEDIA'; |
285 | // HLS tells us that "first play" has commenced | ||
286 | loader.on('firstplay', function() { | ||
287 | this.trackExpiredTime_ = true; | ||
288 | }); | ||
289 | 250 | ||
290 | // live playlist staleness timeout | 251 | // there is already an outstanding playlist request |
291 | loader.on('mediaupdatetimeout', function() { | 252 | if (request) { |
292 | if (loader.state !== 'HAVE_METADATA') { | 253 | if (resolveUrl(loader.master.uri, playlist.uri) === request.url) { |
293 | // only refresh the media playlist if no other activity is going on | 254 | // requesting to switch to the same playlist multiple times |
255 | // has no effect after the first | ||
294 | return; | 256 | return; |
295 | } | 257 | } |
258 | request.onreadystatechange = null; | ||
259 | request.abort(); | ||
260 | request = null; | ||
261 | } | ||
296 | 262 | ||
297 | loader.state = 'HAVE_CURRENT_METADATA'; | 263 | // request the new playlist |
298 | request = xhr({ | 264 | request = XhrModule({ |
299 | uri: resolveUrl(loader.master.uri, loader.media().uri), | 265 | uri: resolveUrl(loader.master.uri, playlist.uri), |
300 | withCredentials: withCredentials | 266 | withCredentials |
301 | }, function(error, request) { | 267 | }, function(error, request) { |
302 | if (error) { | 268 | if (error) { |
303 | return playlistRequestError(request, loader.media().uri); | 269 | return playlistRequestError(request, playlist.uri, startingState); |
304 | } | 270 | } |
305 | haveMetadata(request, loader.media().uri); | 271 | |
306 | }); | 272 | haveMetadata(request, playlist.uri); |
273 | |||
274 | // fire loadedmetadata the first time a media playlist is loaded | ||
275 | if (startingState === 'HAVE_MASTER') { | ||
276 | loader.trigger('loadedmetadata'); | ||
277 | } else { | ||
278 | loader.trigger('mediachange'); | ||
279 | } | ||
307 | }); | 280 | }); |
281 | }; | ||
282 | |||
283 | loader.setBandwidth = function(xhr) { | ||
284 | loader.bandwidth = xhr.bandwidth; | ||
285 | }; | ||
308 | 286 | ||
309 | // request the specified URL | 287 | // In a live list, don't keep track of the expired time until |
310 | request = xhr({ | 288 | // HLS tells us that "first play" has commenced |
311 | uri: srcUrl, | 289 | loader.on('firstplay', function() { |
312 | withCredentials: withCredentials | 290 | this.trackExpiredTime_ = true; |
313 | }, function(error, req) { | 291 | }); |
314 | var parser, i; | ||
315 | 292 | ||
316 | // clear the loader's request reference | 293 | // live playlist staleness timeout |
317 | request = null; | 294 | loader.on('mediaupdatetimeout', function() { |
295 | if (loader.state !== 'HAVE_METADATA') { | ||
296 | // only refresh the media playlist if no other activity is going on | ||
297 | return; | ||
298 | } | ||
318 | 299 | ||
300 | loader.state = 'HAVE_CURRENT_METADATA'; | ||
301 | request = XhrModule({ | ||
302 | uri: resolveUrl(loader.master.uri, loader.media().uri), | ||
303 | withCredentials | ||
304 | }, function(error, request) { | ||
319 | if (error) { | 305 | if (error) { |
320 | loader.error = { | 306 | return playlistRequestError(request, loader.media().uri); |
321 | status: req.status, | ||
322 | message: 'HLS playlist request error at URL: ' + srcUrl, | ||
323 | responseText: req.responseText, | ||
324 | code: 2 // MEDIA_ERR_NETWORK | ||
325 | }; | ||
326 | return loader.trigger('error'); | ||
327 | } | 307 | } |
308 | haveMetadata(request, loader.media().uri); | ||
309 | }); | ||
310 | }); | ||
328 | 311 | ||
329 | parser = new videojs.m3u8.Parser(); | 312 | // request the specified URL |
330 | parser.push(req.responseText); | 313 | request = XhrModule({ |
331 | parser.end(); | 314 | uri: srcUrl, |
315 | withCredentials | ||
316 | }, function(error, req) { | ||
317 | let parser; | ||
318 | let i; | ||
332 | 319 | ||
333 | loader.state = 'HAVE_MASTER'; | 320 | // clear the loader's request reference |
321 | request = null; | ||
334 | 322 | ||
335 | parser.manifest.uri = srcUrl; | 323 | if (error) { |
324 | loader.error = { | ||
325 | status: req.status, | ||
326 | message: 'HLS playlist request error at URL: ' + srcUrl, | ||
327 | responseText: req.responseText, | ||
328 | // MEDIA_ERR_NETWORK | ||
329 | code: 2 | ||
330 | }; | ||
331 | return loader.trigger('error'); | ||
332 | } | ||
336 | 333 | ||
337 | // loaded a master playlist | 334 | parser = new m3u8.Parser(); |
338 | if (parser.manifest.playlists) { | 335 | parser.push(req.responseText); |
339 | loader.master = parser.manifest; | 336 | parser.end(); |
340 | 337 | ||
341 | // setup by-URI lookups | 338 | loader.state = 'HAVE_MASTER'; |
342 | i = loader.master.playlists.length; | ||
343 | while (i--) { | ||
344 | loader.master.playlists[loader.master.playlists[i].uri] = loader.master.playlists[i]; | ||
345 | } | ||
346 | 339 | ||
347 | loader.trigger('loadedplaylist'); | 340 | parser.manifest.uri = srcUrl; |
348 | if (!request) { | 341 | |
349 | // no media playlist was specifically selected so start | 342 | // loaded a master playlist |
350 | // from the first listed one | 343 | if (parser.manifest.playlists) { |
351 | loader.media(parser.manifest.playlists[0]); | 344 | loader.master = parser.manifest; |
352 | } | 345 | |
353 | return; | 346 | // setup by-URI lookups |
347 | i = loader.master.playlists.length; | ||
348 | while (i--) { | ||
349 | loader.master.playlists[loader.master.playlists[i].uri] = loader.master.playlists[i]; | ||
354 | } | 350 | } |
355 | 351 | ||
356 | // loaded a media playlist | 352 | loader.trigger('loadedplaylist'); |
357 | // infer a master playlist if none was previously requested | 353 | if (!request) { |
358 | loader.master = { | 354 | // no media playlist was specifically selected so start |
359 | uri: window.location.href, | 355 | // from the first listed one |
360 | playlists: [{ | 356 | loader.media(parser.manifest.playlists[0]); |
361 | uri: srcUrl | 357 | } |
362 | }] | 358 | return; |
363 | }; | 359 | } |
364 | loader.master.playlists[srcUrl] = loader.master.playlists[0]; | ||
365 | haveMetadata(req, srcUrl); | ||
366 | return loader.trigger('loadedmetadata'); | ||
367 | }); | ||
368 | }; | ||
369 | PlaylistLoader.prototype = new videojs.Hls.Stream(); | ||
370 | 360 | ||
361 | // loaded a media playlist | ||
362 | // infer a master playlist if none was previously requested | ||
363 | loader.master = { | ||
364 | uri: window.location.href, | ||
365 | playlists: [{ | ||
366 | uri: srcUrl | ||
367 | }] | ||
368 | }; | ||
369 | loader.master.playlists[srcUrl] = loader.master.playlists[0]; | ||
370 | haveMetadata(req, srcUrl); | ||
371 | return loader.trigger('loadedmetadata'); | ||
372 | }); | ||
373 | } | ||
371 | /** | 374 | /** |
372 | * Update the PlaylistLoader state to reflect the changes in an | 375 | * Update the PlaylistLoader state to reflect the changes in an |
373 | * update to the current media playlist. | 376 | * update to the current media playlist. |
374 | * @param update {object} the updated media playlist object | 377 | * @param update {object} the updated media playlist object |
375 | */ | 378 | */ |
376 | PlaylistLoader.prototype.updateMediaPlaylist_ = function(update) { | 379 | updateMediaPlaylist_(update) { |
377 | var outdated, i, segment; | 380 | let outdated; |
381 | let i; | ||
382 | let segment; | ||
378 | 383 | ||
379 | outdated = this.media_; | 384 | outdated = this.media_; |
380 | this.media_ = this.master.playlists[update.uri]; | 385 | this.media_ = this.master.playlists[update.uri]; |
... | @@ -432,7 +437,7 @@ | ... | @@ -432,7 +437,7 @@ |
432 | } | 437 | } |
433 | this.expired_ += segment.duration; | 438 | this.expired_ += segment.duration; |
434 | } | 439 | } |
435 | }; | 440 | } |
436 | 441 | ||
437 | /** | 442 | /** |
438 | * Determine the index of the segment that contains a specified | 443 | * Determine the index of the segment that contains a specified |
... | @@ -452,17 +457,16 @@ | ... | @@ -452,17 +457,16 @@ |
452 | * value will be clamped to the index of the segment containing the | 457 | * value will be clamped to the index of the segment containing the |
453 | * closest playback position that is currently available. | 458 | * closest playback position that is currently available. |
454 | */ | 459 | */ |
455 | PlaylistLoader.prototype.getMediaIndexForTime_ = function(time) { | 460 | getMediaIndexForTime_(time) { |
456 | var | 461 | let i; |
457 | i, | 462 | let segment; |
458 | segment, | 463 | let originalTime = time; |
459 | originalTime = time, | 464 | let numSegments = this.media_.segments.length; |
460 | numSegments = this.media_.segments.length, | 465 | let lastSegment = numSegments - 1; |
461 | lastSegment = numSegments - 1, | 466 | let startIndex; |
462 | startIndex, | 467 | let endIndex; |
463 | endIndex, | 468 | let knownStart; |
464 | knownStart, | 469 | let knownEnd; |
465 | knownEnd; | ||
466 | 470 | ||
467 | if (!this.media_) { | 471 | if (!this.media_) { |
468 | return 0; | 472 | return 0; |
... | @@ -558,7 +562,5 @@ | ... | @@ -558,7 +562,5 @@ |
558 | // the one most likely to tell us something about the timeline | 562 | // the one most likely to tell us something about the timeline |
559 | return lastSegment; | 563 | return lastSegment; |
560 | } | 564 | } |
561 | }; | 565 | } |
562 | 566 | } | |
563 | videojs.Hls.PlaylistLoader = PlaylistLoader; | ||
564 | })(window, window.videojs); | ... | ... |
1 | /** | 1 | /** |
2 | * Playlist related utilities. | 2 | * Playlist related utilities. |
3 | */ | 3 | */ |
4 | (function(window, videojs) { | 4 | import {createTimeRange} from 'video.js'; |
5 | 'use strict'; | ||
6 | |||
7 | var Playlist = { | ||
8 | /** | ||
9 | * The number of segments that are unsafe to start playback at in | ||
10 | * a live stream. Changing this value can cause playback stalls. | ||
11 | * See HTTP Live Streaming, "Playing the Media Playlist File" | ||
12 | * https://tools.ietf.org/html/draft-pantos-http-live-streaming-18#section-6.3.3 | ||
13 | */ | ||
14 | UNSAFE_LIVE_SEGMENTS: 3 | ||
15 | }; | ||
16 | |||
17 | var duration, intervalDuration, backwardDuration, forwardDuration, seekable; | ||
18 | |||
19 | backwardDuration = function(playlist, endSequence) { | ||
20 | var result = 0, segment, i; | ||
21 | |||
22 | i = endSequence - playlist.mediaSequence; | ||
23 | // if a start time is available for segment immediately following | ||
24 | // the interval, use it | ||
25 | segment = playlist.segments[i]; | ||
26 | // Walk backward until we find the latest segment with timeline | ||
27 | // information that is earlier than endSequence | ||
28 | if (segment) { | ||
29 | if (segment.start !== undefined) { | ||
30 | return { result: segment.start, precise: true }; | ||
31 | } | ||
32 | if (segment.end !== undefined) { | ||
33 | return { | ||
34 | result: segment.end - segment.duration, | ||
35 | precise: true | ||
36 | }; | ||
37 | } | ||
38 | } | ||
39 | while (i--) { | ||
40 | segment = playlist.segments[i]; | ||
41 | if (segment.end !== undefined) { | ||
42 | return { result: result + segment.end, precise: true }; | ||
43 | } | ||
44 | |||
45 | result += segment.duration; | ||
46 | |||
47 | if (segment.start !== undefined) { | ||
48 | return { result: result + segment.start, precise: true }; | ||
49 | } | ||
50 | } | ||
51 | return { result: result, precise: false }; | ||
52 | }; | ||
53 | |||
54 | forwardDuration = function(playlist, endSequence) { | ||
55 | var result = 0, segment, i; | ||
56 | |||
57 | i = endSequence - playlist.mediaSequence; | ||
58 | // Walk forward until we find the earliest segment with timeline | ||
59 | // information | ||
60 | for (; i < playlist.segments.length; i++) { | ||
61 | segment = playlist.segments[i]; | ||
62 | if (segment.start !== undefined) { | ||
63 | return { | ||
64 | result: segment.start - result, | ||
65 | precise: true | ||
66 | }; | ||
67 | } | ||
68 | |||
69 | result += segment.duration; | ||
70 | |||
71 | if (segment.end !== undefined) { | ||
72 | return { | ||
73 | result: segment.end - result, | ||
74 | precise: true | ||
75 | }; | ||
76 | } | ||
77 | |||
78 | } | ||
79 | // indicate we didn't find a useful duration estimate | ||
80 | return { result: -1, precise: false }; | ||
81 | }; | ||
82 | 5 | ||
6 | let Playlist = { | ||
83 | /** | 7 | /** |
84 | * Calculate the media duration from the segments associated with a | 8 | * The number of segments that are unsafe to start playback at in |
85 | * playlist. The duration of a subinterval of the available segments | 9 | * a live stream. Changing this value can cause playback stalls. |
86 | * may be calculated by specifying an end index. | 10 | * See HTTP Live Streaming, "Playing the Media Playlist File" |
87 | * | 11 | * https://tools.ietf.org/html/draft-pantos-http-live-streaming-18#section-6.3.3 |
88 | * @param playlist {object} a media playlist object | ||
89 | * @param endSequence {number} (optional) an exclusive upper boundary | ||
90 | * for the playlist. Defaults to playlist length. | ||
91 | * @return {number} the duration between the first available segment | ||
92 | * and end index. | ||
93 | */ | 12 | */ |
94 | intervalDuration = function(playlist, endSequence) { | 13 | UNSAFE_LIVE_SEGMENTS: 3 |
95 | var backward, forward; | 14 | }; |
96 | 15 | ||
97 | if (endSequence === undefined) { | 16 | const backwardDuration = function(playlist, endSequence) { |
98 | endSequence = playlist.mediaSequence + playlist.segments.length; | 17 | let result = 0; |
18 | let i = endSequence - playlist.mediaSequence; | ||
19 | // if a start time is available for segment immediately following | ||
20 | // the interval, use it | ||
21 | let segment = playlist.segments[i]; | ||
22 | |||
23 | // Walk backward until we find the latest segment with timeline | ||
24 | // information that is earlier than endSequence | ||
25 | if (segment) { | ||
26 | if (typeof segment.start !== 'undefined') { | ||
27 | return { result: segment.start, precise: true }; | ||
99 | } | 28 | } |
100 | 29 | if (typeof segment.end !== 'undefined') { | |
101 | if (endSequence < playlist.mediaSequence) { | 30 | return { |
102 | return 0; | 31 | result: segment.end - segment.duration, |
32 | precise: true | ||
33 | }; | ||
103 | } | 34 | } |
104 | 35 | } | |
105 | // do a backward walk to estimate the duration | 36 | while (i--) { |
106 | backward = backwardDuration(playlist, endSequence); | 37 | segment = playlist.segments[i]; |
107 | if (backward.precise) { | 38 | if (typeof segment.end !== 'undefined') { |
108 | // if we were able to base our duration estimate on timing | 39 | return { result: result + segment.end, precise: true }; |
109 | // information provided directly from the Media Source, return | ||
110 | // it | ||
111 | return backward.result; | ||
112 | } | 40 | } |
113 | 41 | ||
114 | // walk forward to see if a precise duration estimate can be made | 42 | result += segment.duration; |
115 | // that way | 43 | |
116 | forward = forwardDuration(playlist, endSequence); | 44 | if (typeof segment.start !== 'undefined') { |
117 | if (forward.precise) { | 45 | return { result: result + segment.start, precise: true }; |
118 | // we found a segment that has been buffered and so it's | 46 | } |
119 | // position is known precisely | 47 | } |
120 | return forward.result; | 48 | return { result, precise: false }; |
49 | }; | ||
50 | |||
51 | const forwardDuration = function(playlist, endSequence) { | ||
52 | let result = 0; | ||
53 | let segment; | ||
54 | let i = endSequence - playlist.mediaSequence; | ||
55 | // Walk forward until we find the earliest segment with timeline | ||
56 | // information | ||
57 | |||
58 | for (; i < playlist.segments.length; i++) { | ||
59 | segment = playlist.segments[i]; | ||
60 | if (typeof segment.start !== 'undefined') { | ||
61 | return { | ||
62 | result: segment.start - result, | ||
63 | precise: true | ||
64 | }; | ||
121 | } | 65 | } |
122 | 66 | ||
123 | // return the less-precise, playlist-based duration estimate | 67 | result += segment.duration; |
124 | return backward.result; | ||
125 | }; | ||
126 | 68 | ||
127 | /** | 69 | if (typeof segment.end !== 'undefined') { |
128 | * Calculates the duration of a playlist. If a start and end index | 70 | return { |
129 | * are specified, the duration will be for the subset of the media | 71 | result: segment.end - result, |
130 | * timeline between those two indices. The total duration for live | 72 | precise: true |
131 | * playlists is always Infinity. | 73 | }; |
132 | * @param playlist {object} a media playlist object | ||
133 | * @param endSequence {number} (optional) an exclusive upper | ||
134 | * boundary for the playlist. Defaults to the playlist media | ||
135 | * sequence number plus its length. | ||
136 | * @param includeTrailingTime {boolean} (optional) if false, the | ||
137 | * interval between the final segment and the subsequent segment | ||
138 | * will not be included in the result | ||
139 | * @return {number} the duration between the start index and end | ||
140 | * index. | ||
141 | */ | ||
142 | duration = function(playlist, endSequence, includeTrailingTime) { | ||
143 | if (!playlist) { | ||
144 | return 0; | ||
145 | } | 74 | } |
146 | 75 | ||
147 | if (includeTrailingTime === undefined) { | 76 | } |
148 | includeTrailingTime = true; | 77 | // indicate we didn't find a useful duration estimate |
149 | } | 78 | return { result: -1, precise: false }; |
79 | }; | ||
150 | 80 | ||
151 | // if a slice of the total duration is not requested, use | 81 | /** |
152 | // playlist-level duration indicators when they're present | 82 | * Calculate the media duration from the segments associated with a |
153 | if (endSequence === undefined) { | 83 | * playlist. The duration of a subinterval of the available segments |
154 | // if present, use the duration specified in the playlist | 84 | * may be calculated by specifying an end index. |
155 | if (playlist.totalDuration) { | 85 | * |
156 | return playlist.totalDuration; | 86 | * @param playlist {object} a media playlist object |
157 | } | 87 | * @param endSequence {number} (optional) an exclusive upper boundary |
158 | 88 | * for the playlist. Defaults to playlist length. | |
159 | // duration should be Infinity for live playlists | 89 | * @return {number} the duration between the first available segment |
160 | if (!playlist.endList) { | 90 | * and end index. |
161 | return window.Infinity; | 91 | */ |
162 | } | 92 | const intervalDuration = function(playlist, endSequence) { |
163 | } | 93 | let backward; |
94 | let forward; | ||
95 | |||
96 | if (typeof endSequence === 'undefined') { | ||
97 | endSequence = playlist.mediaSequence + playlist.segments.length; | ||
98 | } | ||
99 | |||
100 | if (endSequence < playlist.mediaSequence) { | ||
101 | return 0; | ||
102 | } | ||
103 | |||
104 | // do a backward walk to estimate the duration | ||
105 | backward = backwardDuration(playlist, endSequence); | ||
106 | if (backward.precise) { | ||
107 | // if we were able to base our duration estimate on timing | ||
108 | // information provided directly from the Media Source, return | ||
109 | // it | ||
110 | return backward.result; | ||
111 | } | ||
164 | 112 | ||
165 | // calculate the total duration based on the segment durations | 113 | // walk forward to see if a precise duration estimate can be made |
166 | return intervalDuration(playlist, | 114 | // that way |
167 | endSequence, | 115 | forward = forwardDuration(playlist, endSequence); |
168 | includeTrailingTime); | 116 | if (forward.precise) { |
169 | }; | 117 | // we found a segment that has been buffered and so it's |
118 | // position is known precisely | ||
119 | return forward.result; | ||
120 | } | ||
170 | 121 | ||
171 | /** | 122 | // return the less-precise, playlist-based duration estimate |
172 | * Calculates the interval of time that is currently seekable in a | 123 | return backward.result; |
173 | * playlist. The returned time ranges are relative to the earliest | 124 | }; |
174 | * moment in the specified playlist that is still available. A full | ||
175 | * seekable implementation for live streams would need to offset | ||
176 | * these values by the duration of content that has expired from the | ||
177 | * stream. | ||
178 | * @param playlist {object} a media playlist object | ||
179 | * @return {TimeRanges} the periods of time that are valid targets | ||
180 | * for seeking | ||
181 | */ | ||
182 | seekable = function(playlist) { | ||
183 | var start, end; | ||
184 | 125 | ||
185 | // without segments, there are no seekable ranges | 126 | /** |
186 | if (!playlist.segments) { | 127 | * Calculates the duration of a playlist. If a start and end index |
187 | return videojs.createTimeRange(); | 128 | * are specified, the duration will be for the subset of the media |
129 | * timeline between those two indices. The total duration for live | ||
130 | * playlists is always Infinity. | ||
131 | * @param playlist {object} a media playlist object | ||
132 | * @param endSequence {number} (optional) an exclusive upper | ||
133 | * boundary for the playlist. Defaults to the playlist media | ||
134 | * sequence number plus its length. | ||
135 | * @param includeTrailingTime {boolean} (optional) if false, the | ||
136 | * interval between the final segment and the subsequent segment | ||
137 | * will not be included in the result | ||
138 | * @return {number} the duration between the start index and end | ||
139 | * index. | ||
140 | */ | ||
141 | export const duration = function(playlist, endSequence, includeTrailingTime) { | ||
142 | if (!playlist) { | ||
143 | return 0; | ||
144 | } | ||
145 | |||
146 | if (typeof includeTrailingTime === 'undefined') { | ||
147 | includeTrailingTime = true; | ||
148 | } | ||
149 | |||
150 | // if a slice of the total duration is not requested, use | ||
151 | // playlist-level duration indicators when they're present | ||
152 | if (typeof endSequence === 'undefined') { | ||
153 | // if present, use the duration specified in the playlist | ||
154 | if (playlist.totalDuration) { | ||
155 | return playlist.totalDuration; | ||
188 | } | 156 | } |
189 | // when the playlist is complete, the entire duration is seekable | 157 | |
190 | if (playlist.endList) { | 158 | // duration should be Infinity for live playlists |
191 | return videojs.createTimeRange(0, duration(playlist)); | 159 | if (!playlist.endList) { |
160 | return window.Infinity; | ||
192 | } | 161 | } |
162 | } | ||
163 | |||
164 | // calculate the total duration based on the segment durations | ||
165 | return intervalDuration(playlist, | ||
166 | endSequence, | ||
167 | includeTrailingTime); | ||
168 | }; | ||
193 | 169 | ||
194 | // live playlists should not expose three segment durations worth | 170 | /** |
195 | // of content from the end of the playlist | 171 | * Calculates the interval of time that is currently seekable in a |
196 | // https://tools.ietf.org/html/draft-pantos-http-live-streaming-16#section-6.3.3 | 172 | * playlist. The returned time ranges are relative to the earliest |
197 | start = intervalDuration(playlist, playlist.mediaSequence); | 173 | * moment in the specified playlist that is still available. A full |
198 | end = intervalDuration(playlist, | 174 | * seekable implementation for live streams would need to offset |
199 | playlist.mediaSequence + | 175 | * these values by the duration of content that has expired from the |
200 | Math.max(0, playlist.segments.length - Playlist.UNSAFE_LIVE_SEGMENTS)); | 176 | * stream. |
201 | return videojs.createTimeRange(start, end); | 177 | * @param playlist {object} a media playlist object |
202 | }; | 178 | * @return {TimeRanges} the periods of time that are valid targets |
203 | 179 | * for seeking | |
204 | // exports | 180 | */ |
205 | Playlist.duration = duration; | 181 | export const seekable = function(playlist) { |
206 | Playlist.seekable = seekable; | 182 | let start; |
207 | videojs.Hls.Playlist = Playlist; | 183 | let end; |
208 | 184 | ||
209 | })(window, window.videojs); | 185 | // without segments, there are no seekable ranges |
186 | if (!playlist.segments) { | ||
187 | return createTimeRange(); | ||
188 | } | ||
189 | // when the playlist is complete, the entire duration is seekable | ||
190 | if (playlist.endList) { | ||
191 | return createTimeRange(0, duration(playlist)); | ||
192 | } | ||
193 | |||
194 | // live playlists should not expose three segment durations worth | ||
195 | // of content from the end of the playlist | ||
196 | // https://tools.ietf.org/html/draft-pantos-http-live-streaming-16#section-6.3.3 | ||
197 | start = intervalDuration(playlist, playlist.mediaSequence); | ||
198 | end = intervalDuration(playlist, | ||
199 | playlist.mediaSequence + | ||
200 | Math.max(0, playlist.segments.length - Playlist.UNSAFE_LIVE_SEGMENTS)); | ||
201 | return createTimeRange(start, end); | ||
202 | }; | ||
203 | |||
204 | Playlist.duration = duration; | ||
205 | Playlist.seekable = seekable; | ||
206 | |||
207 | // exports | ||
208 | export default Playlist; | ... | ... |
src/resolve-url.js
0 → 100644
1 | import document from 'global/document'; | ||
2 | /* eslint-disable max-len */ | ||
3 | /** | ||
4 | * Constructs a new URI by interpreting a path relative to another | ||
5 | * URI. | ||
6 | * @param basePath {string} a relative or absolute URI | ||
7 | * @param path {string} a path part to combine with the base | ||
8 | * @return {string} a URI that is equivalent to composing `base` | ||
9 | * with `path` | ||
10 | * @see http://stackoverflow.com/questions/470832/getting-an-absolute-url-from-a-relative-one-ie6-issue | ||
11 | */ | ||
12 | /* eslint-enable max-len */ | ||
13 | const resolveUrl = function(basePath, path) { | ||
14 | // use the base element to get the browser to handle URI resolution | ||
15 | let oldBase = document.querySelector('base'); | ||
16 | let docHead = document.querySelector('head'); | ||
17 | let a = document.createElement('a'); | ||
18 | let base = oldBase; | ||
19 | let oldHref; | ||
20 | let result; | ||
21 | |||
22 | // prep the document | ||
23 | if (oldBase) { | ||
24 | oldHref = oldBase.href; | ||
25 | } else { | ||
26 | base = docHead.appendChild(document.createElement('base')); | ||
27 | } | ||
28 | |||
29 | base.href = basePath; | ||
30 | a.href = path; | ||
31 | result = a.href; | ||
32 | |||
33 | // clean up | ||
34 | if (oldBase) { | ||
35 | oldBase.href = oldHref; | ||
36 | } else { | ||
37 | docHead.removeChild(base); | ||
38 | } | ||
39 | return result; | ||
40 | }; | ||
41 | |||
42 | export default resolveUrl; |
1 | /** | 1 | /** |
2 | * A lightweight readable stream implemention that handles event dispatching. | 2 | * A lightweight readable stream implemention that handles event dispatching. |
3 | * Objects that inherit from streams should call init in their constructors. | ||
4 | */ | 3 | */ |
5 | (function(videojs, undefined) { | 4 | export default class Stream { |
6 | var Stream = function() { | 5 | constructor() { |
7 | this.init = function() { | 6 | this.listeners = {}; |
8 | var listeners = {}; | 7 | } |
9 | /** | 8 | |
10 | * Add a listener for a specified event type. | 9 | /** |
11 | * @param type {string} the event name | 10 | * Add a listener for a specified event type. |
12 | * @param listener {function} the callback to be invoked when an event of | 11 | * @param type {string} the event name |
13 | * the specified type occurs | 12 | * @param listener {function} the callback to be invoked when an event of |
14 | */ | 13 | * the specified type occurs |
15 | this.on = function(type, listener) { | 14 | */ |
16 | if (!listeners[type]) { | 15 | on(type, listener) { |
17 | listeners[type] = []; | 16 | if (!this.listeners[type]) { |
18 | } | 17 | this.listeners[type] = []; |
19 | listeners[type].push(listener); | 18 | } |
20 | }; | 19 | this.listeners[type].push(listener); |
21 | /** | 20 | } |
22 | * Remove a listener for a specified event type. | 21 | |
23 | * @param type {string} the event name | 22 | /** |
24 | * @param listener {function} a function previously registered for this | 23 | * Remove a listener for a specified event type. |
25 | * type of event through `on` | 24 | * @param type {string} the event name |
26 | */ | 25 | * @param listener {function} a function previously registered for this |
27 | this.off = function(type, listener) { | 26 | * type of event through `on` |
28 | var index; | 27 | */ |
29 | if (!listeners[type]) { | 28 | off(type, listener) { |
30 | return false; | 29 | let index; |
31 | } | 30 | |
32 | index = listeners[type].indexOf(listener); | 31 | if (!this.listeners[type]) { |
33 | listeners[type].splice(index, 1); | 32 | return false; |
34 | return index > -1; | 33 | } |
35 | }; | 34 | index = this.listeners[type].indexOf(listener); |
36 | /** | 35 | this.listeners[type].splice(index, 1); |
37 | * Trigger an event of the specified type on this stream. Any additional | 36 | return index > -1; |
38 | * arguments to this function are passed as parameters to event listeners. | 37 | } |
39 | * @param type {string} the event name | 38 | |
40 | */ | 39 | /** |
41 | this.trigger = function(type) { | 40 | * Trigger an event of the specified type on this stream. Any additional |
42 | var callbacks, i, length, args; | 41 | * arguments to this function are passed as parameters to event listeners. |
43 | callbacks = listeners[type]; | 42 | * @param type {string} the event name |
44 | if (!callbacks) { | 43 | */ |
45 | return; | 44 | trigger(type) { |
46 | } | 45 | let callbacks; |
47 | // Slicing the arguments on every invocation of this method | 46 | let i; |
48 | // can add a significant amount of overhead. Avoid the | 47 | let length; |
49 | // intermediate object creation for the common case of a | 48 | let args; |
50 | // single callback argument | 49 | |
51 | if (arguments.length === 2) { | 50 | callbacks = this.listeners[type]; |
52 | length = callbacks.length; | 51 | if (!callbacks) { |
53 | for (i = 0; i < length; ++i) { | 52 | return; |
54 | callbacks[i].call(this, arguments[1]); | 53 | } |
55 | } | 54 | // Slicing the arguments on every invocation of this method |
56 | } else { | 55 | // can add a significant amount of overhead. Avoid the |
57 | args = Array.prototype.slice.call(arguments, 1); | 56 | // intermediate object creation for the common case of a |
58 | length = callbacks.length; | 57 | // single callback argument |
59 | for (i = 0; i < length; ++i) { | 58 | if (arguments.length === 2) { |
60 | callbacks[i].apply(this, args); | 59 | length = callbacks.length; |
61 | } | 60 | for (i = 0; i < length; ++i) { |
62 | } | 61 | callbacks[i].call(this, arguments[1]); |
63 | }; | 62 | } |
64 | /** | 63 | } else { |
65 | * Destroys the stream and cleans up. | 64 | args = Array.prototype.slice.call(arguments, 1); |
66 | */ | 65 | length = callbacks.length; |
67 | this.dispose = function() { | 66 | for (i = 0; i < length; ++i) { |
68 | listeners = {}; | 67 | callbacks[i].apply(this, args); |
69 | }; | 68 | } |
70 | }; | 69 | } |
71 | }; | 70 | } |
71 | |||
72 | /** | ||
73 | * Destroys the stream and cleans up. | ||
74 | */ | ||
75 | dispose() { | ||
76 | this.listeners = {}; | ||
77 | } | ||
72 | /** | 78 | /** |
73 | * Forwards all `data` events on this stream to the destination stream. The | 79 | * Forwards all `data` events on this stream to the destination stream. The |
74 | * destination stream should provide a method `push` to receive the data | 80 | * destination stream should provide a method `push` to receive the data |
... | @@ -76,11 +82,9 @@ | ... | @@ -76,11 +82,9 @@ |
76 | * @param destination {stream} the stream that will receive all `data` events | 82 | * @param destination {stream} the stream that will receive all `data` events |
77 | * @see http://nodejs.org/api/stream.html#stream_readable_pipe_destination_options | 83 | * @see http://nodejs.org/api/stream.html#stream_readable_pipe_destination_options |
78 | */ | 84 | */ |
79 | Stream.prototype.pipe = function(destination) { | 85 | pipe(destination) { |
80 | this.on('data', function(data) { | 86 | this.on('data', function(data) { |
81 | destination.push(data); | 87 | destination.push(data); |
82 | }); | 88 | }); |
83 | }; | 89 | } |
84 | 90 | } | |
85 | videojs.Hls.Stream = Stream; | ||
86 | })(window.videojs); | ... | ... |
1 | /* | 1 | /** |
2 | * videojs-hls | 2 | * videojs-hls |
3 | * The main file for the HLS project. | 3 | * The main file for the HLS project. |
4 | * License: https://github.com/videojs/videojs-contrib-hls/blob/master/LICENSE | 4 | * License: https://github.com/videojs/videojs-contrib-hls/blob/master/LICENSE |
5 | */ | 5 | */ |
6 | (function(window, videojs, document, undefined) { | 6 | import PlaylistLoader from './playlist-loader'; |
7 | 'use strict'; | 7 | import Playlist from './playlist'; |
8 | 8 | import xhr from './xhr'; | |
9 | var | 9 | import {Decrypter, AsyncStream, decrypt} from './decrypter'; |
10 | // A fudge factor to apply to advertised playlist bitrates to account for | 10 | import utils from './bin-utils'; |
11 | // temporary flucations in client bandwidth | 11 | import {MediaSource, URL} from 'videojs-contrib-media-sources'; |
12 | bandwidthVariance = 1.2, | 12 | import m3u8 from './m3u8'; |
13 | blacklistDuration = 5 * 60 * 1000, // 5 minute blacklist | 13 | import videojs from 'video.js'; |
14 | TIME_FUDGE_FACTOR = 1 / 30, // Fudge factor to account for TimeRanges rounding | 14 | import resolveUrl from './resolve-url'; |
15 | Component = videojs.getComponent('Component'), | 15 | |
16 | 16 | const Hls = { | |
17 | // The amount of time to wait between checking the state of the buffer | 17 | PlaylistLoader, |
18 | bufferCheckInterval = 500, | 18 | Playlist, |
19 | 19 | Decrypter, | |
20 | safeGetComputedStyle, | 20 | AsyncStream, |
21 | keyFailed, | 21 | decrypt, |
22 | resolveUrl; | 22 | utils, |
23 | 23 | xhr | |
24 | // returns true if a key has failed to download within a certain amount of retries | ||
25 | keyFailed = function(key) { | ||
26 | return key.retries && key.retries >= 2; | ||
27 | }; | 24 | }; |
28 | 25 | ||
29 | videojs.Hls = {}; | 26 | // the desired length of video to maintain in the buffer, in seconds |
30 | videojs.HlsHandler = videojs.extend(Component, { | 27 | Hls.GOAL_BUFFER_LENGTH = 30; |
31 | constructor: function(tech, options) { | ||
32 | var self = this, _player; | ||
33 | |||
34 | Component.call(this, tech); | ||
35 | |||
36 | // tech.player() is deprecated but setup a reference to HLS for | ||
37 | // backwards-compatibility | ||
38 | if (tech.options_ && tech.options_.playerId) { | ||
39 | _player = videojs(tech.options_.playerId); | ||
40 | if (!_player.hls) { | ||
41 | Object.defineProperty(_player, 'hls', { | ||
42 | get: function() { | ||
43 | videojs.log.warn('player.hls is deprecated. Use player.tech.hls instead.'); | ||
44 | return self; | ||
45 | } | ||
46 | }); | ||
47 | } | ||
48 | } | ||
49 | this.tech_ = tech; | ||
50 | this.source_ = options.source; | ||
51 | this.mode_ = options.mode; | ||
52 | // the segment info object for a segment that is in the process of | ||
53 | // being downloaded or processed | ||
54 | this.pendingSegment_ = null; | ||
55 | |||
56 | // start playlist selection at a reasonable bandwidth for | ||
57 | // broadband internet | ||
58 | this.bandwidth = options.bandwidth || 4194304; // 0.5 Mbps | ||
59 | this.bytesReceived = 0; | ||
60 | |||
61 | // loadingState_ tracks how far along the buffering process we | ||
62 | // have been given permission to proceed. There are three possible | ||
63 | // values: | ||
64 | // - none: do not load playlists or segments | ||
65 | // - meta: load playlists but not segments | ||
66 | // - segments: load everything | ||
67 | this.loadingState_ = 'none'; | ||
68 | if (this.tech_.preload() !== 'none') { | ||
69 | this.loadingState_ = 'meta'; | ||
70 | } | ||
71 | |||
72 | // periodically check if new data needs to be downloaded or | ||
73 | // buffered data should be appended to the source buffer | ||
74 | this.startCheckingBuffer_(); | ||
75 | |||
76 | this.on(this.tech_, 'seeking', function() { | ||
77 | this.setCurrentTime(this.tech_.currentTime()); | ||
78 | }); | ||
79 | this.on(this.tech_, 'error', function() { | ||
80 | this.stopCheckingBuffer_(); | ||
81 | }); | ||
82 | |||
83 | this.on(this.tech_, 'play', this.play); | ||
84 | } | ||
85 | }); | ||
86 | 28 | ||
87 | // HLS is a source handler, not a tech. Make sure attempts to use it | 29 | // HLS is a source handler, not a tech. Make sure attempts to use it |
88 | // as one do not cause exceptions. | 30 | // as one do not cause exceptions. |
89 | videojs.Hls.canPlaySource = function() { | 31 | Hls.canPlaySource = function() { |
90 | return videojs.log.warn('HLS is no longer a tech. Please remove it from ' + | 32 | return videojs.log.warn('HLS is no longer a tech. Please remove it from ' + |
91 | 'your player\'s techOrder.'); | 33 | 'your player\'s techOrder.'); |
92 | }; | 34 | }; |
93 | 35 | ||
94 | /** | ||
95 | * The Source Handler object, which informs video.js what additional | ||
96 | * MIME types are supported and sets up playback. It is registered | ||
97 | * automatically to the appropriate tech based on the capabilities of | ||
98 | * the browser it is running in. It is not necessary to use or modify | ||
99 | * this object in normal usage. | ||
100 | */ | ||
101 | videojs.HlsSourceHandler = function(mode) { | ||
102 | return { | ||
103 | canHandleSource: function(srcObj) { | ||
104 | return videojs.HlsSourceHandler.canPlayType(srcObj.type); | ||
105 | }, | ||
106 | handleSource: function(source, tech) { | ||
107 | if (mode === 'flash') { | ||
108 | // We need to trigger this asynchronously to give others the chance | ||
109 | // to bind to the event when a source is set at player creation | ||
110 | tech.setTimeout(function() { | ||
111 | tech.trigger('loadstart'); | ||
112 | }, 1); | ||
113 | } | ||
114 | tech.hls = new videojs.HlsHandler(tech, { | ||
115 | source: source, | ||
116 | mode: mode | ||
117 | }); | ||
118 | tech.hls.src(source.src); | ||
119 | return tech.hls; | ||
120 | }, | ||
121 | canPlayType: function(type) { | ||
122 | return videojs.HlsSourceHandler.canPlayType(type); | ||
123 | } | ||
124 | }; | ||
125 | }; | ||
126 | |||
127 | videojs.HlsSourceHandler.canPlayType = function(type) { | ||
128 | var mpegurlRE = /^application\/(?:x-|vnd\.apple\.)mpegurl/i; | ||
129 | |||
130 | // favor native HLS support if it's available | ||
131 | if (videojs.Hls.supportsNativeHls) { | ||
132 | return false; | ||
133 | } | ||
134 | return mpegurlRE.test(type); | ||
135 | }; | ||
136 | |||
137 | // register source handlers with the appropriate techs | ||
138 | if (videojs.MediaSource.supportsNativeMediaSources()) { | ||
139 | videojs.getComponent('Html5').registerSourceHandler(videojs.HlsSourceHandler('html5')); | ||
140 | } | ||
141 | if (window.Uint8Array) { | ||
142 | videojs.getComponent('Flash').registerSourceHandler(videojs.HlsSourceHandler('flash')); | ||
143 | } | ||
144 | |||
145 | // the desired length of video to maintain in the buffer, in seconds | ||
146 | videojs.Hls.GOAL_BUFFER_LENGTH = 30; | ||
147 | |||
148 | videojs.HlsHandler.prototype.src = function(src) { | ||
149 | var oldMediaPlaylist; | ||
150 | |||
151 | // do nothing if the src is falsey | ||
152 | if (!src) { | ||
153 | return; | ||
154 | } | ||
155 | |||
156 | this.mediaSource = new videojs.MediaSource({ mode: this.mode_ }); | ||
157 | |||
158 | // load the MediaSource into the player | ||
159 | this.mediaSource.addEventListener('sourceopen', this.handleSourceOpen.bind(this)); | ||
160 | |||
161 | this.options_ = {}; | ||
162 | if (this.source_.withCredentials !== undefined) { | ||
163 | this.options_.withCredentials = this.source_.withCredentials; | ||
164 | } else if (videojs.options.hls) { | ||
165 | this.options_.withCredentials = videojs.options.hls.withCredentials; | ||
166 | } | ||
167 | this.playlists = new videojs.Hls.PlaylistLoader(this.source_.src, this.options_.withCredentials); | ||
168 | |||
169 | this.tech_.one('canplay', this.setupFirstPlay.bind(this)); | ||
170 | |||
171 | this.playlists.on('loadedmetadata', function() { | ||
172 | oldMediaPlaylist = this.playlists.media(); | ||
173 | |||
174 | // if this isn't a live video and preload permits, start | ||
175 | // downloading segments | ||
176 | if (oldMediaPlaylist.endList && | ||
177 | this.tech_.preload() !== 'metadata' && | ||
178 | this.tech_.preload() !== 'none') { | ||
179 | this.loadingState_ = 'segments'; | ||
180 | } | ||
181 | |||
182 | this.setupSourceBuffer_(); | ||
183 | this.setupFirstPlay(); | ||
184 | this.fillBuffer(); | ||
185 | this.tech_.trigger('loadedmetadata'); | ||
186 | }.bind(this)); | ||
187 | |||
188 | this.playlists.on('error', function() { | ||
189 | this.blacklistCurrentPlaylist_(this.playlists.error); | ||
190 | }.bind(this)); | ||
191 | |||
192 | this.playlists.on('loadedplaylist', function() { | ||
193 | var updatedPlaylist = this.playlists.media(), seekable; | ||
194 | |||
195 | if (!updatedPlaylist) { | ||
196 | // select the initial variant | ||
197 | this.playlists.media(this.selectPlaylist()); | ||
198 | return; | ||
199 | } | ||
200 | |||
201 | this.updateDuration(this.playlists.media()); | ||
202 | |||
203 | // update seekable | ||
204 | seekable = this.seekable(); | ||
205 | if (this.duration() === Infinity && | ||
206 | seekable.length !== 0) { | ||
207 | this.mediaSource.addSeekableRange_(seekable.start(0), seekable.end(0)); | ||
208 | } | ||
209 | |||
210 | oldMediaPlaylist = updatedPlaylist; | ||
211 | }.bind(this)); | ||
212 | |||
213 | this.playlists.on('mediachange', function() { | ||
214 | this.tech_.trigger({ | ||
215 | type: 'mediachange', | ||
216 | bubbles: true | ||
217 | }); | ||
218 | }.bind(this)); | ||
219 | |||
220 | // do nothing if the tech has been disposed already | ||
221 | // this can occur if someone sets the src in player.ready(), for instance | ||
222 | if (!this.tech_.el()) { | ||
223 | return; | ||
224 | } | ||
225 | |||
226 | this.tech_.src(videojs.URL.createObjectURL(this.mediaSource)); | ||
227 | }; | ||
228 | |||
229 | videojs.HlsHandler.prototype.handleSourceOpen = function() { | ||
230 | // Only attempt to create the source buffer if none already exist. | ||
231 | // handleSourceOpen is also called when we are "re-opening" a source buffer | ||
232 | // after `endOfStream` has been called (in response to a seek for instance) | ||
233 | if (!this.sourceBuffer) { | ||
234 | this.setupSourceBuffer_(); | ||
235 | } | ||
236 | |||
237 | // if autoplay is enabled, begin playback. This is duplicative of | ||
238 | // code in video.js but is required because play() must be invoked | ||
239 | // *after* the media source has opened. | ||
240 | // NOTE: moving this invocation of play() after | ||
241 | // sourceBuffer.appendBuffer() below caused live streams with | ||
242 | // autoplay to stall | ||
243 | if (this.tech_.autoplay()) { | ||
244 | this.play(); | ||
245 | } | ||
246 | }; | ||
247 | |||
248 | // Search for a likely end time for the segment that was just appened | 36 | // Search for a likely end time for the segment that was just appened |
249 | // based on the state of the `buffered` property before and after the | 37 | // based on the state of the `buffered` property before and after the |
250 | // append. | 38 | // append. |
251 | // If we found only one such uncommon end-point return it. | 39 | // If we found only one such uncommon end-point return it. |
252 | videojs.Hls.findSoleUncommonTimeRangesEnd_ = function(original, update) { | 40 | Hls.findSoleUncommonTimeRangesEnd_ = function(original, update) { |
253 | var | 41 | let i; |
254 | i, start, end, | 42 | let start; |
255 | result = [], | 43 | let end; |
256 | edges = [], | 44 | let result = []; |
257 | // In order to qualify as a possible candidate, the end point must: | 45 | let edges = []; |
258 | // 1) Not have already existed in the `original` ranges | 46 | |
259 | // 2) Not result from the shrinking of a range that already existed | 47 | // In order to qualify as a possible candidate, the end point must: |
260 | // in the `original` ranges | 48 | // 1) Not have already existed in the `original` ranges |
261 | // 3) Not be contained inside of a range that existed in `original` | 49 | // 2) Not result from the shrinking of a range that already existed |
262 | overlapsCurrentEnd = function(span) { | 50 | // in the `original` ranges |
263 | return (span[0] <= end && span[1] >= end); | 51 | // 3) Not be contained inside of a range that existed in `original` |
264 | }; | 52 | let overlapsCurrentEnd = function(span) { |
53 | return (span[0] <= end && span[1] >= end); | ||
54 | }; | ||
265 | 55 | ||
266 | if (original) { | 56 | if (original) { |
267 | // Save all the edges in the `original` TimeRanges object | 57 | // Save all the edges in the `original` TimeRanges object |
... | @@ -299,6 +89,137 @@ videojs.Hls.findSoleUncommonTimeRangesEnd_ = function(original, update) { | ... | @@ -299,6 +89,137 @@ videojs.Hls.findSoleUncommonTimeRangesEnd_ = function(original, update) { |
299 | }; | 89 | }; |
300 | 90 | ||
301 | /** | 91 | /** |
92 | * Whether the browser has built-in HLS support. | ||
93 | */ | ||
94 | Hls.supportsNativeHls = (function() { | ||
95 | let video = document.createElement('video'); | ||
96 | let xMpegUrl; | ||
97 | let vndMpeg; | ||
98 | |||
99 | // native HLS is definitely not supported if HTML5 video isn't | ||
100 | if (!videojs.getComponent('Html5').isSupported()) { | ||
101 | return false; | ||
102 | } | ||
103 | |||
104 | xMpegUrl = video.canPlayType('application/x-mpegURL'); | ||
105 | vndMpeg = video.canPlayType('application/vnd.apple.mpegURL'); | ||
106 | return (/probably|maybe/).test(xMpegUrl) || | ||
107 | (/probably|maybe/).test(vndMpeg); | ||
108 | }()); | ||
109 | |||
110 | // HLS is a source handler, not a tech. Make sure attempts to use it | ||
111 | // as one do not cause exceptions. | ||
112 | Hls.isSupported = function() { | ||
113 | return videojs.log.warn('HLS is no longer a tech. Please remove it from ' + | ||
114 | 'your player\'s techOrder.'); | ||
115 | }; | ||
116 | |||
117 | /** | ||
118 | * A comparator function to sort two playlist object by bandwidth. | ||
119 | * @param left {object} a media playlist object | ||
120 | * @param right {object} a media playlist object | ||
121 | * @return {number} Greater than zero if the bandwidth attribute of | ||
122 | * left is greater than the corresponding attribute of right. Less | ||
123 | * than zero if the bandwidth of right is greater than left and | ||
124 | * exactly zero if the two are equal. | ||
125 | */ | ||
126 | Hls.comparePlaylistBandwidth = function(left, right) { | ||
127 | let leftBandwidth; | ||
128 | let rightBandwidth; | ||
129 | |||
130 | if (left.attributes && left.attributes.BANDWIDTH) { | ||
131 | leftBandwidth = left.attributes.BANDWIDTH; | ||
132 | } | ||
133 | leftBandwidth = leftBandwidth || window.Number.MAX_VALUE; | ||
134 | if (right.attributes && right.attributes.BANDWIDTH) { | ||
135 | rightBandwidth = right.attributes.BANDWIDTH; | ||
136 | } | ||
137 | rightBandwidth = rightBandwidth || window.Number.MAX_VALUE; | ||
138 | |||
139 | return leftBandwidth - rightBandwidth; | ||
140 | }; | ||
141 | |||
142 | /** | ||
143 | * A comparator function to sort two playlist object by resolution (width). | ||
144 | * @param left {object} a media playlist object | ||
145 | * @param right {object} a media playlist object | ||
146 | * @return {number} Greater than zero if the resolution.width attribute of | ||
147 | * left is greater than the corresponding attribute of right. Less | ||
148 | * than zero if the resolution.width of right is greater than left and | ||
149 | * exactly zero if the two are equal. | ||
150 | */ | ||
151 | Hls.comparePlaylistResolution = function(left, right) { | ||
152 | let leftWidth; | ||
153 | let rightWidth; | ||
154 | |||
155 | if (left.attributes && | ||
156 | left.attributes.RESOLUTION && | ||
157 | left.attributes.RESOLUTION.width) { | ||
158 | leftWidth = left.attributes.RESOLUTION.width; | ||
159 | } | ||
160 | |||
161 | leftWidth = leftWidth || window.Number.MAX_VALUE; | ||
162 | |||
163 | if (right.attributes && | ||
164 | right.attributes.RESOLUTION && | ||
165 | right.attributes.RESOLUTION.width) { | ||
166 | rightWidth = right.attributes.RESOLUTION.width; | ||
167 | } | ||
168 | |||
169 | rightWidth = rightWidth || window.Number.MAX_VALUE; | ||
170 | |||
171 | // NOTE - Fallback to bandwidth sort as appropriate in cases where multiple renditions | ||
172 | // have the same media dimensions/ resolution | ||
173 | if (leftWidth === rightWidth && | ||
174 | left.attributes.BANDWIDTH && | ||
175 | right.attributes.BANDWIDTH) { | ||
176 | return left.attributes.BANDWIDTH - right.attributes.BANDWIDTH; | ||
177 | } | ||
178 | return leftWidth - rightWidth; | ||
179 | }; | ||
180 | |||
181 | // A fudge factor to apply to advertised playlist bitrates to account for | ||
182 | // temporary flucations in client bandwidth | ||
183 | const bandwidthVariance = 1.2; | ||
184 | |||
185 | // 5 minute blacklist | ||
186 | const blacklistDuration = 5 * 60 * 1000; | ||
187 | |||
188 | // Fudge factor to account for TimeRanges rounding | ||
189 | const TIME_FUDGE_FACTOR = 1 / 30; | ||
190 | const Component = videojs.getComponent('Component'); | ||
191 | |||
192 | // The amount of time to wait between checking the state of the buffer | ||
193 | const bufferCheckInterval = 500; | ||
194 | |||
195 | // returns true if a key has failed to download within a certain amount of retries | ||
196 | const keyFailed = function(key) { | ||
197 | return key.retries && key.retries >= 2; | ||
198 | }; | ||
199 | |||
200 | /** | ||
201 | * Returns the CSS value for the specified property on an element | ||
202 | * using `getComputedStyle`. Firefox has a long-standing issue where | ||
203 | * getComputedStyle() may return null when running in an iframe with | ||
204 | * `display: none`. | ||
205 | * @see https://bugzilla.mozilla.org/show_bug.cgi?id=548397 | ||
206 | */ | ||
207 | const safeGetComputedStyle = function(el, property) { | ||
208 | let result; | ||
209 | |||
210 | if (!el) { | ||
211 | return ''; | ||
212 | } | ||
213 | |||
214 | result = getComputedStyle(el); | ||
215 | if (!result) { | ||
216 | return ''; | ||
217 | } | ||
218 | |||
219 | return result[property]; | ||
220 | }; | ||
221 | |||
222 | /** | ||
302 | * Updates segment with information about its end-point in time and, optionally, | 223 | * Updates segment with information about its end-point in time and, optionally, |
303 | * the segment duration if we have enough information to determine a segment duration | 224 | * the segment duration if we have enough information to determine a segment duration |
304 | * accurately. | 225 | * accurately. |
... | @@ -306,17 +227,13 @@ videojs.Hls.findSoleUncommonTimeRangesEnd_ = function(original, update) { | ... | @@ -306,17 +227,13 @@ videojs.Hls.findSoleUncommonTimeRangesEnd_ = function(original, update) { |
306 | * @param segmentIndex {number} the index of segment we last appended | 227 | * @param segmentIndex {number} the index of segment we last appended |
307 | * @param segmentEnd {number} the known of the segment referenced by segmentIndex | 228 | * @param segmentEnd {number} the known of the segment referenced by segmentIndex |
308 | */ | 229 | */ |
309 | videojs.HlsHandler.prototype.updateSegmentMetadata_ = function(playlist, segmentIndex, segmentEnd) { | 230 | const updateSegmentMetadata = function(playlist, segmentIndex, segmentEnd) { |
310 | var | ||
311 | segment, | ||
312 | previousSegment; | ||
313 | |||
314 | if (!playlist) { | 231 | if (!playlist) { |
315 | return; | 232 | return; |
316 | } | 233 | } |
317 | 234 | ||
318 | segment = playlist.segments[segmentIndex]; | 235 | let segment = playlist.segments[segmentIndex]; |
319 | previousSegment = playlist.segments[segmentIndex - 1]; | 236 | let previousSegment = playlist.segments[segmentIndex - 1]; |
320 | 237 | ||
321 | if (segmentEnd && segment) { | 238 | if (segmentEnd && segment) { |
322 | segment.end = segmentEnd; | 239 | segment.end = segmentEnd; |
... | @@ -336,36 +253,34 @@ videojs.HlsHandler.prototype.updateSegmentMetadata_ = function(playlist, segment | ... | @@ -336,36 +253,34 @@ videojs.HlsHandler.prototype.updateSegmentMetadata_ = function(playlist, segment |
336 | * Determines if we should call endOfStream on the media source based on the state | 253 | * Determines if we should call endOfStream on the media source based on the state |
337 | * of the buffer or if appened segment was the final segment in the playlist. | 254 | * of the buffer or if appened segment was the final segment in the playlist. |
338 | * @param playlist {object} a media playlist object | 255 | * @param playlist {object} a media playlist object |
256 | * @param mediaSource {object} the MediaSource object | ||
339 | * @param segmentIndex {number} the index of segment we last appended | 257 | * @param segmentIndex {number} the index of segment we last appended |
340 | * @param currentBuffered {object} the buffered region that currentTime resides in | 258 | * @param currentBuffered {object} the buffered region that currentTime resides in |
341 | * @return {boolean} whether the calling function should call endOfStream on the MediaSource | 259 | * @return {boolean} whether the calling function should call endOfStream on the MediaSource |
342 | */ | 260 | */ |
343 | videojs.HlsHandler.prototype.isEndOfStream_ = function(playlist, segmentIndex, currentBuffered) { | 261 | const detectEndOfStream = function(playlist, mediaSource, segmentIndex, currentBuffered) { |
344 | var | ||
345 | segments = playlist.segments, | ||
346 | appendedLastSegment, | ||
347 | bufferedToEnd; | ||
348 | |||
349 | if (!playlist) { | 262 | if (!playlist) { |
350 | return false; | 263 | return false; |
351 | } | 264 | } |
352 | 265 | ||
266 | let segments = playlist.segments; | ||
267 | |||
353 | // determine a few boolean values to help make the branch below easier | 268 | // determine a few boolean values to help make the branch below easier |
354 | // to read | 269 | // to read |
355 | appendedLastSegment = (segmentIndex === segments.length - 1); | 270 | let appendedLastSegment = (segmentIndex === segments.length - 1); |
356 | bufferedToEnd = (currentBuffered.length && | 271 | let bufferedToEnd = (currentBuffered.length && |
357 | segments[segments.length - 1].end <= currentBuffered.end(0)); | 272 | segments[segments.length - 1].end <= currentBuffered.end(0)); |
358 | 273 | ||
359 | // if we've buffered to the end of the video, we need to call endOfStream | 274 | // if we've buffered to the end of the video, we need to call endOfStream |
360 | // so that MediaSources can trigger the `ended` event when it runs out of | 275 | // so that MediaSources can trigger the `ended` event when it runs out of |
361 | // buffered data instead of waiting for me | 276 | // buffered data instead of waiting for me |
362 | return playlist.endList && | 277 | return playlist.endList && |
363 | this.mediaSource.readyState === 'open' && | 278 | mediaSource.readyState === 'open' && |
364 | (appendedLastSegment || bufferedToEnd); | 279 | (appendedLastSegment || bufferedToEnd); |
365 | }; | 280 | }; |
366 | 281 | ||
367 | var parseCodecs = function(codecs) { | 282 | const parseCodecs = function(codecs) { |
368 | var result = { | 283 | let result = { |
369 | codecCount: 0, | 284 | codecCount: 0, |
370 | videoCodec: null, | 285 | videoCodec: null, |
371 | audioProfile: null | 286 | audioProfile: null |
... | @@ -375,1233 +290,1319 @@ var parseCodecs = function(codecs) { | ... | @@ -375,1233 +290,1319 @@ var parseCodecs = function(codecs) { |
375 | result.codecCount = result.codecCount || 2; | 290 | result.codecCount = result.codecCount || 2; |
376 | 291 | ||
377 | // parse the video codec but ignore the version | 292 | // parse the video codec but ignore the version |
378 | result.videoCodec = /(^|\s|,)+(avc1)[^ ,]*/i.exec(codecs); | 293 | result.videoCodec = (/(^|\s|,)+(avc1)[^ ,]*/i).exec(codecs); |
379 | result.videoCodec = result.videoCodec && result.videoCodec[2]; | 294 | result.videoCodec = result.videoCodec && result.videoCodec[2]; |
380 | 295 | ||
381 | // parse the last field of the audio codec | 296 | // parse the last field of the audio codec |
382 | result.audioProfile = /(^|\s|,)+mp4a.\d+\.(\d+)/i.exec(codecs); | 297 | result.audioProfile = (/(^|\s|,)+mp4a.\d+\.(\d+)/i).exec(codecs); |
383 | result.audioProfile = result.audioProfile && result.audioProfile[2]; | 298 | result.audioProfile = result.audioProfile && result.audioProfile[2]; |
384 | 299 | ||
385 | return result; | 300 | return result; |
386 | }; | 301 | }; |
387 | 302 | ||
388 | /** | 303 | const filterBufferedRanges = function(predicate) { |
389 | * Blacklist playlists that are known to be codec or | 304 | return function(time) { |
390 | * stream-incompatible with the SourceBuffer configuration. For | 305 | let i; |
391 | * instance, Media Source Extensions would cause the video element to | 306 | let ranges = []; |
392 | * stall waiting for video data if you switched from a variant with | 307 | let tech = this.tech_; |
393 | * video and audio to an audio-only one. | 308 | |
394 | * | 309 | // !!The order of the next two assignments is important!! |
395 | * @param media {object} a media playlist compatible with the current | 310 | // `currentTime` must be equal-to or greater-than the start of the |
396 | * set of SourceBuffers. Variants in the current master playlist that | 311 | // buffered range. Flash executes out-of-process so, every value can |
397 | * do not appear to have compatible codec or stream configurations | 312 | // change behind the scenes from line-to-line. By reading `currentTime` |
398 | * will be excluded from the default playlist selection algorithm | 313 | // after `buffered`, we ensure that it is always a current or later |
399 | * indefinitely. | 314 | // value during playback. |
400 | */ | 315 | let buffered = tech.buffered(); |
401 | videojs.HlsHandler.prototype.excludeIncompatibleVariants_ = function(media) { | 316 | |
402 | var | 317 | if (typeof time === 'undefined') { |
403 | master = this.playlists.master, | 318 | time = tech.currentTime(); |
404 | codecCount = 2, | ||
405 | videoCodec = null, | ||
406 | audioProfile = null, | ||
407 | codecs; | ||
408 | |||
409 | if (media.attributes && media.attributes.CODECS) { | ||
410 | codecs = parseCodecs(media.attributes.CODECS); | ||
411 | videoCodec = codecs.videoCodec; | ||
412 | audioProfile = codecs.audioProfile; | ||
413 | codecCount = codecs.codecCount; | ||
414 | } | ||
415 | master.playlists.forEach(function(variant) { | ||
416 | var variantCodecs = { | ||
417 | codecCount: 2, | ||
418 | videoCodec: null, | ||
419 | audioProfile: null | ||
420 | }; | ||
421 | |||
422 | if (variant.attributes && variant.attributes.CODECS) { | ||
423 | variantCodecs = parseCodecs(variant.attributes.CODECS); | ||
424 | } | 319 | } |
425 | 320 | ||
426 | // if the streams differ in the presence or absence of audio or | 321 | // IE 11 has a bug where it will report a the video as fully buffered |
427 | // video, they are incompatible | 322 | // before any data has been loaded. This is a work around where we |
428 | if (variantCodecs.codecCount !== codecCount) { | 323 | // report a fully empty buffer until SourceBuffers have been created |
429 | variant.excludeUntil = Infinity; | 324 | // which is after a segment has been loaded and transmuxed. |
325 | if (!this.mediaSource || | ||
326 | !this.mediaSource.mediaSource_ || | ||
327 | !this.mediaSource.mediaSource_.sourceBuffers.length) { | ||
328 | return videojs.createTimeRanges([]); | ||
430 | } | 329 | } |
431 | 330 | ||
432 | // if h.264 is specified on the current playlist, some flavor of | 331 | if (buffered && buffered.length) { |
433 | // it must be specified on all compatible variants | 332 | // Search for a range containing the play-head |
434 | if (variantCodecs.videoCodec !== videoCodec) { | 333 | for (i = 0; i < buffered.length; i++) { |
435 | variant.excludeUntil = Infinity; | 334 | if (predicate(buffered.start(i), buffered.end(i), time)) { |
436 | } | 335 | ranges.push([buffered.start(i), buffered.end(i)]); |
437 | // HE-AAC ("mp4a.40.5") is incompatible with all other versions of | 336 | } |
438 | // AAC audio in Chrome 46. Don't mix the two. | 337 | } |
439 | if ((variantCodecs.audioProfile === '5' && audioProfile !== '5') || | ||
440 | (audioProfile === '5' && variantCodecs.audioProfile !== '5')) { | ||
441 | variant.excludeUntil = Infinity; | ||
442 | } | 338 | } |
443 | }); | 339 | |
340 | return videojs.createTimeRanges(ranges); | ||
341 | }; | ||
444 | }; | 342 | }; |
445 | 343 | ||
446 | videojs.HlsHandler.prototype.setupSourceBuffer_ = function() { | 344 | export default class HlsHandler extends Component { |
447 | var media = this.playlists.media(), mimeType; | 345 | constructor(tech, options) { |
346 | super(tech); | ||
347 | let _player; | ||
448 | 348 | ||
449 | // wait until a media playlist is available and the Media Source is | 349 | // tech.player() is deprecated but setup a reference to HLS for |
450 | // attached | 350 | // backwards-compatibility |
451 | if (!media || this.mediaSource.readyState !== 'open') { | 351 | if (tech.options_ && tech.options_.playerId) { |
452 | return; | 352 | _player = videojs(tech.options_.playerId); |
453 | } | 353 | if (!_player.hls) { |
354 | Object.defineProperty(_player, 'hls', { | ||
355 | get: () => { | ||
356 | videojs.log.warn('player.hls is deprecated. Use player.tech.hls instead.'); | ||
357 | return this; | ||
358 | } | ||
359 | }); | ||
360 | } | ||
361 | } | ||
362 | this.tech_ = tech; | ||
363 | this.source_ = options.source; | ||
364 | this.mode_ = options.mode; | ||
365 | // the segment info object for a segment that is in the process of | ||
366 | // being downloaded or processed | ||
367 | this.pendingSegment_ = null; | ||
368 | |||
369 | // start playlist selection at a reasonable bandwidth for | ||
370 | // broadband internet | ||
371 | // 0.5 Mbps | ||
372 | this.bandwidth = options.bandwidth || 4194304; | ||
373 | this.bytesReceived = 0; | ||
374 | |||
375 | // loadingState_ tracks how far along the buffering process we | ||
376 | // have been given permission to proceed. There are three possible | ||
377 | // values: | ||
378 | // - none: do not load playlists or segments | ||
379 | // - meta: load playlists but not segments | ||
380 | // - segments: load everything | ||
381 | this.loadingState_ = 'none'; | ||
382 | if (this.tech_.preload() !== 'none') { | ||
383 | this.loadingState_ = 'meta'; | ||
384 | } | ||
385 | |||
386 | // periodically check if new data needs to be downloaded or | ||
387 | // buffered data should be appended to the source buffer | ||
388 | this.startCheckingBuffer_(); | ||
389 | |||
390 | this.on(this.tech_, 'seeking', function() { | ||
391 | this.setCurrentTime(this.tech_.currentTime()); | ||
392 | }); | ||
393 | this.on(this.tech_, 'error', function() { | ||
394 | this.stopCheckingBuffer_(); | ||
395 | }); | ||
454 | 396 | ||
455 | // if the codecs were explicitly specified, pass them along to the | 397 | this.on(this.tech_, 'play', this.play); |
456 | // source buffer | ||
457 | mimeType = 'video/mp2t'; | ||
458 | if (media.attributes && media.attributes.CODECS) { | ||
459 | mimeType += '; codecs="' + media.attributes.CODECS + '"'; | ||
460 | } | 398 | } |
461 | this.sourceBuffer = this.mediaSource.addSourceBuffer(mimeType); | 399 | src(src) { |
400 | let oldMediaPlaylist; | ||
462 | 401 | ||
463 | // exclude any incompatible variant streams from future playlist | 402 | // do nothing if the src is falsey |
464 | // selection | 403 | if (!src) { |
465 | this.excludeIncompatibleVariants_(media); | 404 | return; |
405 | } | ||
466 | 406 | ||
467 | // transition the sourcebuffer to the ended state if we've hit the end of | 407 | this.mediaSource = new videojs.MediaSource({ mode: this.mode_ }); |
468 | // the playlist | ||
469 | this.sourceBuffer.addEventListener('updateend', this.updateEndHandler_.bind(this)); | ||
470 | }; | ||
471 | 408 | ||
472 | /** | 409 | // load the MediaSource into the player |
473 | * Seek to the latest media position if this is a live video and the | 410 | this.mediaSource.addEventListener('sourceopen', this.handleSourceOpen.bind(this)); |
474 | * player and video are loaded and initialized. | ||
475 | */ | ||
476 | videojs.HlsHandler.prototype.setupFirstPlay = function() { | ||
477 | var seekable, media; | ||
478 | media = this.playlists.media(); | ||
479 | 411 | ||
412 | this.options_ = {}; | ||
413 | if (typeof this.source_.withCredentials !== 'undefined') { | ||
414 | this.options_.withCredentials = this.source_.withCredentials; | ||
415 | } else if (videojs.options.hls) { | ||
416 | this.options_.withCredentials = videojs.options.hls.withCredentials; | ||
417 | } | ||
418 | this.playlists = new Hls.PlaylistLoader(this.source_.src, | ||
419 | this.options_.withCredentials); | ||
480 | 420 | ||
481 | // check that everything is ready to begin buffering | 421 | this.tech_.one('canplay', this.setupFirstPlay.bind(this)); |
482 | 422 | ||
483 | // 1) the video is a live stream of unknown duration | 423 | this.playlists.on('loadedmetadata', () => { |
484 | if (this.duration() === Infinity && | 424 | oldMediaPlaylist = this.playlists.media(); |
485 | 425 | ||
486 | // 2) the player has not played before and is not paused | 426 | // if this isn't a live video and preload permits, start |
487 | this.tech_.played().length === 0 && | 427 | // downloading segments |
488 | !this.tech_.paused() && | 428 | if (oldMediaPlaylist.endList && |
429 | this.tech_.preload() !== 'metadata' && | ||
430 | this.tech_.preload() !== 'none') { | ||
431 | this.loadingState_ = 'segments'; | ||
432 | } | ||
489 | 433 | ||
490 | // 3) the Media Source and Source Buffers are ready | 434 | this.setupSourceBuffer_(); |
491 | this.sourceBuffer && | 435 | this.setupFirstPlay(); |
436 | this.fillBuffer(); | ||
437 | this.tech_.trigger('loadedmetadata'); | ||
438 | }); | ||
492 | 439 | ||
493 | // 4) the active media playlist is available | 440 | this.playlists.on('error', () => { |
494 | media && | 441 | this.blacklistCurrentPlaylist_(this.playlists.error); |
442 | }); | ||
495 | 443 | ||
496 | // 5) the video element or flash player is in a readyState of | 444 | this.playlists.on('loadedplaylist', () => { |
497 | // at least HAVE_FUTURE_DATA | 445 | let updatedPlaylist = this.playlists.media(); |
498 | this.tech_.readyState() >= 1) { | 446 | let seekable; |
499 | 447 | ||
500 | // trigger the playlist loader to start "expired time"-tracking | 448 | if (!updatedPlaylist) { |
501 | this.playlists.trigger('firstplay'); | 449 | // select the initial variant |
450 | this.playlists.media(this.selectPlaylist()); | ||
451 | return; | ||
452 | } | ||
502 | 453 | ||
503 | // seek to the latest media position for live videos | 454 | this.updateDuration(this.playlists.media()); |
504 | seekable = this.seekable(); | ||
505 | if (seekable.length) { | ||
506 | this.tech_.setCurrentTime(seekable.end(0)); | ||
507 | } | ||
508 | } | ||
509 | }; | ||
510 | 455 | ||
511 | /** | 456 | // update seekable |
512 | * Begin playing the video. | 457 | seekable = this.seekable(); |
513 | */ | 458 | if (this.duration() === Infinity && |
514 | videojs.HlsHandler.prototype.play = function() { | 459 | seekable.length !== 0) { |
515 | this.loadingState_ = 'segments'; | 460 | this.mediaSource.addSeekableRange_(seekable.start(0), seekable.end(0)); |
461 | } | ||
516 | 462 | ||
517 | if (this.tech_.ended()) { | 463 | oldMediaPlaylist = updatedPlaylist; |
518 | this.tech_.setCurrentTime(0); | 464 | }); |
519 | } | ||
520 | 465 | ||
521 | if (this.tech_.played().length === 0) { | 466 | this.playlists.on('mediachange', () => { |
522 | return this.setupFirstPlay(); | 467 | this.tech_.trigger({ |
468 | type: 'mediachange', | ||
469 | bubbles: true | ||
470 | }); | ||
471 | }); | ||
472 | |||
473 | // do nothing if the tech has been disposed already | ||
474 | // this can occur if someone sets the src in player.ready(), for instance | ||
475 | if (!this.tech_.el()) { | ||
476 | return; | ||
477 | } | ||
478 | |||
479 | this.tech_.src(videojs.URL.createObjectURL(this.mediaSource)); | ||
523 | } | 480 | } |
481 | handleSourceOpen() { | ||
482 | // Only attempt to create the source buffer if none already exist. | ||
483 | // handleSourceOpen is also called when we are "re-opening" a source buffer | ||
484 | // after `endOfStream` has been called (in response to a seek for instance) | ||
485 | if (!this.sourceBuffer) { | ||
486 | this.setupSourceBuffer_(); | ||
487 | } | ||
524 | 488 | ||
525 | // if the viewer has paused and we fell out of the live window, | 489 | // if autoplay is enabled, begin playback. This is duplicative of |
526 | // seek forward to the earliest available position | 490 | // code in video.js but is required because play() must be invoked |
527 | if (this.duration() === Infinity) { | 491 | // *after* the media source has opened. |
528 | if (this.tech_.currentTime() < this.seekable().start(0)) { | 492 | // NOTE: moving this invocation of play() after |
529 | this.tech_.setCurrentTime(this.seekable().start(0)); | 493 | // sourceBuffer.appendBuffer() below caused live streams with |
494 | // autoplay to stall | ||
495 | if (this.tech_.autoplay()) { | ||
496 | this.play(); | ||
530 | } | 497 | } |
531 | } | 498 | } |
532 | }; | ||
533 | 499 | ||
534 | videojs.HlsHandler.prototype.setCurrentTime = function(currentTime) { | 500 | /** |
535 | var | 501 | * Blacklist playlists that are known to be codec or |
536 | buffered = this.findBufferedRange_(); | 502 | * stream-incompatible with the SourceBuffer configuration. For |
503 | * instance, Media Source Extensions would cause the video element to | ||
504 | * stall waiting for video data if you switched from a variant with | ||
505 | * video and audio to an audio-only one. | ||
506 | * | ||
507 | * @param media {object} a media playlist compatible with the current | ||
508 | * set of SourceBuffers. Variants in the current master playlist that | ||
509 | * do not appear to have compatible codec or stream configurations | ||
510 | * will be excluded from the default playlist selection algorithm | ||
511 | * indefinitely. | ||
512 | */ | ||
513 | excludeIncompatibleVariants_(media) { | ||
514 | let master = this.playlists.master; | ||
515 | let codecCount = 2; | ||
516 | let videoCodec = null; | ||
517 | let audioProfile = null; | ||
518 | let codecs; | ||
519 | |||
520 | if (media.attributes && media.attributes.CODECS) { | ||
521 | codecs = parseCodecs(media.attributes.CODECS); | ||
522 | videoCodec = codecs.videoCodec; | ||
523 | audioProfile = codecs.audioProfile; | ||
524 | codecCount = codecs.codecCount; | ||
525 | } | ||
526 | master.playlists.forEach(function(variant) { | ||
527 | let variantCodecs = { | ||
528 | codecCount: 2, | ||
529 | videoCodec: null, | ||
530 | audioProfile: null | ||
531 | }; | ||
532 | |||
533 | if (variant.attributes && variant.attributes.CODECS) { | ||
534 | variantCodecs = parseCodecs(variant.attributes.CODECS); | ||
535 | } | ||
537 | 536 | ||
538 | if (!(this.playlists && this.playlists.media())) { | 537 | // if the streams differ in the presence or absence of audio or |
539 | // return immediately if the metadata is not ready yet | 538 | // video, they are incompatible |
540 | return 0; | 539 | if (variantCodecs.codecCount !== codecCount) { |
541 | } | 540 | variant.excludeUntil = Infinity; |
541 | } | ||
542 | 542 | ||
543 | // it's clearly an edge-case but don't thrown an error if asked to | 543 | // if h.264 is specified on the current playlist, some flavor of |
544 | // seek within an empty playlist | 544 | // it must be specified on all compatible variants |
545 | if (!this.playlists.media().segments) { | 545 | if (variantCodecs.videoCodec !== videoCodec) { |
546 | return 0; | 546 | variant.excludeUntil = Infinity; |
547 | } | ||
548 | // HE-AAC ("mp4a.40.5") is incompatible with all other versions of | ||
549 | // AAC audio in Chrome 46. Don't mix the two. | ||
550 | if ((variantCodecs.audioProfile === '5' && audioProfile !== '5') || | ||
551 | (audioProfile === '5' && variantCodecs.audioProfile !== '5')) { | ||
552 | variant.excludeUntil = Infinity; | ||
553 | } | ||
554 | }); | ||
547 | } | 555 | } |
548 | 556 | ||
549 | // if the seek location is already buffered, continue buffering as | 557 | setupSourceBuffer_() { |
550 | // usual | 558 | let media = this.playlists.media(); |
551 | if (buffered && buffered.length) { | 559 | let mimeType; |
552 | return currentTime; | ||
553 | } | ||
554 | 560 | ||
555 | // if we are in the middle of appending a segment, let it finish up | 561 | // wait until a media playlist is available and the Media Source is |
556 | if (this.pendingSegment_ && this.pendingSegment_.buffered) { | 562 | // attached |
557 | return currentTime; | 563 | if (!media || this.mediaSource.readyState !== 'open') { |
558 | } | 564 | return; |
565 | } | ||
559 | 566 | ||
560 | this.lastSegmentLoaded_ = null; | 567 | // if the codecs were explicitly specified, pass them along to the |
568 | // source buffer | ||
569 | mimeType = 'video/mp2t'; | ||
570 | if (media.attributes && media.attributes.CODECS) { | ||
571 | mimeType += '; codecs="' + media.attributes.CODECS + '"'; | ||
572 | } | ||
573 | this.sourceBuffer = this.mediaSource.addSourceBuffer(mimeType); | ||
561 | 574 | ||
562 | // cancel outstanding requests and buffer appends | 575 | // exclude any incompatible variant streams from future playlist |
563 | this.cancelSegmentXhr(); | 576 | // selection |
577 | this.excludeIncompatibleVariants_(media); | ||
564 | 578 | ||
565 | // abort outstanding key requests, if necessary | 579 | // transition the sourcebuffer to the ended state if we've hit the end of |
566 | if (this.keyXhr_) { | 580 | // the playlist |
567 | this.keyXhr_.aborted = true; | 581 | this.sourceBuffer.addEventListener('updateend', this.updateEndHandler_.bind(this)); |
568 | this.cancelKeyXhr(); | ||
569 | } | 582 | } |
570 | 583 | ||
571 | // begin filling the buffer at the new position | 584 | /** |
572 | this.fillBuffer(this.playlists.getMediaIndexForTime_(currentTime)); | 585 | * Seek to the latest media position if this is a live video and the |
573 | }; | 586 | * player and video are loaded and initialized. |
587 | */ | ||
588 | setupFirstPlay() { | ||
589 | let seekable; | ||
590 | let media = this.playlists.media(); | ||
574 | 591 | ||
575 | videojs.HlsHandler.prototype.duration = function() { | 592 | // check that everything is ready to begin buffering |
576 | var | ||
577 | playlists = this.playlists; | ||
578 | 593 | ||
579 | if (!playlists) { | 594 | // 1) the video is a live stream of unknown duration |
580 | return 0; | 595 | if (this.duration() === Infinity && |
581 | } | ||
582 | 596 | ||
583 | if (this.mediaSource) { | 597 | // 2) the player has not played before and is not paused |
584 | return this.mediaSource.duration; | 598 | this.tech_.played().length === 0 && |
585 | } | 599 | !this.tech_.paused() && |
586 | 600 | ||
587 | return videojs.Hls.Playlist.duration(playlists.media()); | 601 | // 3) the Media Source and Source Buffers are ready |
588 | }; | 602 | this.sourceBuffer && |
589 | 603 | ||
590 | videojs.HlsHandler.prototype.seekable = function() { | 604 | // 4) the active media playlist is available |
591 | var media, seekable; | 605 | media && |
592 | 606 | ||
593 | if (!this.playlists) { | 607 | // 5) the video element or flash player is in a readyState of |
594 | return videojs.createTimeRanges(); | 608 | // at least HAVE_FUTURE_DATA |
595 | } | 609 | this.tech_.readyState() >= 1) { |
596 | media = this.playlists.media(); | ||
597 | if (!media) { | ||
598 | return videojs.createTimeRanges(); | ||
599 | } | ||
600 | 610 | ||
601 | seekable = videojs.Hls.Playlist.seekable(media); | 611 | // trigger the playlist loader to start "expired time"-tracking |
602 | if (seekable.length === 0) { | 612 | this.playlists.trigger('firstplay'); |
603 | return seekable; | ||
604 | } | ||
605 | 613 | ||
606 | // if the seekable start is zero, it may be because the player has | 614 | // seek to the latest media position for live videos |
607 | // been paused for a long time and stopped buffering. in that case, | 615 | seekable = this.seekable(); |
608 | // fall back to the playlist loader's running estimate of expired | 616 | if (seekable.length) { |
609 | // time | 617 | this.tech_.setCurrentTime(seekable.end(0)); |
610 | if (seekable.start(0) === 0) { | 618 | } |
611 | return videojs.createTimeRanges([[ | 619 | } |
612 | this.playlists.expired_, | ||
613 | this.playlists.expired_ + seekable.end(0) | ||
614 | ]]); | ||
615 | } | 620 | } |
616 | 621 | ||
617 | // seekable has been calculated based on buffering video data so it | 622 | /** |
618 | // can be returned directly | 623 | * Begin playing the video. |
619 | return seekable; | 624 | */ |
620 | }; | 625 | play() { |
626 | this.loadingState_ = 'segments'; | ||
621 | 627 | ||
622 | /** | 628 | if (this.tech_.ended()) { |
623 | * Update the player duration | 629 | this.tech_.setCurrentTime(0); |
624 | */ | 630 | } |
625 | videojs.HlsHandler.prototype.updateDuration = function(playlist) { | ||
626 | var oldDuration = this.mediaSource.duration, | ||
627 | newDuration = videojs.Hls.Playlist.duration(playlist), | ||
628 | buffered = this.tech_.buffered(), | ||
629 | setDuration = function() { | ||
630 | this.mediaSource.duration = newDuration; | ||
631 | this.tech_.trigger('durationchange'); | ||
632 | 631 | ||
633 | this.mediaSource.removeEventListener('sourceopen', setDuration); | 632 | if (this.tech_.played().length === 0) { |
634 | }.bind(this); | 633 | return this.setupFirstPlay(); |
634 | } | ||
635 | 635 | ||
636 | if (buffered.length > 0) { | 636 | // if the viewer has paused and we fell out of the live window, |
637 | newDuration = Math.max(newDuration, buffered.end(buffered.length - 1)); | 637 | // seek forward to the earliest available position |
638 | if (this.duration() === Infinity) { | ||
639 | if (this.tech_.currentTime() < this.seekable().start(0)) { | ||
640 | this.tech_.setCurrentTime(this.seekable().start(0)); | ||
641 | } | ||
642 | } | ||
638 | } | 643 | } |
639 | 644 | ||
640 | // if the duration has changed, invalidate the cached value | 645 | setCurrentTime(currentTime) { |
641 | if (oldDuration !== newDuration) { | 646 | let buffered = this.findBufferedRange_(); |
642 | // update the duration | 647 | |
643 | if (this.mediaSource.readyState !== 'open') { | 648 | if (!(this.playlists && this.playlists.media())) { |
644 | this.mediaSource.addEventListener('sourceopen', setDuration); | 649 | // return immediately if the metadata is not ready yet |
645 | } else if (!this.sourceBuffer || !this.sourceBuffer.updating) { | 650 | return 0; |
646 | this.mediaSource.duration = newDuration; | ||
647 | this.tech_.trigger('durationchange'); | ||
648 | } | 651 | } |
649 | } | ||
650 | }; | ||
651 | 652 | ||
652 | /** | 653 | // it's clearly an edge-case but don't thrown an error if asked to |
653 | * Clear all buffers and reset any state relevant to the current | 654 | // seek within an empty playlist |
654 | * source. After this function is called, the tech should be in a | 655 | if (!this.playlists.media().segments) { |
655 | * state suitable for switching to a different video. | 656 | return 0; |
656 | */ | 657 | } |
657 | videojs.HlsHandler.prototype.resetSrc_ = function() { | ||
658 | this.cancelSegmentXhr(); | ||
659 | this.cancelKeyXhr(); | ||
660 | 658 | ||
661 | if (this.sourceBuffer && this.mediaSource.readyState === 'open') { | 659 | // if the seek location is already buffered, continue buffering as |
662 | this.sourceBuffer.abort(); | 660 | // usual |
663 | } | 661 | if (buffered && buffered.length) { |
664 | }; | 662 | return currentTime; |
663 | } | ||
665 | 664 | ||
666 | videojs.HlsHandler.prototype.cancelKeyXhr = function() { | 665 | // if we are in the middle of appending a segment, let it finish up |
667 | if (this.keyXhr_) { | 666 | if (this.pendingSegment_ && this.pendingSegment_.buffered) { |
668 | this.keyXhr_.onreadystatechange = null; | 667 | return currentTime; |
669 | this.keyXhr_.abort(); | 668 | } |
670 | this.keyXhr_ = null; | ||
671 | } | ||
672 | }; | ||
673 | 669 | ||
674 | videojs.HlsHandler.prototype.cancelSegmentXhr = function() { | 670 | this.lastSegmentLoaded_ = null; |
675 | if (this.segmentXhr_) { | ||
676 | // Prevent error handler from running. | ||
677 | this.segmentXhr_.onreadystatechange = null; | ||
678 | this.segmentXhr_.abort(); | ||
679 | this.segmentXhr_ = null; | ||
680 | } | ||
681 | 671 | ||
682 | // clear out the segment being processed | 672 | // cancel outstanding requests and buffer appends |
683 | this.pendingSegment_ = null; | 673 | this.cancelSegmentXhr(); |
684 | }; | ||
685 | 674 | ||
686 | /** | 675 | // abort outstanding key requests, if necessary |
687 | * Returns the CSS value for the specified property on an element | 676 | if (this.keyXhr_) { |
688 | * using `getComputedStyle`. Firefox has a long-standing issue where | 677 | this.keyXhr_.aborted = true; |
689 | * getComputedStyle() may return null when running in an iframe with | 678 | this.cancelKeyXhr(); |
690 | * `display: none`. | 679 | } |
691 | * @see https://bugzilla.mozilla.org/show_bug.cgi?id=548397 | ||
692 | */ | ||
693 | safeGetComputedStyle = function(el, property) { | ||
694 | var result; | ||
695 | if (!el) { | ||
696 | return ''; | ||
697 | } | ||
698 | 680 | ||
699 | result = getComputedStyle(el); | 681 | // begin filling the buffer at the new position |
700 | if (!result) { | 682 | this.fillBuffer(this.playlists.getMediaIndexForTime_(currentTime)); |
701 | return ''; | ||
702 | } | 683 | } |
703 | 684 | ||
704 | return result[property]; | 685 | duration() { |
705 | }; | 686 | let playlists = this.playlists; |
706 | 687 | ||
707 | /** | 688 | if (!playlists) { |
708 | * Abort all outstanding work and cleanup. | 689 | return 0; |
709 | */ | 690 | } |
710 | videojs.HlsHandler.prototype.dispose = function() { | ||
711 | this.stopCheckingBuffer_(); | ||
712 | 691 | ||
713 | if (this.playlists) { | 692 | if (this.mediaSource) { |
714 | this.playlists.dispose(); | 693 | return this.mediaSource.duration; |
694 | } | ||
695 | |||
696 | return Hls.Playlist.duration(playlists.media()); | ||
715 | } | 697 | } |
716 | 698 | ||
717 | this.resetSrc_(); | 699 | seekable() { |
718 | Component.prototype.dispose.call(this); | 700 | let media; |
719 | }; | 701 | let seekable; |
720 | 702 | ||
721 | /** | 703 | if (!this.playlists) { |
722 | * Chooses the appropriate media playlist based on the current | 704 | return videojs.createTimeRanges(); |
723 | * bandwidth estimate and the player size. | 705 | } |
724 | * @return the highest bitrate playlist less than the currently detected | 706 | media = this.playlists.media(); |
725 | * bandwidth, accounting for some amount of bandwidth variance | 707 | if (!media) { |
726 | */ | 708 | return videojs.createTimeRanges(); |
727 | videojs.HlsHandler.prototype.selectPlaylist = function () { | 709 | } |
728 | var | ||
729 | effectiveBitrate, | ||
730 | sortedPlaylists = this.playlists.master.playlists.slice(), | ||
731 | bandwidthPlaylists = [], | ||
732 | now = +new Date(), | ||
733 | i, | ||
734 | variant, | ||
735 | bandwidthBestVariant, | ||
736 | resolutionPlusOne, | ||
737 | resolutionBestVariant, | ||
738 | width, | ||
739 | height; | ||
740 | |||
741 | sortedPlaylists.sort(videojs.Hls.comparePlaylistBandwidth); | ||
742 | |||
743 | // filter out any playlists that have been excluded due to | ||
744 | // incompatible configurations or playback errors | ||
745 | sortedPlaylists = sortedPlaylists.filter(function(variant) { | ||
746 | if (variant.excludeUntil !== undefined) { | ||
747 | return now >= variant.excludeUntil; | ||
748 | } | ||
749 | return true; | ||
750 | }); | ||
751 | 710 | ||
752 | // filter out any variant that has greater effective bitrate | 711 | seekable = Hls.Playlist.seekable(media); |
753 | // than the current estimated bandwidth | 712 | if (seekable.length === 0) { |
754 | i = sortedPlaylists.length; | 713 | return seekable; |
755 | while (i--) { | 714 | } |
756 | variant = sortedPlaylists[i]; | ||
757 | 715 | ||
758 | // ignore playlists without bandwidth information | 716 | // if the seekable start is zero, it may be because the player has |
759 | if (!variant.attributes || !variant.attributes.BANDWIDTH) { | 717 | // been paused for a long time and stopped buffering. in that case, |
760 | continue; | 718 | // fall back to the playlist loader's running estimate of expired |
719 | // time | ||
720 | if (seekable.start(0) === 0) { | ||
721 | return videojs.createTimeRanges([[this.playlists.expired_, | ||
722 | this.playlists.expired_ + seekable.end(0)]]); | ||
761 | } | 723 | } |
762 | 724 | ||
763 | effectiveBitrate = variant.attributes.BANDWIDTH * bandwidthVariance; | 725 | // seekable has been calculated based on buffering video data so it |
726 | // can be returned directly | ||
727 | return seekable; | ||
728 | } | ||
764 | 729 | ||
765 | if (effectiveBitrate < this.bandwidth) { | 730 | /** |
766 | bandwidthPlaylists.push(variant); | 731 | * Update the player duration |
732 | */ | ||
733 | updateDuration(playlist) { | ||
734 | let oldDuration = this.mediaSource.duration; | ||
735 | let newDuration = Hls.Playlist.duration(playlist); | ||
736 | let buffered = this.tech_.buffered(); | ||
737 | let setDuration = () => { | ||
738 | this.mediaSource.duration = newDuration; | ||
739 | this.tech_.trigger('durationchange'); | ||
740 | |||
741 | this.mediaSource.removeEventListener('sourceopen', setDuration); | ||
742 | }; | ||
767 | 743 | ||
768 | // since the playlists are sorted in ascending order by | 744 | if (buffered.length > 0) { |
769 | // bandwidth, the first viable variant is the best | 745 | newDuration = Math.max(newDuration, buffered.end(buffered.length - 1)); |
770 | if (!bandwidthBestVariant) { | 746 | } |
771 | bandwidthBestVariant = variant; | 747 | |
748 | // if the duration has changed, invalidate the cached value | ||
749 | if (oldDuration !== newDuration) { | ||
750 | // update the duration | ||
751 | if (this.mediaSource.readyState !== 'open') { | ||
752 | this.mediaSource.addEventListener('sourceopen', setDuration); | ||
753 | } else if (!this.sourceBuffer || !this.sourceBuffer.updating) { | ||
754 | this.mediaSource.duration = newDuration; | ||
755 | this.tech_.trigger('durationchange'); | ||
772 | } | 756 | } |
773 | } | 757 | } |
774 | } | 758 | } |
775 | 759 | ||
776 | i = bandwidthPlaylists.length; | 760 | /** |
777 | 761 | * Clear all buffers and reset any state relevant to the current | |
778 | // sort variants by resolution | 762 | * source. After this function is called, the tech should be in a |
779 | bandwidthPlaylists.sort(videojs.Hls.comparePlaylistResolution); | 763 | * state suitable for switching to a different video. |
780 | 764 | */ | |
781 | // forget our old variant from above, or we might choose that in high-bandwidth scenarios | 765 | resetSrc_() { |
782 | // (this could be the lowest bitrate rendition as we go through all of them above) | 766 | this.cancelSegmentXhr(); |
783 | variant = null; | 767 | this.cancelKeyXhr(); |
784 | 768 | ||
785 | width = parseInt(safeGetComputedStyle(this.tech_.el(), 'width'), 10); | 769 | if (this.sourceBuffer && this.mediaSource.readyState === 'open') { |
786 | height = parseInt(safeGetComputedStyle(this.tech_.el(), 'height'), 10); | 770 | this.sourceBuffer.abort(); |
787 | |||
788 | // iterate through the bandwidth-filtered playlists and find | ||
789 | // best rendition by player dimension | ||
790 | while (i--) { | ||
791 | variant = bandwidthPlaylists[i]; | ||
792 | |||
793 | // ignore playlists without resolution information | ||
794 | if (!variant.attributes || | ||
795 | !variant.attributes.RESOLUTION || | ||
796 | !variant.attributes.RESOLUTION.width || | ||
797 | !variant.attributes.RESOLUTION.height) { | ||
798 | continue; | ||
799 | } | ||
800 | |||
801 | // since the playlists are sorted, the first variant that has | ||
802 | // dimensions less than or equal to the player size is the best | ||
803 | |||
804 | if (variant.attributes.RESOLUTION.width === width && | ||
805 | variant.attributes.RESOLUTION.height === height) { | ||
806 | // if we have the exact resolution as the player use it | ||
807 | resolutionPlusOne = null; | ||
808 | resolutionBestVariant = variant; | ||
809 | break; | ||
810 | } else if (variant.attributes.RESOLUTION.width < width && | ||
811 | variant.attributes.RESOLUTION.height < height) { | ||
812 | // if both dimensions are less than the player use the | ||
813 | // previous (next-largest) variant | ||
814 | break; | ||
815 | } else if (!resolutionPlusOne || | ||
816 | (variant.attributes.RESOLUTION.width < resolutionPlusOne.attributes.RESOLUTION.width && | ||
817 | variant.attributes.RESOLUTION.height < resolutionPlusOne.attributes.RESOLUTION.height)) { | ||
818 | // If we still haven't found a good match keep a | ||
819 | // reference to the previous variant for the next loop | ||
820 | // iteration | ||
821 | |||
822 | // By only saving variants if they are smaller than the | ||
823 | // previously saved variant, we ensure that we also pick | ||
824 | // the highest bandwidth variant that is just-larger-than | ||
825 | // the video player | ||
826 | resolutionPlusOne = variant; | ||
827 | } | 771 | } |
828 | } | 772 | } |
829 | 773 | ||
830 | // fallback chain of variants | 774 | cancelKeyXhr() { |
831 | return resolutionPlusOne || resolutionBestVariant || bandwidthBestVariant || sortedPlaylists[0]; | 775 | if (this.keyXhr_) { |
832 | }; | 776 | this.keyXhr_.onreadystatechange = null; |
833 | 777 | this.keyXhr_.abort(); | |
834 | /** | 778 | this.keyXhr_ = null; |
835 | * Periodically request new segments and append video data. | 779 | } |
836 | */ | ||
837 | videojs.HlsHandler.prototype.checkBuffer_ = function() { | ||
838 | // calling this method directly resets any outstanding buffer checks | ||
839 | if (this.checkBufferTimeout_) { | ||
840 | window.clearTimeout(this.checkBufferTimeout_); | ||
841 | this.checkBufferTimeout_ = null; | ||
842 | } | 780 | } |
843 | 781 | ||
844 | this.fillBuffer(); | 782 | cancelSegmentXhr() { |
845 | this.drainBuffer(); | 783 | if (this.segmentXhr_) { |
784 | // Prevent error handler from running. | ||
785 | this.segmentXhr_.onreadystatechange = null; | ||
786 | this.segmentXhr_.abort(); | ||
787 | this.segmentXhr_ = null; | ||
788 | } | ||
846 | 789 | ||
847 | // wait awhile and try again | 790 | // clear out the segment being processed |
848 | this.checkBufferTimeout_ = window.setTimeout((this.checkBuffer_).bind(this), | 791 | this.pendingSegment_ = null; |
849 | bufferCheckInterval); | 792 | } |
850 | }; | ||
851 | 793 | ||
852 | /** | 794 | /** |
853 | * Setup a periodic task to request new segments if necessary and | 795 | * Abort all outstanding work and cleanup. |
854 | * append bytes into the SourceBuffer. | 796 | */ |
855 | */ | 797 | dispose() { |
856 | videojs.HlsHandler.prototype.startCheckingBuffer_ = function() { | 798 | this.stopCheckingBuffer_(); |
857 | this.checkBuffer_(); | ||
858 | }; | ||
859 | 799 | ||
860 | /** | 800 | if (this.playlists) { |
861 | * Stop the periodic task requesting new segments and feeding the | 801 | this.playlists.dispose(); |
862 | * SourceBuffer. | 802 | } |
863 | */ | 803 | |
864 | videojs.HlsHandler.prototype.stopCheckingBuffer_ = function() { | 804 | this.resetSrc_(); |
865 | if (this.checkBufferTimeout_) { | 805 | super.dispose(); |
866 | window.clearTimeout(this.checkBufferTimeout_); | ||
867 | this.checkBufferTimeout_ = null; | ||
868 | } | 806 | } |
869 | }; | ||
870 | 807 | ||
871 | var filterBufferedRanges = function(predicate) { | 808 | /** |
872 | return function(time) { | 809 | * Chooses the appropriate media playlist based on the current |
873 | var | 810 | * bandwidth estimate and the player size. |
874 | i, | 811 | * @return the highest bitrate playlist less than the currently detected |
875 | ranges = [], | 812 | * bandwidth, accounting for some amount of bandwidth variance |
876 | tech = this.tech_, | 813 | */ |
877 | // !!The order of the next two assignments is important!! | 814 | selectPlaylist() { |
878 | // `currentTime` must be equal-to or greater-than the start of the | 815 | let effectiveBitrate; |
879 | // buffered range. Flash executes out-of-process so, every value can | 816 | let sortedPlaylists = this.playlists.master.playlists.slice(); |
880 | // change behind the scenes from line-to-line. By reading `currentTime` | 817 | let bandwidthPlaylists = []; |
881 | // after `buffered`, we ensure that it is always a current or later | 818 | let now = +new Date(); |
882 | // value during playback. | 819 | let i; |
883 | buffered = tech.buffered(); | 820 | let variant; |
884 | 821 | let bandwidthBestVariant; | |
885 | 822 | let resolutionPlusOne; | |
886 | if (time === undefined) { | 823 | let resolutionPlusOneAttribute; |
887 | time = tech.currentTime(); | 824 | let resolutionBestVariant; |
888 | } | 825 | let width; |
826 | let height; | ||
827 | |||
828 | sortedPlaylists.sort(Hls.comparePlaylistBandwidth); | ||
829 | |||
830 | // filter out any playlists that have been excluded due to | ||
831 | // incompatible configurations or playback errors | ||
832 | sortedPlaylists = sortedPlaylists.filter((localVariant) => { | ||
833 | if (typeof localVariant.excludeUntil !== 'undefined') { | ||
834 | return now >= localVariant.excludeUntil; | ||
835 | } | ||
836 | return true; | ||
837 | }); | ||
889 | 838 | ||
890 | // IE 11 has a bug where it will report a the video as fully buffered | 839 | // filter out any variant that has greater effective bitrate |
891 | // before any data has been loaded. This is a work around where we | 840 | // than the current estimated bandwidth |
892 | // report a fully empty buffer until SourceBuffers have been created | 841 | i = sortedPlaylists.length; |
893 | // which is after a segment has been loaded and transmuxed. | 842 | while (i--) { |
894 | if (!this.mediaSource || | 843 | variant = sortedPlaylists[i]; |
895 | !this.mediaSource.mediaSource_ || | ||
896 | !this.mediaSource.mediaSource_.sourceBuffers.length) { | ||
897 | return videojs.createTimeRanges([]); | ||
898 | } | ||
899 | 844 | ||
900 | if (buffered && buffered.length) { | 845 | // ignore playlists without bandwidth information |
901 | // Search for a range containing the play-head | 846 | if (!variant.attributes || !variant.attributes.BANDWIDTH) { |
902 | for (i = 0; i < buffered.length; i++) { | 847 | continue; |
903 | if (predicate(buffered.start(i), buffered.end(i), time)) { | 848 | } |
904 | ranges.push([buffered.start(i), buffered.end(i)]); | 849 | |
850 | effectiveBitrate = variant.attributes.BANDWIDTH * bandwidthVariance; | ||
851 | |||
852 | if (effectiveBitrate < this.bandwidth) { | ||
853 | bandwidthPlaylists.push(variant); | ||
854 | |||
855 | // since the playlists are sorted in ascending order by | ||
856 | // bandwidth, the first viable variant is the best | ||
857 | if (!bandwidthBestVariant) { | ||
858 | bandwidthBestVariant = variant; | ||
905 | } | 859 | } |
906 | } | 860 | } |
907 | } | 861 | } |
908 | 862 | ||
909 | return videojs.createTimeRanges(ranges); | 863 | i = bandwidthPlaylists.length; |
910 | }; | ||
911 | }; | ||
912 | 864 | ||
913 | /** | 865 | // sort variants by resolution |
914 | * Attempts to find the buffered TimeRange that contains the specified | 866 | bandwidthPlaylists.sort(Hls.comparePlaylistResolution); |
915 | * time, or where playback is currently happening if no specific time | ||
916 | * is specified. | ||
917 | * @param time (optional) {number} the time to filter on. Defaults to | ||
918 | * currentTime. | ||
919 | * @return a new TimeRanges object. | ||
920 | */ | ||
921 | videojs.HlsHandler.prototype.findBufferedRange_ = filterBufferedRanges(function(start, end, time) { | ||
922 | return start - TIME_FUDGE_FACTOR <= time && | ||
923 | end + TIME_FUDGE_FACTOR >= time; | ||
924 | }); | ||
925 | 867 | ||
926 | /** | 868 | // forget our old variant from above, |
927 | * Returns the TimeRanges that begin at or later than the specified | 869 | // or we might choose that in high-bandwidth scenarios |
928 | * time. | 870 | // (this could be the lowest bitrate rendition as we go through all of them above) |
929 | * @param time (optional) {number} the time to filter on. Defaults to | 871 | variant = null; |
930 | * currentTime. | ||
931 | * @return a new TimeRanges object. | ||
932 | */ | ||
933 | videojs.HlsHandler.prototype.findNextBufferedRange_ = filterBufferedRanges(function(start, end, time) { | ||
934 | return start - TIME_FUDGE_FACTOR >= time; | ||
935 | }); | ||
936 | 872 | ||
937 | /** | 873 | width = parseInt(safeGetComputedStyle(this.tech_.el(), 'width'), 10); |
938 | * Determines whether there is enough video data currently in the buffer | 874 | height = parseInt(safeGetComputedStyle(this.tech_.el(), 'height'), 10); |
939 | * and downloads a new segment if the buffered time is less than the goal. | 875 | |
940 | * @param seekToTime (optional) {number} the offset into the downloaded segment | 876 | // iterate through the bandwidth-filtered playlists and find |
941 | * to seek to, in seconds | 877 | // best rendition by player dimension |
942 | */ | 878 | while (i--) { |
943 | videojs.HlsHandler.prototype.fillBuffer = function(mediaIndex) { | 879 | variant = bandwidthPlaylists[i]; |
944 | var | 880 | |
945 | tech = this.tech_, | 881 | // ignore playlists without resolution information |
946 | currentTime = tech.currentTime(), | 882 | if (!variant.attributes || |
947 | hasBufferedContent = (this.tech_.buffered().length !== 0), | 883 | !variant.attributes.RESOLUTION || |
948 | currentBuffered = this.findBufferedRange_(), | 884 | !variant.attributes.RESOLUTION.width || |
949 | outsideBufferedRanges = !(currentBuffered && currentBuffered.length), | 885 | !variant.attributes.RESOLUTION.height) { |
950 | currentBufferedEnd = 0, | 886 | continue; |
951 | bufferedTime = 0, | 887 | } |
952 | segment, | 888 | |
953 | segmentInfo, | 889 | // since the playlists are sorted, the first variant that has |
954 | segmentTimestampOffset; | 890 | // dimensions less than or equal to the player size is the best |
955 | 891 | ||
956 | // if preload is set to "none", do not download segments until playback is requested | 892 | let variantResolution = variant.attributes.RESOLUTION; |
957 | if (this.loadingState_ !== 'segments') { | 893 | |
958 | return; | 894 | if (variantResolution.width === width && |
895 | variantResolution.height === height) { | ||
896 | // if we have the exact resolution as the player use it | ||
897 | resolutionPlusOne = null; | ||
898 | resolutionBestVariant = variant; | ||
899 | break; | ||
900 | } else if (variantResolution.width < width && | ||
901 | variantResolution.height < height) { | ||
902 | // if both dimensions are less than the player use the | ||
903 | // previous (next-largest) variant | ||
904 | break; | ||
905 | } else if (!resolutionPlusOne || | ||
906 | (variantResolution.width < resolutionPlusOneAttribute.width && | ||
907 | variantResolution.height < resolutionPlusOneAttribute.height)) { | ||
908 | // If we still haven't found a good match keep a | ||
909 | // reference to the previous variant for the next loop | ||
910 | // iteration | ||
911 | |||
912 | // By only saving variants if they are smaller than the | ||
913 | // previously saved variant, we ensure that we also pick | ||
914 | // the highest bandwidth variant that is just-larger-than | ||
915 | // the video player | ||
916 | resolutionPlusOne = variant; | ||
917 | resolutionPlusOneAttribute = resolutionPlusOne.attributes.RESOLUTION; | ||
918 | } | ||
919 | } | ||
920 | |||
921 | // fallback chain of variants | ||
922 | return resolutionPlusOne || | ||
923 | resolutionBestVariant || | ||
924 | bandwidthBestVariant || | ||
925 | sortedPlaylists[0]; | ||
959 | } | 926 | } |
960 | 927 | ||
961 | // if a video has not been specified, do nothing | 928 | /** |
962 | if (!tech.currentSrc() || !this.playlists) { | 929 | * Periodically request new segments and append video data. |
963 | return; | 930 | */ |
931 | checkBuffer_() { | ||
932 | // calling this method directly resets any outstanding buffer checks | ||
933 | if (this.checkBufferTimeout_) { | ||
934 | window.clearTimeout(this.checkBufferTimeout_); | ||
935 | this.checkBufferTimeout_ = null; | ||
936 | } | ||
937 | |||
938 | this.fillBuffer(); | ||
939 | this.drainBuffer(); | ||
940 | |||
941 | // wait awhile and try again | ||
942 | this.checkBufferTimeout_ = window.setTimeout((this.checkBuffer_).bind(this), | ||
943 | bufferCheckInterval); | ||
964 | } | 944 | } |
965 | 945 | ||
966 | // if there is a request already in flight, do nothing | 946 | /** |
967 | if (this.segmentXhr_) { | 947 | * Setup a periodic task to request new segments if necessary and |
968 | return; | 948 | * append bytes into the SourceBuffer. |
949 | */ | ||
950 | startCheckingBuffer_() { | ||
951 | this.checkBuffer_(); | ||
969 | } | 952 | } |
970 | 953 | ||
971 | // wait until the buffer is up to date | 954 | /** |
972 | if (this.pendingSegment_) { | 955 | * Stop the periodic task requesting new segments and feeding the |
973 | return; | 956 | * SourceBuffer. |
957 | */ | ||
958 | stopCheckingBuffer_() { | ||
959 | if (this.checkBufferTimeout_) { | ||
960 | window.clearTimeout(this.checkBufferTimeout_); | ||
961 | this.checkBufferTimeout_ = null; | ||
962 | } | ||
974 | } | 963 | } |
975 | 964 | ||
976 | // if no segments are available, do nothing | 965 | /** |
977 | if (this.playlists.state === "HAVE_NOTHING" || | 966 | * Determines whether there is enough video data currently in the buffer |
978 | !this.playlists.media() || | 967 | * and downloads a new segment if the buffered time is less than the goal. |
979 | !this.playlists.media().segments) { | 968 | * @param seekToTime (optional) {number} the offset into the downloaded segment |
980 | return; | 969 | * to seek to, in seconds |
981 | } | 970 | */ |
971 | fillBuffer(mediaIndex) { | ||
972 | let tech = this.tech_; | ||
973 | let currentTime = tech.currentTime(); | ||
974 | let hasBufferedContent = (this.tech_.buffered().length !== 0); | ||
975 | let currentBuffered = this.findBufferedRange_(); | ||
976 | let outsideBufferedRanges = !(currentBuffered && currentBuffered.length); | ||
977 | let currentBufferedEnd = 0; | ||
978 | let bufferedTime = 0; | ||
979 | let segment; | ||
980 | let segmentInfo; | ||
981 | let segmentTimestampOffset; | ||
982 | |||
983 | // if preload is set to "none", do not download segments until playback is requested | ||
984 | if (this.loadingState_ !== 'segments') { | ||
985 | return; | ||
986 | } | ||
987 | |||
988 | // if a video has not been specified, do nothing | ||
989 | if (!tech.currentSrc() || !this.playlists) { | ||
990 | return; | ||
991 | } | ||
992 | |||
993 | // if there is a request already in flight, do nothing | ||
994 | if (this.segmentXhr_) { | ||
995 | return; | ||
996 | } | ||
997 | |||
998 | // wait until the buffer is up to date | ||
999 | if (this.pendingSegment_) { | ||
1000 | return; | ||
1001 | } | ||
1002 | |||
1003 | // if no segments are available, do nothing | ||
1004 | if (this.playlists.state === 'HAVE_NOTHING' || | ||
1005 | !this.playlists.media() || | ||
1006 | !this.playlists.media().segments) { | ||
1007 | return; | ||
1008 | } | ||
982 | 1009 | ||
983 | // if a playlist switch is in progress, wait for it to finish | 1010 | // if a playlist switch is in progress, wait for it to finish |
984 | if (this.playlists.state === 'SWITCHING_MEDIA') { | 1011 | if (this.playlists.state === 'SWITCHING_MEDIA') { |
985 | return; | 1012 | return; |
986 | } | 1013 | } |
987 | 1014 | ||
988 | if (mediaIndex === undefined) { | 1015 | if (typeof mediaIndex === 'undefined') { |
989 | if (currentBuffered && currentBuffered.length) { | 1016 | if (currentBuffered && currentBuffered.length) { |
990 | currentBufferedEnd = currentBuffered.end(0); | 1017 | currentBufferedEnd = currentBuffered.end(0); |
991 | mediaIndex = this.playlists.getMediaIndexForTime_(currentBufferedEnd); | 1018 | mediaIndex = this.playlists.getMediaIndexForTime_(currentBufferedEnd); |
992 | bufferedTime = Math.max(0, currentBufferedEnd - currentTime); | 1019 | bufferedTime = Math.max(0, currentBufferedEnd - currentTime); |
993 | 1020 | ||
994 | // if there is plenty of content in the buffer and we're not | 1021 | // if there is plenty of content in the buffer and we're not |
995 | // seeking, relax for awhile | 1022 | // seeking, relax for awhile |
996 | if (bufferedTime >= videojs.Hls.GOAL_BUFFER_LENGTH) { | 1023 | if (bufferedTime >= Hls.GOAL_BUFFER_LENGTH) { |
997 | return; | 1024 | return; |
1025 | } | ||
1026 | } else { | ||
1027 | mediaIndex = this.playlists.getMediaIndexForTime_(this.tech_.currentTime()); | ||
998 | } | 1028 | } |
999 | } else { | ||
1000 | mediaIndex = this.playlists.getMediaIndexForTime_(this.tech_.currentTime()); | ||
1001 | } | 1029 | } |
1002 | } | 1030 | segment = this.playlists.media().segments[mediaIndex]; |
1003 | segment = this.playlists.media().segments[mediaIndex]; | ||
1004 | 1031 | ||
1005 | // if the video has finished downloading | 1032 | // if the video has finished downloading |
1006 | if (!segment) { | 1033 | if (!segment) { |
1007 | return; | 1034 | return; |
1008 | } | 1035 | } |
1009 | 1036 | ||
1010 | // we have entered a state where we are fetching the same segment, | 1037 | // we have entered a state where we are fetching the same segment, |
1011 | // try to walk forward | 1038 | // try to walk forward |
1012 | if (this.lastSegmentLoaded_ && | 1039 | if (this.lastSegmentLoaded_ && |
1013 | this.playlistUriToUrl(this.lastSegmentLoaded_.uri) === this.playlistUriToUrl(segment.uri) && | 1040 | this.playlistUriToUrl(this.lastSegmentLoaded_.uri) === this.playlistUriToUrl(segment.uri) && |
1014 | this.lastSegmentLoaded_.byterange === segment.byterange) { | 1041 | this.lastSegmentLoaded_.byterange === segment.byterange) { |
1015 | return this.fillBuffer(mediaIndex + 1); | 1042 | return this.fillBuffer(mediaIndex + 1); |
1016 | } | 1043 | } |
1017 | 1044 | ||
1018 | // package up all the work to append the segment | 1045 | // package up all the work to append the segment |
1019 | segmentInfo = { | 1046 | segmentInfo = { |
1020 | // resolve the segment URL relative to the playlist | 1047 | // resolve the segment URL relative to the playlist |
1021 | uri: this.playlistUriToUrl(segment.uri), | 1048 | uri: this.playlistUriToUrl(segment.uri), |
1022 | // the segment's mediaIndex & mediaSequence at the time it was requested | 1049 | // the segment's mediaIndex & mediaSequence at the time it was requested |
1023 | mediaIndex: mediaIndex, | 1050 | mediaIndex, |
1024 | mediaSequence: this.playlists.media().mediaSequence, | 1051 | mediaSequence: this.playlists.media().mediaSequence, |
1025 | // the segment's playlist | 1052 | // the segment's playlist |
1026 | playlist: this.playlists.media(), | 1053 | playlist: this.playlists.media(), |
1027 | // The state of the buffer when this segment was requested | 1054 | // The state of the buffer when this segment was requested |
1028 | currentBufferedEnd: currentBufferedEnd, | 1055 | currentBufferedEnd, |
1029 | // unencrypted bytes of the segment | 1056 | // unencrypted bytes of the segment |
1030 | bytes: null, | 1057 | bytes: null, |
1031 | // when a key is defined for this segment, the encrypted bytes | 1058 | // when a key is defined for this segment, the encrypted bytes |
1032 | encryptedBytes: null, | 1059 | encryptedBytes: null, |
1033 | // optionally, the decrypter that is unencrypting the segment | 1060 | // optionally, the decrypter that is unencrypting the segment |
1034 | decrypter: null, | 1061 | decrypter: null, |
1035 | // the state of the buffer before a segment is appended will be | 1062 | // the state of the buffer before a segment is appended will be |
1036 | // stored here so that the actual segment duration can be | 1063 | // stored here so that the actual segment duration can be |
1037 | // determined after it has been appended | 1064 | // determined after it has been appended |
1038 | buffered: null, | 1065 | buffered: null, |
1039 | // The target timestampOffset for this segment when we append it | 1066 | // The target timestampOffset for this segment when we append it |
1040 | // to the source buffer | 1067 | // to the source buffer |
1041 | timestampOffset: null | 1068 | timestampOffset: null |
1042 | }; | 1069 | }; |
1043 | 1070 | ||
1044 | if (mediaIndex > 0) { | 1071 | if (mediaIndex > 0) { |
1045 | segmentTimestampOffset = videojs.Hls.Playlist.duration(segmentInfo.playlist, | 1072 | segmentTimestampOffset = Hls.Playlist.duration(segmentInfo.playlist, |
1046 | segmentInfo.playlist.mediaSequence + mediaIndex) + this.playlists.expired_; | 1073 | segmentInfo.playlist.mediaSequence + mediaIndex) + this.playlists.expired_; |
1047 | } | 1074 | } |
1048 | 1075 | ||
1049 | if (this.tech_.seeking() && outsideBufferedRanges) { | 1076 | if (this.tech_.seeking() && outsideBufferedRanges) { |
1050 | // If there are discontinuities in the playlist, we can't be sure of anything | 1077 | // If there are discontinuities in the playlist, we can't be sure of anything |
1051 | // related to time so we reset the timestamp offset and start appending data | 1078 | // related to time so we reset the timestamp offset and start appending data |
1052 | // anew on every seek | 1079 | // anew on every seek |
1053 | if (segmentInfo.playlist.discontinuityStarts.length) { | 1080 | if (segmentInfo.playlist.discontinuityStarts.length) { |
1081 | segmentInfo.timestampOffset = segmentTimestampOffset; | ||
1082 | } | ||
1083 | } else if (segment.discontinuity && currentBuffered.length) { | ||
1084 | // If we aren't seeking and are crossing a discontinuity, we should set | ||
1085 | // timestampOffset for new segments to be appended the end of the current | ||
1086 | // buffered time-range | ||
1087 | segmentInfo.timestampOffset = currentBuffered.end(0); | ||
1088 | } else if (!hasBufferedContent && this.tech_.currentTime() > 0.05) { | ||
1089 | // If we are trying to play at a position that is not zero but we aren't | ||
1090 | // currently seeking according to the video element | ||
1054 | segmentInfo.timestampOffset = segmentTimestampOffset; | 1091 | segmentInfo.timestampOffset = segmentTimestampOffset; |
1055 | } | 1092 | } |
1056 | } else if (segment.discontinuity && currentBuffered.length) { | 1093 | |
1057 | // If we aren't seeking and are crossing a discontinuity, we should set | 1094 | this.loadSegment(segmentInfo); |
1058 | // timestampOffset for new segments to be appended the end of the current | ||
1059 | // buffered time-range | ||
1060 | segmentInfo.timestampOffset = currentBuffered.end(0); | ||
1061 | } else if (!hasBufferedContent && this.tech_.currentTime() > 0.05) { | ||
1062 | // If we are trying to play at a position that is not zero but we aren't | ||
1063 | // currently seeking according to the video element | ||
1064 | segmentInfo.timestampOffset = segmentTimestampOffset; | ||
1065 | } | 1095 | } |
1066 | 1096 | ||
1067 | this.loadSegment(segmentInfo); | 1097 | playlistUriToUrl(segmentRelativeUrl) { |
1068 | }; | 1098 | let playListUrl; |
1069 | 1099 | ||
1070 | videojs.HlsHandler.prototype.playlistUriToUrl = function(segmentRelativeUrl) { | 1100 | // resolve the segment URL relative to the playlist |
1071 | var playListUrl; | 1101 | if (this.playlists.media().uri === this.source_.src) { |
1072 | // resolve the segment URL relative to the playlist | 1102 | playListUrl = resolveUrl(this.source_.src, segmentRelativeUrl); |
1073 | if (this.playlists.media().uri === this.source_.src) { | 1103 | } else { |
1074 | playListUrl = resolveUrl(this.source_.src, segmentRelativeUrl); | 1104 | playListUrl = |
1075 | } else { | 1105 | resolveUrl(resolveUrl(this.source_.src, this.playlists.media().uri || ''), |
1076 | playListUrl = resolveUrl(resolveUrl(this.source_.src, this.playlists.media().uri || ''), segmentRelativeUrl); | 1106 | segmentRelativeUrl); |
1107 | } | ||
1108 | return playListUrl; | ||
1077 | } | 1109 | } |
1078 | return playListUrl; | ||
1079 | }; | ||
1080 | 1110 | ||
1081 | /* Turns segment byterange into a string suitable for use in | 1111 | /* |
1082 | * HTTP Range requests | 1112 | * Turns segment byterange into a string suitable for use in |
1083 | */ | 1113 | * HTTP Range requests |
1084 | videojs.HlsHandler.prototype.byterangeStr_ = function(byterange) { | 1114 | */ |
1085 | var byterangeStart, byterangeEnd; | 1115 | byterangeStr_(byterange) { |
1116 | let byterangeStart; | ||
1117 | let byterangeEnd; | ||
1086 | 1118 | ||
1087 | // `byterangeEnd` is one less than `offset + length` because the HTTP range | 1119 | // `byterangeEnd` is one less than `offset + length` because the HTTP range |
1088 | // header uses inclusive ranges | 1120 | // header uses inclusive ranges |
1089 | byterangeEnd = byterange.offset + byterange.length - 1; | 1121 | byterangeEnd = byterange.offset + byterange.length - 1; |
1090 | byterangeStart = byterange.offset; | 1122 | byterangeStart = byterange.offset; |
1091 | return "bytes=" + byterangeStart + "-" + byterangeEnd; | 1123 | return 'bytes=' + byterangeStart + '-' + byterangeEnd; |
1092 | }; | ||
1093 | |||
1094 | /* Defines headers for use in the xhr request for a particular segment. | ||
1095 | */ | ||
1096 | videojs.HlsHandler.prototype.segmentXhrHeaders_ = function(segment) { | ||
1097 | var headers = {}; | ||
1098 | if ('byterange' in segment) { | ||
1099 | headers['Range'] = this.byterangeStr_(segment.byterange); | ||
1100 | } | 1124 | } |
1101 | return headers; | ||
1102 | }; | ||
1103 | 1125 | ||
1104 | /* | 1126 | /* |
1105 | * Sets `bandwidth`, `segmentXhrTime`, and appends to the `bytesReceived. | 1127 | * Defines headers for use in the xhr request for a particular segment. |
1106 | * Expects an object with: | 1128 | */ |
1107 | * * `roundTripTime` - the round trip time for the request we're setting the time for | 1129 | segmentXhrHeaders_(segment) { |
1108 | * * `bandwidth` - the bandwidth we want to set | 1130 | let headers = {}; |
1109 | * * `bytesReceived` - amount of bytes downloaded | ||
1110 | * `bandwidth` is the only required property. | ||
1111 | */ | ||
1112 | videojs.HlsHandler.prototype.setBandwidth = function(xhr) { | ||
1113 | // calculate the download bandwidth | ||
1114 | this.segmentXhrTime = xhr.roundTripTime; | ||
1115 | this.bandwidth = xhr.bandwidth; | ||
1116 | this.bytesReceived += xhr.bytesReceived || 0; | ||
1117 | 1131 | ||
1118 | this.tech_.trigger('bandwidthupdate'); | 1132 | if ('byterange' in segment) { |
1119 | }; | 1133 | headers.Range = this.byterangeStr_(segment.byterange); |
1134 | } | ||
1135 | return headers; | ||
1136 | } | ||
1120 | 1137 | ||
1121 | /* | 1138 | /* |
1122 | * Blacklists a playlist when an error occurs for a set amount of time | 1139 | * Sets `bandwidth`, `segmentXhrTime`, and appends to the `bytesReceived. |
1123 | * making it unavailable for selection by the rendition selection algorithm | 1140 | * Expects an object with: |
1124 | * and then forces a new playlist (rendition) selection. | 1141 | * * `roundTripTime` - the round trip time for the request we're setting the time for |
1125 | */ | 1142 | * * `bandwidth` - the bandwidth we want to set |
1126 | videojs.HlsHandler.prototype.blacklistCurrentPlaylist_ = function(error) { | 1143 | * * `bytesReceived` - amount of bytes downloaded |
1127 | var currentPlaylist, nextPlaylist; | 1144 | * `bandwidth` is the only required property. |
1128 | 1145 | */ | |
1129 | // If the `error` was generated by the playlist loader, it will contain | 1146 | setBandwidth(localXhr) { |
1130 | // the playlist we were trying to load (but failed) and that should be | 1147 | // calculate the download bandwidth |
1131 | // blacklisted instead of the currently selected playlist which is likely | 1148 | this.segmentXhrTime = localXhr.roundTripTime; |
1132 | // out-of-date in this scenario | 1149 | this.bandwidth = localXhr.bandwidth; |
1133 | currentPlaylist = error.playlist || this.playlists.media(); | 1150 | this.bytesReceived += localXhr.bytesReceived || 0; |
1134 | 1151 | ||
1135 | // If there is no current playlist, then an error occurred while we were | 1152 | this.tech_.trigger('bandwidthupdate'); |
1136 | // trying to load the master OR while we were disposing of the tech | ||
1137 | if (!currentPlaylist) { | ||
1138 | this.error = error; | ||
1139 | return this.mediaSource.endOfStream('network'); | ||
1140 | } | 1153 | } |
1141 | 1154 | ||
1142 | // Blacklist this playlist | 1155 | /* |
1143 | currentPlaylist.excludeUntil = Date.now() + blacklistDuration; | 1156 | * Blacklists a playlist when an error occurs for a set amount of time |
1157 | * making it unavailable for selection by the rendition selection algorithm | ||
1158 | * and then forces a new playlist (rendition) selection. | ||
1159 | */ | ||
1160 | blacklistCurrentPlaylist_(error) { | ||
1161 | let currentPlaylist; | ||
1162 | let nextPlaylist; | ||
1163 | |||
1164 | // If the `error` was generated by the playlist loader, it will contain | ||
1165 | // the playlist we were trying to load (but failed) and that should be | ||
1166 | // blacklisted instead of the currently selected playlist which is likely | ||
1167 | // out-of-date in this scenario | ||
1168 | currentPlaylist = error.playlist || this.playlists.media(); | ||
1169 | |||
1170 | // If there is no current playlist, then an error occurred while we were | ||
1171 | // trying to load the master OR while we were disposing of the tech | ||
1172 | if (!currentPlaylist) { | ||
1173 | this.error = error; | ||
1174 | return this.mediaSource.endOfStream('network'); | ||
1175 | } | ||
1176 | |||
1177 | // Blacklist this playlist | ||
1178 | currentPlaylist.excludeUntil = Date.now() + blacklistDuration; | ||
1144 | 1179 | ||
1145 | // Select a new playlist | 1180 | // Select a new playlist |
1146 | nextPlaylist = this.selectPlaylist(); | 1181 | nextPlaylist = this.selectPlaylist(); |
1147 | 1182 | ||
1148 | if (nextPlaylist) { | 1183 | if (nextPlaylist) { |
1149 | videojs.log.warn('Problem encountered with the current HLS playlist. Switching to another playlist.'); | 1184 | videojs.log.warn('Problem encountered with the current ' + |
1185 | 'HLS playlist. Switching to another playlist.'); | ||
1150 | 1186 | ||
1151 | return this.playlists.media(nextPlaylist); | 1187 | return this.playlists.media(nextPlaylist); |
1152 | } else { | 1188 | } |
1153 | videojs.log.warn('Problem encountered with the current HLS playlist. No suitable alternatives found.'); | 1189 | videojs.log.warn('Problem encountered with the current ' + |
1190 | 'HLS playlist. No suitable alternatives found.'); | ||
1154 | // We have no more playlists we can select so we must fail | 1191 | // We have no more playlists we can select so we must fail |
1155 | this.error = error; | 1192 | this.error = error; |
1156 | return this.mediaSource.endOfStream('network'); | 1193 | return this.mediaSource.endOfStream('network'); |
1157 | } | 1194 | } |
1158 | }; | ||
1159 | 1195 | ||
1160 | videojs.HlsHandler.prototype.loadSegment = function(segmentInfo) { | 1196 | loadSegment(segmentInfo) { |
1161 | var | 1197 | let segment = segmentInfo.playlist.segments[segmentInfo.mediaIndex]; |
1162 | self = this, | 1198 | let removeToTime = 0; |
1163 | segment = segmentInfo.playlist.segments[segmentInfo.mediaIndex], | 1199 | let seekable = this.seekable(); |
1164 | removeToTime = 0, | 1200 | let currentTime = this.tech_.currentTime(); |
1165 | seekable = this.seekable(), | 1201 | |
1166 | currentTime = this.tech_.currentTime(); | 1202 | // Chrome has a hard limit of 150mb of |
1167 | 1203 | // buffer and a very conservative "garbage collector" | |
1168 | // Chrome has a hard limit of 150mb of buffer and a very conservative "garbage collector" | 1204 | // We manually clear out the old buffer to ensure |
1169 | // We manually clear out the old buffer to ensure we don't trigger the QuotaExceeded error | 1205 | // we don't trigger the QuotaExceeded error |
1170 | // on the source buffer during subsequent appends | 1206 | // on the source buffer during subsequent appends |
1171 | if (this.sourceBuffer && !this.sourceBuffer.updating) { | 1207 | if (this.sourceBuffer && !this.sourceBuffer.updating) { |
1172 | // If we have a seekable range use that as the limit for what can be removed safely | 1208 | // If we have a seekable range use that as the limit for what can be removed safely |
1173 | // otherwise remove anything older than 1 minute before the current play head | 1209 | // otherwise remove anything older than 1 minute before the current play head |
1174 | if (seekable.length && seekable.start(0) > 0 && seekable.start(0) < currentTime) { | 1210 | if (seekable.length && seekable.start(0) > 0 && seekable.start(0) < currentTime) { |
1175 | removeToTime = seekable.start(0); | 1211 | removeToTime = seekable.start(0); |
1176 | } else { | 1212 | } else { |
1177 | removeToTime = currentTime - 60; | 1213 | removeToTime = currentTime - 60; |
1178 | } | 1214 | } |
1179 | 1215 | ||
1180 | if (removeToTime > 0) { | 1216 | if (removeToTime > 0) { |
1181 | this.sourceBuffer.remove(0, removeToTime); | 1217 | this.sourceBuffer.remove(0, removeToTime); |
1218 | } | ||
1182 | } | 1219 | } |
1183 | } | ||
1184 | |||
1185 | // if the segment is encrypted, request the key | ||
1186 | if (segment.key) { | ||
1187 | this.fetchKey_(segment); | ||
1188 | } | ||
1189 | 1220 | ||
1190 | // request the next segment | 1221 | // if the segment is encrypted, request the key |
1191 | this.segmentXhr_ = videojs.Hls.xhr({ | 1222 | if (segment.key) { |
1192 | uri: segmentInfo.uri, | 1223 | this.fetchKey_(segment); |
1193 | responseType: 'arraybuffer', | ||
1194 | withCredentials: this.source_.withCredentials, | ||
1195 | // Set xhr timeout to 150% of the segment duration to allow us | ||
1196 | // some time to switch renditions in the event of a catastrophic | ||
1197 | // decrease in network performance or a server issue. | ||
1198 | timeout: (segment.duration * 1.5) * 1000, | ||
1199 | headers: this.segmentXhrHeaders_(segment) | ||
1200 | }, function(error, request) { | ||
1201 | // This is a timeout of a previously aborted segment request | ||
1202 | // so simply ignore it | ||
1203 | if (!self.segmentXhr_ || request !== self.segmentXhr_) { | ||
1204 | return; | ||
1205 | } | 1224 | } |
1206 | 1225 | ||
1207 | // the segment request is no longer outstanding | 1226 | // request the next segment |
1208 | self.segmentXhr_ = null; | 1227 | this.segmentXhr_ = Hls.xhr({ |
1209 | 1228 | uri: segmentInfo.uri, | |
1210 | // if a segment request times out, we may have better luck with another playlist | 1229 | responseType: 'arraybuffer', |
1211 | if (request.timedout) { | 1230 | withCredentials: this.source_.withCredentials, |
1212 | self.bandwidth = 1; | 1231 | // Set xhr timeout to 150% of the segment duration to allow us |
1213 | return self.playlists.media(self.selectPlaylist()); | 1232 | // some time to switch renditions in the event of a catastrophic |
1214 | } | 1233 | // decrease in network performance or a server issue. |
1234 | timeout: (segment.duration * 1.5) * 1000, | ||
1235 | headers: this.segmentXhrHeaders_(segment) | ||
1236 | }, (error, request) => { | ||
1237 | // This is a timeout of a previously aborted segment request | ||
1238 | // so simply ignore it | ||
1239 | if (!this.segmentXhr_ || request !== this.segmentXhr_) { | ||
1240 | return; | ||
1241 | } | ||
1215 | 1242 | ||
1216 | // otherwise, trigger a network error | 1243 | // the segment request is no longer outstanding |
1217 | if (!request.aborted && error) { | 1244 | this.segmentXhr_ = null; |
1218 | return self.blacklistCurrentPlaylist_({ | ||
1219 | status: request.status, | ||
1220 | message: 'HLS segment request error at URL: ' + segmentInfo.uri, | ||
1221 | code: (request.status >= 500) ? 4 : 2 | ||
1222 | }); | ||
1223 | } | ||
1224 | 1245 | ||
1225 | // stop processing if the request was aborted | 1246 | // if a segment request times out, we may have better luck with another playlist |
1226 | if (!request.response) { | 1247 | if (request.timedout) { |
1227 | return; | 1248 | this.bandwidth = 1; |
1228 | } | 1249 | return this.playlists.media(this.selectPlaylist()); |
1250 | } | ||
1229 | 1251 | ||
1230 | self.lastSegmentLoaded_ = segment; | 1252 | // otherwise, trigger a network error |
1231 | self.setBandwidth(request); | 1253 | if (!request.aborted && error) { |
1254 | return this.blacklistCurrentPlaylist_({ | ||
1255 | status: request.status, | ||
1256 | message: 'HLS segment request error at URL: ' + segmentInfo.uri, | ||
1257 | code: (request.status >= 500) ? 4 : 2 | ||
1258 | }); | ||
1259 | } | ||
1232 | 1260 | ||
1233 | if (segment.key) { | 1261 | // stop processing if the request was aborted |
1234 | segmentInfo.encryptedBytes = new Uint8Array(request.response); | 1262 | if (!request.response) { |
1235 | } else { | 1263 | return; |
1236 | segmentInfo.bytes = new Uint8Array(request.response); | 1264 | } |
1237 | } | ||
1238 | 1265 | ||
1239 | self.pendingSegment_ = segmentInfo; | 1266 | this.lastSegmentLoaded_ = segment; |
1267 | this.setBandwidth(request); | ||
1240 | 1268 | ||
1241 | self.tech_.trigger('progress'); | 1269 | if (segment.key) { |
1242 | self.drainBuffer(); | 1270 | segmentInfo.encryptedBytes = new Uint8Array(request.response); |
1271 | } else { | ||
1272 | segmentInfo.bytes = new Uint8Array(request.response); | ||
1273 | } | ||
1243 | 1274 | ||
1244 | // figure out what stream the next segment should be downloaded from | 1275 | this.pendingSegment_ = segmentInfo; |
1245 | // with the updated bandwidth information | ||
1246 | self.playlists.media(self.selectPlaylist()); | ||
1247 | }); | ||
1248 | 1276 | ||
1249 | }; | 1277 | this.tech_.trigger('progress'); |
1278 | this.drainBuffer(); | ||
1250 | 1279 | ||
1251 | videojs.HlsHandler.prototype.drainBuffer = function() { | 1280 | // figure out what stream the next segment should be downloaded from |
1252 | var | 1281 | // with the updated bandwidth information |
1253 | segmentInfo, | 1282 | this.playlists.media(this.selectPlaylist()); |
1254 | mediaIndex, | 1283 | }); |
1255 | playlist, | ||
1256 | offset, | ||
1257 | bytes, | ||
1258 | segment, | ||
1259 | decrypter, | ||
1260 | segIv; | ||
1261 | |||
1262 | // if the buffer is empty or the source buffer hasn't been created | ||
1263 | // yet, do nothing | ||
1264 | if (!this.pendingSegment_ || !this.sourceBuffer) { | ||
1265 | return; | ||
1266 | } | ||
1267 | 1284 | ||
1268 | // the pending segment has already been appended and we're waiting | ||
1269 | // for updateend to fire | ||
1270 | if (this.pendingSegment_.buffered) { | ||
1271 | return; | ||
1272 | } | 1285 | } |
1273 | 1286 | ||
1274 | // we can't append more data if the source buffer is busy processing | 1287 | drainBuffer() { |
1275 | // what we've already sent | 1288 | let segmentInfo; |
1276 | if (this.sourceBuffer.updating) { | 1289 | let mediaIndex; |
1277 | return; | 1290 | let playlist; |
1278 | } | 1291 | let bytes; |
1292 | let segment; | ||
1293 | let decrypter; | ||
1294 | let segIv; | ||
1279 | 1295 | ||
1280 | segmentInfo = this.pendingSegment_; | 1296 | // if the buffer is empty or the source buffer hasn't been created |
1281 | mediaIndex = segmentInfo.mediaIndex; | 1297 | // yet, do nothing |
1282 | playlist = segmentInfo.playlist; | 1298 | if (!this.pendingSegment_ || !this.sourceBuffer) { |
1283 | offset = segmentInfo.offset; | 1299 | return; |
1284 | bytes = segmentInfo.bytes; | 1300 | } |
1285 | segment = playlist.segments[mediaIndex]; | ||
1286 | |||
1287 | if (segment.key && !bytes) { | ||
1288 | // this is an encrypted segment | ||
1289 | // if the key download failed, we want to skip this segment | ||
1290 | // but if the key hasn't downloaded yet, we want to try again later | ||
1291 | if (keyFailed(segment.key)) { | ||
1292 | return this.blacklistCurrentPlaylist_({ | ||
1293 | message: 'HLS segment key request error.', | ||
1294 | code: 4 | ||
1295 | }); | ||
1296 | } else if (!segment.key.bytes) { | ||
1297 | 1301 | ||
1298 | // waiting for the key bytes, try again later | 1302 | // the pending segment has already been appended and we're waiting |
1303 | // for updateend to fire | ||
1304 | if (this.pendingSegment_.buffered) { | ||
1299 | return; | 1305 | return; |
1300 | } else if (segmentInfo.decrypter) { | 1306 | } |
1301 | 1307 | ||
1302 | // decryption is in progress, try again later | 1308 | // we can't append more data if the source buffer is busy processing |
1309 | // what we've already sent | ||
1310 | if (this.sourceBuffer.updating) { | ||
1303 | return; | 1311 | return; |
1304 | } else { | 1312 | } |
1305 | 1313 | ||
1314 | segmentInfo = this.pendingSegment_; | ||
1315 | mediaIndex = segmentInfo.mediaIndex; | ||
1316 | playlist = segmentInfo.playlist; | ||
1317 | bytes = segmentInfo.bytes; | ||
1318 | segment = playlist.segments[mediaIndex]; | ||
1319 | |||
1320 | if (segment.key && !bytes) { | ||
1321 | // this is an encrypted segment | ||
1322 | // if the key download failed, we want to skip this segment | ||
1323 | // but if the key hasn't downloaded yet, we want to try again later | ||
1324 | if (keyFailed(segment.key)) { | ||
1325 | return this.blacklistCurrentPlaylist_({ | ||
1326 | message: 'HLS segment key request error.', | ||
1327 | code: 4 | ||
1328 | }); | ||
1329 | } else if (!segment.key.bytes) { | ||
1330 | // waiting for the key bytes, try again later | ||
1331 | return; | ||
1332 | } else if (segmentInfo.decrypter) { | ||
1333 | // decryption is in progress, try again later | ||
1334 | return; | ||
1335 | } | ||
1306 | // if the media sequence is greater than 2^32, the IV will be incorrect | 1336 | // if the media sequence is greater than 2^32, the IV will be incorrect |
1307 | // assuming 10s segments, that would be about 1300 years | 1337 | // assuming 10s segments, that would be about 1300 years |
1308 | segIv = segment.key.iv || new Uint32Array([0, 0, 0, mediaIndex + playlist.mediaSequence]); | 1338 | segIv = segment.key.iv || |
1339 | new Uint32Array([0, 0, 0, mediaIndex + playlist.mediaSequence]); | ||
1309 | 1340 | ||
1310 | // create a decrypter to incrementally decrypt the segment | 1341 | // create a decrypter to incrementally decrypt the segment |
1311 | decrypter = new videojs.Hls.Decrypter(segmentInfo.encryptedBytes, | 1342 | decrypter = new Hls.Decrypter(segmentInfo.encryptedBytes, |
1312 | segment.key.bytes, | 1343 | segment.key.bytes, |
1313 | segIv, | 1344 | segIv, |
1314 | function(err, bytes) { | 1345 | function(error, localBytes) { |
1315 | segmentInfo.bytes = bytes; | 1346 | if (error) { |
1316 | }); | 1347 | videojs.log.warn(error); |
1348 | } | ||
1349 | segmentInfo.bytes = localBytes; | ||
1350 | }); | ||
1317 | segmentInfo.decrypter = decrypter; | 1351 | segmentInfo.decrypter = decrypter; |
1318 | return; | 1352 | return; |
1319 | } | 1353 | } |
1320 | } | ||
1321 | |||
1322 | this.pendingSegment_.buffered = this.tech_.buffered(); | ||
1323 | 1354 | ||
1324 | if (segmentInfo.timestampOffset !== null) { | 1355 | this.pendingSegment_.buffered = this.tech_.buffered(); |
1325 | this.sourceBuffer.timestampOffset = segmentInfo.timestampOffset; | ||
1326 | } | ||
1327 | 1356 | ||
1328 | // the segment is asynchronously added to the current buffered data | 1357 | if (segmentInfo.timestampOffset !== null) { |
1329 | this.sourceBuffer.appendBuffer(bytes); | 1358 | this.sourceBuffer.timestampOffset = segmentInfo.timestampOffset; |
1330 | }; | 1359 | } |
1331 | 1360 | ||
1332 | videojs.HlsHandler.prototype.updateEndHandler_ = function () { | 1361 | // the segment is asynchronously added to the current buffered data |
1333 | var | 1362 | this.sourceBuffer.appendBuffer(bytes); |
1334 | segmentInfo = this.pendingSegment_, | ||
1335 | segment, | ||
1336 | segments, | ||
1337 | playlist, | ||
1338 | currentMediaIndex, | ||
1339 | currentBuffered, | ||
1340 | seekable, | ||
1341 | timelineUpdate, | ||
1342 | isEndOfStream; | ||
1343 | |||
1344 | // stop here if the update errored or was aborted | ||
1345 | if (!segmentInfo) { | ||
1346 | return; | ||
1347 | } | 1363 | } |
1348 | 1364 | ||
1349 | // In Firefox, the updateend event is triggered for both removing from the buffer and | 1365 | updateEndHandler_() { |
1350 | // adding to the buffer. To prevent this code from executing on removals, we wait for | 1366 | let segmentInfo = this.pendingSegment_; |
1351 | // segmentInfo to have a filled in buffered value before we continue processing. | 1367 | let playlist; |
1352 | if (!segmentInfo.buffered) { | 1368 | let currentMediaIndex; |
1353 | return; | 1369 | let currentBuffered; |
1354 | } | 1370 | let seekable; |
1371 | let timelineUpdate; | ||
1372 | let isEndOfStream; | ||
1355 | 1373 | ||
1356 | this.pendingSegment_ = null; | 1374 | // stop here if the update errored or was aborted |
1375 | if (!segmentInfo) { | ||
1376 | this.pendingSegment_ = null; | ||
1377 | return; | ||
1378 | } | ||
1379 | |||
1380 | // In Firefox, the updateend event is triggered for both removing from the buffer and | ||
1381 | // adding to the buffer. To prevent this code from executing on removals, we wait for | ||
1382 | // segmentInfo to have a filled in buffered value before we continue processing. | ||
1383 | if (!segmentInfo.buffered) { | ||
1384 | return; | ||
1385 | } | ||
1357 | 1386 | ||
1358 | playlist = segmentInfo.playlist; | 1387 | this.pendingSegment_ = null; |
1359 | segments = playlist.segments; | ||
1360 | currentMediaIndex = segmentInfo.mediaIndex + (segmentInfo.mediaSequence - playlist.mediaSequence); | ||
1361 | currentBuffered = this.findBufferedRange_(); | ||
1362 | isEndOfStream = this.isEndOfStream_(playlist, currentMediaIndex, currentBuffered); | ||
1363 | 1388 | ||
1364 | // if we switched renditions don't try to add segment timeline | 1389 | playlist = segmentInfo.playlist; |
1365 | // information to the playlist | 1390 | currentMediaIndex = segmentInfo.mediaIndex + |
1366 | if (segmentInfo.playlist.uri !== this.playlists.media().uri) { | 1391 | (segmentInfo.mediaSequence - playlist.mediaSequence); |
1367 | if (isEndOfStream) { | 1392 | currentBuffered = this.findBufferedRange_(); |
1368 | return this.mediaSource.endOfStream(); | 1393 | isEndOfStream = detectEndOfStream(playlist, this.mediaSource, currentMediaIndex, currentBuffered); |
1394 | |||
1395 | // if we switched renditions don't try to add segment timeline | ||
1396 | // information to the playlist | ||
1397 | if (segmentInfo.playlist.uri !== this.playlists.media().uri) { | ||
1398 | if (isEndOfStream) { | ||
1399 | return this.mediaSource.endOfStream(); | ||
1400 | } | ||
1401 | return this.fillBuffer(); | ||
1369 | } | 1402 | } |
1370 | return this.fillBuffer(); | ||
1371 | } | ||
1372 | 1403 | ||
1373 | // annotate the segment with any start and end time information | 1404 | // when seeking to the beginning of the seekable range, it's |
1374 | // added by the media processing | 1405 | // possible that imprecise timing information may cause the seek to |
1375 | segment = playlist.segments[currentMediaIndex]; | 1406 | // end up earlier than the start of the range |
1376 | 1407 | // in that case, seek again | |
1377 | // when seeking to the beginning of the seekable range, it's | 1408 | seekable = this.seekable(); |
1378 | // possible that imprecise timing information may cause the seek to | 1409 | if (this.tech_.seeking() && |
1379 | // end up earlier than the start of the range | 1410 | currentBuffered.length === 0) { |
1380 | // in that case, seek again | 1411 | if (seekable.length && |
1381 | seekable = this.seekable(); | 1412 | this.tech_.currentTime() < seekable.start(0)) { |
1382 | if (this.tech_.seeking() && | 1413 | let next = this.findNextBufferedRange_(); |
1383 | currentBuffered.length === 0) { | 1414 | |
1384 | if (seekable.length && | 1415 | if (next.length) { |
1385 | this.tech_.currentTime() < seekable.start(0)) { | 1416 | videojs.log('tried seeking to', this.tech_.currentTime(), |
1386 | var next = this.findNextBufferedRange_(); | 1417 | 'but that was too early, retrying at', next.start(0)); |
1387 | if (next.length) { | 1418 | this.tech_.setCurrentTime(next.start(0) + TIME_FUDGE_FACTOR); |
1388 | videojs.log('tried seeking to', this.tech_.currentTime(), 'but that was too early, retrying at', next.start(0)); | 1419 | } |
1389 | this.tech_.setCurrentTime(next.start(0) + TIME_FUDGE_FACTOR); | ||
1390 | } | 1420 | } |
1391 | } | 1421 | } |
1392 | } | ||
1393 | 1422 | ||
1394 | timelineUpdate = videojs.Hls.findSoleUncommonTimeRangesEnd_(segmentInfo.buffered, | 1423 | timelineUpdate = Hls.findSoleUncommonTimeRangesEnd_(segmentInfo.buffered, |
1395 | this.tech_.buffered()); | 1424 | this.tech_.buffered()); |
1396 | 1425 | ||
1397 | // Update segment meta-data (duration and end-point) based on timeline | 1426 | // Update segment meta-data (duration and end-point) based on timeline |
1398 | this.updateSegmentMetadata_(playlist, currentMediaIndex, timelineUpdate); | 1427 | updateSegmentMetadata(playlist, currentMediaIndex, timelineUpdate); |
1399 | 1428 | ||
1400 | // If we decide to signal the end of stream, then we can return instead | 1429 | // If we decide to signal the end of stream, then we can return instead |
1401 | // of trying to fetch more segments | 1430 | // of trying to fetch more segments |
1402 | if (isEndOfStream) { | 1431 | if (isEndOfStream) { |
1403 | return this.mediaSource.endOfStream(); | 1432 | return this.mediaSource.endOfStream(); |
1404 | } | 1433 | } |
1405 | 1434 | ||
1406 | if (timelineUpdate !== null || | 1435 | if (timelineUpdate !== null || |
1407 | segmentInfo.buffered.length !== this.tech_.buffered().length) { | 1436 | segmentInfo.buffered.length !== this.tech_.buffered().length) { |
1408 | this.updateDuration(playlist); | 1437 | this.updateDuration(playlist); |
1409 | // check if it's time to download the next segment | 1438 | // check if it's time to download the next segment |
1410 | this.fillBuffer(); | 1439 | this.fillBuffer(); |
1440 | return; | ||
1441 | } | ||
1442 | |||
1443 | // the last segment append must have been entirely in the | ||
1444 | // already buffered time ranges. just buffer forward until we | ||
1445 | // find a segment that adds to the buffered time ranges and | ||
1446 | // improves subsequent media index calculations. | ||
1447 | this.fillBuffer(currentMediaIndex + 1); | ||
1411 | return; | 1448 | return; |
1412 | } | 1449 | } |
1413 | 1450 | ||
1414 | // the last segment append must have been entirely in the | 1451 | /** |
1415 | // already buffered time ranges. just buffer forward until we | 1452 | * Attempt to retrieve the key for a particular media segment. |
1416 | // find a segment that adds to the buffered time ranges and | 1453 | */ |
1417 | // improves subsequent media index calculations. | 1454 | fetchKey_(segment) { |
1418 | this.fillBuffer(currentMediaIndex + 1); | 1455 | let key; |
1419 | return; | 1456 | let settings; |
1420 | }; | 1457 | let receiveKey; |
1421 | 1458 | ||
1422 | /** | 1459 | // if there is a pending XHR or no segments, don't do anything |
1423 | * Attempt to retrieve the key for a particular media segment. | 1460 | if (this.keyXhr_) { |
1424 | */ | 1461 | return; |
1425 | videojs.HlsHandler.prototype.fetchKey_ = function(segment) { | 1462 | } |
1426 | var key, self, settings, receiveKey; | ||
1427 | 1463 | ||
1428 | // if there is a pending XHR or no segments, don't do anything | 1464 | settings = this.options_; |
1429 | if (this.keyXhr_) { | ||
1430 | return; | ||
1431 | } | ||
1432 | 1465 | ||
1433 | self = this; | 1466 | /** |
1434 | settings = this.options_; | 1467 | * Handle a key XHR response. |
1468 | */ | ||
1469 | receiveKey = (keyRecieved) => { | ||
1470 | return (error, request) => { | ||
1471 | let view; | ||
1435 | 1472 | ||
1436 | /** | 1473 | this.keyXhr_ = null; |
1437 | * Handle a key XHR response. | ||
1438 | */ | ||
1439 | receiveKey = function(key) { | ||
1440 | return function(error, request) { | ||
1441 | var view; | ||
1442 | self.keyXhr_ = null; | ||
1443 | |||
1444 | if (error || !request.response || request.response.byteLength !== 16) { | ||
1445 | key.retries = key.retries || 0; | ||
1446 | key.retries++; | ||
1447 | if (!request.aborted) { | ||
1448 | // try fetching again | ||
1449 | self.fetchKey_(segment); | ||
1450 | } | ||
1451 | return; | ||
1452 | } | ||
1453 | 1474 | ||
1454 | view = new DataView(request.response); | 1475 | if (error || !request.response || request.response.byteLength !== 16) { |
1455 | key.bytes = new Uint32Array([ | 1476 | keyRecieved.retries = keyRecieved.retries || 0; |
1456 | view.getUint32(0), | 1477 | keyRecieved.retries++; |
1457 | view.getUint32(4), | 1478 | if (!request.aborted) { |
1458 | view.getUint32(8), | 1479 | // try fetching again |
1459 | view.getUint32(12) | 1480 | this.fetchKey_(segment); |
1460 | ]); | 1481 | } |
1482 | return; | ||
1483 | } | ||
1461 | 1484 | ||
1462 | // check to see if this allows us to make progress buffering now | 1485 | view = new DataView(request.response); |
1463 | self.checkBuffer_(); | 1486 | keyRecieved.bytes = new Uint32Array([ |
1487 | view.getUint32(0), | ||
1488 | view.getUint32(4), | ||
1489 | view.getUint32(8), | ||
1490 | view.getUint32(12) | ||
1491 | ]); | ||
1492 | |||
1493 | // check to see if this allows us to make progress buffering now | ||
1494 | this.checkBuffer_(); | ||
1495 | }; | ||
1464 | }; | 1496 | }; |
1465 | }; | ||
1466 | 1497 | ||
1467 | key = segment.key; | 1498 | key = segment.key; |
1468 | 1499 | ||
1469 | // nothing to do if this segment is unencrypted | 1500 | // nothing to do if this segment is unencrypted |
1470 | if (!key) { | 1501 | if (!key) { |
1471 | return; | 1502 | return; |
1472 | } | 1503 | } |
1473 | 1504 | ||
1474 | // request the key if the retry limit hasn't been reached | 1505 | // request the key if the retry limit hasn't been reached |
1475 | if (!key.bytes && !keyFailed(key)) { | 1506 | if (!key.bytes && !keyFailed(key)) { |
1476 | this.keyXhr_ = videojs.Hls.xhr({ | 1507 | this.keyXhr_ = Hls.xhr({ |
1477 | uri: this.playlistUriToUrl(key.uri), | 1508 | uri: this.playlistUriToUrl(key.uri), |
1478 | responseType: 'arraybuffer', | 1509 | responseType: 'arraybuffer', |
1479 | withCredentials: settings.withCredentials | 1510 | withCredentials: settings.withCredentials |
1480 | }, receiveKey(key)); | 1511 | }, receiveKey(key)); |
1481 | return; | 1512 | return; |
1513 | } | ||
1482 | } | 1514 | } |
1483 | }; | 1515 | } |
1484 | 1516 | ||
1485 | /** | 1517 | /** |
1486 | * Whether the browser has built-in HLS support. | 1518 | * Attempts to find the buffered TimeRange that contains the specified |
1519 | * time, or where playback is currently happening if no specific time | ||
1520 | * is specified. | ||
1521 | * @param time (optional) {number} the time to filter on. Defaults to | ||
1522 | * currentTime. | ||
1523 | * @return a new TimeRanges object. | ||
1487 | */ | 1524 | */ |
1488 | videojs.Hls.supportsNativeHls = (function() { | 1525 | HlsHandler.prototype.findBufferedRange_ = |
1489 | var | 1526 | filterBufferedRanges(function(start, end, time) { |
1490 | video = document.createElement('video'), | 1527 | return start - TIME_FUDGE_FACTOR <= time && |
1491 | xMpegUrl, | 1528 | end + TIME_FUDGE_FACTOR >= time; |
1492 | vndMpeg; | 1529 | }); |
1493 | |||
1494 | // native HLS is definitely not supported if HTML5 video isn't | ||
1495 | if (!videojs.getComponent('Html5').isSupported()) { | ||
1496 | return false; | ||
1497 | } | ||
1498 | |||
1499 | xMpegUrl = video.canPlayType('application/x-mpegURL'); | ||
1500 | vndMpeg = video.canPlayType('application/vnd.apple.mpegURL'); | ||
1501 | return (/probably|maybe/).test(xMpegUrl) || | ||
1502 | (/probably|maybe/).test(vndMpeg); | ||
1503 | })(); | ||
1504 | |||
1505 | // HLS is a source handler, not a tech. Make sure attempts to use it | ||
1506 | // as one do not cause exceptions. | ||
1507 | videojs.Hls.isSupported = function() { | ||
1508 | return videojs.log.warn('HLS is no longer a tech. Please remove it from ' + | ||
1509 | 'your player\'s techOrder.'); | ||
1510 | }; | ||
1511 | |||
1512 | /** | 1530 | /** |
1513 | * A comparator function to sort two playlist object by bandwidth. | 1531 | * Returns the TimeRanges that begin at or later than the specified |
1514 | * @param left {object} a media playlist object | 1532 | * time. |
1515 | * @param right {object} a media playlist object | 1533 | * @param time (optional) {number} the time to filter on. Defaults to |
1516 | * @return {number} Greater than zero if the bandwidth attribute of | 1534 | * currentTime. |
1517 | * left is greater than the corresponding attribute of right. Less | 1535 | * @return a new TimeRanges object. |
1518 | * than zero if the bandwidth of right is greater than left and | ||
1519 | * exactly zero if the two are equal. | ||
1520 | */ | 1536 | */ |
1521 | videojs.Hls.comparePlaylistBandwidth = function(left, right) { | 1537 | HlsHandler.prototype.findNextBufferedRange_ = |
1522 | var leftBandwidth, rightBandwidth; | 1538 | filterBufferedRanges(function(start, end, time) { |
1523 | if (left.attributes && left.attributes.BANDWIDTH) { | 1539 | return start - TIME_FUDGE_FACTOR >= time; |
1524 | leftBandwidth = left.attributes.BANDWIDTH; | 1540 | }); |
1525 | } | ||
1526 | leftBandwidth = leftBandwidth || window.Number.MAX_VALUE; | ||
1527 | if (right.attributes && right.attributes.BANDWIDTH) { | ||
1528 | rightBandwidth = right.attributes.BANDWIDTH; | ||
1529 | } | ||
1530 | rightBandwidth = rightBandwidth || window.Number.MAX_VALUE; | ||
1531 | |||
1532 | return leftBandwidth - rightBandwidth; | ||
1533 | }; | ||
1534 | 1541 | ||
1535 | /** | 1542 | /** |
1536 | * A comparator function to sort two playlist object by resolution (width). | 1543 | * The Source Handler object, which informs video.js what additional |
1537 | * @param left {object} a media playlist object | 1544 | * MIME types are supported and sets up playback. It is registered |
1538 | * @param right {object} a media playlist object | 1545 | * automatically to the appropriate tech based on the capabilities of |
1539 | * @return {number} Greater than zero if the resolution.width attribute of | 1546 | * the browser it is running in. It is not necessary to use or modify |
1540 | * left is greater than the corresponding attribute of right. Less | 1547 | * this object in normal usage. |
1541 | * than zero if the resolution.width of right is greater than left and | ||
1542 | * exactly zero if the two are equal. | ||
1543 | */ | 1548 | */ |
1544 | videojs.Hls.comparePlaylistResolution = function(left, right) { | 1549 | const HlsSourceHandler = function(mode) { |
1545 | var leftWidth, rightWidth; | 1550 | return { |
1546 | 1551 | canHandleSource(srcObj) { | |
1547 | if (left.attributes && left.attributes.RESOLUTION && left.attributes.RESOLUTION.width) { | 1552 | return HlsSourceHandler.canPlayType(srcObj.type); |
1548 | leftWidth = left.attributes.RESOLUTION.width; | 1553 | }, |
1549 | } | 1554 | handleSource(source, tech) { |
1550 | 1555 | if (mode === 'flash') { | |
1551 | leftWidth = leftWidth || window.Number.MAX_VALUE; | 1556 | // We need to trigger this asynchronously to give others the chance |
1552 | 1557 | // to bind to the event when a source is set at player creation | |
1553 | if (right.attributes && right.attributes.RESOLUTION && right.attributes.RESOLUTION.width) { | 1558 | tech.setTimeout(function() { |
1554 | rightWidth = right.attributes.RESOLUTION.width; | 1559 | tech.trigger('loadstart'); |
1555 | } | 1560 | }, 1); |
1561 | } | ||
1562 | tech.hls = new HlsHandler(tech, { | ||
1563 | source, | ||
1564 | mode | ||
1565 | }); | ||
1566 | tech.hls.src(source.src); | ||
1567 | return tech.hls; | ||
1568 | }, | ||
1569 | canPlayType(type) { | ||
1570 | return HlsSourceHandler.canPlayType(type); | ||
1571 | } | ||
1572 | }; | ||
1573 | }; | ||
1556 | 1574 | ||
1557 | rightWidth = rightWidth || window.Number.MAX_VALUE; | 1575 | HlsSourceHandler.canPlayType = function(type) { |
1576 | let mpegurlRE = /^application\/(?:x-|vnd\.apple\.)mpegurl/i; | ||
1558 | 1577 | ||
1559 | // NOTE - Fallback to bandwidth sort as appropriate in cases where multiple renditions | 1578 | // favor native HLS support if it's available |
1560 | // have the same media dimensions/ resolution | 1579 | if (Hls.supportsNativeHls) { |
1561 | if (leftWidth === rightWidth && left.attributes.BANDWIDTH && right.attributes.BANDWIDTH) { | 1580 | return false; |
1562 | return left.attributes.BANDWIDTH - right.attributes.BANDWIDTH; | ||
1563 | } else { | ||
1564 | return leftWidth - rightWidth; | ||
1565 | } | 1581 | } |
1582 | return mpegurlRE.test(type); | ||
1566 | }; | 1583 | }; |
1567 | 1584 | ||
1568 | /** | 1585 | if (typeof videojs.MediaSource === 'undefined' || |
1569 | * Constructs a new URI by interpreting a path relative to another | 1586 | typeof videojs.URL === 'undefined') { |
1570 | * URI. | 1587 | videojs.MediaSource = MediaSource; |
1571 | * @param basePath {string} a relative or absolute URI | 1588 | videojs.URL = URL; |
1572 | * @param path {string} a path part to combine with the base | 1589 | } |
1573 | * @return {string} a URI that is equivalent to composing `base` | ||
1574 | * with `path` | ||
1575 | * @see http://stackoverflow.com/questions/470832/getting-an-absolute-url-from-a-relative-one-ie6-issue | ||
1576 | */ | ||
1577 | resolveUrl = videojs.Hls.resolveUrl = function(basePath, path) { | ||
1578 | // use the base element to get the browser to handle URI resolution | ||
1579 | var | ||
1580 | oldBase = document.querySelector('base'), | ||
1581 | docHead = document.querySelector('head'), | ||
1582 | a = document.createElement('a'), | ||
1583 | base = oldBase, | ||
1584 | oldHref, | ||
1585 | result; | ||
1586 | |||
1587 | // prep the document | ||
1588 | if (oldBase) { | ||
1589 | oldHref = oldBase.href; | ||
1590 | } else { | ||
1591 | base = docHead.appendChild(document.createElement('base')); | ||
1592 | } | ||
1593 | 1590 | ||
1594 | base.href = basePath; | 1591 | // register source handlers with the appropriate techs |
1595 | a.href = path; | 1592 | if (MediaSource.supportsNativeMediaSources()) { |
1596 | result = a.href; | 1593 | videojs.getComponent('Html5').registerSourceHandler(HlsSourceHandler('html5')); |
1594 | } | ||
1595 | if (window.Uint8Array) { | ||
1596 | videojs.getComponent('Flash').registerSourceHandler(HlsSourceHandler('flash')); | ||
1597 | } | ||
1597 | 1598 | ||
1598 | // clean up | 1599 | videojs.HlsHandler = HlsHandler; |
1599 | if (oldBase) { | 1600 | videojs.HlsSourceHandler = HlsSourceHandler; |
1600 | oldBase.href = oldHref; | 1601 | videojs.Hls = Hls; |
1601 | } else { | 1602 | videojs.m3u8 = m3u8; |
1602 | docHead.removeChild(base); | ||
1603 | } | ||
1604 | return result; | ||
1605 | }; | ||
1606 | 1603 | ||
1607 | })(window, window.videojs, document); | 1604 | export default { |
1605 | Hls, | ||
1606 | HlsHandler, | ||
1607 | HlsSourceHandler | ||
1608 | }; | ... | ... |
1 | (function(videojs) { | 1 | /** |
2 | 'use strict'; | 2 | * A wrapper for videojs.xhr that tracks bandwidth. |
3 | */ | ||
4 | import {xhr as videojsXHR, mergeOptions} from 'video.js'; | ||
5 | const xhr = function(options, callback) { | ||
6 | // Add a default timeout for all hls requests | ||
7 | options = mergeOptions({ | ||
8 | timeout: 45e3 | ||
9 | }, options); | ||
3 | 10 | ||
4 | /** | 11 | let request = videojsXHR(options, function(error, response) { |
5 | * A wrapper for videojs.xhr that tracks bandwidth. | 12 | if (!error && request.response) { |
6 | */ | 13 | request.responseTime = (new Date()).getTime(); |
7 | videojs.Hls.xhr = function(options, callback) { | 14 | request.roundTripTime = request.responseTime - request.requestTime; |
8 | // Add a default timeout for all hls requests | 15 | request.bytesReceived = request.response.byteLength || request.response.length; |
9 | options = videojs.mergeOptions({ | 16 | if (!request.bandwidth) { |
10 | timeout: 45e3 | 17 | request.bandwidth = |
11 | }, options); | 18 | Math.floor((request.bytesReceived / request.roundTripTime) * 8 * 1000); |
12 | |||
13 | var request = videojs.xhr(options, function(error, response) { | ||
14 | if (!error && request.response) { | ||
15 | request.responseTime = (new Date()).getTime(); | ||
16 | request.roundTripTime = request.responseTime - request.requestTime; | ||
17 | request.bytesReceived = request.response.byteLength || request.response.length; | ||
18 | if (!request.bandwidth) { | ||
19 | request.bandwidth = Math.floor((request.bytesReceived / request.roundTripTime) * 8 * 1000); | ||
20 | } | ||
21 | } | 19 | } |
20 | } | ||
22 | 21 | ||
23 | // videojs.xhr now uses a specific code on the error object to signal that a request has | 22 | // videojs.xhr now uses a specific code |
24 | // timed out errors of setting a boolean on the request object | 23 | // on the error object to signal that a request has |
25 | if (error || request.timedout) { | 24 | // timed out errors of setting a boolean on the request object |
26 | request.timedout = request.timedout || (error.code === 'ETIMEDOUT'); | 25 | if (error || request.timedout) { |
27 | } else { | 26 | request.timedout = request.timedout || (error.code === 'ETIMEDOUT'); |
28 | request.timedout = false; | 27 | } else { |
29 | } | 28 | request.timedout = false; |
29 | } | ||
30 | 30 | ||
31 | // videojs.xhr no longer considers status codes outside of 200 and 0 | 31 | // videojs.xhr no longer considers status codes outside of 200 and 0 |
32 | // (for file uris) to be errors, but the old XHR did, so emulate that | 32 | // (for file uris) to be errors, but the old XHR did, so emulate that |
33 | // behavior. Status 206 may be used in response to byterange requests. | 33 | // behavior. Status 206 may be used in response to byterange requests. |
34 | if (!error && | 34 | if (!error && |
35 | response.statusCode !== 200 && | 35 | response.statusCode !== 200 && |
36 | response.statusCode !== 206 && | 36 | response.statusCode !== 206 && |
37 | response.statusCode !== 0) { | 37 | response.statusCode !== 0) { |
38 | error = new Error('XHR Failed with a response of: ' + | 38 | error = new Error('XHR Failed with a response of: ' + |
39 | (request && (request.response || request.responseText))); | 39 | (request && (request.response || request.responseText))); |
40 | } | 40 | } |
41 | |||
42 | callback(error, request); | ||
43 | }); | ||
41 | 44 | ||
42 | callback(error, request); | 45 | request.requestTime = (new Date()).getTime(); |
43 | }); | 46 | return request; |
47 | }; | ||
44 | 48 | ||
45 | request.requestTime = (new Date()).getTime(); | 49 | export default xhr; |
46 | return request; | ||
47 | }; | ||
48 | })(window.videojs); | ... | ... |
test/.jshintrc
deleted
100644 → 0
1 | { | ||
2 | "curly": true, | ||
3 | "eqeqeq": true, | ||
4 | "immed": true, | ||
5 | "latedef": true, | ||
6 | "newcap": true, | ||
7 | "noarg": true, | ||
8 | "sub": true, | ||
9 | "undef": true, | ||
10 | "unused": true, | ||
11 | "boss": true, | ||
12 | "eqnull": true, | ||
13 | "browser": true, | ||
14 | "node": true, | ||
15 | "predef": [ | ||
16 | "QUnit", | ||
17 | "module", | ||
18 | "test", | ||
19 | "asyncTest", | ||
20 | "expect", | ||
21 | "start", | ||
22 | "stop", | ||
23 | "ok", | ||
24 | "equal", | ||
25 | "notEqual", | ||
26 | "deepEqual", | ||
27 | "notDeepEqual", | ||
28 | "strictEqual", | ||
29 | "notStrictEqual", | ||
30 | "throws", | ||
31 | "sinon", | ||
32 | "process" | ||
33 | ] | ||
34 | } |
test/decrypter.test.js
0 → 100644
1 | // see docs/hlse.md for instructions on how test data was generated | ||
2 | import QUnit from 'qunit'; | ||
3 | import {unpad} from 'pkcs7'; | ||
4 | import sinon from 'sinon'; | ||
5 | import {decrypt, Decrypter, AsyncStream} from '../src/decrypter'; | ||
6 | |||
7 | // see docs/hlse.md for instructions on how test data was generated | ||
8 | const stringFromBytes = function(bytes) { | ||
9 | let result = ''; | ||
10 | |||
11 | for (let i = 0; i < bytes.length; i++) { | ||
12 | result += String.fromCharCode(bytes[i]); | ||
13 | } | ||
14 | return result; | ||
15 | }; | ||
16 | |||
17 | QUnit.module('Decryption'); | ||
18 | QUnit.test('decrypts a single AES-128 with PKCS7 block', function() { | ||
19 | let key = new Uint32Array([0, 0, 0, 0]); | ||
20 | let initVector = key; | ||
21 | // the string "howdy folks" encrypted | ||
22 | let encrypted = new Uint8Array([ | ||
23 | 0xce, 0x90, 0x97, 0xd0, | ||
24 | 0x08, 0x46, 0x4d, 0x18, | ||
25 | 0x4f, 0xae, 0x01, 0x1c, | ||
26 | 0x82, 0xa8, 0xf0, 0x67 | ||
27 | ]); | ||
28 | |||
29 | QUnit.deepEqual('howdy folks', | ||
30 | stringFromBytes(unpad(decrypt(encrypted, key, initVector))), | ||
31 | 'decrypted with a byte array key' | ||
32 | ); | ||
33 | }); | ||
34 | |||
35 | QUnit.test('decrypts multiple AES-128 blocks with CBC', function() { | ||
36 | let key = new Uint32Array([0, 0, 0, 0]); | ||
37 | let initVector = key; | ||
38 | // the string "0123456789abcdef01234" encrypted | ||
39 | let encrypted = new Uint8Array([ | ||
40 | 0x14, 0xf5, 0xfe, 0x74, | ||
41 | 0x69, 0x66, 0xf2, 0x92, | ||
42 | 0x65, 0x1c, 0x22, 0x88, | ||
43 | 0xbb, 0xff, 0x46, 0x09, | ||
44 | |||
45 | 0x0b, 0xde, 0x5e, 0x71, | ||
46 | 0x77, 0x87, 0xeb, 0x84, | ||
47 | 0xa9, 0x54, 0xc2, 0x45, | ||
48 | 0xe9, 0x4e, 0x29, 0xb3 | ||
49 | ]); | ||
50 | |||
51 | QUnit.deepEqual('0123456789abcdef01234', | ||
52 | stringFromBytes(unpad(decrypt(encrypted, key, initVector))), | ||
53 | 'decrypted multiple blocks'); | ||
54 | }); | ||
55 | |||
56 | QUnit.test( | ||
57 | 'verify that the deepcopy works by doing two decrypts in the same test', | ||
58 | function() { | ||
59 | let key = new Uint32Array([0, 0, 0, 0]); | ||
60 | let initVector = key; | ||
61 | // the string "howdy folks" encrypted | ||
62 | let pkcs7Block = new Uint8Array([ | ||
63 | 0xce, 0x90, 0x97, 0xd0, | ||
64 | 0x08, 0x46, 0x4d, 0x18, | ||
65 | 0x4f, 0xae, 0x01, 0x1c, | ||
66 | 0x82, 0xa8, 0xf0, 0x67 | ||
67 | ]); | ||
68 | |||
69 | QUnit.deepEqual('howdy folks', | ||
70 | stringFromBytes(unpad(decrypt(pkcs7Block, key, initVector))), | ||
71 | 'decrypted with a byte array key' | ||
72 | ); | ||
73 | |||
74 | // the string "0123456789abcdef01234" encrypted | ||
75 | let cbcBlocks = new Uint8Array([ | ||
76 | 0x14, 0xf5, 0xfe, 0x74, | ||
77 | 0x69, 0x66, 0xf2, 0x92, | ||
78 | 0x65, 0x1c, 0x22, 0x88, | ||
79 | 0xbb, 0xff, 0x46, 0x09, | ||
80 | |||
81 | 0x0b, 0xde, 0x5e, 0x71, | ||
82 | 0x77, 0x87, 0xeb, 0x84, | ||
83 | 0xa9, 0x54, 0xc2, 0x45, | ||
84 | 0xe9, 0x4e, 0x29, 0xb3 | ||
85 | ]); | ||
86 | |||
87 | QUnit.deepEqual('0123456789abcdef01234', | ||
88 | stringFromBytes(unpad(decrypt(cbcBlocks, key, initVector))), | ||
89 | 'decrypted multiple blocks'); | ||
90 | |||
91 | }); | ||
92 | |||
93 | QUnit.module('Incremental Processing', { | ||
94 | beforeEach() { | ||
95 | this.clock = sinon.useFakeTimers(); | ||
96 | }, | ||
97 | afterEach() { | ||
98 | this.clock.restore(); | ||
99 | } | ||
100 | }); | ||
101 | |||
102 | QUnit.test('executes a callback after a timeout', function() { | ||
103 | let asyncStream = new AsyncStream(); | ||
104 | let calls = ''; | ||
105 | |||
106 | asyncStream.push(function() { | ||
107 | calls += 'a'; | ||
108 | }); | ||
109 | |||
110 | this.clock.tick(asyncStream.delay); | ||
111 | QUnit.equal(calls, 'a', 'invoked the callback once'); | ||
112 | this.clock.tick(asyncStream.delay); | ||
113 | QUnit.equal(calls, 'a', 'only invoked the callback once'); | ||
114 | }); | ||
115 | |||
116 | QUnit.test('executes callback in series', function() { | ||
117 | let asyncStream = new AsyncStream(); | ||
118 | let calls = ''; | ||
119 | |||
120 | asyncStream.push(function() { | ||
121 | calls += 'a'; | ||
122 | }); | ||
123 | asyncStream.push(function() { | ||
124 | calls += 'b'; | ||
125 | }); | ||
126 | |||
127 | this.clock.tick(asyncStream.delay); | ||
128 | QUnit.equal(calls, 'a', 'invoked the first callback'); | ||
129 | this.clock.tick(asyncStream.delay); | ||
130 | QUnit.equal(calls, 'ab', 'invoked the second'); | ||
131 | }); | ||
132 | |||
133 | QUnit.module('Incremental Decryption', { | ||
134 | beforeEach() { | ||
135 | this.clock = sinon.useFakeTimers(); | ||
136 | }, | ||
137 | afterEach() { | ||
138 | this.clock.restore(); | ||
139 | } | ||
140 | }); | ||
141 | |||
142 | QUnit.test('asynchronously decrypts a 4-word block', function() { | ||
143 | let key = new Uint32Array([0, 0, 0, 0]); | ||
144 | let initVector = key; | ||
145 | // the string "howdy folks" encrypted | ||
146 | let encrypted = new Uint8Array([0xce, 0x90, 0x97, 0xd0, | ||
147 | 0x08, 0x46, 0x4d, 0x18, | ||
148 | 0x4f, 0xae, 0x01, 0x1c, | ||
149 | 0x82, 0xa8, 0xf0, 0x67]); | ||
150 | let decrypted; | ||
151 | let decrypter = new Decrypter(encrypted, | ||
152 | key, | ||
153 | initVector, | ||
154 | function(error, result) { | ||
155 | if (error) { | ||
156 | throw new Error(error); | ||
157 | } | ||
158 | decrypted = result; | ||
159 | }); | ||
160 | |||
161 | QUnit.ok(!decrypted, 'asynchronously decrypts'); | ||
162 | this.clock.tick(decrypter.asyncStream_.delay * 2); | ||
163 | |||
164 | QUnit.ok(decrypted, 'completed decryption'); | ||
165 | QUnit.deepEqual('howdy folks', | ||
166 | stringFromBytes(decrypted), | ||
167 | 'decrypts and unpads the result'); | ||
168 | }); | ||
169 | |||
170 | QUnit.test('breaks up input greater than the step value', function() { | ||
171 | let encrypted = new Int32Array(Decrypter.STEP + 4); | ||
172 | let done = false; | ||
173 | let decrypter = new Decrypter(encrypted, | ||
174 | new Uint32Array(4), | ||
175 | new Uint32Array(4), | ||
176 | function() { | ||
177 | done = true; | ||
178 | }); | ||
179 | |||
180 | this.clock.tick(decrypter.asyncStream_.delay * 2); | ||
181 | QUnit.ok(!done, 'not finished after two ticks'); | ||
182 | |||
183 | this.clock.tick(decrypter.asyncStream_.delay); | ||
184 | QUnit.ok(done, 'finished after the last chunk is decrypted'); | ||
185 | }); |
test/decrypter_test.js
deleted
100644 → 0
1 | (function(window, videojs, unpad, undefined) { | ||
2 | 'use strict'; | ||
3 | /* | ||
4 | ======== A Handy Little QUnit Reference ======== | ||
5 | http://api.qunitjs.com/ | ||
6 | |||
7 | Test methods: | ||
8 | module(name, {[setup][ ,teardown]}) | ||
9 | test(name, callback) | ||
10 | expect(numberOfAssertions) | ||
11 | stop(increment) | ||
12 | start(decrement) | ||
13 | Test assertions: | ||
14 | ok(value, [message]) | ||
15 | equal(actual, expected, [message]) | ||
16 | notEqual(actual, expected, [message]) | ||
17 | deepEqual(actual, expected, [message]) | ||
18 | notDeepEqual(actual, expected, [message]) | ||
19 | strictEqual(actual, expected, [message]) | ||
20 | notStrictEqual(actual, expected, [message]) | ||
21 | throws(block, [expected], [message]) | ||
22 | */ | ||
23 | |||
24 | // see docs/hlse.md for instructions on how test data was generated | ||
25 | |||
26 | var stringFromBytes = function(bytes) { | ||
27 | var result = '', i; | ||
28 | |||
29 | for (i = 0; i < bytes.length; i++) { | ||
30 | result += String.fromCharCode(bytes[i]); | ||
31 | } | ||
32 | return result; | ||
33 | }; | ||
34 | |||
35 | module('Decryption'); | ||
36 | |||
37 | test('decrypts a single AES-128 with PKCS7 block', function() { | ||
38 | var | ||
39 | key = new Uint32Array([0, 0, 0, 0]), | ||
40 | initVector = key, | ||
41 | // the string "howdy folks" encrypted | ||
42 | encrypted = new Uint8Array([ | ||
43 | 0xce, 0x90, 0x97, 0xd0, | ||
44 | 0x08, 0x46, 0x4d, 0x18, | ||
45 | 0x4f, 0xae, 0x01, 0x1c, | ||
46 | 0x82, 0xa8, 0xf0, 0x67]); | ||
47 | |||
48 | deepEqual('howdy folks', | ||
49 | stringFromBytes(unpad(videojs.Hls.decrypt(encrypted, key, initVector))), | ||
50 | 'decrypted with a byte array key'); | ||
51 | }); | ||
52 | |||
53 | test('decrypts multiple AES-128 blocks with CBC', function() { | ||
54 | var | ||
55 | key = new Uint32Array([0, 0, 0, 0]), | ||
56 | initVector = key, | ||
57 | // the string "0123456789abcdef01234" encrypted | ||
58 | encrypted = new Uint8Array([ | ||
59 | 0x14, 0xf5, 0xfe, 0x74, | ||
60 | 0x69, 0x66, 0xf2, 0x92, | ||
61 | 0x65, 0x1c, 0x22, 0x88, | ||
62 | 0xbb, 0xff, 0x46, 0x09, | ||
63 | |||
64 | 0x0b, 0xde, 0x5e, 0x71, | ||
65 | 0x77, 0x87, 0xeb, 0x84, | ||
66 | 0xa9, 0x54, 0xc2, 0x45, | ||
67 | 0xe9, 0x4e, 0x29, 0xb3 | ||
68 | ]); | ||
69 | |||
70 | deepEqual('0123456789abcdef01234', | ||
71 | stringFromBytes(unpad(videojs.Hls.decrypt(encrypted, key, initVector))), | ||
72 | 'decrypted multiple blocks'); | ||
73 | }); | ||
74 | |||
75 | var clock; | ||
76 | |||
77 | module('Incremental Processing', { | ||
78 | setup: function() { | ||
79 | clock = sinon.useFakeTimers(); | ||
80 | }, | ||
81 | teardown: function() { | ||
82 | clock.restore(); | ||
83 | } | ||
84 | }); | ||
85 | |||
86 | test('executes a callback after a timeout', function() { | ||
87 | var asyncStream = new videojs.Hls.AsyncStream(), | ||
88 | calls = ''; | ||
89 | asyncStream.push(function() { | ||
90 | calls += 'a'; | ||
91 | }); | ||
92 | |||
93 | clock.tick(asyncStream.delay); | ||
94 | equal(calls, 'a', 'invoked the callback once'); | ||
95 | clock.tick(asyncStream.delay); | ||
96 | equal(calls, 'a', 'only invoked the callback once'); | ||
97 | }); | ||
98 | |||
99 | test('executes callback in series', function() { | ||
100 | var asyncStream = new videojs.Hls.AsyncStream(), | ||
101 | calls = ''; | ||
102 | asyncStream.push(function() { | ||
103 | calls += 'a'; | ||
104 | }); | ||
105 | asyncStream.push(function() { | ||
106 | calls += 'b'; | ||
107 | }); | ||
108 | |||
109 | clock.tick(asyncStream.delay); | ||
110 | equal(calls, 'a', 'invoked the first callback'); | ||
111 | clock.tick(asyncStream.delay); | ||
112 | equal(calls, 'ab', 'invoked the second'); | ||
113 | }); | ||
114 | |||
115 | var decrypter; | ||
116 | |||
117 | module('Incremental Decryption', { | ||
118 | setup: function() { | ||
119 | clock = sinon.useFakeTimers(); | ||
120 | }, | ||
121 | teardown: function() { | ||
122 | clock.restore(); | ||
123 | } | ||
124 | }); | ||
125 | |||
126 | test('asynchronously decrypts a 4-word block', function() { | ||
127 | var | ||
128 | key = new Uint32Array([0, 0, 0, 0]), | ||
129 | initVector = key, | ||
130 | // the string "howdy folks" encrypted | ||
131 | encrypted = new Uint8Array([ | ||
132 | 0xce, 0x90, 0x97, 0xd0, | ||
133 | 0x08, 0x46, 0x4d, 0x18, | ||
134 | 0x4f, 0xae, 0x01, 0x1c, | ||
135 | 0x82, 0xa8, 0xf0, 0x67]), | ||
136 | decrypted; | ||
137 | |||
138 | decrypter = new videojs.Hls.Decrypter(encrypted, key, initVector, function(error, result) { | ||
139 | decrypted = result; | ||
140 | }); | ||
141 | ok(!decrypted, 'asynchronously decrypts'); | ||
142 | |||
143 | clock.tick(decrypter.asyncStream_.delay * 2); | ||
144 | |||
145 | ok(decrypted, 'completed decryption'); | ||
146 | deepEqual('howdy folks', | ||
147 | stringFromBytes(decrypted), | ||
148 | 'decrypts and unpads the result'); | ||
149 | }); | ||
150 | |||
151 | test('breaks up input greater than the step value', function() { | ||
152 | var encrypted = new Int32Array(videojs.Hls.Decrypter.STEP + 4), | ||
153 | done = false, | ||
154 | decrypter = new videojs.Hls.Decrypter(encrypted, | ||
155 | new Uint32Array(4), | ||
156 | new Uint32Array(4), | ||
157 | function() { | ||
158 | done = true; | ||
159 | }); | ||
160 | clock.tick(decrypter.asyncStream_.delay * 2); | ||
161 | ok(!done, 'not finished after two ticks'); | ||
162 | |||
163 | clock.tick(decrypter.asyncStream_.delay); | ||
164 | ok(done, 'finished after the last chunk is decrypted'); | ||
165 | }); | ||
166 | |||
167 | })(window, window.videojs, window.pkcs7.unpad); |
test/index.html
0 → 100644
1 | <!DOCTYPE html> | ||
2 | <html> | ||
3 | <head> | ||
4 | <meta charset="utf-8"> | ||
5 | <title>video.js HLS Plugin Test Suite</title> | ||
6 | <link rel="stylesheet" href="/node_modules/qunitjs/qunit/qunit.css" media="screen"> | ||
7 | <link rel="stylesheet" href="/node_modules/video.js/dist/video-js.css" media="screen"> | ||
8 | </head> | ||
9 | <body> | ||
10 | <div id="qunit"></div> | ||
11 | <div id="qunit-fixture"></div> | ||
12 | <!-- NOTE in order for test to pass we require sinon 1.10.2 exactly --> | ||
13 | <script src="/node_modules/sinon/pkg/sinon.js"></script> | ||
14 | <script src="/node_modules/qunitjs/qunit/qunit.js"></script> | ||
15 | <script src="/node_modules/video.js/dist/video.js"></script> | ||
16 | <script src="/dist-test/videojs-contrib-hls.js"></script> | ||
17 | |||
18 | </body> | ||
19 | </html> |
test/karma-qunit-shim.js
deleted
100644 → 0
test/karma.conf.js
deleted
100644 → 0
1 | // Karma example configuration file | ||
2 | // NOTE: To configure Karma tests, do the following: | ||
3 | // 1. Copy this file and rename the copy with a .conf.js extension, for example: karma.conf.js | ||
4 | // 2. Configure the properties below in your conf.js copy | ||
5 | // 3. Run your tests | ||
6 | |||
7 | module.exports = function(config) { | ||
8 | var customLaunchers = { | ||
9 | chrome_sl: { | ||
10 | singleRun: true, | ||
11 | base: 'SauceLabs', | ||
12 | browserName: 'chrome', | ||
13 | platform: 'Windows 7' | ||
14 | }, | ||
15 | |||
16 | firefox_sl: { | ||
17 | singleRun: true, | ||
18 | base: 'SauceLabs', | ||
19 | browserName: 'firefox', | ||
20 | platform: 'Windows 8' | ||
21 | }, | ||
22 | |||
23 | safari_sl: { | ||
24 | singleRun: true, | ||
25 | base: 'SauceLabs', | ||
26 | browserName: 'safari', | ||
27 | platform: 'OS X 10.8' | ||
28 | }, | ||
29 | |||
30 | ipad_sl: { | ||
31 | singleRun: true, | ||
32 | base: 'SauceLabs', | ||
33 | browserName: 'ipad', | ||
34 | platform:'OS X 10.9', | ||
35 | version: '7.1' | ||
36 | }, | ||
37 | |||
38 | android_sl: { | ||
39 | singleRun: true, | ||
40 | base: 'SauceLabs', | ||
41 | browserName: 'android', | ||
42 | platform:'Linux' | ||
43 | } | ||
44 | }; | ||
45 | |||
46 | config.set({ | ||
47 | // base path, that will be used to resolve files and exclude | ||
48 | basePath: '', | ||
49 | |||
50 | frameworks: ['qunit'], | ||
51 | |||
52 | // Set autoWatch to true if you plan to run `grunt karma` continuously, to automatically test changes as you make them. | ||
53 | autoWatch: false, | ||
54 | |||
55 | // 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. | ||
56 | singleRun: true, | ||
57 | |||
58 | // custom launchers for sauce labs | ||
59 | //define SL browsers | ||
60 | customLaunchers: customLaunchers, | ||
61 | |||
62 | // Start these browsers | ||
63 | browsers: ['chrome_sl'], //Object.keys(customLaunchers), | ||
64 | |||
65 | // List of files / patterns to load in the browser | ||
66 | // Add any new src files to this list. | ||
67 | // If you add new unit tests, they will be picked up automatically by Karma, | ||
68 | // unless you've added them to a nested directory, in which case you should | ||
69 | // add their paths to this list. | ||
70 | |||
71 | files: [ | ||
72 | '../node_modules/sinon/pkg/sinon.js', | ||
73 | '../node_modules/video.js/dist/video-js.css', | ||
74 | '../node_modules/video.js/dist/video.js', | ||
75 | '../node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js', | ||
76 | '../node_modules/pkcs7/dist/pkcs7.unpad.js', | ||
77 | '../test/karma-qunit-shim.js', | ||
78 | '../src/videojs-hls.js', | ||
79 | '../src/stream.js', | ||
80 | '../src/m3u8/m3u8-parser.js', | ||
81 | '../src/xhr.js', | ||
82 | '../src/playlist.js', | ||
83 | '../src/playlist-loader.js', | ||
84 | '../src/decrypter.js', | ||
85 | '../tmp/manifests.js', | ||
86 | '../tmp/expected.js', | ||
87 | 'tsSegment-bc.js', | ||
88 | '../src/bin-utils.js', | ||
89 | '../test/*.js', | ||
90 | ], | ||
91 | |||
92 | plugins: [ | ||
93 | 'karma-qunit', | ||
94 | 'karma-chrome-launcher', | ||
95 | 'karma-firefox-launcher', | ||
96 | 'karma-ie-launcher', | ||
97 | 'karma-opera-launcher', | ||
98 | 'karma-phantomjs-launcher', | ||
99 | 'karma-safari-launcher', | ||
100 | 'karma-sauce-launcher' | ||
101 | ], | ||
102 | |||
103 | // test results reporter to use | ||
104 | // possible values: 'dots', 'progress', 'junit' | ||
105 | reporters: ['dots', 'progress'], | ||
106 | |||
107 | // web server port | ||
108 | port: 9876, | ||
109 | |||
110 | // cli runner port | ||
111 | runnerPort: 9100, | ||
112 | |||
113 | // enable / disable colors in the output (reporters and logs) | ||
114 | colors: true, | ||
115 | |||
116 | // level of logging | ||
117 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG | ||
118 | //logLevel: config.LOG_INFO, | ||
119 | |||
120 | // If browser does not capture in given timeout [ms], kill it | ||
121 | captureTimeout: 60000, | ||
122 | |||
123 | // global config for SauceLabs | ||
124 | sauceLabs: { | ||
125 | startConnect: false, | ||
126 | tunnelIdentifier: process.env.TRAVIS_JOB_NUMBER, | ||
127 | build: process.env.TRAVIS_BUILD_NUMBER, | ||
128 | testName: process.env.TRAVIS_BUILD_NUMBER + process.env.TRAVIS_BRANCH, | ||
129 | recordScreenshots: false | ||
130 | } | ||
131 | }); | ||
132 | }; |
test/karma/chrome.js
0 → 100644
test/karma/common.js
0 → 100644
1 | var merge = require('lodash-compat/object/merge'); | ||
2 | |||
3 | var DEFAULTS = { | ||
4 | basePath: '../..', | ||
5 | frameworks: ['browserify', 'qunit'], | ||
6 | |||
7 | |||
8 | files: [ | ||
9 | 'node_modules/sinon/pkg/sinon.js', | ||
10 | 'node_modules/sinon/pkg/sinon-ie.js', | ||
11 | 'node_modules/video.js/dist/video.js', | ||
12 | 'node_modules/video.js/dist/video-js.css', | ||
13 | |||
14 | 'test/**/*.test.js' | ||
15 | ], | ||
16 | |||
17 | exclude: [ | ||
18 | 'test/data/**' | ||
19 | ], | ||
20 | |||
21 | plugins: [ | ||
22 | 'karma-browserify', | ||
23 | 'karma-qunit' | ||
24 | ], | ||
25 | |||
26 | preprocessors: { | ||
27 | 'test/**/*.test.js': ['browserify'] | ||
28 | }, | ||
29 | |||
30 | reporters: ['dots'], | ||
31 | port: 9876, | ||
32 | colors: true, | ||
33 | autoWatch: false, | ||
34 | singleRun: true, | ||
35 | concurrency: Infinity, | ||
36 | |||
37 | browserify: { | ||
38 | debug: true, | ||
39 | transform: [ | ||
40 | 'babelify', | ||
41 | 'browserify-shim' | ||
42 | ], | ||
43 | noParse: [ | ||
44 | 'test/data/**', | ||
45 | ] | ||
46 | } | ||
47 | }; | ||
48 | |||
49 | /** | ||
50 | * Customizes target/source merging with lodash merge. | ||
51 | * | ||
52 | * @param {Mixed} target | ||
53 | * @param {Mixed} source | ||
54 | * @return {Mixed} | ||
55 | */ | ||
56 | var customizer = function(target, source) { | ||
57 | if (Array.isArray(target)) { | ||
58 | return target.concat(source); | ||
59 | } | ||
60 | }; | ||
61 | |||
62 | /** | ||
63 | * Generates a new Karma config with a common set of base configuration. | ||
64 | * | ||
65 | * @param {Object} custom | ||
66 | * Configuration that will be deep-merged. Arrays will be | ||
67 | * concatenated. | ||
68 | * @return {Object} | ||
69 | */ | ||
70 | module.exports = function(custom) { | ||
71 | return merge({}, custom, DEFAULTS, customizer); | ||
72 | }; |
test/karma/detected.js
0 → 100644
1 | var common = require('./common'); | ||
2 | |||
3 | // Runs default testing configuration in multiple environments. | ||
4 | |||
5 | module.exports = function(config) { | ||
6 | |||
7 | // Travis CI should run in its available Firefox headless browser. | ||
8 | if (process.env.TRAVIS) { | ||
9 | |||
10 | config.set(common({ | ||
11 | browsers: ['Firefox'], | ||
12 | plugins: ['karma-firefox-launcher'] | ||
13 | })) | ||
14 | } else { | ||
15 | config.set(common({ | ||
16 | |||
17 | frameworks: ['detectBrowsers'], | ||
18 | |||
19 | plugins: [ | ||
20 | 'karma-chrome-launcher', | ||
21 | 'karma-detect-browsers', | ||
22 | 'karma-firefox-launcher', | ||
23 | 'karma-ie-launcher', | ||
24 | 'karma-safari-launcher' | ||
25 | ], | ||
26 | |||
27 | detectBrowsers: { | ||
28 | // disable safari as it was not previously supported and causes test failures | ||
29 | postDetection: function(availableBrowsers) { | ||
30 | var safariIndex = availableBrowsers.indexOf('Safari'); | ||
31 | if(safariIndex !== -1) { | ||
32 | console.log("Not running safari it is/was broken"); | ||
33 | availableBrowsers.splice(safariIndex, 1); | ||
34 | } | ||
35 | return availableBrowsers; | ||
36 | }, | ||
37 | usePhantomJS: false | ||
38 | } | ||
39 | })); | ||
40 | } | ||
41 | }; |
test/karma/firefox.js
0 → 100644
test/karma/ie.js
0 → 100644
test/karma/safari.js
0 → 100644
test/localkarma.conf.js
deleted
100644 → 0
1 | // Karma example configuration file | ||
2 | // NOTE: To configure Karma tests, do the following: | ||
3 | // 1. Copy this file and rename the copy with a .conf.js extension, for example: karma.conf.js | ||
4 | // 2. Configure the properties below in your conf.js copy | ||
5 | // 3. Run your tests | ||
6 | |||
7 | module.exports = function(config) { | ||
8 | config.set({ | ||
9 | // base path, that will be used to resolve files and exclude | ||
10 | basePath: '', | ||
11 | |||
12 | frameworks: ['qunit'], | ||
13 | |||
14 | // Set autoWatch to true if you plan to run `grunt karma` continuously, to automatically test changes as you make them. | ||
15 | autoWatch: false, | ||
16 | |||
17 | // 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. | ||
18 | singleRun: true, | ||
19 | |||
20 | // Start these browsers, currently available: | ||
21 | // - Chrome | ||
22 | // - ChromeCanary | ||
23 | // - Firefox | ||
24 | // - Opera | ||
25 | // - Safari (only Mac) | ||
26 | // - PhantomJS | ||
27 | // - IE (only Windows) | ||
28 | // Example usage: | ||
29 | // browsers: [], | ||
30 | // List of files / patterns to load in the browser | ||
31 | // Add any new src files to this list. | ||
32 | // If you add new unit tests, they will be picked up automatically by Karma, | ||
33 | // unless you've added them to a nested directory, in which case you should | ||
34 | // add their paths to this list. | ||
35 | |||
36 | files: [ | ||
37 | '../node_modules/sinon/pkg/sinon.js', | ||
38 | '../node_modules/video.js/dist/video-js.css', | ||
39 | '../node_modules/video.js/dist/video.js', | ||
40 | '../node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js', | ||
41 | '../node_modules/pkcs7/dist/pkcs7.unpad.js', | ||
42 | '../test/karma-qunit-shim.js', | ||
43 | '../src/videojs-hls.js', | ||
44 | '../src/stream.js', | ||
45 | '../src/m3u8/m3u8-parser.js', | ||
46 | '../src/xhr.js', | ||
47 | '../src/playlist.js', | ||
48 | '../src/playlist-loader.js', | ||
49 | '../src/decrypter.js', | ||
50 | '../tmp/manifests.js', | ||
51 | '../tmp/expected.js', | ||
52 | 'tsSegment-bc.js', | ||
53 | '../src/bin-utils.js', | ||
54 | '../test/*.js', | ||
55 | ], | ||
56 | |||
57 | plugins: [ | ||
58 | 'karma-qunit', | ||
59 | 'karma-chrome-launcher', | ||
60 | 'karma-firefox-launcher', | ||
61 | 'karma-ie-launcher', | ||
62 | 'karma-opera-launcher', | ||
63 | 'karma-phantomjs-launcher', | ||
64 | 'karma-safari-launcher' | ||
65 | ], | ||
66 | |||
67 | // list of files to exclude | ||
68 | exclude: [ | ||
69 | |||
70 | ], | ||
71 | |||
72 | |||
73 | // test results reporter to use | ||
74 | // possible values: 'dots', 'progress', 'junit' | ||
75 | reporters: ['progress'], | ||
76 | |||
77 | |||
78 | // web server port | ||
79 | port: 9876, | ||
80 | |||
81 | |||
82 | // cli runner port | ||
83 | runnerPort: 9100, | ||
84 | |||
85 | |||
86 | // enable / disable colors in the output (reporters and logs) | ||
87 | colors: true, | ||
88 | |||
89 | |||
90 | // level of logging | ||
91 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG | ||
92 | logLevel: config.LOG_DISABLE, | ||
93 | |||
94 | // If browser does not capture in given timeout [ms], kill it | ||
95 | captureTimeout: 60000 | ||
96 | }); | ||
97 | }; |
test/m3u8.test.js
0 → 100644
1 | import {ParseStream, LineStream, Parser} from '../src/m3u8'; | ||
2 | import QUnit from 'qunit'; | ||
3 | import testDataExpected from './test-expected.js'; | ||
4 | import testDataManifests from './test-manifests.js'; | ||
5 | |||
6 | QUnit.module('LineStream', { | ||
7 | beforeEach() { | ||
8 | this.lineStream = new LineStream(); | ||
9 | } | ||
10 | }); | ||
11 | QUnit.test('empty inputs produce no tokens', function() { | ||
12 | let data = false; | ||
13 | |||
14 | this.lineStream.on('data', function() { | ||
15 | data = true; | ||
16 | }); | ||
17 | this.lineStream.push(''); | ||
18 | QUnit.ok(!data, 'no tokens were produced'); | ||
19 | }); | ||
20 | QUnit.test('splits on newlines', function() { | ||
21 | let lines = []; | ||
22 | |||
23 | this.lineStream.on('data', function(line) { | ||
24 | lines.push(line); | ||
25 | }); | ||
26 | this.lineStream.push('#EXTM3U\nmovie.ts\n'); | ||
27 | |||
28 | QUnit.strictEqual(2, lines.length, 'two lines are ready'); | ||
29 | QUnit.strictEqual('#EXTM3U', lines.shift(), 'the first line is the first token'); | ||
30 | QUnit.strictEqual('movie.ts', lines.shift(), 'the second line is the second token'); | ||
31 | }); | ||
32 | QUnit.test('empty lines become empty strings', function() { | ||
33 | let lines = []; | ||
34 | |||
35 | this.lineStream.on('data', function(line) { | ||
36 | lines.push(line); | ||
37 | }); | ||
38 | this.lineStream.push('\n\n'); | ||
39 | |||
40 | QUnit.strictEqual(2, lines.length, 'two lines are ready'); | ||
41 | QUnit.strictEqual('', lines.shift(), 'the first line is empty'); | ||
42 | QUnit.strictEqual('', lines.shift(), 'the second line is empty'); | ||
43 | }); | ||
44 | QUnit.test('handles lines broken across appends', function() { | ||
45 | let lines = []; | ||
46 | |||
47 | this.lineStream.on('data', function(line) { | ||
48 | lines.push(line); | ||
49 | }); | ||
50 | this.lineStream.push('#EXTM'); | ||
51 | QUnit.strictEqual(0, lines.length, 'no lines are ready'); | ||
52 | |||
53 | this.lineStream.push('3U\nmovie.ts\n'); | ||
54 | QUnit.strictEqual(2, lines.length, 'two lines are ready'); | ||
55 | QUnit.strictEqual('#EXTM3U', lines.shift(), 'the first line is the first token'); | ||
56 | QUnit.strictEqual('movie.ts', lines.shift(), 'the second line is the second token'); | ||
57 | }); | ||
58 | QUnit.test('stops sending events after deregistering', function() { | ||
59 | let temporaryLines = []; | ||
60 | let temporary = function(line) { | ||
61 | temporaryLines.push(line); | ||
62 | }; | ||
63 | let permanentLines = []; | ||
64 | let permanent = function(line) { | ||
65 | permanentLines.push(line); | ||
66 | }; | ||
67 | |||
68 | this.lineStream.on('data', temporary); | ||
69 | this.lineStream.on('data', permanent); | ||
70 | this.lineStream.push('line one\n'); | ||
71 | QUnit.strictEqual(temporaryLines.length, | ||
72 | permanentLines.length, | ||
73 | 'both callbacks receive the event'); | ||
74 | |||
75 | QUnit.ok(this.lineStream.off('data', temporary), 'a listener was removed'); | ||
76 | this.lineStream.push('line two\n'); | ||
77 | QUnit.strictEqual(1, temporaryLines.length, 'no new events are received'); | ||
78 | QUnit.strictEqual(2, permanentLines.length, 'new events are still received'); | ||
79 | }); | ||
80 | |||
81 | QUnit.module('ParseStream', { | ||
82 | beforeEach() { | ||
83 | this.lineStream = new LineStream(); | ||
84 | this.parseStream = new ParseStream(); | ||
85 | this.lineStream.pipe(this.parseStream); | ||
86 | } | ||
87 | }); | ||
88 | QUnit.test('parses comment lines', function() { | ||
89 | let manifest = '# a line that starts with a hash mark without "EXT" is a comment\n'; | ||
90 | let element; | ||
91 | |||
92 | this.parseStream.on('data', function(elem) { | ||
93 | element = elem; | ||
94 | }); | ||
95 | this.lineStream.push(manifest); | ||
96 | |||
97 | QUnit.ok(element, 'an event was triggered'); | ||
98 | QUnit.strictEqual(element.type, 'comment', 'the type is comment'); | ||
99 | QUnit.strictEqual(element.text, | ||
100 | manifest.slice(1, manifest.length - 1), | ||
101 | 'the comment text is parsed'); | ||
102 | }); | ||
103 | QUnit.test('parses uri lines', function() { | ||
104 | let manifest = 'any non-blank line that does not start with a hash-mark is a URI\n'; | ||
105 | let element; | ||
106 | |||
107 | this.parseStream.on('data', function(elem) { | ||
108 | element = elem; | ||
109 | }); | ||
110 | this.lineStream.push(manifest); | ||
111 | |||
112 | QUnit.ok(element, 'an event was triggered'); | ||
113 | QUnit.strictEqual(element.type, 'uri', 'the type is uri'); | ||
114 | QUnit.strictEqual(element.uri, | ||
115 | manifest.substring(0, manifest.length - 1), | ||
116 | 'the uri text is parsed'); | ||
117 | }); | ||
118 | QUnit.test('parses unknown tag types', function() { | ||
119 | let manifest = '#EXT-X-EXAMPLE-TAG:some,additional,stuff\n'; | ||
120 | let element; | ||
121 | |||
122 | this.parseStream.on('data', function(elem) { | ||
123 | element = elem; | ||
124 | }); | ||
125 | this.lineStream.push(manifest); | ||
126 | |||
127 | QUnit.ok(element, 'an event was triggered'); | ||
128 | QUnit.strictEqual(element.type, 'tag', 'the type is tag'); | ||
129 | QUnit.strictEqual(element.data, | ||
130 | manifest.slice(4, manifest.length - 1), | ||
131 | 'unknown tag data is preserved'); | ||
132 | }); | ||
133 | |||
134 | // #EXTM3U | ||
135 | QUnit.test('parses #EXTM3U tags', function() { | ||
136 | let manifest = '#EXTM3U\n'; | ||
137 | let element; | ||
138 | |||
139 | this.parseStream.on('data', function(elem) { | ||
140 | element = elem; | ||
141 | }); | ||
142 | this.lineStream.push(manifest); | ||
143 | |||
144 | QUnit.ok(element, 'an event was triggered'); | ||
145 | QUnit.strictEqual(element.type, 'tag', 'the line type is tag'); | ||
146 | QUnit.strictEqual(element.tagType, 'm3u', 'the tag type is m3u'); | ||
147 | }); | ||
148 | |||
149 | // #EXTINF | ||
150 | QUnit.test('parses minimal #EXTINF tags', function() { | ||
151 | let manifest = '#EXTINF\n'; | ||
152 | let element; | ||
153 | |||
154 | this.parseStream.on('data', function(elem) { | ||
155 | element = elem; | ||
156 | }); | ||
157 | this.lineStream.push(manifest); | ||
158 | |||
159 | QUnit.ok(element, 'an event was triggered'); | ||
160 | QUnit.strictEqual(element.type, 'tag', 'the line type is tag'); | ||
161 | QUnit.strictEqual(element.tagType, 'inf', 'the tag type is inf'); | ||
162 | }); | ||
163 | QUnit.test('parses #EXTINF tags with durations', function() { | ||
164 | let manifest = '#EXTINF:15\n'; | ||
165 | let element; | ||
166 | |||
167 | this.parseStream.on('data', function(elem) { | ||
168 | element = elem; | ||
169 | }); | ||
170 | this.lineStream.push(manifest); | ||
171 | |||
172 | QUnit.ok(element, 'an event was triggered'); | ||
173 | QUnit.strictEqual(element.type, 'tag', 'the line type is tag'); | ||
174 | QUnit.strictEqual(element.tagType, 'inf', 'the tag type is inf'); | ||
175 | QUnit.strictEqual(element.duration, 15, 'the duration is parsed'); | ||
176 | QUnit.ok(!('title' in element), 'no title is parsed'); | ||
177 | |||
178 | manifest = '#EXTINF:21,\n'; | ||
179 | this.lineStream.push(manifest); | ||
180 | |||
181 | QUnit.ok(element, 'an event was triggered'); | ||
182 | QUnit.strictEqual(element.type, 'tag', 'the line type is tag'); | ||
183 | QUnit.strictEqual(element.tagType, 'inf', 'the tag type is inf'); | ||
184 | QUnit.strictEqual(element.duration, 21, 'the duration is parsed'); | ||
185 | QUnit.ok(!('title' in element), 'no title is parsed'); | ||
186 | }); | ||
187 | QUnit.test('parses #EXTINF tags with a duration and title', function() { | ||
188 | let manifest = '#EXTINF:13,Does anyone really use the title attribute?\n'; | ||
189 | let element; | ||
190 | |||
191 | this.parseStream.on('data', function(elem) { | ||
192 | element = elem; | ||
193 | }); | ||
194 | this.lineStream.push(manifest); | ||
195 | |||
196 | QUnit.ok(element, 'an event was triggered'); | ||
197 | QUnit.strictEqual(element.type, 'tag', 'the line type is tag'); | ||
198 | QUnit.strictEqual(element.tagType, 'inf', 'the tag type is inf'); | ||
199 | QUnit.strictEqual(element.duration, 13, 'the duration is parsed'); | ||
200 | QUnit.strictEqual(element.title, | ||
201 | manifest.substring(manifest.indexOf(',') + 1, manifest.length - 1), | ||
202 | 'the title is parsed'); | ||
203 | }); | ||
204 | QUnit.test('parses #EXTINF tags with carriage returns', function() { | ||
205 | let manifest = '#EXTINF:13,Does anyone really use the title attribute?\r\n'; | ||
206 | let element; | ||
207 | |||
208 | this.parseStream.on('data', function(elem) { | ||
209 | element = elem; | ||
210 | }); | ||
211 | this.lineStream.push(manifest); | ||
212 | |||
213 | QUnit.ok(element, 'an event was triggered'); | ||
214 | QUnit.strictEqual(element.type, 'tag', 'the line type is tag'); | ||
215 | QUnit.strictEqual(element.tagType, 'inf', 'the tag type is inf'); | ||
216 | QUnit.strictEqual(element.duration, 13, 'the duration is parsed'); | ||
217 | QUnit.strictEqual(element.title, | ||
218 | manifest.substring(manifest.indexOf(',') + 1, manifest.length - 2), | ||
219 | 'the title is parsed'); | ||
220 | }); | ||
221 | |||
222 | // #EXT-X-TARGETDURATION | ||
223 | QUnit.test('parses minimal #EXT-X-TARGETDURATION tags', function() { | ||
224 | let manifest = '#EXT-X-TARGETDURATION\n'; | ||
225 | let element; | ||
226 | |||
227 | this.parseStream.on('data', function(elem) { | ||
228 | element = elem; | ||
229 | }); | ||
230 | this.lineStream.push(manifest); | ||
231 | |||
232 | QUnit.ok(element, 'an event was triggered'); | ||
233 | QUnit.strictEqual(element.type, 'tag', 'the line type is tag'); | ||
234 | QUnit.strictEqual(element.tagType, 'targetduration', 'the tag type is targetduration'); | ||
235 | QUnit.ok(!('duration' in element), 'no duration is parsed'); | ||
236 | }); | ||
237 | QUnit.test('parses #EXT-X-TARGETDURATION with duration', function() { | ||
238 | let manifest = '#EXT-X-TARGETDURATION:47\n'; | ||
239 | let element; | ||
240 | |||
241 | this.parseStream.on('data', function(elem) { | ||
242 | element = elem; | ||
243 | }); | ||
244 | this.lineStream.push(manifest); | ||
245 | |||
246 | QUnit.ok(element, 'an event was triggered'); | ||
247 | QUnit.strictEqual(element.type, 'tag', 'the line type is tag'); | ||
248 | QUnit.strictEqual(element.tagType, 'targetduration', 'the tag type is targetduration'); | ||
249 | QUnit.strictEqual(element.duration, 47, 'the duration is parsed'); | ||
250 | }); | ||
251 | |||
252 | // #EXT-X-VERSION | ||
253 | QUnit.test('parses minimal #EXT-X-VERSION tags', function() { | ||
254 | let manifest = '#EXT-X-VERSION:\n'; | ||
255 | let element; | ||
256 | |||
257 | this.parseStream.on('data', function(elem) { | ||
258 | element = elem; | ||
259 | }); | ||
260 | this.lineStream.push(manifest); | ||
261 | |||
262 | QUnit.ok(element, 'an event was triggered'); | ||
263 | QUnit.strictEqual(element.type, 'tag', 'the line type is tag'); | ||
264 | QUnit.strictEqual(element.tagType, 'version', 'the tag type is version'); | ||
265 | QUnit.ok(!('version' in element), 'no version is present'); | ||
266 | }); | ||
267 | QUnit.test('parses #EXT-X-VERSION with a version', function() { | ||
268 | let manifest = '#EXT-X-VERSION:99\n'; | ||
269 | let element; | ||
270 | |||
271 | this.parseStream.on('data', function(elem) { | ||
272 | element = elem; | ||
273 | }); | ||
274 | this.lineStream.push(manifest); | ||
275 | |||
276 | QUnit.ok(element, 'an event was triggered'); | ||
277 | QUnit.strictEqual(element.type, 'tag', 'the line type is tag'); | ||
278 | QUnit.strictEqual(element.tagType, 'version', 'the tag type is version'); | ||
279 | QUnit.strictEqual(element.version, 99, 'the version is parsed'); | ||
280 | }); | ||
281 | |||
282 | // #EXT-X-MEDIA-SEQUENCE | ||
283 | QUnit.test('parses minimal #EXT-X-MEDIA-SEQUENCE tags', function() { | ||
284 | let manifest = '#EXT-X-MEDIA-SEQUENCE\n'; | ||
285 | let element; | ||
286 | |||
287 | this.parseStream.on('data', function(elem) { | ||
288 | element = elem; | ||
289 | }); | ||
290 | this.lineStream.push(manifest); | ||
291 | |||
292 | QUnit.ok(element, 'an event was triggered'); | ||
293 | QUnit.strictEqual(element.type, 'tag', 'the line type is tag'); | ||
294 | QUnit.strictEqual(element.tagType, 'media-sequence', 'the tag type is media-sequence'); | ||
295 | QUnit.ok(!('number' in element), 'no number is present'); | ||
296 | }); | ||
297 | QUnit.test('parses #EXT-X-MEDIA-SEQUENCE with sequence numbers', function() { | ||
298 | let manifest = '#EXT-X-MEDIA-SEQUENCE:109\n'; | ||
299 | let element; | ||
300 | |||
301 | this.parseStream.on('data', function(elem) { | ||
302 | element = elem; | ||
303 | }); | ||
304 | this.lineStream.push(manifest); | ||
305 | |||
306 | QUnit.ok(element, 'an event was triggered'); | ||
307 | QUnit.strictEqual(element.type, 'tag', 'the line type is tag'); | ||
308 | QUnit.strictEqual(element.tagType, 'media-sequence', 'the tag type is media-sequence'); | ||
309 | QUnit.ok(element.number, 109, 'the number is parsed'); | ||
310 | }); | ||
311 | |||
312 | // #EXT-X-PLAYLIST-TYPE | ||
313 | QUnit.test('parses minimal #EXT-X-PLAYLIST-TYPE tags', function() { | ||
314 | let manifest = '#EXT-X-PLAYLIST-TYPE:\n'; | ||
315 | let element; | ||
316 | |||
317 | this.parseStream.on('data', function(elem) { | ||
318 | element = elem; | ||
319 | }); | ||
320 | this.lineStream.push(manifest); | ||
321 | |||
322 | QUnit.ok(element, 'an event was triggered'); | ||
323 | QUnit.strictEqual(element.type, 'tag', 'the line type is tag'); | ||
324 | QUnit.strictEqual(element.tagType, 'playlist-type', 'the tag type is playlist-type'); | ||
325 | QUnit.ok(!('playlistType' in element), 'no playlist type is present'); | ||
326 | }); | ||
327 | QUnit.test('parses #EXT-X-PLAYLIST-TYPE with mutability info', function() { | ||
328 | let manifest = '#EXT-X-PLAYLIST-TYPE:EVENT\n'; | ||
329 | let element; | ||
330 | |||
331 | this.parseStream.on('data', function(elem) { | ||
332 | element = elem; | ||
333 | }); | ||
334 | this.lineStream.push(manifest); | ||
335 | |||
336 | QUnit.ok(element, 'an event was triggered'); | ||
337 | QUnit.strictEqual(element.type, 'tag', 'the line type is tag'); | ||
338 | QUnit.strictEqual(element.tagType, 'playlist-type', 'the tag type is playlist-type'); | ||
339 | QUnit.strictEqual(element.playlistType, 'EVENT', 'the playlist type is EVENT'); | ||
340 | |||
341 | manifest = '#EXT-X-PLAYLIST-TYPE:VOD\n'; | ||
342 | this.lineStream.push(manifest); | ||
343 | QUnit.ok(element, 'an event was triggered'); | ||
344 | QUnit.strictEqual(element.type, 'tag', 'the line type is tag'); | ||
345 | QUnit.strictEqual(element.tagType, 'playlist-type', 'the tag type is playlist-type'); | ||
346 | QUnit.strictEqual(element.playlistType, 'VOD', 'the playlist type is VOD'); | ||
347 | |||
348 | manifest = '#EXT-X-PLAYLIST-TYPE:nonsense\n'; | ||
349 | this.lineStream.push(manifest); | ||
350 | QUnit.ok(element, 'an event was triggered'); | ||
351 | QUnit.strictEqual(element.type, 'tag', 'the line type is tag'); | ||
352 | QUnit.strictEqual(element.tagType, 'playlist-type', 'the tag type is playlist-type'); | ||
353 | QUnit.strictEqual(element.playlistType, 'nonsense', 'the playlist type is parsed'); | ||
354 | }); | ||
355 | |||
356 | // #EXT-X-BYTERANGE | ||
357 | QUnit.test('parses minimal #EXT-X-BYTERANGE tags', function() { | ||
358 | let manifest = '#EXT-X-BYTERANGE\n'; | ||
359 | let element; | ||
360 | |||
361 | this.parseStream.on('data', function(elem) { | ||
362 | element = elem; | ||
363 | }); | ||
364 | this.lineStream.push(manifest); | ||
365 | |||
366 | QUnit.ok(element, 'an event was triggered'); | ||
367 | QUnit.strictEqual(element.type, 'tag', 'the line type is tag'); | ||
368 | QUnit.strictEqual(element.tagType, 'byterange', 'the tag type is byterange'); | ||
369 | QUnit.ok(!('length' in element), 'no length is present'); | ||
370 | QUnit.ok(!('offset' in element), 'no offset is present'); | ||
371 | }); | ||
372 | QUnit.test('parses #EXT-X-BYTERANGE with length and offset', function() { | ||
373 | let manifest = '#EXT-X-BYTERANGE:45\n'; | ||
374 | let element; | ||
375 | |||
376 | this.parseStream.on('data', function(elem) { | ||
377 | element = elem; | ||
378 | }); | ||
379 | this.lineStream.push(manifest); | ||
380 | |||
381 | QUnit.ok(element, 'an event was triggered'); | ||
382 | QUnit.strictEqual(element.type, 'tag', 'the line type is tag'); | ||
383 | QUnit.strictEqual(element.tagType, 'byterange', 'the tag type is byterange'); | ||
384 | QUnit.strictEqual(element.length, 45, 'length is parsed'); | ||
385 | QUnit.ok(!('offset' in element), 'no offset is present'); | ||
386 | |||
387 | manifest = '#EXT-X-BYTERANGE:108@16\n'; | ||
388 | this.lineStream.push(manifest); | ||
389 | QUnit.ok(element, 'an event was triggered'); | ||
390 | QUnit.strictEqual(element.type, 'tag', 'the line type is tag'); | ||
391 | QUnit.strictEqual(element.tagType, 'byterange', 'the tag type is byterange'); | ||
392 | QUnit.strictEqual(element.length, 108, 'length is parsed'); | ||
393 | QUnit.strictEqual(element.offset, 16, 'offset is parsed'); | ||
394 | }); | ||
395 | |||
396 | // #EXT-X-ALLOW-CACHE | ||
397 | QUnit.test('parses minimal #EXT-X-ALLOW-CACHE tags', function() { | ||
398 | let manifest = '#EXT-X-ALLOW-CACHE:\n'; | ||
399 | let element; | ||
400 | |||
401 | this.parseStream.on('data', function(elem) { | ||
402 | element = elem; | ||
403 | }); | ||
404 | this.lineStream.push(manifest); | ||
405 | |||
406 | QUnit.ok(element, 'an event was triggered'); | ||
407 | QUnit.strictEqual(element.type, 'tag', 'the line type is tag'); | ||
408 | QUnit.strictEqual(element.tagType, 'allow-cache', 'the tag type is allow-cache'); | ||
409 | QUnit.ok(!('allowed' in element), 'no allowed is present'); | ||
410 | }); | ||
411 | QUnit.test('parses valid #EXT-X-ALLOW-CACHE tags', function() { | ||
412 | let manifest = '#EXT-X-ALLOW-CACHE:YES\n'; | ||
413 | let element; | ||
414 | |||
415 | this.parseStream.on('data', function(elem) { | ||
416 | element = elem; | ||
417 | }); | ||
418 | this.lineStream.push(manifest); | ||
419 | |||
420 | QUnit.ok(element, 'an event was triggered'); | ||
421 | QUnit.strictEqual(element.type, 'tag', 'the line type is tag'); | ||
422 | QUnit.strictEqual(element.tagType, 'allow-cache', 'the tag type is allow-cache'); | ||
423 | QUnit.ok(element.allowed, 'allowed is parsed'); | ||
424 | |||
425 | manifest = '#EXT-X-ALLOW-CACHE:NO\n'; | ||
426 | this.lineStream.push(manifest); | ||
427 | |||
428 | QUnit.ok(element, 'an event was triggered'); | ||
429 | QUnit.strictEqual(element.type, 'tag', 'the line type is tag'); | ||
430 | QUnit.strictEqual(element.tagType, 'allow-cache', 'the tag type is allow-cache'); | ||
431 | QUnit.ok(!element.allowed, 'allowed is parsed'); | ||
432 | }); | ||
433 | // #EXT-X-STREAM-INF | ||
434 | QUnit.test('parses minimal #EXT-X-STREAM-INF tags', function() { | ||
435 | let manifest = '#EXT-X-STREAM-INF\n'; | ||
436 | let element; | ||
437 | |||
438 | this.parseStream.on('data', function(elem) { | ||
439 | element = elem; | ||
440 | }); | ||
441 | this.lineStream.push(manifest); | ||
442 | |||
443 | QUnit.ok(element, 'an event was triggered'); | ||
444 | QUnit.strictEqual(element.type, 'tag', 'the line type is tag'); | ||
445 | QUnit.strictEqual(element.tagType, 'stream-inf', 'the tag type is stream-inf'); | ||
446 | QUnit.ok(!('attributes' in element), 'no attributes are present'); | ||
447 | }); | ||
448 | QUnit.test('parses #EXT-X-STREAM-INF with common attributes', function() { | ||
449 | let manifest = '#EXT-X-STREAM-INF:BANDWIDTH=14400\n'; | ||
450 | let element; | ||
451 | |||
452 | this.parseStream.on('data', function(elem) { | ||
453 | element = elem; | ||
454 | }); | ||
455 | this.lineStream.push(manifest); | ||
456 | |||
457 | QUnit.ok(element, 'an event was triggered'); | ||
458 | QUnit.strictEqual(element.type, 'tag', 'the line type is tag'); | ||
459 | QUnit.strictEqual(element.tagType, 'stream-inf', 'the tag type is stream-inf'); | ||
460 | QUnit.strictEqual(element.attributes.BANDWIDTH, 14400, 'bandwidth is parsed'); | ||
461 | |||
462 | manifest = '#EXT-X-STREAM-INF:PROGRAM-ID=7\n'; | ||
463 | this.lineStream.push(manifest); | ||
464 | |||
465 | QUnit.ok(element, 'an event was triggered'); | ||
466 | QUnit.strictEqual(element.type, 'tag', 'the line type is tag'); | ||
467 | QUnit.strictEqual(element.tagType, 'stream-inf', 'the tag type is stream-inf'); | ||
468 | QUnit.strictEqual(element.attributes['PROGRAM-ID'], 7, 'program-id is parsed'); | ||
469 | |||
470 | manifest = '#EXT-X-STREAM-INF:RESOLUTION=396x224\n'; | ||
471 | this.lineStream.push(manifest); | ||
472 | |||
473 | QUnit.ok(element, 'an event was triggered'); | ||
474 | QUnit.strictEqual(element.type, 'tag', 'the line type is tag'); | ||
475 | QUnit.strictEqual(element.tagType, 'stream-inf', 'the tag type is stream-inf'); | ||
476 | QUnit.strictEqual(element.attributes.RESOLUTION.width, 396, 'width is parsed'); | ||
477 | QUnit.strictEqual(element.attributes.RESOLUTION.height, 224, 'heigth is parsed'); | ||
478 | |||
479 | manifest = '#EXT-X-STREAM-INF:CODECS="avc1.4d400d, mp4a.40.2"\n'; | ||
480 | this.lineStream.push(manifest); | ||
481 | |||
482 | QUnit.ok(element, 'an event was triggered'); | ||
483 | QUnit.strictEqual(element.type, 'tag', 'the line type is tag'); | ||
484 | QUnit.strictEqual(element.tagType, 'stream-inf', 'the tag type is stream-inf'); | ||
485 | QUnit.strictEqual(element.attributes.CODECS, | ||
486 | 'avc1.4d400d, mp4a.40.2', | ||
487 | 'codecs are parsed'); | ||
488 | }); | ||
489 | QUnit.test('parses #EXT-X-STREAM-INF with arbitrary attributes', function() { | ||
490 | let manifest = '#EXT-X-STREAM-INF:NUMERIC=24,ALPHA=Value,MIXED=123abc\n'; | ||
491 | let element; | ||
492 | |||
493 | this.parseStream.on('data', function(elem) { | ||
494 | element = elem; | ||
495 | }); | ||
496 | this.lineStream.push(manifest); | ||
497 | |||
498 | QUnit.ok(element, 'an event was triggered'); | ||
499 | QUnit.strictEqual(element.type, 'tag', 'the line type is tag'); | ||
500 | QUnit.strictEqual(element.tagType, 'stream-inf', 'the tag type is stream-inf'); | ||
501 | QUnit.strictEqual(element.attributes.NUMERIC, '24', 'numeric attributes are parsed'); | ||
502 | QUnit.strictEqual(element.attributes.ALPHA, | ||
503 | 'Value', | ||
504 | 'alphabetic attributes are parsed'); | ||
505 | QUnit.strictEqual(element.attributes.MIXED, '123abc', 'mixed attributes are parsed'); | ||
506 | }); | ||
507 | // #EXT-X-ENDLIST | ||
508 | QUnit.test('parses #EXT-X-ENDLIST tags', function() { | ||
509 | let manifest = '#EXT-X-ENDLIST\n'; | ||
510 | let element; | ||
511 | |||
512 | this.parseStream.on('data', function(elem) { | ||
513 | element = elem; | ||
514 | }); | ||
515 | this.lineStream.push(manifest); | ||
516 | |||
517 | QUnit.ok(element, 'an event was triggered'); | ||
518 | QUnit.strictEqual(element.type, 'tag', 'the line type is tag'); | ||
519 | QUnit.strictEqual(element.tagType, 'endlist', 'the tag type is stream-inf'); | ||
520 | }); | ||
521 | |||
522 | // #EXT-X-KEY | ||
523 | QUnit.test('parses valid #EXT-X-KEY tags', function() { | ||
524 | let manifest = | ||
525 | '#EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=52"\n'; | ||
526 | let element; | ||
527 | |||
528 | this.parseStream.on('data', function(elem) { | ||
529 | element = elem; | ||
530 | }); | ||
531 | this.lineStream.push(manifest); | ||
532 | |||
533 | QUnit.ok(element, 'an event was triggered'); | ||
534 | QUnit.deepEqual(element, { | ||
535 | type: 'tag', | ||
536 | tagType: 'key', | ||
537 | attributes: { | ||
538 | METHOD: 'AES-128', | ||
539 | URI: 'https://priv.example.com/key.php?r=52' | ||
540 | } | ||
541 | }, 'parsed a valid key'); | ||
542 | |||
543 | manifest = '#EXT-X-KEY:URI="https://example.com/key#1",METHOD=FutureType-1024\n'; | ||
544 | this.lineStream.push(manifest); | ||
545 | QUnit.ok(element, 'an event was triggered'); | ||
546 | QUnit.deepEqual(element, { | ||
547 | type: 'tag', | ||
548 | tagType: 'key', | ||
549 | attributes: { | ||
550 | METHOD: 'FutureType-1024', | ||
551 | URI: 'https://example.com/key#1' | ||
552 | } | ||
553 | }, 'parsed the attribute list independent of order'); | ||
554 | |||
555 | manifest = '#EXT-X-KEY:IV=1234567890abcdef1234567890abcdef\n'; | ||
556 | this.lineStream.push(manifest); | ||
557 | QUnit.ok(element.attributes.IV, 'detected an IV attribute'); | ||
558 | QUnit.deepEqual(element.attributes.IV, new Uint32Array([ | ||
559 | 0x12345678, | ||
560 | 0x90abcdef, | ||
561 | 0x12345678, | ||
562 | 0x90abcdef | ||
563 | ]), 'parsed an IV value'); | ||
564 | }); | ||
565 | |||
566 | QUnit.test('parses minimal #EXT-X-KEY tags', function() { | ||
567 | let manifest = '#EXT-X-KEY:\n'; | ||
568 | let element; | ||
569 | |||
570 | this.parseStream.on('data', function(elem) { | ||
571 | element = elem; | ||
572 | }); | ||
573 | this.lineStream.push(manifest); | ||
574 | |||
575 | QUnit.ok(element, 'an event was triggered'); | ||
576 | QUnit.deepEqual(element, { | ||
577 | type: 'tag', | ||
578 | tagType: 'key' | ||
579 | }, 'parsed a minimal key tag'); | ||
580 | }); | ||
581 | |||
582 | QUnit.test('parses lightly-broken #EXT-X-KEY tags', function() { | ||
583 | let manifest = '#EXT-X-KEY:URI=\'https://example.com/single-quote\',METHOD=AES-128\n'; | ||
584 | let element; | ||
585 | |||
586 | this.parseStream.on('data', function(elem) { | ||
587 | element = elem; | ||
588 | }); | ||
589 | this.lineStream.push(manifest); | ||
590 | |||
591 | QUnit.strictEqual(element.attributes.URI, | ||
592 | 'https://example.com/single-quote', | ||
593 | 'parsed a single-quoted uri'); | ||
594 | |||
595 | element = null; | ||
596 | manifest = '#EXT-X-KEYURI="https://example.com/key",METHOD=AES-128\n'; | ||
597 | this.lineStream.push(manifest); | ||
598 | QUnit.strictEqual(element.tagType, 'key', 'parsed the tag type'); | ||
599 | QUnit.strictEqual(element.attributes.URI, | ||
600 | 'https://example.com/key', | ||
601 | 'inferred a colon after the tag type'); | ||
602 | |||
603 | element = null; | ||
604 | manifest = '#EXT-X-KEY: URI = "https://example.com/key",METHOD=AES-128\n'; | ||
605 | this.lineStream.push(manifest); | ||
606 | QUnit.strictEqual(element.attributes.URI, | ||
607 | 'https://example.com/key', | ||
608 | 'trims and removes quotes around the URI'); | ||
609 | }); | ||
610 | |||
611 | QUnit.test('ignores empty lines', function() { | ||
612 | let manifest = '\n'; | ||
613 | let event = false; | ||
614 | |||
615 | this.parseStream.on('data', function() { | ||
616 | event = true; | ||
617 | }); | ||
618 | this.lineStream.push(manifest); | ||
619 | |||
620 | QUnit.ok(!event, 'no event is triggered'); | ||
621 | }); | ||
622 | |||
623 | QUnit.module('m3u8 parser'); | ||
624 | |||
625 | QUnit.test('can be constructed', function() { | ||
626 | QUnit.notStrictEqual(typeof new Parser(), 'undefined', 'parser is defined'); | ||
627 | }); | ||
628 | |||
629 | QUnit.module('m3u8s'); | ||
630 | |||
631 | QUnit.test('parses static manifests as expected', function() { | ||
632 | let key; | ||
633 | |||
634 | for (key in testDataManifests) { | ||
635 | if (testDataExpected[key]) { | ||
636 | let parser = new Parser(); | ||
637 | |||
638 | parser.push(testDataManifests[key]); | ||
639 | QUnit.deepEqual(parser.manifest, | ||
640 | testDataExpected[key], | ||
641 | key + '.m3u8 was parsed correctly' | ||
642 | ); | ||
643 | } | ||
644 | } | ||
645 | }); |
test/m3u8_test.js
deleted
100644 → 0
1 | (function(window, undefined) { | ||
2 | var | ||
3 | //manifestController = this.manifestController, | ||
4 | m3u8 = window.videojs.m3u8, | ||
5 | ParseStream = m3u8.ParseStream, | ||
6 | parseStream, | ||
7 | LineStream = m3u8.LineStream, | ||
8 | lineStream, | ||
9 | Parser = m3u8.Parser, | ||
10 | parser; | ||
11 | |||
12 | /* | ||
13 | M3U8 Test Suite | ||
14 | */ | ||
15 | |||
16 | module('LineStream', { | ||
17 | setup: function() { | ||
18 | lineStream = new LineStream(); | ||
19 | } | ||
20 | }); | ||
21 | test('empty inputs produce no tokens', function() { | ||
22 | var data = false; | ||
23 | lineStream.on('data', function() { | ||
24 | data = true; | ||
25 | }); | ||
26 | lineStream.push(''); | ||
27 | ok(!data, 'no tokens were produced'); | ||
28 | }); | ||
29 | test('splits on newlines', function() { | ||
30 | var lines = []; | ||
31 | lineStream.on('data', function(line) { | ||
32 | lines.push(line); | ||
33 | }); | ||
34 | lineStream.push('#EXTM3U\nmovie.ts\n'); | ||
35 | |||
36 | strictEqual(2, lines.length, 'two lines are ready'); | ||
37 | strictEqual('#EXTM3U', lines.shift(), 'the first line is the first token'); | ||
38 | strictEqual('movie.ts', lines.shift(), 'the second line is the second token'); | ||
39 | }); | ||
40 | test('empty lines become empty strings', function() { | ||
41 | var lines = []; | ||
42 | lineStream.on('data', function(line) { | ||
43 | lines.push(line); | ||
44 | }); | ||
45 | lineStream.push('\n\n'); | ||
46 | |||
47 | strictEqual(2, lines.length, 'two lines are ready'); | ||
48 | strictEqual('', lines.shift(), 'the first line is empty'); | ||
49 | strictEqual('', lines.shift(), 'the second line is empty'); | ||
50 | }); | ||
51 | test('handles lines broken across appends', function() { | ||
52 | var lines = []; | ||
53 | lineStream.on('data', function(line) { | ||
54 | lines.push(line); | ||
55 | }); | ||
56 | lineStream.push('#EXTM'); | ||
57 | strictEqual(0, lines.length, 'no lines are ready'); | ||
58 | |||
59 | lineStream.push('3U\nmovie.ts\n'); | ||
60 | strictEqual(2, lines.length, 'two lines are ready'); | ||
61 | strictEqual('#EXTM3U', lines.shift(), 'the first line is the first token'); | ||
62 | strictEqual('movie.ts', lines.shift(), 'the second line is the second token'); | ||
63 | }); | ||
64 | test('stops sending events after deregistering', function() { | ||
65 | var | ||
66 | temporaryLines = [], | ||
67 | temporary = function(line) { | ||
68 | temporaryLines.push(line); | ||
69 | }, | ||
70 | permanentLines = [], | ||
71 | permanent = function(line) { | ||
72 | permanentLines.push(line); | ||
73 | }; | ||
74 | |||
75 | lineStream.on('data', temporary); | ||
76 | lineStream.on('data', permanent); | ||
77 | lineStream.push('line one\n'); | ||
78 | strictEqual(temporaryLines.length, permanentLines.length, 'both callbacks receive the event'); | ||
79 | |||
80 | ok(lineStream.off('data', temporary), 'a listener was removed'); | ||
81 | lineStream.push('line two\n'); | ||
82 | strictEqual(1, temporaryLines.length, 'no new events are received'); | ||
83 | strictEqual(2, permanentLines.length, 'new events are still received'); | ||
84 | }); | ||
85 | |||
86 | module('ParseStream', { | ||
87 | setup: function() { | ||
88 | lineStream = new LineStream(); | ||
89 | parseStream = new ParseStream(); | ||
90 | lineStream.pipe(parseStream); | ||
91 | } | ||
92 | }); | ||
93 | test('parses comment lines', function() { | ||
94 | var | ||
95 | manifest = '# a line that starts with a hash mark without "EXT" is a comment\n', | ||
96 | element; | ||
97 | parseStream.on('data', function(elem) { | ||
98 | element = elem; | ||
99 | }); | ||
100 | lineStream.push(manifest); | ||
101 | |||
102 | ok(element, 'an event was triggered'); | ||
103 | strictEqual(element.type, 'comment', 'the type is comment'); | ||
104 | strictEqual(element.text, | ||
105 | manifest.slice(1, manifest.length - 1), | ||
106 | 'the comment text is parsed'); | ||
107 | }); | ||
108 | test('parses uri lines', function() { | ||
109 | var | ||
110 | manifest = 'any non-blank line that does not start with a hash-mark is a URI\n', | ||
111 | element; | ||
112 | parseStream.on('data', function(elem) { | ||
113 | element = elem; | ||
114 | }); | ||
115 | lineStream.push(manifest); | ||
116 | |||
117 | ok(element, 'an event was triggered'); | ||
118 | strictEqual(element.type, 'uri', 'the type is uri'); | ||
119 | strictEqual(element.uri, | ||
120 | manifest.substring(0, manifest.length - 1), | ||
121 | 'the uri text is parsed'); | ||
122 | }); | ||
123 | test('parses unknown tag types', function() { | ||
124 | var | ||
125 | manifest = '#EXT-X-EXAMPLE-TAG:some,additional,stuff\n', | ||
126 | element; | ||
127 | parseStream.on('data', function(elem) { | ||
128 | element = elem; | ||
129 | }); | ||
130 | lineStream.push(manifest); | ||
131 | |||
132 | ok(element, 'an event was triggered'); | ||
133 | strictEqual(element.type, 'tag', 'the type is tag'); | ||
134 | strictEqual(element.data, | ||
135 | manifest.slice(4, manifest.length - 1), | ||
136 | 'unknown tag data is preserved'); | ||
137 | }); | ||
138 | |||
139 | // #EXTM3U | ||
140 | test('parses #EXTM3U tags', function() { | ||
141 | var | ||
142 | manifest = '#EXTM3U\n', | ||
143 | element; | ||
144 | parseStream.on('data', function(elem) { | ||
145 | element = elem; | ||
146 | }); | ||
147 | lineStream.push(manifest); | ||
148 | |||
149 | ok(element, 'an event was triggered'); | ||
150 | strictEqual(element.type, 'tag', 'the line type is tag'); | ||
151 | strictEqual(element.tagType, 'm3u', 'the tag type is m3u'); | ||
152 | }); | ||
153 | |||
154 | // #EXTINF | ||
155 | test('parses minimal #EXTINF tags', function() { | ||
156 | var | ||
157 | manifest = '#EXTINF\n', | ||
158 | element; | ||
159 | parseStream.on('data', function(elem) { | ||
160 | element = elem; | ||
161 | }); | ||
162 | lineStream.push(manifest); | ||
163 | |||
164 | ok(element, 'an event was triggered'); | ||
165 | strictEqual(element.type, 'tag', 'the line type is tag'); | ||
166 | strictEqual(element.tagType, 'inf', 'the tag type is inf'); | ||
167 | }); | ||
168 | test('parses #EXTINF tags with durations', function() { | ||
169 | var | ||
170 | manifest = '#EXTINF:15\n', | ||
171 | element; | ||
172 | parseStream.on('data', function(elem) { | ||
173 | element = elem; | ||
174 | }); | ||
175 | lineStream.push(manifest); | ||
176 | |||
177 | ok(element, 'an event was triggered'); | ||
178 | strictEqual(element.type, 'tag', 'the line type is tag'); | ||
179 | strictEqual(element.tagType, 'inf', 'the tag type is inf'); | ||
180 | strictEqual(element.duration, 15, 'the duration is parsed'); | ||
181 | ok(!('title' in element), 'no title is parsed'); | ||
182 | |||
183 | manifest = '#EXTINF:21,\n'; | ||
184 | lineStream.push(manifest); | ||
185 | |||
186 | ok(element, 'an event was triggered'); | ||
187 | strictEqual(element.type, 'tag', 'the line type is tag'); | ||
188 | strictEqual(element.tagType, 'inf', 'the tag type is inf'); | ||
189 | strictEqual(element.duration, 21, 'the duration is parsed'); | ||
190 | ok(!('title' in element), 'no title is parsed'); | ||
191 | }); | ||
192 | test('parses #EXTINF tags with a duration and title', function() { | ||
193 | var | ||
194 | manifest = '#EXTINF:13,Does anyone really use the title attribute?\n', | ||
195 | element; | ||
196 | parseStream.on('data', function(elem) { | ||
197 | element = elem; | ||
198 | }); | ||
199 | lineStream.push(manifest); | ||
200 | |||
201 | ok(element, 'an event was triggered'); | ||
202 | strictEqual(element.type, 'tag', 'the line type is tag'); | ||
203 | strictEqual(element.tagType, 'inf', 'the tag type is inf'); | ||
204 | strictEqual(element.duration, 13, 'the duration is parsed'); | ||
205 | strictEqual(element.title, | ||
206 | manifest.substring(manifest.indexOf(',') + 1, manifest.length - 1), | ||
207 | 'the title is parsed'); | ||
208 | }); | ||
209 | test('parses #EXTINF tags with carriage returns', function() { | ||
210 | var | ||
211 | manifest = '#EXTINF:13,Does anyone really use the title attribute?\r\n', | ||
212 | element; | ||
213 | parseStream.on('data', function(elem) { | ||
214 | element = elem; | ||
215 | }); | ||
216 | lineStream.push(manifest); | ||
217 | |||
218 | ok(element, 'an event was triggered'); | ||
219 | strictEqual(element.type, 'tag', 'the line type is tag'); | ||
220 | strictEqual(element.tagType, 'inf', 'the tag type is inf'); | ||
221 | strictEqual(element.duration, 13, 'the duration is parsed'); | ||
222 | strictEqual(element.title, | ||
223 | manifest.substring(manifest.indexOf(',') + 1, manifest.length - 2), | ||
224 | 'the title is parsed'); | ||
225 | }); | ||
226 | |||
227 | // #EXT-X-TARGETDURATION | ||
228 | test('parses minimal #EXT-X-TARGETDURATION tags', function() { | ||
229 | var | ||
230 | manifest = '#EXT-X-TARGETDURATION\n', | ||
231 | element; | ||
232 | parseStream.on('data', function(elem) { | ||
233 | element = elem; | ||
234 | }); | ||
235 | lineStream.push(manifest); | ||
236 | |||
237 | ok(element, 'an event was triggered'); | ||
238 | strictEqual(element.type, 'tag', 'the line type is tag'); | ||
239 | strictEqual(element.tagType, 'targetduration', 'the tag type is targetduration'); | ||
240 | ok(!('duration' in element), 'no duration is parsed'); | ||
241 | }); | ||
242 | test('parses #EXT-X-TARGETDURATION with duration', function() { | ||
243 | var | ||
244 | manifest = '#EXT-X-TARGETDURATION:47\n', | ||
245 | element; | ||
246 | parseStream.on('data', function(elem) { | ||
247 | element = elem; | ||
248 | }); | ||
249 | lineStream.push(manifest); | ||
250 | |||
251 | ok(element, 'an event was triggered'); | ||
252 | strictEqual(element.type, 'tag', 'the line type is tag'); | ||
253 | strictEqual(element.tagType, 'targetduration', 'the tag type is targetduration'); | ||
254 | strictEqual(element.duration, 47, 'the duration is parsed'); | ||
255 | }); | ||
256 | |||
257 | // #EXT-X-VERSION | ||
258 | test('parses minimal #EXT-X-VERSION tags', function() { | ||
259 | var | ||
260 | manifest = '#EXT-X-VERSION:\n', | ||
261 | element; | ||
262 | parseStream.on('data', function(elem) { | ||
263 | element = elem; | ||
264 | }); | ||
265 | lineStream.push(manifest); | ||
266 | |||
267 | ok(element, 'an event was triggered'); | ||
268 | strictEqual(element.type, 'tag', 'the line type is tag'); | ||
269 | strictEqual(element.tagType, 'version', 'the tag type is version'); | ||
270 | ok(!('version' in element), 'no version is present'); | ||
271 | }); | ||
272 | test('parses #EXT-X-VERSION with a version', function() { | ||
273 | var | ||
274 | manifest = '#EXT-X-VERSION:99\n', | ||
275 | element; | ||
276 | parseStream.on('data', function(elem) { | ||
277 | element = elem; | ||
278 | }); | ||
279 | lineStream.push(manifest); | ||
280 | |||
281 | ok(element, 'an event was triggered'); | ||
282 | strictEqual(element.type, 'tag', 'the line type is tag'); | ||
283 | strictEqual(element.tagType, 'version', 'the tag type is version'); | ||
284 | strictEqual(element.version, 99, 'the version is parsed'); | ||
285 | }); | ||
286 | |||
287 | // #EXT-X-MEDIA-SEQUENCE | ||
288 | test('parses minimal #EXT-X-MEDIA-SEQUENCE tags', function() { | ||
289 | var | ||
290 | manifest = '#EXT-X-MEDIA-SEQUENCE\n', | ||
291 | element; | ||
292 | parseStream.on('data', function(elem) { | ||
293 | element = elem; | ||
294 | }); | ||
295 | lineStream.push(manifest); | ||
296 | |||
297 | ok(element, 'an event was triggered'); | ||
298 | strictEqual(element.type, 'tag', 'the line type is tag'); | ||
299 | strictEqual(element.tagType, 'media-sequence', 'the tag type is media-sequence'); | ||
300 | ok(!('number' in element), 'no number is present'); | ||
301 | }); | ||
302 | test('parses #EXT-X-MEDIA-SEQUENCE with sequence numbers', function() { | ||
303 | var | ||
304 | manifest = '#EXT-X-MEDIA-SEQUENCE:109\n', | ||
305 | element; | ||
306 | parseStream.on('data', function(elem) { | ||
307 | element = elem; | ||
308 | }); | ||
309 | lineStream.push(manifest); | ||
310 | |||
311 | ok(element, 'an event was triggered'); | ||
312 | strictEqual(element.type, 'tag', 'the line type is tag'); | ||
313 | strictEqual(element.tagType, 'media-sequence', 'the tag type is media-sequence'); | ||
314 | ok(element.number, 109, 'the number is parsed'); | ||
315 | }); | ||
316 | |||
317 | // #EXT-X-PLAYLIST-TYPE | ||
318 | test('parses minimal #EXT-X-PLAYLIST-TYPE tags', function() { | ||
319 | var | ||
320 | manifest = '#EXT-X-PLAYLIST-TYPE:\n', | ||
321 | element; | ||
322 | parseStream.on('data', function(elem) { | ||
323 | element = elem; | ||
324 | }); | ||
325 | lineStream.push(manifest); | ||
326 | |||
327 | ok(element, 'an event was triggered'); | ||
328 | strictEqual(element.type, 'tag', 'the line type is tag'); | ||
329 | strictEqual(element.tagType, 'playlist-type', 'the tag type is playlist-type'); | ||
330 | ok(!('playlistType' in element), 'no playlist type is present'); | ||
331 | }); | ||
332 | test('parses #EXT-X-PLAYLIST-TYPE with mutability info', function() { | ||
333 | var | ||
334 | manifest = '#EXT-X-PLAYLIST-TYPE:EVENT\n', | ||
335 | element; | ||
336 | parseStream.on('data', function(elem) { | ||
337 | element = elem; | ||
338 | }); | ||
339 | lineStream.push(manifest); | ||
340 | |||
341 | ok(element, 'an event was triggered'); | ||
342 | strictEqual(element.type, 'tag', 'the line type is tag'); | ||
343 | strictEqual(element.tagType, 'playlist-type', 'the tag type is playlist-type'); | ||
344 | strictEqual(element.playlistType, 'EVENT', 'the playlist type is EVENT'); | ||
345 | |||
346 | manifest = '#EXT-X-PLAYLIST-TYPE:VOD\n'; | ||
347 | lineStream.push(manifest); | ||
348 | ok(element, 'an event was triggered'); | ||
349 | strictEqual(element.type, 'tag', 'the line type is tag'); | ||
350 | strictEqual(element.tagType, 'playlist-type', 'the tag type is playlist-type'); | ||
351 | strictEqual(element.playlistType, 'VOD', 'the playlist type is VOD'); | ||
352 | |||
353 | manifest = '#EXT-X-PLAYLIST-TYPE:nonsense\n'; | ||
354 | lineStream.push(manifest); | ||
355 | ok(element, 'an event was triggered'); | ||
356 | strictEqual(element.type, 'tag', 'the line type is tag'); | ||
357 | strictEqual(element.tagType, 'playlist-type', 'the tag type is playlist-type'); | ||
358 | strictEqual(element.playlistType, 'nonsense', 'the playlist type is parsed'); | ||
359 | }); | ||
360 | |||
361 | // #EXT-X-BYTERANGE | ||
362 | test('parses minimal #EXT-X-BYTERANGE tags', function() { | ||
363 | var | ||
364 | manifest = '#EXT-X-BYTERANGE\n', | ||
365 | element; | ||
366 | parseStream.on('data', function(elem) { | ||
367 | element = elem; | ||
368 | }); | ||
369 | lineStream.push(manifest); | ||
370 | |||
371 | ok(element, 'an event was triggered'); | ||
372 | strictEqual(element.type, 'tag', 'the line type is tag'); | ||
373 | strictEqual(element.tagType, 'byterange', 'the tag type is byterange'); | ||
374 | ok(!('length' in element), 'no length is present'); | ||
375 | ok(!('offset' in element), 'no offset is present'); | ||
376 | }); | ||
377 | test('parses #EXT-X-BYTERANGE with length and offset', function() { | ||
378 | var | ||
379 | manifest = '#EXT-X-BYTERANGE:45\n', | ||
380 | element; | ||
381 | parseStream.on('data', function(elem) { | ||
382 | element = elem; | ||
383 | }); | ||
384 | lineStream.push(manifest); | ||
385 | |||
386 | ok(element, 'an event was triggered'); | ||
387 | strictEqual(element.type, 'tag', 'the line type is tag'); | ||
388 | strictEqual(element.tagType, 'byterange', 'the tag type is byterange'); | ||
389 | strictEqual(element.length, 45, 'length is parsed'); | ||
390 | ok(!('offset' in element), 'no offset is present'); | ||
391 | |||
392 | manifest = '#EXT-X-BYTERANGE:108@16\n'; | ||
393 | lineStream.push(manifest); | ||
394 | ok(element, 'an event was triggered'); | ||
395 | strictEqual(element.type, 'tag', 'the line type is tag'); | ||
396 | strictEqual(element.tagType, 'byterange', 'the tag type is byterange'); | ||
397 | strictEqual(element.length, 108, 'length is parsed'); | ||
398 | strictEqual(element.offset, 16, 'offset is parsed'); | ||
399 | }); | ||
400 | |||
401 | // #EXT-X-ALLOW-CACHE | ||
402 | test('parses minimal #EXT-X-ALLOW-CACHE tags', function() { | ||
403 | var | ||
404 | manifest = '#EXT-X-ALLOW-CACHE:\n', | ||
405 | element; | ||
406 | parseStream.on('data', function(elem) { | ||
407 | element = elem; | ||
408 | }); | ||
409 | lineStream.push(manifest); | ||
410 | |||
411 | ok(element, 'an event was triggered'); | ||
412 | strictEqual(element.type, 'tag', 'the line type is tag'); | ||
413 | strictEqual(element.tagType, 'allow-cache', 'the tag type is allow-cache'); | ||
414 | ok(!('allowed' in element), 'no allowed is present'); | ||
415 | }); | ||
416 | test('parses valid #EXT-X-ALLOW-CACHE tags', function() { | ||
417 | var | ||
418 | manifest = '#EXT-X-ALLOW-CACHE:YES\n', | ||
419 | element; | ||
420 | parseStream.on('data', function(elem) { | ||
421 | element = elem; | ||
422 | }); | ||
423 | lineStream.push(manifest); | ||
424 | |||
425 | ok(element, 'an event was triggered'); | ||
426 | strictEqual(element.type, 'tag', 'the line type is tag'); | ||
427 | strictEqual(element.tagType, 'allow-cache', 'the tag type is allow-cache'); | ||
428 | ok(element.allowed, 'allowed is parsed'); | ||
429 | |||
430 | manifest = '#EXT-X-ALLOW-CACHE:NO\n'; | ||
431 | lineStream.push(manifest); | ||
432 | |||
433 | ok(element, 'an event was triggered'); | ||
434 | strictEqual(element.type, 'tag', 'the line type is tag'); | ||
435 | strictEqual(element.tagType, 'allow-cache', 'the tag type is allow-cache'); | ||
436 | ok(!element.allowed, 'allowed is parsed'); | ||
437 | }); | ||
438 | // #EXT-X-STREAM-INF | ||
439 | test('parses minimal #EXT-X-STREAM-INF tags', function() { | ||
440 | var | ||
441 | manifest = '#EXT-X-STREAM-INF\n', | ||
442 | element; | ||
443 | parseStream.on('data', function(elem) { | ||
444 | element = elem; | ||
445 | }); | ||
446 | lineStream.push(manifest); | ||
447 | |||
448 | ok(element, 'an event was triggered'); | ||
449 | strictEqual(element.type, 'tag', 'the line type is tag'); | ||
450 | strictEqual(element.tagType, 'stream-inf', 'the tag type is stream-inf'); | ||
451 | ok(!('attributes' in element), 'no attributes are present'); | ||
452 | }); | ||
453 | test('parses #EXT-X-STREAM-INF with common attributes', function() { | ||
454 | var | ||
455 | manifest = '#EXT-X-STREAM-INF:BANDWIDTH=14400\n', | ||
456 | element; | ||
457 | parseStream.on('data', function(elem) { | ||
458 | element = elem; | ||
459 | }); | ||
460 | lineStream.push(manifest); | ||
461 | |||
462 | ok(element, 'an event was triggered'); | ||
463 | strictEqual(element.type, 'tag', 'the line type is tag'); | ||
464 | strictEqual(element.tagType, 'stream-inf', 'the tag type is stream-inf'); | ||
465 | strictEqual(element.attributes.BANDWIDTH, 14400, 'bandwidth is parsed'); | ||
466 | |||
467 | manifest = '#EXT-X-STREAM-INF:PROGRAM-ID=7\n'; | ||
468 | lineStream.push(manifest); | ||
469 | |||
470 | ok(element, 'an event was triggered'); | ||
471 | strictEqual(element.type, 'tag', 'the line type is tag'); | ||
472 | strictEqual(element.tagType, 'stream-inf', 'the tag type is stream-inf'); | ||
473 | strictEqual(element.attributes['PROGRAM-ID'], 7, 'program-id is parsed'); | ||
474 | |||
475 | manifest = '#EXT-X-STREAM-INF:RESOLUTION=396x224\n'; | ||
476 | lineStream.push(manifest); | ||
477 | |||
478 | ok(element, 'an event was triggered'); | ||
479 | strictEqual(element.type, 'tag', 'the line type is tag'); | ||
480 | strictEqual(element.tagType, 'stream-inf', 'the tag type is stream-inf'); | ||
481 | strictEqual(element.attributes.RESOLUTION.width, 396, 'width is parsed'); | ||
482 | strictEqual(element.attributes.RESOLUTION.height, 224, 'heigth is parsed'); | ||
483 | |||
484 | manifest = '#EXT-X-STREAM-INF:CODECS="avc1.4d400d, mp4a.40.2"\n'; | ||
485 | lineStream.push(manifest); | ||
486 | |||
487 | ok(element, 'an event was triggered'); | ||
488 | strictEqual(element.type, 'tag', 'the line type is tag'); | ||
489 | strictEqual(element.tagType, 'stream-inf', 'the tag type is stream-inf'); | ||
490 | strictEqual(element.attributes.CODECS, | ||
491 | 'avc1.4d400d, mp4a.40.2', | ||
492 | 'codecs are parsed'); | ||
493 | }); | ||
494 | test('parses #EXT-X-STREAM-INF with arbitrary attributes', function() { | ||
495 | var | ||
496 | manifest = '#EXT-X-STREAM-INF:NUMERIC=24,ALPHA=Value,MIXED=123abc\n', | ||
497 | element; | ||
498 | parseStream.on('data', function(elem) { | ||
499 | element = elem; | ||
500 | }); | ||
501 | lineStream.push(manifest); | ||
502 | |||
503 | ok(element, 'an event was triggered'); | ||
504 | strictEqual(element.type, 'tag', 'the line type is tag'); | ||
505 | strictEqual(element.tagType, 'stream-inf', 'the tag type is stream-inf'); | ||
506 | strictEqual(element.attributes.NUMERIC, '24', 'numeric attributes are parsed'); | ||
507 | strictEqual(element.attributes.ALPHA, 'Value', 'alphabetic attributes are parsed'); | ||
508 | strictEqual(element.attributes.MIXED, '123abc', 'mixed attributes are parsed'); | ||
509 | }); | ||
510 | // #EXT-X-ENDLIST | ||
511 | test('parses #EXT-X-ENDLIST tags', function() { | ||
512 | var | ||
513 | manifest = '#EXT-X-ENDLIST\n', | ||
514 | element; | ||
515 | parseStream.on('data', function(elem) { | ||
516 | element = elem; | ||
517 | }); | ||
518 | lineStream.push(manifest); | ||
519 | |||
520 | ok(element, 'an event was triggered'); | ||
521 | strictEqual(element.type, 'tag', 'the line type is tag'); | ||
522 | strictEqual(element.tagType, 'endlist', 'the tag type is stream-inf'); | ||
523 | }); | ||
524 | |||
525 | // #EXT-X-KEY | ||
526 | test('parses valid #EXT-X-KEY tags', function() { | ||
527 | var | ||
528 | manifest = '#EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=52"\n', | ||
529 | element; | ||
530 | parseStream.on('data', function(elem) { | ||
531 | element = elem; | ||
532 | }); | ||
533 | lineStream.push(manifest); | ||
534 | |||
535 | ok(element, 'an event was triggered'); | ||
536 | deepEqual(element, { | ||
537 | type: 'tag', | ||
538 | tagType: 'key', | ||
539 | attributes: { | ||
540 | METHOD: 'AES-128', | ||
541 | URI: 'https://priv.example.com/key.php?r=52' | ||
542 | } | ||
543 | }, 'parsed a valid key'); | ||
544 | |||
545 | manifest = '#EXT-X-KEY:URI="https://example.com/key#1",METHOD=FutureType-1024\n'; | ||
546 | lineStream.push(manifest); | ||
547 | ok(element, 'an event was triggered'); | ||
548 | deepEqual(element, { | ||
549 | type: 'tag', | ||
550 | tagType: 'key', | ||
551 | attributes: { | ||
552 | METHOD: 'FutureType-1024', | ||
553 | URI: 'https://example.com/key#1' | ||
554 | } | ||
555 | }, 'parsed the attribute list independent of order'); | ||
556 | |||
557 | manifest = '#EXT-X-KEY:IV=1234567890abcdef1234567890abcdef\n'; | ||
558 | lineStream.push(manifest); | ||
559 | ok(element.attributes.IV, 'detected an IV attribute'); | ||
560 | deepEqual(element.attributes.IV, new Uint32Array([ | ||
561 | 0x12345678, | ||
562 | 0x90abcdef, | ||
563 | 0x12345678, | ||
564 | 0x90abcdef | ||
565 | ]), 'parsed an IV value'); | ||
566 | }); | ||
567 | |||
568 | test('parses minimal #EXT-X-KEY tags', function() { | ||
569 | var | ||
570 | manifest = '#EXT-X-KEY:\n', | ||
571 | element; | ||
572 | parseStream.on('data', function(elem) { | ||
573 | element = elem; | ||
574 | }); | ||
575 | lineStream.push(manifest); | ||
576 | |||
577 | ok(element, 'an event was triggered'); | ||
578 | deepEqual(element, { | ||
579 | type: 'tag', | ||
580 | tagType: 'key' | ||
581 | }, 'parsed a minimal key tag'); | ||
582 | }); | ||
583 | |||
584 | test('parses lightly-broken #EXT-X-KEY tags', function() { | ||
585 | var | ||
586 | manifest = '#EXT-X-KEY:URI=\'https://example.com/single-quote\',METHOD=AES-128\n', | ||
587 | element; | ||
588 | parseStream.on('data', function(elem) { | ||
589 | element = elem; | ||
590 | }); | ||
591 | lineStream.push(manifest); | ||
592 | |||
593 | strictEqual(element.attributes.URI, | ||
594 | 'https://example.com/single-quote', | ||
595 | 'parsed a single-quoted uri'); | ||
596 | |||
597 | element = null; | ||
598 | manifest = '#EXT-X-KEYURI="https://example.com/key",METHOD=AES-128\n'; | ||
599 | lineStream.push(manifest); | ||
600 | strictEqual(element.tagType, 'key', 'parsed the tag type'); | ||
601 | strictEqual(element.attributes.URI, | ||
602 | 'https://example.com/key', | ||
603 | 'inferred a colon after the tag type'); | ||
604 | |||
605 | element = null; | ||
606 | manifest = '#EXT-X-KEY: URI = "https://example.com/key",METHOD=AES-128\n'; | ||
607 | lineStream.push(manifest); | ||
608 | strictEqual(element.attributes.URI, | ||
609 | 'https://example.com/key', | ||
610 | 'trims and removes quotes around the URI'); | ||
611 | }); | ||
612 | |||
613 | test('ignores empty lines', function() { | ||
614 | var | ||
615 | manifest = '\n', | ||
616 | event = false; | ||
617 | parseStream.on('data', function() { | ||
618 | event = true; | ||
619 | }); | ||
620 | lineStream.push(manifest); | ||
621 | |||
622 | ok(!event, 'no event is triggered'); | ||
623 | }); | ||
624 | |||
625 | module('m3u8 parser'); | ||
626 | |||
627 | test('can be constructed', function() { | ||
628 | notStrictEqual(new Parser(), undefined, 'parser is defined'); | ||
629 | }); | ||
630 | |||
631 | module('m3u8s'); | ||
632 | |||
633 | test('parses static manifests as expected', function() { | ||
634 | var key; | ||
635 | for (key in window.manifests) { | ||
636 | if (window.expected[key]) { | ||
637 | parser = new Parser(); | ||
638 | parser.push(window.manifests[key]); | ||
639 | deepEqual(parser.manifest, | ||
640 | window.expected[key], | ||
641 | key + '.m3u8 was parsed correctly'); | ||
642 | } | ||
643 | } | ||
644 | }); | ||
645 | |||
646 | })(window, window.console); |
test/perf.html
deleted
100644 → 0
1 | <!doctype html> | ||
2 | <html> | ||
3 | <head> | ||
4 | <title>MPEG-TS Parser Performance Workbench</title> | ||
5 | <!-- video.js --> | ||
6 | <script src="../node_modules/video.js/video.dev.js"></script> | ||
7 | |||
8 | <!-- HLS plugin --> | ||
9 | <script src="../src/video-js-hls.js"></script> | ||
10 | <script src="../src/flv-tag.js"></script> | ||
11 | <script src="../src/exp-golomb.js"></script> | ||
12 | <script src="../src/h264-stream.js"></script> | ||
13 | <script src="../src/aac-stream.js"></script> | ||
14 | <script src="../src/segment-parser.js"></script> | ||
15 | |||
16 | <!-- MPEG-TS segment --> | ||
17 | <script src="tsSegment-bc.js"></script> | ||
18 | <style> | ||
19 | .desc { | ||
20 | background-color: #ddd; | ||
21 | border: thin solid #333; | ||
22 | padding: 8px; | ||
23 | } | ||
24 | </style> | ||
25 | </head> | ||
26 | <body> | ||
27 | <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> | ||
28 | <form> | ||
29 | <input name="iterations" min="1" type="number" value="1"> | ||
30 | <button type="sumbit">Run</button> | ||
31 | </form> | ||
32 | <table> | ||
33 | <thead> | ||
34 | <th>Iterations</th><th>Time</th><th>MB/second</th> | ||
35 | </thead> | ||
36 | <tbody class="results"></tbody> | ||
37 | </table> | ||
38 | <script> | ||
39 | var | ||
40 | button = document.querySelector('button'), | ||
41 | input = document.querySelector('input'), | ||
42 | results = document.querySelector('.results'), | ||
43 | |||
44 | reportResults = function(count, elapsed) { | ||
45 | var | ||
46 | row = document.createElement('tr'), | ||
47 | countCell = document.createElement('td'), | ||
48 | elapsedCell = document.createElement('td'), | ||
49 | throughputCell = document.createElement('td'); | ||
50 | |||
51 | countCell.innerText = count; | ||
52 | elapsedCell.innerText = elapsed; | ||
53 | throughputCell.innerText = (((bcSegment.byteLength * count * 1000) / elapsed) / (Math.pow(2, 20))).toFixed(3); | ||
54 | row.appendChild(countCell); | ||
55 | row.appendChild(elapsedCell); | ||
56 | row.appendChild(throughputCell); | ||
57 | |||
58 | results.insertBefore(row, results.firstChild); | ||
59 | }; | ||
60 | |||
61 | button.addEventListener('click', function(event) { | ||
62 | var | ||
63 | iterations = input.value, | ||
64 | parser = new window.videojs.hls.SegmentParser(), | ||
65 | start; | ||
66 | |||
67 | // setup | ||
68 | start = +new Date(); | ||
69 | |||
70 | while (iterations--) { | ||
71 | |||
72 | // parse the segment | ||
73 | parser.parseSegmentBinaryData(window.bcSegment); | ||
74 | |||
75 | // finalize all the FLV tags | ||
76 | while (parser.tagsAvailable()) { | ||
77 | parser.getNextTag(); | ||
78 | } | ||
79 | } | ||
80 | |||
81 | // report | ||
82 | reportResults(input.value, (+new Date()) - start); | ||
83 | |||
84 | // don't actually submit the form | ||
85 | event.preventDefault(); | ||
86 | }, false); | ||
87 | </script> | ||
88 | </body> | ||
89 | </html> |
test/playlist-loader.test.js
0 → 100644
1 | import sinon from 'sinon'; | ||
2 | import QUnit from 'qunit'; | ||
3 | import PlaylistLoader from '../src/playlist-loader'; | ||
4 | import videojs from 'video.js'; | ||
5 | // Attempts to produce an absolute URL to a given relative path | ||
6 | // based on window.location.href | ||
7 | const urlTo = function(path) { | ||
8 | return window.location.href | ||
9 | .split('/') | ||
10 | .slice(0, -1) | ||
11 | .concat([path]) | ||
12 | .join('/'); | ||
13 | }; | ||
14 | |||
15 | QUnit.module('Playlist Loader', { | ||
16 | beforeEach() { | ||
17 | // fake XHRs | ||
18 | this.oldXHR = videojs.xhr.XMLHttpRequest; | ||
19 | this.sinonXhr = sinon.useFakeXMLHttpRequest(); | ||
20 | this.requests = []; | ||
21 | this.sinonXhr.onCreate = (xhr) => { | ||
22 | // force the XHR2 timeout polyfill | ||
23 | xhr.timeout = null; | ||
24 | this.requests.push(xhr); | ||
25 | }; | ||
26 | |||
27 | // fake timers | ||
28 | this.clock = sinon.useFakeTimers(); | ||
29 | videojs.xhr.XMLHttpRequest = this.sinonXhr; | ||
30 | }, | ||
31 | afterEach() { | ||
32 | this.sinonXhr.restore(); | ||
33 | this.clock.restore(); | ||
34 | videojs.xhr.XMLHttpRequest = this.oldXHR; | ||
35 | } | ||
36 | }); | ||
37 | |||
38 | QUnit.test('throws if the playlist url is empty or undefined', function() { | ||
39 | QUnit.throws(function() { | ||
40 | PlaylistLoader(); | ||
41 | }, 'requires an argument'); | ||
42 | QUnit.throws(function() { | ||
43 | PlaylistLoader(''); | ||
44 | }, 'does not accept the empty string'); | ||
45 | }); | ||
46 | |||
47 | QUnit.test('starts without any metadata', function() { | ||
48 | let loader = new PlaylistLoader('master.m3u8'); | ||
49 | |||
50 | QUnit.strictEqual(loader.state, 'HAVE_NOTHING', 'no metadata has loaded yet'); | ||
51 | }); | ||
52 | |||
53 | QUnit.test('starts with no expired time', function() { | ||
54 | let loader = new PlaylistLoader('media.m3u8'); | ||
55 | |||
56 | this.requests.pop().respond(200, null, | ||
57 | '#EXTM3U\n' + | ||
58 | '#EXTINF:10,\n' + | ||
59 | '0.ts\n'); | ||
60 | QUnit.equal(loader.expired_, | ||
61 | 0, | ||
62 | 'zero seconds expired'); | ||
63 | }); | ||
64 | |||
65 | QUnit.test('requests the initial playlist immediately', function() { | ||
66 | /* eslint-disable no-unused-vars */ | ||
67 | let loader = new PlaylistLoader('master.m3u8'); | ||
68 | /* eslint-enable no-unused-vars */ | ||
69 | |||
70 | QUnit.strictEqual(this.requests.length, 1, 'made a request'); | ||
71 | QUnit.strictEqual(this.requests[0].url, | ||
72 | 'master.m3u8', | ||
73 | 'requested the initial playlist'); | ||
74 | }); | ||
75 | |||
76 | QUnit.test('moves to HAVE_MASTER after loading a master playlist', function() { | ||
77 | let loader = new PlaylistLoader('master.m3u8'); | ||
78 | let state; | ||
79 | |||
80 | loader.on('loadedplaylist', function() { | ||
81 | state = loader.state; | ||
82 | }); | ||
83 | this.requests.pop().respond(200, null, | ||
84 | '#EXTM3U\n' + | ||
85 | '#EXT-X-STREAM-INF:\n' + | ||
86 | 'media.m3u8\n'); | ||
87 | QUnit.ok(loader.master, 'the master playlist is available'); | ||
88 | QUnit.strictEqual(state, 'HAVE_MASTER', 'the state at loadedplaylist correct'); | ||
89 | }); | ||
90 | |||
91 | QUnit.test('jumps to HAVE_METADATA when initialized with a media playlist', function() { | ||
92 | let loadedmetadatas = 0; | ||
93 | let loader = new PlaylistLoader('media.m3u8'); | ||
94 | |||
95 | loader.on('loadedmetadata', function() { | ||
96 | loadedmetadatas++; | ||
97 | }); | ||
98 | this.requests.pop().respond(200, null, | ||
99 | '#EXTM3U\n' + | ||
100 | '#EXTINF:10,\n' + | ||
101 | '0.ts\n' + | ||
102 | '#EXT-X-ENDLIST\n'); | ||
103 | QUnit.ok(loader.master, 'infers a master playlist'); | ||
104 | QUnit.ok(loader.media(), 'sets the media playlist'); | ||
105 | QUnit.ok(loader.media().uri, 'sets the media playlist URI'); | ||
106 | QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct'); | ||
107 | QUnit.strictEqual(this.requests.length, 0, 'no more requests are made'); | ||
108 | QUnit.strictEqual(loadedmetadatas, 1, 'fired one loadedmetadata'); | ||
109 | }); | ||
110 | |||
111 | QUnit.test('jumps to HAVE_METADATA when initialized with a live media playlist', | ||
112 | function() { | ||
113 | let loader = new PlaylistLoader('media.m3u8'); | ||
114 | |||
115 | this.requests.pop().respond(200, null, | ||
116 | '#EXTM3U\n' + | ||
117 | '#EXTINF:10,\n' + | ||
118 | '0.ts\n'); | ||
119 | QUnit.ok(loader.master, 'infers a master playlist'); | ||
120 | QUnit.ok(loader.media(), 'sets the media playlist'); | ||
121 | QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct'); | ||
122 | }); | ||
123 | |||
124 | QUnit.test('moves to HAVE_METADATA after loading a media playlist', function() { | ||
125 | let loadedPlaylist = 0; | ||
126 | let loadedMetadata = 0; | ||
127 | let loader = new PlaylistLoader('master.m3u8'); | ||
128 | |||
129 | loader.on('loadedplaylist', function() { | ||
130 | loadedPlaylist++; | ||
131 | }); | ||
132 | loader.on('loadedmetadata', function() { | ||
133 | loadedMetadata++; | ||
134 | }); | ||
135 | this.requests.pop().respond(200, null, | ||
136 | '#EXTM3U\n' + | ||
137 | '#EXT-X-STREAM-INF:\n' + | ||
138 | 'media.m3u8\n' + | ||
139 | 'alt.m3u8\n'); | ||
140 | QUnit.strictEqual(loadedPlaylist, 1, 'fired loadedplaylist once'); | ||
141 | QUnit.strictEqual(loadedMetadata, 0, 'did not fire loadedmetadata'); | ||
142 | QUnit.strictEqual(this.requests.length, 1, 'requests the media playlist'); | ||
143 | QUnit.strictEqual(this.requests[0].method, 'GET', 'GETs the media playlist'); | ||
144 | QUnit.strictEqual(this.requests[0].url, | ||
145 | urlTo('media.m3u8'), | ||
146 | 'requests the first playlist'); | ||
147 | |||
148 | this.requests.pop().respond(200, null, | ||
149 | '#EXTM3U\n' + | ||
150 | '#EXTINF:10,\n' + | ||
151 | '0.ts\n'); | ||
152 | QUnit.ok(loader.master, 'sets the master playlist'); | ||
153 | QUnit.ok(loader.media(), 'sets the media playlist'); | ||
154 | QUnit.strictEqual(loadedPlaylist, 2, 'fired loadedplaylist twice'); | ||
155 | QUnit.strictEqual(loadedMetadata, 1, 'fired loadedmetadata once'); | ||
156 | QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct'); | ||
157 | }); | ||
158 | |||
159 | QUnit.test('moves to HAVE_CURRENT_METADATA when refreshing the playlist', function() { | ||
160 | let loader = new PlaylistLoader('live.m3u8'); | ||
161 | |||
162 | this.requests.pop().respond(200, null, | ||
163 | '#EXTM3U\n' + | ||
164 | '#EXTINF:10,\n' + | ||
165 | '0.ts\n'); | ||
166 | // 10s, one target duration | ||
167 | this.clock.tick(10 * 1000); | ||
168 | QUnit.strictEqual(loader.state, 'HAVE_CURRENT_METADATA', 'the state is correct'); | ||
169 | QUnit.strictEqual(this.requests.length, 1, 'requested playlist'); | ||
170 | QUnit.strictEqual(this.requests[0].url, | ||
171 | urlTo('live.m3u8'), | ||
172 | 'refreshes the media playlist'); | ||
173 | }); | ||
174 | |||
175 | QUnit.test('returns to HAVE_METADATA after refreshing the playlist', function() { | ||
176 | let loader = new PlaylistLoader('live.m3u8'); | ||
177 | |||
178 | this.requests.pop().respond(200, null, | ||
179 | '#EXTM3U\n' + | ||
180 | '#EXTINF:10,\n' + | ||
181 | '0.ts\n'); | ||
182 | // 10s, one target duration | ||
183 | this.clock.tick(10 * 1000); | ||
184 | this.requests.pop().respond(200, null, | ||
185 | '#EXTM3U\n' + | ||
186 | '#EXTINF:10,\n' + | ||
187 | '1.ts\n'); | ||
188 | QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct'); | ||
189 | }); | ||
190 | |||
191 | QUnit.test('does not increment expired seconds before firstplay is triggered', | ||
192 | function() { | ||
193 | let loader = new PlaylistLoader('live.m3u8'); | ||
194 | |||
195 | this.requests.pop().respond(200, null, | ||
196 | '#EXTM3U\n' + | ||
197 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
198 | '#EXTINF:10,\n' + | ||
199 | '0.ts\n' + | ||
200 | '#EXTINF:10,\n' + | ||
201 | '1.ts\n' + | ||
202 | '#EXTINF:10,\n' + | ||
203 | '2.ts\n' + | ||
204 | '#EXTINF:10,\n' + | ||
205 | '3.ts\n'); | ||
206 | // 10s, one target duration | ||
207 | this.clock.tick(10 * 1000); | ||
208 | this.requests.pop().respond(200, null, | ||
209 | '#EXTM3U\n' + | ||
210 | '#EXT-X-MEDIA-SEQUENCE:1\n' + | ||
211 | '#EXTINF:10,\n' + | ||
212 | '1.ts\n' + | ||
213 | '#EXTINF:10,\n' + | ||
214 | '2.ts\n' + | ||
215 | '#EXTINF:10,\n' + | ||
216 | '3.ts\n' + | ||
217 | '#EXTINF:10,\n' + | ||
218 | '4.ts\n'); | ||
219 | QUnit.equal(loader.expired_, 0, 'expired one segment'); | ||
220 | }); | ||
221 | |||
222 | QUnit.test('increments expired seconds after a segment is removed', function() { | ||
223 | let loader = new PlaylistLoader('live.m3u8'); | ||
224 | |||
225 | loader.trigger('firstplay'); | ||
226 | this.requests.pop().respond(200, null, | ||
227 | '#EXTM3U\n' + | ||
228 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
229 | '#EXTINF:10,\n' + | ||
230 | '0.ts\n' + | ||
231 | '#EXTINF:10,\n' + | ||
232 | '1.ts\n' + | ||
233 | '#EXTINF:10,\n' + | ||
234 | '2.ts\n' + | ||
235 | '#EXTINF:10,\n' + | ||
236 | '3.ts\n'); | ||
237 | // 10s, one target duration | ||
238 | this.clock.tick(10 * 1000); | ||
239 | this.requests.pop().respond(200, null, | ||
240 | '#EXTM3U\n' + | ||
241 | '#EXT-X-MEDIA-SEQUENCE:1\n' + | ||
242 | '#EXTINF:10,\n' + | ||
243 | '1.ts\n' + | ||
244 | '#EXTINF:10,\n' + | ||
245 | '2.ts\n' + | ||
246 | '#EXTINF:10,\n' + | ||
247 | '3.ts\n' + | ||
248 | '#EXTINF:10,\n' + | ||
249 | '4.ts\n'); | ||
250 | QUnit.equal(loader.expired_, 10, 'expired one segment'); | ||
251 | }); | ||
252 | |||
253 | QUnit.test('increments expired seconds after a discontinuity', function() { | ||
254 | let loader = new PlaylistLoader('live.m3u8'); | ||
255 | |||
256 | loader.trigger('firstplay'); | ||
257 | this.requests.pop().respond(200, null, | ||
258 | '#EXTM3U\n' + | ||
259 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
260 | '#EXTINF:10,\n' + | ||
261 | '0.ts\n' + | ||
262 | '#EXTINF:3,\n' + | ||
263 | '1.ts\n' + | ||
264 | '#EXT-X-DISCONTINUITY\n' + | ||
265 | '#EXTINF:4,\n' + | ||
266 | '2.ts\n'); | ||
267 | // 10s, one target duration | ||
268 | this.clock.tick(10 * 1000); | ||
269 | this.requests.pop().respond(200, null, | ||
270 | '#EXTM3U\n' + | ||
271 | '#EXT-X-MEDIA-SEQUENCE:1\n' + | ||
272 | '#EXTINF:3,\n' + | ||
273 | '1.ts\n' + | ||
274 | '#EXT-X-DISCONTINUITY\n' + | ||
275 | '#EXTINF:4,\n' + | ||
276 | '2.ts\n'); | ||
277 | QUnit.equal(loader.expired_, 10, 'expired one segment'); | ||
278 | |||
279 | // 10s, one target duration | ||
280 | this.clock.tick(10 * 1000); | ||
281 | this.requests.pop().respond(200, null, | ||
282 | '#EXTM3U\n' + | ||
283 | '#EXT-X-MEDIA-SEQUENCE:2\n' + | ||
284 | '#EXT-X-DISCONTINUITY\n' + | ||
285 | '#EXTINF:4,\n' + | ||
286 | '2.ts\n'); | ||
287 | QUnit.equal(loader.expired_, 13, 'no expirations after the discontinuity yet'); | ||
288 | |||
289 | // 10s, one target duration | ||
290 | this.clock.tick(10 * 1000); | ||
291 | this.requests.pop().respond(200, null, | ||
292 | '#EXTM3U\n' + | ||
293 | '#EXT-X-MEDIA-SEQUENCE:3\n' + | ||
294 | '#EXT-X-DISCONTINUITY-SEQUENCE:1\n' + | ||
295 | '#EXTINF:10,\n' + | ||
296 | '3.ts\n'); | ||
297 | QUnit.equal(loader.expired_, 17, 'tracked expiration across the discontinuity'); | ||
298 | }); | ||
299 | |||
300 | QUnit.test('tracks expired seconds properly when two discontinuities expire at once', | ||
301 | function() { | ||
302 | let loader = new PlaylistLoader('live.m3u8'); | ||
303 | |||
304 | loader.trigger('firstplay'); | ||
305 | this.requests.pop().respond(200, null, | ||
306 | '#EXTM3U\n' + | ||
307 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
308 | '#EXTINF:4,\n' + | ||
309 | '0.ts\n' + | ||
310 | '#EXT-X-DISCONTINUITY\n' + | ||
311 | '#EXTINF:5,\n' + | ||
312 | '1.ts\n' + | ||
313 | '#EXT-X-DISCONTINUITY\n' + | ||
314 | '#EXTINF:6,\n' + | ||
315 | '2.ts\n' + | ||
316 | '#EXTINF:7,\n' + | ||
317 | '3.ts\n'); | ||
318 | this.clock.tick(10 * 1000); | ||
319 | this.requests.pop().respond(200, null, | ||
320 | '#EXTM3U\n' + | ||
321 | '#EXT-X-MEDIA-SEQUENCE:3\n' + | ||
322 | '#EXT-X-DISCONTINUITY-SEQUENCE:2\n' + | ||
323 | '#EXTINF:7,\n' + | ||
324 | '3.ts\n'); | ||
325 | QUnit.equal(loader.expired_, 4 + 5 + 6, 'tracked multiple expiring discontinuities'); | ||
326 | }); | ||
327 | |||
328 | QUnit.test('estimates expired if an entire window elapses between live playlist updates', | ||
329 | function() { | ||
330 | let loader = new PlaylistLoader('live.m3u8'); | ||
331 | |||
332 | loader.trigger('firstplay'); | ||
333 | this.requests.pop().respond(200, null, | ||
334 | '#EXTM3U\n' + | ||
335 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
336 | '#EXTINF:4,\n' + | ||
337 | '0.ts\n' + | ||
338 | '#EXTINF:5,\n' + | ||
339 | '1.ts\n'); | ||
340 | |||
341 | this.clock.tick(10 * 1000); | ||
342 | this.requests.pop().respond(200, null, | ||
343 | '#EXTM3U\n' + | ||
344 | '#EXT-X-MEDIA-SEQUENCE:4\n' + | ||
345 | '#EXTINF:6,\n' + | ||
346 | '4.ts\n' + | ||
347 | '#EXTINF:7,\n' + | ||
348 | '5.ts\n'); | ||
349 | |||
350 | QUnit.equal(loader.expired_, | ||
351 | 4 + 5 + (2 * 10), | ||
352 | 'made a very rough estimate of expired time'); | ||
353 | }); | ||
354 | |||
355 | QUnit.test('emits an error when an initial playlist request fails', function() { | ||
356 | let errors = []; | ||
357 | let loader = new PlaylistLoader('master.m3u8'); | ||
358 | |||
359 | loader.on('error', function() { | ||
360 | errors.push(loader.error); | ||
361 | }); | ||
362 | this.requests.pop().respond(500); | ||
363 | |||
364 | QUnit.strictEqual(errors.length, 1, 'emitted one error'); | ||
365 | QUnit.strictEqual(errors[0].status, 500, 'http status is captured'); | ||
366 | }); | ||
367 | |||
368 | QUnit.test('errors when an initial media playlist request fails', function() { | ||
369 | let errors = []; | ||
370 | let loader = new PlaylistLoader('master.m3u8'); | ||
371 | |||
372 | loader.on('error', function() { | ||
373 | errors.push(loader.error); | ||
374 | }); | ||
375 | this.requests.pop().respond(200, null, | ||
376 | '#EXTM3U\n' + | ||
377 | '#EXT-X-STREAM-INF:\n' + | ||
378 | 'media.m3u8\n'); | ||
379 | |||
380 | QUnit.strictEqual(errors.length, 0, 'emitted no errors'); | ||
381 | |||
382 | this.requests.pop().respond(500); | ||
383 | |||
384 | QUnit.strictEqual(errors.length, 1, 'emitted one error'); | ||
385 | QUnit.strictEqual(errors[0].status, 500, 'http status is captured'); | ||
386 | }); | ||
387 | |||
388 | // http://tools.ietf.org/html/draft-pantos-http-live-streaming-12#section-6.3.4 | ||
389 | QUnit.test('halves the refresh timeout if a playlist is unchanged since the last reload', | ||
390 | function() { | ||
391 | /* eslint-disable no-unused-vars */ | ||
392 | let loader = new PlaylistLoader('live.m3u8'); | ||
393 | /* eslint-enable no-unused-vars */ | ||
394 | |||
395 | this.requests.pop().respond(200, null, | ||
396 | '#EXTM3U\n' + | ||
397 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
398 | '#EXTINF:10,\n' + | ||
399 | '0.ts\n'); | ||
400 | // trigger a refresh | ||
401 | this.clock.tick(10 * 1000); | ||
402 | this.requests.pop().respond(200, null, | ||
403 | '#EXTM3U\n' + | ||
404 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
405 | '#EXTINF:10,\n' + | ||
406 | '0.ts\n'); | ||
407 | // half the default target-duration | ||
408 | this.clock.tick(5 * 1000); | ||
409 | |||
410 | QUnit.strictEqual(this.requests.length, 1, 'sent a request'); | ||
411 | QUnit.strictEqual(this.requests[0].url, | ||
412 | urlTo('live.m3u8'), | ||
413 | 'requested the media playlist'); | ||
414 | }); | ||
415 | |||
416 | QUnit.test('preserves segment metadata across playlist refreshes', function() { | ||
417 | let loader = new PlaylistLoader('live.m3u8'); | ||
418 | let segment; | ||
419 | |||
420 | this.requests.pop().respond(200, null, | ||
421 | '#EXTM3U\n' + | ||
422 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
423 | '#EXTINF:10,\n' + | ||
424 | '0.ts\n' + | ||
425 | '#EXTINF:10,\n' + | ||
426 | '1.ts\n' + | ||
427 | '#EXTINF:10,\n' + | ||
428 | '2.ts\n'); | ||
429 | // add PTS info to 1.ts | ||
430 | segment = loader.media().segments[1]; | ||
431 | segment.minVideoPts = 14; | ||
432 | segment.maxAudioPts = 27; | ||
433 | segment.preciseDuration = 10.045; | ||
434 | |||
435 | // trigger a refresh | ||
436 | this.clock.tick(10 * 1000); | ||
437 | this.requests.pop().respond(200, null, | ||
438 | '#EXTM3U\n' + | ||
439 | '#EXT-X-MEDIA-SEQUENCE:1\n' + | ||
440 | '#EXTINF:10,\n' + | ||
441 | '1.ts\n' + | ||
442 | '#EXTINF:10,\n' + | ||
443 | '2.ts\n'); | ||
444 | |||
445 | QUnit.deepEqual(loader.media().segments[0], segment, 'preserved segment attributes'); | ||
446 | }); | ||
447 | |||
448 | QUnit.test('clears the update timeout when switching quality', function() { | ||
449 | let loader = new PlaylistLoader('live-master.m3u8'); | ||
450 | let refreshes = 0; | ||
451 | |||
452 | // track the number of playlist refreshes triggered | ||
453 | loader.on('mediaupdatetimeout', function() { | ||
454 | refreshes++; | ||
455 | }); | ||
456 | // deliver the master | ||
457 | this.requests.pop().respond(200, null, | ||
458 | '#EXTM3U\n' + | ||
459 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
460 | 'live-low.m3u8\n' + | ||
461 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
462 | 'live-high.m3u8\n'); | ||
463 | // deliver the low quality playlist | ||
464 | this.requests.pop().respond(200, null, | ||
465 | '#EXTM3U\n' + | ||
466 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
467 | '#EXTINF:10,\n' + | ||
468 | 'low-0.ts\n'); | ||
469 | // change to a higher quality playlist | ||
470 | loader.media('live-high.m3u8'); | ||
471 | this.requests.pop().respond(200, null, | ||
472 | '#EXTM3U\n' + | ||
473 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
474 | '#EXTINF:10,\n' + | ||
475 | 'high-0.ts\n'); | ||
476 | // trigger a refresh | ||
477 | this.clock.tick(10 * 1000); | ||
478 | |||
479 | QUnit.equal(1, refreshes, 'only one refresh was triggered'); | ||
480 | }); | ||
481 | |||
482 | QUnit.test('media-sequence updates are considered a playlist change', function() { | ||
483 | /* eslint-disable no-unused-vars */ | ||
484 | let loader = new PlaylistLoader('live.m3u8'); | ||
485 | /* eslint-enable no-unused-vars */ | ||
486 | |||
487 | this.requests.pop().respond(200, null, | ||
488 | '#EXTM3U\n' + | ||
489 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
490 | '#EXTINF:10,\n' + | ||
491 | '0.ts\n'); | ||
492 | // trigger a refresh | ||
493 | this.clock.tick(10 * 1000); | ||
494 | this.requests.pop().respond(200, null, | ||
495 | '#EXTM3U\n' + | ||
496 | '#EXT-X-MEDIA-SEQUENCE:1\n' + | ||
497 | '#EXTINF:10,\n' + | ||
498 | '0.ts\n'); | ||
499 | // half the default target-duration | ||
500 | this.clock.tick(5 * 1000); | ||
501 | |||
502 | QUnit.strictEqual(this.requests.length, 0, 'no request is sent'); | ||
503 | }); | ||
504 | |||
505 | QUnit.test('emits an error if a media refresh fails', function() { | ||
506 | let errors = 0; | ||
507 | let errorResponseText = 'custom error message'; | ||
508 | let loader = new PlaylistLoader('live.m3u8'); | ||
509 | |||
510 | loader.on('error', function() { | ||
511 | errors++; | ||
512 | }); | ||
513 | this.requests.pop().respond(200, null, | ||
514 | '#EXTM3U\n' + | ||
515 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
516 | '#EXTINF:10,\n' + | ||
517 | '0.ts\n'); | ||
518 | // trigger a refresh | ||
519 | this.clock.tick(10 * 1000); | ||
520 | this.requests.pop().respond(500, null, errorResponseText); | ||
521 | |||
522 | QUnit.strictEqual(errors, 1, 'emitted an error'); | ||
523 | QUnit.strictEqual(loader.error.status, 500, 'captured the status code'); | ||
524 | QUnit.strictEqual(loader.error.responseText, | ||
525 | errorResponseText, | ||
526 | 'captured the responseText'); | ||
527 | }); | ||
528 | |||
529 | QUnit.test('switches media playlists when requested', function() { | ||
530 | let loader = new PlaylistLoader('master.m3u8'); | ||
531 | |||
532 | this.requests.pop().respond(200, null, | ||
533 | '#EXTM3U\n' + | ||
534 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
535 | 'low.m3u8\n' + | ||
536 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
537 | 'high.m3u8\n'); | ||
538 | this.requests.pop().respond(200, null, | ||
539 | '#EXTM3U\n' + | ||
540 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
541 | '#EXTINF:10,\n' + | ||
542 | 'low-0.ts\n'); | ||
543 | |||
544 | loader.media(loader.master.playlists[1]); | ||
545 | QUnit.strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state'); | ||
546 | |||
547 | this.requests.pop().respond(200, null, | ||
548 | '#EXTM3U\n' + | ||
549 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
550 | '#EXTINF:10,\n' + | ||
551 | 'high-0.ts\n'); | ||
552 | QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'switched active media'); | ||
553 | QUnit.strictEqual(loader.media(), | ||
554 | loader.master.playlists[1], | ||
555 | 'updated the active media'); | ||
556 | }); | ||
557 | |||
558 | QUnit.test('can switch playlists immediately after the master is downloaded', function() { | ||
559 | let loader = new PlaylistLoader('master.m3u8'); | ||
560 | |||
561 | loader.on('loadedplaylist', function() { | ||
562 | loader.media('high.m3u8'); | ||
563 | }); | ||
564 | this.requests.pop().respond(200, null, | ||
565 | '#EXTM3U\n' + | ||
566 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
567 | 'low.m3u8\n' + | ||
568 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
569 | 'high.m3u8\n'); | ||
570 | QUnit.equal(this.requests[0].url, urlTo('high.m3u8'), 'switched variants immediately'); | ||
571 | }); | ||
572 | |||
573 | QUnit.test('can switch media playlists based on URI', function() { | ||
574 | let loader = new PlaylistLoader('master.m3u8'); | ||
575 | |||
576 | this.requests.pop().respond(200, null, | ||
577 | '#EXTM3U\n' + | ||
578 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
579 | 'low.m3u8\n' + | ||
580 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
581 | 'high.m3u8\n'); | ||
582 | this.requests.pop().respond(200, null, | ||
583 | '#EXTM3U\n' + | ||
584 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
585 | '#EXTINF:10,\n' + | ||
586 | 'low-0.ts\n'); | ||
587 | |||
588 | loader.media('high.m3u8'); | ||
589 | QUnit.strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state'); | ||
590 | |||
591 | this.requests.pop().respond(200, null, | ||
592 | '#EXTM3U\n' + | ||
593 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
594 | '#EXTINF:10,\n' + | ||
595 | 'high-0.ts\n'); | ||
596 | QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'switched active media'); | ||
597 | QUnit.strictEqual(loader.media(), | ||
598 | loader.master.playlists[1], | ||
599 | 'updated the active media'); | ||
600 | }); | ||
601 | |||
602 | QUnit.test('aborts in-flight playlist refreshes when switching', function() { | ||
603 | let loader = new PlaylistLoader('master.m3u8'); | ||
604 | |||
605 | this.requests.pop().respond(200, null, | ||
606 | '#EXTM3U\n' + | ||
607 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
608 | 'low.m3u8\n' + | ||
609 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
610 | 'high.m3u8\n'); | ||
611 | this.requests.pop().respond(200, null, | ||
612 | '#EXTM3U\n' + | ||
613 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
614 | '#EXTINF:10,\n' + | ||
615 | 'low-0.ts\n'); | ||
616 | this.clock.tick(10 * 1000); | ||
617 | loader.media('high.m3u8'); | ||
618 | QUnit.strictEqual(this.requests[0].aborted, true, 'aborted refresh request'); | ||
619 | QUnit.ok(!this.requests[0].onreadystatechange, | ||
620 | 'onreadystatechange handlers should be removed on abort'); | ||
621 | QUnit.strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state'); | ||
622 | }); | ||
623 | |||
624 | QUnit.test('switching to the active playlist is a no-op', function() { | ||
625 | let loader = new PlaylistLoader('master.m3u8'); | ||
626 | |||
627 | this.requests.pop().respond(200, null, | ||
628 | '#EXTM3U\n' + | ||
629 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
630 | 'low.m3u8\n' + | ||
631 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
632 | 'high.m3u8\n'); | ||
633 | this.requests.pop().respond(200, null, | ||
634 | '#EXTM3U\n' + | ||
635 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
636 | '#EXTINF:10,\n' + | ||
637 | 'low-0.ts\n' + | ||
638 | '#EXT-X-ENDLIST\n'); | ||
639 | loader.media('low.m3u8'); | ||
640 | |||
641 | QUnit.strictEqual(this.requests.length, 0, 'no requests are sent'); | ||
642 | }); | ||
643 | |||
644 | QUnit.test('switching to the active live playlist is a no-op', function() { | ||
645 | let loader = new PlaylistLoader('master.m3u8'); | ||
646 | |||
647 | this.requests.pop().respond(200, null, | ||
648 | '#EXTM3U\n' + | ||
649 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
650 | 'low.m3u8\n' + | ||
651 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
652 | 'high.m3u8\n'); | ||
653 | this.requests.pop().respond(200, null, | ||
654 | '#EXTM3U\n' + | ||
655 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
656 | '#EXTINF:10,\n' + | ||
657 | 'low-0.ts\n'); | ||
658 | loader.media('low.m3u8'); | ||
659 | |||
660 | QUnit.strictEqual(this.requests.length, 0, 'no requests are sent'); | ||
661 | }); | ||
662 | |||
663 | QUnit.test('switches back to loaded playlists without re-requesting them', function() { | ||
664 | let loader = new PlaylistLoader('master.m3u8'); | ||
665 | |||
666 | this.requests.pop().respond(200, null, | ||
667 | '#EXTM3U\n' + | ||
668 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
669 | 'low.m3u8\n' + | ||
670 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
671 | 'high.m3u8\n'); | ||
672 | this.requests.pop().respond(200, null, | ||
673 | '#EXTM3U\n' + | ||
674 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
675 | '#EXTINF:10,\n' + | ||
676 | 'low-0.ts\n' + | ||
677 | '#EXT-X-ENDLIST\n'); | ||
678 | loader.media('high.m3u8'); | ||
679 | this.requests.pop().respond(200, null, | ||
680 | '#EXTM3U\n' + | ||
681 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
682 | '#EXTINF:10,\n' + | ||
683 | 'high-0.ts\n' + | ||
684 | '#EXT-X-ENDLIST\n'); | ||
685 | loader.media('low.m3u8'); | ||
686 | |||
687 | QUnit.strictEqual(this.requests.length, 0, 'no outstanding requests'); | ||
688 | QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'returned to loaded playlist'); | ||
689 | }); | ||
690 | |||
691 | QUnit.test('aborts outstanding requests if switching back to an already loaded playlist', | ||
692 | function() { | ||
693 | let loader = new PlaylistLoader('master.m3u8'); | ||
694 | |||
695 | this.requests.pop().respond(200, null, | ||
696 | '#EXTM3U\n' + | ||
697 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
698 | 'low.m3u8\n' + | ||
699 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
700 | 'high.m3u8\n'); | ||
701 | this.requests.pop().respond(200, null, | ||
702 | '#EXTM3U\n' + | ||
703 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
704 | '#EXTINF:10,\n' + | ||
705 | 'low-0.ts\n' + | ||
706 | '#EXT-X-ENDLIST\n'); | ||
707 | loader.media('high.m3u8'); | ||
708 | loader.media('low.m3u8'); | ||
709 | |||
710 | QUnit.strictEqual(this.requests.length, | ||
711 | 1, | ||
712 | 'requested high playlist'); | ||
713 | QUnit.ok(this.requests[0].aborted, | ||
714 | 'aborted playlist request'); | ||
715 | QUnit.ok(!this.requests[0].onreadystatechange, | ||
716 | 'onreadystatechange handlers should be removed on abort'); | ||
717 | QUnit.strictEqual(loader.state, | ||
718 | 'HAVE_METADATA', | ||
719 | 'returned to loaded playlist'); | ||
720 | QUnit.strictEqual(loader.media(), | ||
721 | loader.master.playlists[0], | ||
722 | 'switched to loaded playlist'); | ||
723 | }); | ||
724 | |||
725 | QUnit.test('does not abort requests when the same playlist is re-requested', | ||
726 | function() { | ||
727 | let loader = new PlaylistLoader('master.m3u8'); | ||
728 | |||
729 | this.requests.pop().respond(200, null, | ||
730 | '#EXTM3U\n' + | ||
731 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
732 | 'low.m3u8\n' + | ||
733 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
734 | 'high.m3u8\n'); | ||
735 | this.requests.pop().respond(200, null, | ||
736 | '#EXTM3U\n' + | ||
737 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
738 | '#EXTINF:10,\n' + | ||
739 | 'low-0.ts\n' + | ||
740 | '#EXT-X-ENDLIST\n'); | ||
741 | loader.media('high.m3u8'); | ||
742 | loader.media('high.m3u8'); | ||
743 | |||
744 | QUnit.strictEqual(this.requests.length, 1, 'made only one request'); | ||
745 | QUnit.ok(!this.requests[0].aborted, 'request not aborted'); | ||
746 | }); | ||
747 | |||
748 | QUnit.test('throws an error if a media switch is initiated too early', function() { | ||
749 | let loader = new PlaylistLoader('master.m3u8'); | ||
750 | |||
751 | QUnit.throws(function() { | ||
752 | loader.media('high.m3u8'); | ||
753 | }, 'threw an error from HAVE_NOTHING'); | ||
754 | |||
755 | this.requests.pop().respond(200, null, | ||
756 | '#EXTM3U\n' + | ||
757 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
758 | 'low.m3u8\n' + | ||
759 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
760 | 'high.m3u8\n'); | ||
761 | }); | ||
762 | |||
763 | QUnit.test('throws an error if a switch to an unrecognized playlist is requested', | ||
764 | function() { | ||
765 | let loader = new PlaylistLoader('master.m3u8'); | ||
766 | |||
767 | this.requests.pop().respond(200, null, | ||
768 | '#EXTM3U\n' + | ||
769 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
770 | 'media.m3u8\n'); | ||
771 | |||
772 | QUnit.throws(function() { | ||
773 | loader.media('unrecognized.m3u8'); | ||
774 | }, 'throws an error'); | ||
775 | }); | ||
776 | |||
777 | QUnit.test('dispose cancels the refresh timeout', function() { | ||
778 | let loader = new PlaylistLoader('live.m3u8'); | ||
779 | |||
780 | this.requests.pop().respond(200, null, | ||
781 | '#EXTM3U\n' + | ||
782 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
783 | '#EXTINF:10,\n' + | ||
784 | '0.ts\n'); | ||
785 | loader.dispose(); | ||
786 | // a lot of time passes... | ||
787 | this.clock.tick(15 * 1000); | ||
788 | |||
789 | QUnit.strictEqual(this.requests.length, 0, 'no refresh request was made'); | ||
790 | }); | ||
791 | |||
792 | QUnit.test('dispose aborts pending refresh requests', function() { | ||
793 | let loader = new PlaylistLoader('live.m3u8'); | ||
794 | |||
795 | this.requests.pop().respond(200, null, | ||
796 | '#EXTM3U\n' + | ||
797 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
798 | '#EXTINF:10,\n' + | ||
799 | '0.ts\n'); | ||
800 | this.clock.tick(10 * 1000); | ||
801 | |||
802 | loader.dispose(); | ||
803 | QUnit.ok(this.requests[0].aborted, 'refresh request aborted'); | ||
804 | QUnit.ok(!this.requests[0].onreadystatechange, | ||
805 | 'onreadystatechange handler should not exist after dispose called' | ||
806 | ); | ||
807 | }); | ||
808 | |||
809 | QUnit.test('errors if requests take longer than 45s', function() { | ||
810 | let loader = new PlaylistLoader('media.m3u8'); | ||
811 | let errors = 0; | ||
812 | |||
813 | loader.on('error', function() { | ||
814 | errors++; | ||
815 | }); | ||
816 | this.clock.tick(45 * 1000); | ||
817 | |||
818 | QUnit.strictEqual(errors, 1, 'fired one error'); | ||
819 | QUnit.strictEqual(loader.error.code, 2, 'fired a network error'); | ||
820 | }); | ||
821 | |||
822 | QUnit.test('triggers an event when the active media changes', function() { | ||
823 | let loader = new PlaylistLoader('master.m3u8'); | ||
824 | let mediaChanges = 0; | ||
825 | |||
826 | loader.on('mediachange', function() { | ||
827 | mediaChanges++; | ||
828 | }); | ||
829 | this.requests.pop().respond(200, null, | ||
830 | '#EXTM3U\n' + | ||
831 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
832 | 'low.m3u8\n' + | ||
833 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
834 | 'high.m3u8\n'); | ||
835 | this.requests.shift().respond(200, null, | ||
836 | '#EXTM3U\n' + | ||
837 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
838 | '#EXTINF:10,\n' + | ||
839 | 'low-0.ts\n' + | ||
840 | '#EXT-X-ENDLIST\n'); | ||
841 | QUnit.strictEqual(mediaChanges, 0, 'initial selection is not a media change'); | ||
842 | |||
843 | loader.media('high.m3u8'); | ||
844 | QUnit.strictEqual(mediaChanges, 0, 'mediachange does not fire immediately'); | ||
845 | |||
846 | this.requests.shift().respond(200, null, | ||
847 | '#EXTM3U\n' + | ||
848 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
849 | '#EXTINF:10,\n' + | ||
850 | 'high-0.ts\n' + | ||
851 | '#EXT-X-ENDLIST\n'); | ||
852 | QUnit.strictEqual(mediaChanges, 1, 'fired a mediachange'); | ||
853 | |||
854 | // switch back to an already loaded playlist | ||
855 | loader.media('low.m3u8'); | ||
856 | QUnit.strictEqual(mediaChanges, 2, 'fired a mediachange'); | ||
857 | |||
858 | // trigger a no-op switch | ||
859 | loader.media('low.m3u8'); | ||
860 | QUnit.strictEqual(mediaChanges, 2, 'ignored a no-op media change'); | ||
861 | }); | ||
862 | |||
863 | QUnit.test('can get media index by playback position for non-live videos', function() { | ||
864 | let loader = new PlaylistLoader('media.m3u8'); | ||
865 | |||
866 | this.requests.shift().respond(200, null, | ||
867 | '#EXTM3U\n' + | ||
868 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
869 | '#EXTINF:4,\n' + | ||
870 | '0.ts\n' + | ||
871 | '#EXTINF:5,\n' + | ||
872 | '1.ts\n' + | ||
873 | '#EXTINF:6,\n' + | ||
874 | '2.ts\n' + | ||
875 | '#EXT-X-ENDLIST\n'); | ||
876 | |||
877 | QUnit.equal(loader.getMediaIndexForTime_(-1), | ||
878 | 0, | ||
879 | 'the index is never less than zero'); | ||
880 | QUnit.equal(loader.getMediaIndexForTime_(0), 0, 'time zero is index zero'); | ||
881 | QUnit.equal(loader.getMediaIndexForTime_(3), 0, 'time three is index zero'); | ||
882 | QUnit.equal(loader.getMediaIndexForTime_(10), 2, 'time 10 is index 2'); | ||
883 | QUnit.equal(loader.getMediaIndexForTime_(22), | ||
884 | 2, | ||
885 | 'time greater than the length is index 2'); | ||
886 | }); | ||
887 | |||
888 | QUnit.test('returns the lower index when calculating for a segment boundary', function() { | ||
889 | let loader = new PlaylistLoader('media.m3u8'); | ||
890 | |||
891 | this.requests.shift().respond(200, null, | ||
892 | '#EXTM3U\n' + | ||
893 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
894 | '#EXTINF:4,\n' + | ||
895 | '0.ts\n' + | ||
896 | '#EXTINF:5,\n' + | ||
897 | '1.ts\n' + | ||
898 | '#EXT-X-ENDLIST\n'); | ||
899 | QUnit.equal(loader.getMediaIndexForTime_(4), 1, 'rounds up exact matches'); | ||
900 | QUnit.equal(loader.getMediaIndexForTime_(3.7), 0, 'rounds down'); | ||
901 | QUnit.equal(loader.getMediaIndexForTime_(4.5), 1, 'rounds up at 0.5'); | ||
902 | }); | ||
903 | |||
904 | QUnit.test('accounts for non-zero starting segment time when calculating media index', | ||
905 | function() { | ||
906 | let loader = new PlaylistLoader('media.m3u8'); | ||
907 | |||
908 | this.requests.shift().respond(200, null, | ||
909 | '#EXTM3U\n' + | ||
910 | '#EXT-X-MEDIA-SEQUENCE:1001\n' + | ||
911 | '#EXTINF:4,\n' + | ||
912 | '1001.ts\n' + | ||
913 | '#EXTINF:5,\n' + | ||
914 | '1002.ts\n'); | ||
915 | loader.media().segments[0].end = 154; | ||
916 | |||
917 | QUnit.equal(loader.getMediaIndexForTime_(0), | ||
918 | -1, | ||
919 | 'the lowest returned value is negative one'); | ||
920 | QUnit.equal(loader.getMediaIndexForTime_(45), | ||
921 | -1, | ||
922 | 'expired content returns negative one'); | ||
923 | QUnit.equal(loader.getMediaIndexForTime_(75), | ||
924 | -1, | ||
925 | 'expired content returns negative one'); | ||
926 | QUnit.equal(loader.getMediaIndexForTime_(50 + 100), | ||
927 | 0, | ||
928 | 'calculates the earliest available position'); | ||
929 | QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 2), | ||
930 | 0, | ||
931 | 'calculates within the first segment'); | ||
932 | QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 4), | ||
933 | 1, | ||
934 | 'calculates within the second segment'); | ||
935 | QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 4.5), | ||
936 | 1, | ||
937 | 'calculates within the second segment'); | ||
938 | QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 6), | ||
939 | 1, | ||
940 | 'calculates within the second segment'); | ||
941 | }); | ||
942 | |||
943 | QUnit.test('prefers precise segment timing when tracking expired time', function() { | ||
944 | let loader = new PlaylistLoader('media.m3u8'); | ||
945 | |||
946 | loader.trigger('firstplay'); | ||
947 | this.requests.shift().respond(200, null, | ||
948 | '#EXTM3U\n' + | ||
949 | '#EXT-X-MEDIA-SEQUENCE:1001\n' + | ||
950 | '#EXTINF:4,\n' + | ||
951 | '1001.ts\n' + | ||
952 | '#EXTINF:5,\n' + | ||
953 | '1002.ts\n'); | ||
954 | // setup the loader with an "imprecise" value as if it had been | ||
955 | // accumulating segment durations as they expire | ||
956 | loader.expired_ = 160; | ||
957 | // annotate the first segment with a start time | ||
958 | // this number would be coming from the Source Buffer in practice | ||
959 | loader.media().segments[0].end = 150; | ||
960 | |||
961 | QUnit.equal(loader.getMediaIndexForTime_(149), | ||
962 | 0, | ||
963 | 'prefers the value on the first segment'); | ||
964 | |||
965 | // trigger a playlist refresh | ||
966 | this.clock.tick(10 * 1000); | ||
967 | this.requests.shift().respond(200, null, | ||
968 | '#EXTM3U\n' + | ||
969 | '#EXT-X-MEDIA-SEQUENCE:1002\n' + | ||
970 | '#EXTINF:5,\n' + | ||
971 | '1002.ts\n'); | ||
972 | QUnit.equal(loader.getMediaIndexForTime_(150 + 4 + 1), | ||
973 | 0, | ||
974 | 'tracks precise expired times'); | ||
975 | }); | ||
976 | |||
977 | QUnit.test('accounts for expired time when calculating media index', function() { | ||
978 | let loader = new PlaylistLoader('media.m3u8'); | ||
979 | |||
980 | this.requests.shift().respond(200, null, | ||
981 | '#EXTM3U\n' + | ||
982 | '#EXT-X-MEDIA-SEQUENCE:1001\n' + | ||
983 | '#EXTINF:4,\n' + | ||
984 | '1001.ts\n' + | ||
985 | '#EXTINF:5,\n' + | ||
986 | '1002.ts\n'); | ||
987 | loader.expired_ = 150; | ||
988 | |||
989 | QUnit.equal(loader.getMediaIndexForTime_(0), | ||
990 | -1, | ||
991 | 'expired content returns a negative index'); | ||
992 | QUnit.equal(loader.getMediaIndexForTime_(75), | ||
993 | -1, | ||
994 | 'expired content returns a negative index'); | ||
995 | QUnit.equal(loader.getMediaIndexForTime_(50 + 100), | ||
996 | 0, | ||
997 | 'calculates the earliest available position'); | ||
998 | QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 2), | ||
999 | 0, | ||
1000 | 'calculates within the first segment'); | ||
1001 | QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 4.5), | ||
1002 | 1, | ||
1003 | 'calculates within the second segment'); | ||
1004 | QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 6), | ||
1005 | 1, | ||
1006 | 'calculates within the second segment'); | ||
1007 | }); | ||
1008 | |||
1009 | QUnit.test('does not misintrepret playlists missing newlines at the end', function() { | ||
1010 | let loader = new PlaylistLoader('media.m3u8'); | ||
1011 | |||
1012 | // no newline | ||
1013 | this.requests.shift().respond(200, null, | ||
1014 | '#EXTM3U\n' + | ||
1015 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
1016 | '#EXTINF:10,\n' + | ||
1017 | 'low-0.ts\n' + | ||
1018 | '#EXT-X-ENDLIST'); | ||
1019 | QUnit.ok(loader.media().endList, 'flushed the final line of input'); | ||
1020 | }); |
test/playlist-loader_test.js
deleted
100644 → 0
1 | (function(window) { | ||
2 | 'use strict'; | ||
3 | var | ||
4 | sinonXhr, | ||
5 | clock, | ||
6 | requests, | ||
7 | videojs = window.videojs, | ||
8 | |||
9 | // Attempts to produce an absolute URL to a given relative path | ||
10 | // based on window.location.href | ||
11 | urlTo = function(path) { | ||
12 | return window.location.href | ||
13 | .split('/') | ||
14 | .slice(0, -1) | ||
15 | .concat([path]) | ||
16 | .join('/'); | ||
17 | }; | ||
18 | |||
19 | module('Playlist Loader', { | ||
20 | setup: function() { | ||
21 | // fake XHRs | ||
22 | sinonXhr = sinon.useFakeXMLHttpRequest(); | ||
23 | videojs.xhr.XMLHttpRequest = sinonXhr; | ||
24 | |||
25 | requests = []; | ||
26 | sinonXhr.onCreate = function(xhr) { | ||
27 | // force the XHR2 timeout polyfill | ||
28 | xhr.timeout = undefined; | ||
29 | requests.push(xhr); | ||
30 | }; | ||
31 | |||
32 | // fake timers | ||
33 | clock = sinon.useFakeTimers(); | ||
34 | }, | ||
35 | teardown: function() { | ||
36 | sinonXhr.restore(); | ||
37 | videojs.xhr.XMLHttpRequest = window.XMLHttpRequest; | ||
38 | clock.restore(); | ||
39 | } | ||
40 | }); | ||
41 | |||
42 | test('throws if the playlist url is empty or undefined', function() { | ||
43 | throws(function() { | ||
44 | videojs.Hls.PlaylistLoader(); | ||
45 | }, 'requires an argument'); | ||
46 | throws(function() { | ||
47 | videojs.Hls.PlaylistLoader(''); | ||
48 | }, 'does not accept the empty string'); | ||
49 | }); | ||
50 | |||
51 | test('starts without any metadata', function() { | ||
52 | var loader = new videojs.Hls.PlaylistLoader('master.m3u8'); | ||
53 | strictEqual(loader.state, 'HAVE_NOTHING', 'no metadata has loaded yet'); | ||
54 | }); | ||
55 | |||
56 | test('starts with no expired time', function() { | ||
57 | var loader = new videojs.Hls.PlaylistLoader('media.m3u8'); | ||
58 | requests.pop().respond(200, null, | ||
59 | '#EXTM3U\n' + | ||
60 | '#EXTINF:10,\n' + | ||
61 | '0.ts\n'); | ||
62 | equal(loader.expired_, | ||
63 | 0, | ||
64 | 'zero seconds expired'); | ||
65 | }); | ||
66 | |||
67 | test('requests the initial playlist immediately', function() { | ||
68 | new videojs.Hls.PlaylistLoader('master.m3u8'); | ||
69 | strictEqual(requests.length, 1, 'made a request'); | ||
70 | strictEqual(requests[0].url, 'master.m3u8', 'requested the initial playlist'); | ||
71 | }); | ||
72 | |||
73 | test('moves to HAVE_MASTER after loading a master playlist', function() { | ||
74 | var loader = new videojs.Hls.PlaylistLoader('master.m3u8'), state; | ||
75 | loader.on('loadedplaylist', function() { | ||
76 | state = loader.state; | ||
77 | }); | ||
78 | requests.pop().respond(200, null, | ||
79 | '#EXTM3U\n' + | ||
80 | '#EXT-X-STREAM-INF:\n' + | ||
81 | 'media.m3u8\n'); | ||
82 | ok(loader.master, 'the master playlist is available'); | ||
83 | strictEqual(state, 'HAVE_MASTER', 'the state at loadedplaylist correct'); | ||
84 | }); | ||
85 | |||
86 | test('jumps to HAVE_METADATA when initialized with a media playlist', function() { | ||
87 | var | ||
88 | loadedmetadatas = 0, | ||
89 | loader = new videojs.Hls.PlaylistLoader('media.m3u8'); | ||
90 | loader.on('loadedmetadata', function() { | ||
91 | loadedmetadatas++; | ||
92 | }); | ||
93 | requests.pop().respond(200, null, | ||
94 | '#EXTM3U\n' + | ||
95 | '#EXTINF:10,\n' + | ||
96 | '0.ts\n' + | ||
97 | '#EXT-X-ENDLIST\n'); | ||
98 | ok(loader.master, 'infers a master playlist'); | ||
99 | ok(loader.media(), 'sets the media playlist'); | ||
100 | ok(loader.media().uri, 'sets the media playlist URI'); | ||
101 | strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct'); | ||
102 | strictEqual(requests.length, 0, 'no more requests are made'); | ||
103 | strictEqual(loadedmetadatas, 1, 'fired one loadedmetadata'); | ||
104 | }); | ||
105 | |||
106 | test('jumps to HAVE_METADATA when initialized with a live media playlist', function() { | ||
107 | var loader = new videojs.Hls.PlaylistLoader('media.m3u8'); | ||
108 | requests.pop().respond(200, null, | ||
109 | '#EXTM3U\n' + | ||
110 | '#EXTINF:10,\n' + | ||
111 | '0.ts\n'); | ||
112 | ok(loader.master, 'infers a master playlist'); | ||
113 | ok(loader.media(), 'sets the media playlist'); | ||
114 | strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct'); | ||
115 | }); | ||
116 | |||
117 | test('moves to HAVE_METADATA after loading a media playlist', function() { | ||
118 | var | ||
119 | loadedPlaylist = 0, | ||
120 | loadedMetadata = 0, | ||
121 | loader = new videojs.Hls.PlaylistLoader('master.m3u8'); | ||
122 | loader.on('loadedplaylist', function() { | ||
123 | loadedPlaylist++; | ||
124 | }); | ||
125 | loader.on('loadedmetadata', function() { | ||
126 | loadedMetadata++; | ||
127 | }); | ||
128 | requests.pop().respond(200, null, | ||
129 | '#EXTM3U\n' + | ||
130 | '#EXT-X-STREAM-INF:\n' + | ||
131 | 'media.m3u8\n' + | ||
132 | 'alt.m3u8\n'); | ||
133 | strictEqual(loadedPlaylist, 1, 'fired loadedplaylist once'); | ||
134 | strictEqual(loadedMetadata, 0, 'did not fire loadedmetadata'); | ||
135 | strictEqual(requests.length, 1, 'requests the media playlist'); | ||
136 | strictEqual(requests[0].method, 'GET', 'GETs the media playlist'); | ||
137 | strictEqual(requests[0].url, | ||
138 | urlTo('media.m3u8'), | ||
139 | 'requests the first playlist'); | ||
140 | |||
141 | requests.pop().respond(200, null, | ||
142 | '#EXTM3U\n' + | ||
143 | '#EXTINF:10,\n' + | ||
144 | '0.ts\n'); | ||
145 | ok(loader.master, 'sets the master playlist'); | ||
146 | ok(loader.media(), 'sets the media playlist'); | ||
147 | strictEqual(loadedPlaylist, 2, 'fired loadedplaylist twice'); | ||
148 | strictEqual(loadedMetadata, 1, 'fired loadedmetadata once'); | ||
149 | strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct'); | ||
150 | }); | ||
151 | |||
152 | test('moves to HAVE_CURRENT_METADATA when refreshing the playlist', function() { | ||
153 | var loader = new videojs.Hls.PlaylistLoader('live.m3u8'); | ||
154 | requests.pop().respond(200, null, | ||
155 | '#EXTM3U\n' + | ||
156 | '#EXTINF:10,\n' + | ||
157 | '0.ts\n'); | ||
158 | clock.tick(10 * 1000); // 10s, one target duration | ||
159 | strictEqual(loader.state, 'HAVE_CURRENT_METADATA', 'the state is correct'); | ||
160 | strictEqual(requests.length, 1, 'requested playlist'); | ||
161 | strictEqual(requests[0].url, | ||
162 | urlTo('live.m3u8'), | ||
163 | 'refreshes the media playlist'); | ||
164 | }); | ||
165 | |||
166 | test('returns to HAVE_METADATA after refreshing the playlist', function() { | ||
167 | var loader = new videojs.Hls.PlaylistLoader('live.m3u8'); | ||
168 | requests.pop().respond(200, null, | ||
169 | '#EXTM3U\n' + | ||
170 | '#EXTINF:10,\n' + | ||
171 | '0.ts\n'); | ||
172 | clock.tick(10 * 1000); // 10s, one target duration | ||
173 | requests.pop().respond(200, null, | ||
174 | '#EXTM3U\n' + | ||
175 | '#EXTINF:10,\n' + | ||
176 | '1.ts\n'); | ||
177 | strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct'); | ||
178 | }); | ||
179 | |||
180 | test('does not increment expired seconds before firstplay is triggered', function() { | ||
181 | var loader = new videojs.Hls.PlaylistLoader('live.m3u8'); | ||
182 | requests.pop().respond(200, null, | ||
183 | '#EXTM3U\n' + | ||
184 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
185 | '#EXTINF:10,\n' + | ||
186 | '0.ts\n' + | ||
187 | '#EXTINF:10,\n' + | ||
188 | '1.ts\n' + | ||
189 | '#EXTINF:10,\n' + | ||
190 | '2.ts\n' + | ||
191 | '#EXTINF:10,\n' + | ||
192 | '3.ts\n'); | ||
193 | clock.tick(10 * 1000); // 10s, one target duration | ||
194 | requests.pop().respond(200, null, | ||
195 | '#EXTM3U\n' + | ||
196 | '#EXT-X-MEDIA-SEQUENCE:1\n' + | ||
197 | '#EXTINF:10,\n' + | ||
198 | '1.ts\n' + | ||
199 | '#EXTINF:10,\n' + | ||
200 | '2.ts\n' + | ||
201 | '#EXTINF:10,\n' + | ||
202 | '3.ts\n' + | ||
203 | '#EXTINF:10,\n' + | ||
204 | '4.ts\n'); | ||
205 | equal(loader.expired_, 0, 'expired one segment'); | ||
206 | }); | ||
207 | |||
208 | test('increments expired seconds after a segment is removed', function() { | ||
209 | var loader = new videojs.Hls.PlaylistLoader('live.m3u8'); | ||
210 | loader.trigger('firstplay'); | ||
211 | requests.pop().respond(200, null, | ||
212 | '#EXTM3U\n' + | ||
213 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
214 | '#EXTINF:10,\n' + | ||
215 | '0.ts\n' + | ||
216 | '#EXTINF:10,\n' + | ||
217 | '1.ts\n' + | ||
218 | '#EXTINF:10,\n' + | ||
219 | '2.ts\n' + | ||
220 | '#EXTINF:10,\n' + | ||
221 | '3.ts\n'); | ||
222 | clock.tick(10 * 1000); // 10s, one target duration | ||
223 | requests.pop().respond(200, null, | ||
224 | '#EXTM3U\n' + | ||
225 | '#EXT-X-MEDIA-SEQUENCE:1\n' + | ||
226 | '#EXTINF:10,\n' + | ||
227 | '1.ts\n' + | ||
228 | '#EXTINF:10,\n' + | ||
229 | '2.ts\n' + | ||
230 | '#EXTINF:10,\n' + | ||
231 | '3.ts\n' + | ||
232 | '#EXTINF:10,\n' + | ||
233 | '4.ts\n'); | ||
234 | equal(loader.expired_, 10, 'expired one segment'); | ||
235 | }); | ||
236 | |||
237 | test('increments expired seconds after a discontinuity', function() { | ||
238 | var loader = new videojs.Hls.PlaylistLoader('live.m3u8'); | ||
239 | loader.trigger('firstplay'); | ||
240 | requests.pop().respond(200, null, | ||
241 | '#EXTM3U\n' + | ||
242 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
243 | '#EXTINF:10,\n' + | ||
244 | '0.ts\n' + | ||
245 | '#EXTINF:3,\n' + | ||
246 | '1.ts\n' + | ||
247 | '#EXT-X-DISCONTINUITY\n' + | ||
248 | '#EXTINF:4,\n' + | ||
249 | '2.ts\n'); | ||
250 | clock.tick(10 * 1000); // 10s, one target duration | ||
251 | requests.pop().respond(200, null, | ||
252 | '#EXTM3U\n' + | ||
253 | '#EXT-X-MEDIA-SEQUENCE:1\n' + | ||
254 | '#EXTINF:3,\n' + | ||
255 | '1.ts\n' + | ||
256 | '#EXT-X-DISCONTINUITY\n' + | ||
257 | '#EXTINF:4,\n' + | ||
258 | '2.ts\n'); | ||
259 | equal(loader.expired_, 10, 'expired one segment'); | ||
260 | |||
261 | clock.tick(10 * 1000); // 10s, one target duration | ||
262 | requests.pop().respond(200, null, | ||
263 | '#EXTM3U\n' + | ||
264 | '#EXT-X-MEDIA-SEQUENCE:2\n' + | ||
265 | '#EXT-X-DISCONTINUITY\n' + | ||
266 | '#EXTINF:4,\n' + | ||
267 | '2.ts\n'); | ||
268 | equal(loader.expired_, 13, 'no expirations after the discontinuity yet'); | ||
269 | |||
270 | clock.tick(10 * 1000); // 10s, one target duration | ||
271 | requests.pop().respond(200, null, | ||
272 | '#EXTM3U\n' + | ||
273 | '#EXT-X-MEDIA-SEQUENCE:3\n' + | ||
274 | '#EXT-X-DISCONTINUITY-SEQUENCE:1\n' + | ||
275 | '#EXTINF:10,\n' + | ||
276 | '3.ts\n'); | ||
277 | equal(loader.expired_, 17, 'tracked expiration across the discontinuity'); | ||
278 | }); | ||
279 | |||
280 | test('tracks expired seconds properly when two discontinuities expire at once', function() { | ||
281 | var loader = new videojs.Hls.PlaylistLoader('live.m3u8'); | ||
282 | loader.trigger('firstplay'); | ||
283 | requests.pop().respond(200, null, | ||
284 | '#EXTM3U\n' + | ||
285 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
286 | '#EXTINF:4,\n' + | ||
287 | '0.ts\n' + | ||
288 | '#EXT-X-DISCONTINUITY\n' + | ||
289 | '#EXTINF:5,\n' + | ||
290 | '1.ts\n' + | ||
291 | '#EXT-X-DISCONTINUITY\n' + | ||
292 | '#EXTINF:6,\n' + | ||
293 | '2.ts\n' + | ||
294 | '#EXTINF:7,\n' + | ||
295 | '3.ts\n'); | ||
296 | clock.tick(10 * 1000); | ||
297 | requests.pop().respond(200, null, | ||
298 | '#EXTM3U\n' + | ||
299 | '#EXT-X-MEDIA-SEQUENCE:3\n' + | ||
300 | '#EXT-X-DISCONTINUITY-SEQUENCE:2\n' + | ||
301 | '#EXTINF:7,\n' + | ||
302 | '3.ts\n'); | ||
303 | equal(loader.expired_, 4 + 5 + 6, 'tracked multiple expiring discontinuities'); | ||
304 | }); | ||
305 | |||
306 | test('estimates expired if an entire window elapses between live playlist updates', function() { | ||
307 | var loader = new videojs.Hls.PlaylistLoader('live.m3u8'); | ||
308 | loader.trigger('firstplay'); | ||
309 | requests.pop().respond(200, null, | ||
310 | '#EXTM3U\n' + | ||
311 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
312 | '#EXTINF:4,\n' + | ||
313 | '0.ts\n' + | ||
314 | '#EXTINF:5,\n' + | ||
315 | '1.ts\n'); | ||
316 | |||
317 | clock.tick(10 * 1000); | ||
318 | requests.pop().respond(200, null, | ||
319 | '#EXTM3U\n' + | ||
320 | '#EXT-X-MEDIA-SEQUENCE:4\n' + | ||
321 | '#EXTINF:6,\n' + | ||
322 | '4.ts\n' + | ||
323 | '#EXTINF:7,\n' + | ||
324 | '5.ts\n'); | ||
325 | |||
326 | equal(loader.expired_, | ||
327 | 4 + 5 + (2 * 10), | ||
328 | 'made a very rough estimate of expired time'); | ||
329 | }); | ||
330 | |||
331 | test('emits an error when an initial playlist request fails', function() { | ||
332 | var | ||
333 | errors = [], | ||
334 | loader = new videojs.Hls.PlaylistLoader('master.m3u8'); | ||
335 | |||
336 | loader.on('error', function() { | ||
337 | errors.push(loader.error); | ||
338 | }); | ||
339 | requests.pop().respond(500); | ||
340 | |||
341 | strictEqual(errors.length, 1, 'emitted one error'); | ||
342 | strictEqual(errors[0].status, 500, 'http status is captured'); | ||
343 | }); | ||
344 | |||
345 | test('errors when an initial media playlist request fails', function() { | ||
346 | var | ||
347 | errors = [], | ||
348 | loader = new videojs.Hls.PlaylistLoader('master.m3u8'); | ||
349 | |||
350 | loader.on('error', function() { | ||
351 | errors.push(loader.error); | ||
352 | }); | ||
353 | requests.pop().respond(200, null, | ||
354 | '#EXTM3U\n' + | ||
355 | '#EXT-X-STREAM-INF:\n' + | ||
356 | 'media.m3u8\n'); | ||
357 | |||
358 | strictEqual(errors.length, 0, 'emitted no errors'); | ||
359 | |||
360 | requests.pop().respond(500); | ||
361 | |||
362 | strictEqual(errors.length, 1, 'emitted one error'); | ||
363 | strictEqual(errors[0].status, 500, 'http status is captured'); | ||
364 | }); | ||
365 | |||
366 | // http://tools.ietf.org/html/draft-pantos-http-live-streaming-12#section-6.3.4 | ||
367 | test('halves the refresh timeout if a playlist is unchanged' + | ||
368 | 'since the last reload', function() { | ||
369 | new videojs.Hls.PlaylistLoader('live.m3u8'); | ||
370 | requests.pop().respond(200, null, | ||
371 | '#EXTM3U\n' + | ||
372 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
373 | '#EXTINF:10,\n' + | ||
374 | '0.ts\n'); | ||
375 | clock.tick(10 * 1000); // trigger a refresh | ||
376 | requests.pop().respond(200, null, | ||
377 | '#EXTM3U\n' + | ||
378 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
379 | '#EXTINF:10,\n' + | ||
380 | '0.ts\n'); | ||
381 | clock.tick(5 * 1000); // half the default target-duration | ||
382 | |||
383 | strictEqual(requests.length, 1, 'sent a request'); | ||
384 | strictEqual(requests[0].url, | ||
385 | urlTo('live.m3u8'), | ||
386 | 'requested the media playlist'); | ||
387 | }); | ||
388 | |||
389 | test('preserves segment metadata across playlist refreshes', function() { | ||
390 | var loader = new videojs.Hls.PlaylistLoader('live.m3u8'), segment; | ||
391 | requests.pop().respond(200, null, | ||
392 | '#EXTM3U\n' + | ||
393 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
394 | '#EXTINF:10,\n' + | ||
395 | '0.ts\n' + | ||
396 | '#EXTINF:10,\n' + | ||
397 | '1.ts\n' + | ||
398 | '#EXTINF:10,\n' + | ||
399 | '2.ts\n'); | ||
400 | // add PTS info to 1.ts | ||
401 | segment = loader.media().segments[1]; | ||
402 | segment.minVideoPts = 14; | ||
403 | segment.maxAudioPts = 27; | ||
404 | segment.preciseDuration = 10.045; | ||
405 | |||
406 | clock.tick(10 * 1000); // trigger a refresh | ||
407 | requests.pop().respond(200, null, | ||
408 | '#EXTM3U\n' + | ||
409 | '#EXT-X-MEDIA-SEQUENCE:1\n' + | ||
410 | '#EXTINF:10,\n' + | ||
411 | '1.ts\n' + | ||
412 | '#EXTINF:10,\n' + | ||
413 | '2.ts\n'); | ||
414 | |||
415 | deepEqual(loader.media().segments[0], segment, 'preserved segment attributes'); | ||
416 | }); | ||
417 | |||
418 | test('clears the update timeout when switching quality', function() { | ||
419 | var loader = new videojs.Hls.PlaylistLoader('live-master.m3u8'), refreshes = 0; | ||
420 | // track the number of playlist refreshes triggered | ||
421 | loader.on('mediaupdatetimeout', function() { | ||
422 | refreshes++; | ||
423 | }); | ||
424 | // deliver the master | ||
425 | requests.pop().respond(200, null, | ||
426 | '#EXTM3U\n' + | ||
427 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
428 | 'live-low.m3u8\n' + | ||
429 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
430 | 'live-high.m3u8\n'); | ||
431 | // deliver the low quality playlist | ||
432 | requests.pop().respond(200, null, | ||
433 | '#EXTM3U\n' + | ||
434 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
435 | '#EXTINF:10,\n' + | ||
436 | 'low-0.ts\n'); | ||
437 | // change to a higher quality playlist | ||
438 | loader.media('live-high.m3u8'); | ||
439 | requests.pop().respond(200, null, | ||
440 | '#EXTM3U\n' + | ||
441 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
442 | '#EXTINF:10,\n' + | ||
443 | 'high-0.ts\n'); | ||
444 | clock.tick(10 * 1000); // trigger a refresh | ||
445 | |||
446 | equal(1, refreshes, 'only one refresh was triggered'); | ||
447 | }); | ||
448 | |||
449 | test('media-sequence updates are considered a playlist change', function() { | ||
450 | new videojs.Hls.PlaylistLoader('live.m3u8'); | ||
451 | requests.pop().respond(200, null, | ||
452 | '#EXTM3U\n' + | ||
453 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
454 | '#EXTINF:10,\n' + | ||
455 | '0.ts\n'); | ||
456 | clock.tick(10 * 1000); // trigger a refresh | ||
457 | requests.pop().respond(200, null, | ||
458 | '#EXTM3U\n' + | ||
459 | '#EXT-X-MEDIA-SEQUENCE:1\n' + | ||
460 | '#EXTINF:10,\n' + | ||
461 | '0.ts\n'); | ||
462 | clock.tick(5 * 1000); // half the default target-duration | ||
463 | |||
464 | strictEqual(requests.length, 0, 'no request is sent'); | ||
465 | }); | ||
466 | |||
467 | test('emits an error if a media refresh fails', function() { | ||
468 | var | ||
469 | errors = 0, | ||
470 | errorResponseText = 'custom error message', | ||
471 | loader = new videojs.Hls.PlaylistLoader('live.m3u8'); | ||
472 | |||
473 | loader.on('error', function() { | ||
474 | errors++; | ||
475 | }); | ||
476 | requests.pop().respond(200, null, | ||
477 | '#EXTM3U\n' + | ||
478 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
479 | '#EXTINF:10,\n' + | ||
480 | '0.ts\n'); | ||
481 | clock.tick(10 * 1000); // trigger a refresh | ||
482 | requests.pop().respond(500, null, errorResponseText); | ||
483 | |||
484 | strictEqual(errors, 1, 'emitted an error'); | ||
485 | strictEqual(loader.error.status, 500, 'captured the status code'); | ||
486 | strictEqual(loader.error.responseText, errorResponseText, 'captured the responseText'); | ||
487 | }); | ||
488 | |||
489 | test('switches media playlists when requested', function() { | ||
490 | var loader = new videojs.Hls.PlaylistLoader('master.m3u8'); | ||
491 | requests.pop().respond(200, null, | ||
492 | '#EXTM3U\n' + | ||
493 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
494 | 'low.m3u8\n' + | ||
495 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
496 | 'high.m3u8\n'); | ||
497 | requests.pop().respond(200, null, | ||
498 | '#EXTM3U\n' + | ||
499 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
500 | '#EXTINF:10,\n' + | ||
501 | 'low-0.ts\n'); | ||
502 | |||
503 | loader.media(loader.master.playlists[1]); | ||
504 | strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state'); | ||
505 | |||
506 | requests.pop().respond(200, null, | ||
507 | '#EXTM3U\n' + | ||
508 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
509 | '#EXTINF:10,\n' + | ||
510 | 'high-0.ts\n'); | ||
511 | strictEqual(loader.state, 'HAVE_METADATA', 'switched active media'); | ||
512 | strictEqual(loader.media(), | ||
513 | loader.master.playlists[1], | ||
514 | 'updated the active media'); | ||
515 | }); | ||
516 | |||
517 | test('can switch playlists immediately after the master is downloaded', function() { | ||
518 | var loader = new videojs.Hls.PlaylistLoader('master.m3u8'); | ||
519 | loader.on('loadedplaylist', function() { | ||
520 | loader.media('high.m3u8'); | ||
521 | }); | ||
522 | requests.pop().respond(200, null, | ||
523 | '#EXTM3U\n' + | ||
524 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
525 | 'low.m3u8\n' + | ||
526 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
527 | 'high.m3u8\n'); | ||
528 | equal(requests[0].url, urlTo('high.m3u8'), 'switched variants immediately'); | ||
529 | }); | ||
530 | |||
531 | test('can switch media playlists based on URI', function() { | ||
532 | var loader = new videojs.Hls.PlaylistLoader('master.m3u8'); | ||
533 | requests.pop().respond(200, null, | ||
534 | '#EXTM3U\n' + | ||
535 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
536 | 'low.m3u8\n' + | ||
537 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
538 | 'high.m3u8\n'); | ||
539 | requests.pop().respond(200, null, | ||
540 | '#EXTM3U\n' + | ||
541 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
542 | '#EXTINF:10,\n' + | ||
543 | 'low-0.ts\n'); | ||
544 | |||
545 | loader.media('high.m3u8'); | ||
546 | strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state'); | ||
547 | |||
548 | requests.pop().respond(200, null, | ||
549 | '#EXTM3U\n' + | ||
550 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
551 | '#EXTINF:10,\n' + | ||
552 | 'high-0.ts\n'); | ||
553 | strictEqual(loader.state, 'HAVE_METADATA', 'switched active media'); | ||
554 | strictEqual(loader.media(), | ||
555 | loader.master.playlists[1], | ||
556 | 'updated the active media'); | ||
557 | }); | ||
558 | |||
559 | test('aborts in-flight playlist refreshes when switching', function() { | ||
560 | var loader = new videojs.Hls.PlaylistLoader('master.m3u8'); | ||
561 | requests.pop().respond(200, null, | ||
562 | '#EXTM3U\n' + | ||
563 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
564 | 'low.m3u8\n' + | ||
565 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
566 | 'high.m3u8\n'); | ||
567 | requests.pop().respond(200, null, | ||
568 | '#EXTM3U\n' + | ||
569 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
570 | '#EXTINF:10,\n' + | ||
571 | 'low-0.ts\n'); | ||
572 | clock.tick(10 * 1000); | ||
573 | loader.media('high.m3u8'); | ||
574 | strictEqual(requests[0].aborted, true, 'aborted refresh request'); | ||
575 | ok(!requests[0].onreadystatechange, 'onreadystatechange handlers should be removed on abort'); | ||
576 | strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state'); | ||
577 | }); | ||
578 | |||
579 | test('switching to the active playlist is a no-op', function() { | ||
580 | var loader = new videojs.Hls.PlaylistLoader('master.m3u8'); | ||
581 | requests.pop().respond(200, null, | ||
582 | '#EXTM3U\n' + | ||
583 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
584 | 'low.m3u8\n' + | ||
585 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
586 | 'high.m3u8\n'); | ||
587 | requests.pop().respond(200, null, | ||
588 | '#EXTM3U\n' + | ||
589 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
590 | '#EXTINF:10,\n' + | ||
591 | 'low-0.ts\n' + | ||
592 | '#EXT-X-ENDLIST\n'); | ||
593 | loader.media('low.m3u8'); | ||
594 | |||
595 | strictEqual(requests.length, 0, 'no requests are sent'); | ||
596 | }); | ||
597 | |||
598 | test('switching to the active live playlist is a no-op', function() { | ||
599 | var loader = new videojs.Hls.PlaylistLoader('master.m3u8'); | ||
600 | requests.pop().respond(200, null, | ||
601 | '#EXTM3U\n' + | ||
602 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
603 | 'low.m3u8\n' + | ||
604 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
605 | 'high.m3u8\n'); | ||
606 | requests.pop().respond(200, null, | ||
607 | '#EXTM3U\n' + | ||
608 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
609 | '#EXTINF:10,\n' + | ||
610 | 'low-0.ts\n'); | ||
611 | loader.media('low.m3u8'); | ||
612 | |||
613 | strictEqual(requests.length, 0, 'no requests are sent'); | ||
614 | }); | ||
615 | |||
616 | test('switches back to loaded playlists without re-requesting them', function() { | ||
617 | var loader = new videojs.Hls.PlaylistLoader('master.m3u8'); | ||
618 | requests.pop().respond(200, null, | ||
619 | '#EXTM3U\n' + | ||
620 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
621 | 'low.m3u8\n' + | ||
622 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
623 | 'high.m3u8\n'); | ||
624 | requests.pop().respond(200, null, | ||
625 | '#EXTM3U\n' + | ||
626 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
627 | '#EXTINF:10,\n' + | ||
628 | 'low-0.ts\n' + | ||
629 | '#EXT-X-ENDLIST\n'); | ||
630 | loader.media('high.m3u8'); | ||
631 | requests.pop().respond(200, null, | ||
632 | '#EXTM3U\n' + | ||
633 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
634 | '#EXTINF:10,\n' + | ||
635 | 'high-0.ts\n' + | ||
636 | '#EXT-X-ENDLIST\n'); | ||
637 | loader.media('low.m3u8'); | ||
638 | |||
639 | strictEqual(requests.length, 0, 'no outstanding requests'); | ||
640 | strictEqual(loader.state, 'HAVE_METADATA', 'returned to loaded playlist'); | ||
641 | }); | ||
642 | |||
643 | test('aborts outstanding requests if switching back to an already loaded playlist', function() { | ||
644 | var loader = new videojs.Hls.PlaylistLoader('master.m3u8'); | ||
645 | requests.pop().respond(200, null, | ||
646 | '#EXTM3U\n' + | ||
647 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
648 | 'low.m3u8\n' + | ||
649 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
650 | 'high.m3u8\n'); | ||
651 | requests.pop().respond(200, null, | ||
652 | '#EXTM3U\n' + | ||
653 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
654 | '#EXTINF:10,\n' + | ||
655 | 'low-0.ts\n' + | ||
656 | '#EXT-X-ENDLIST\n'); | ||
657 | loader.media('high.m3u8'); | ||
658 | loader.media('low.m3u8'); | ||
659 | |||
660 | strictEqual(requests.length, 1, 'requested high playlist'); | ||
661 | ok(requests[0].aborted, 'aborted playlist request'); | ||
662 | ok(!requests[0].onreadystatechange, 'onreadystatechange handlers should be removed on abort'); | ||
663 | strictEqual(loader.state, 'HAVE_METADATA', 'returned to loaded playlist'); | ||
664 | strictEqual(loader.media(), loader.master.playlists[0], 'switched to loaded playlist'); | ||
665 | }); | ||
666 | |||
667 | |||
668 | test('does not abort requests when the same playlist is re-requested', function() { | ||
669 | var loader = new videojs.Hls.PlaylistLoader('master.m3u8'); | ||
670 | requests.pop().respond(200, null, | ||
671 | '#EXTM3U\n' + | ||
672 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
673 | 'low.m3u8\n' + | ||
674 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
675 | 'high.m3u8\n'); | ||
676 | requests.pop().respond(200, null, | ||
677 | '#EXTM3U\n' + | ||
678 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
679 | '#EXTINF:10,\n' + | ||
680 | 'low-0.ts\n' + | ||
681 | '#EXT-X-ENDLIST\n'); | ||
682 | loader.media('high.m3u8'); | ||
683 | loader.media('high.m3u8'); | ||
684 | |||
685 | strictEqual(requests.length, 1, 'made only one request'); | ||
686 | ok(!requests[0].aborted, 'request not aborted'); | ||
687 | }); | ||
688 | |||
689 | test('throws an error if a media switch is initiated too early', function() { | ||
690 | var loader = new videojs.Hls.PlaylistLoader('master.m3u8'); | ||
691 | |||
692 | throws(function() { | ||
693 | loader.media('high.m3u8'); | ||
694 | }, 'threw an error from HAVE_NOTHING'); | ||
695 | |||
696 | requests.pop().respond(200, null, | ||
697 | '#EXTM3U\n' + | ||
698 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
699 | 'low.m3u8\n' + | ||
700 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
701 | 'high.m3u8\n'); | ||
702 | }); | ||
703 | |||
704 | test('throws an error if a switch to an unrecognized playlist is requested', function() { | ||
705 | var loader = new videojs.Hls.PlaylistLoader('master.m3u8'); | ||
706 | requests.pop().respond(200, null, | ||
707 | '#EXTM3U\n' + | ||
708 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
709 | 'media.m3u8\n'); | ||
710 | |||
711 | throws(function() { | ||
712 | loader.media('unrecognized.m3u8'); | ||
713 | }, 'throws an error'); | ||
714 | }); | ||
715 | |||
716 | test('dispose cancels the refresh timeout', function() { | ||
717 | var loader = new videojs.Hls.PlaylistLoader('live.m3u8'); | ||
718 | requests.pop().respond(200, null, | ||
719 | '#EXTM3U\n' + | ||
720 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
721 | '#EXTINF:10,\n' + | ||
722 | '0.ts\n'); | ||
723 | loader.dispose(); | ||
724 | // a lot of time passes... | ||
725 | clock.tick(15 * 1000); | ||
726 | |||
727 | strictEqual(requests.length, 0, 'no refresh request was made'); | ||
728 | }); | ||
729 | |||
730 | test('dispose aborts pending refresh requests', function() { | ||
731 | var loader = new videojs.Hls.PlaylistLoader('live.m3u8'); | ||
732 | requests.pop().respond(200, null, | ||
733 | '#EXTM3U\n' + | ||
734 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
735 | '#EXTINF:10,\n' + | ||
736 | '0.ts\n'); | ||
737 | clock.tick(10 * 1000); | ||
738 | |||
739 | loader.dispose(); | ||
740 | ok(requests[0].aborted, 'refresh request aborted'); | ||
741 | ok(!requests[0].onreadystatechange, 'onreadystatechange handler should not exist after dispose called'); | ||
742 | }); | ||
743 | |||
744 | test('errors if requests take longer than 45s', function() { | ||
745 | var | ||
746 | loader = new videojs.Hls.PlaylistLoader('media.m3u8'), | ||
747 | errors = 0; | ||
748 | loader.on('error', function() { | ||
749 | errors++; | ||
750 | }); | ||
751 | clock.tick(45 * 1000); | ||
752 | |||
753 | strictEqual(errors, 1, 'fired one error'); | ||
754 | strictEqual(loader.error.code, 2, 'fired a network error'); | ||
755 | }); | ||
756 | |||
757 | test('triggers an event when the active media changes', function() { | ||
758 | var | ||
759 | loader = new videojs.Hls.PlaylistLoader('master.m3u8'), | ||
760 | mediaChanges = 0; | ||
761 | loader.on('mediachange', function() { | ||
762 | mediaChanges++; | ||
763 | }); | ||
764 | requests.pop().respond(200, null, | ||
765 | '#EXTM3U\n' + | ||
766 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
767 | 'low.m3u8\n' + | ||
768 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
769 | 'high.m3u8\n'); | ||
770 | requests.shift().respond(200, null, | ||
771 | '#EXTM3U\n' + | ||
772 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
773 | '#EXTINF:10,\n' + | ||
774 | 'low-0.ts\n' + | ||
775 | '#EXT-X-ENDLIST\n'); | ||
776 | strictEqual(mediaChanges, 0, 'initial selection is not a media change'); | ||
777 | |||
778 | loader.media('high.m3u8'); | ||
779 | strictEqual(mediaChanges, 0, 'mediachange does not fire immediately'); | ||
780 | |||
781 | requests.shift().respond(200, null, | ||
782 | '#EXTM3U\n' + | ||
783 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
784 | '#EXTINF:10,\n' + | ||
785 | 'high-0.ts\n' + | ||
786 | '#EXT-X-ENDLIST\n'); | ||
787 | strictEqual(mediaChanges, 1, 'fired a mediachange'); | ||
788 | |||
789 | // switch back to an already loaded playlist | ||
790 | loader.media('low.m3u8'); | ||
791 | strictEqual(mediaChanges, 2, 'fired a mediachange'); | ||
792 | |||
793 | // trigger a no-op switch | ||
794 | loader.media('low.m3u8'); | ||
795 | strictEqual(mediaChanges, 2, 'ignored a no-op media change'); | ||
796 | }); | ||
797 | |||
798 | test('can get media index by playback position for non-live videos', function() { | ||
799 | var loader = new videojs.Hls.PlaylistLoader('media.m3u8'); | ||
800 | requests.shift().respond(200, null, | ||
801 | '#EXTM3U\n' + | ||
802 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
803 | '#EXTINF:4,\n' + | ||
804 | '0.ts\n' + | ||
805 | '#EXTINF:5,\n' + | ||
806 | '1.ts\n' + | ||
807 | '#EXTINF:6,\n' + | ||
808 | '2.ts\n' + | ||
809 | '#EXT-X-ENDLIST\n'); | ||
810 | |||
811 | equal(loader.getMediaIndexForTime_(-1), | ||
812 | 0, | ||
813 | 'the index is never less than zero'); | ||
814 | equal(loader.getMediaIndexForTime_(0), 0, 'time zero is index zero'); | ||
815 | equal(loader.getMediaIndexForTime_(3), 0, 'time three is index zero'); | ||
816 | equal(loader.getMediaIndexForTime_(10), 2, 'time 10 is index 2'); | ||
817 | equal(loader.getMediaIndexForTime_(22), | ||
818 | 2, | ||
819 | 'time greater than the length is index 2'); | ||
820 | }); | ||
821 | |||
822 | test('returns the lower index when calculating for a segment boundary', function() { | ||
823 | var loader = new videojs.Hls.PlaylistLoader('media.m3u8'); | ||
824 | requests.shift().respond(200, null, | ||
825 | '#EXTM3U\n' + | ||
826 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
827 | '#EXTINF:4,\n' + | ||
828 | '0.ts\n' + | ||
829 | '#EXTINF:5,\n' + | ||
830 | '1.ts\n' + | ||
831 | '#EXT-X-ENDLIST\n'); | ||
832 | equal(loader.getMediaIndexForTime_(4), 1, 'rounds up exact matches'); | ||
833 | equal(loader.getMediaIndexForTime_(3.7), 0, 'rounds down'); | ||
834 | equal(loader.getMediaIndexForTime_(4.5), 1, 'rounds up at 0.5'); | ||
835 | }); | ||
836 | |||
837 | test('accounts for non-zero starting segment time when calculating media index', function() { | ||
838 | var loader = new videojs.Hls.PlaylistLoader('media.m3u8'); | ||
839 | requests.shift().respond(200, null, | ||
840 | '#EXTM3U\n' + | ||
841 | '#EXT-X-MEDIA-SEQUENCE:1001\n' + | ||
842 | '#EXTINF:4,\n' + | ||
843 | '1001.ts\n' + | ||
844 | '#EXTINF:5,\n' + | ||
845 | '1002.ts\n'); | ||
846 | loader.media().segments[0].end = 154; | ||
847 | |||
848 | equal(loader.getMediaIndexForTime_(0), -1, 'the lowest returned value is negative one'); | ||
849 | equal(loader.getMediaIndexForTime_(45), -1, 'expired content returns negative one'); | ||
850 | equal(loader.getMediaIndexForTime_(75), -1, 'expired content returns negative one'); | ||
851 | equal(loader.getMediaIndexForTime_(50 + 100), 0, 'calculates the earliest available position'); | ||
852 | equal(loader.getMediaIndexForTime_(50 + 100 + 2), 0, 'calculates within the first segment'); | ||
853 | equal(loader.getMediaIndexForTime_(50 + 100 + 2), 0, 'calculates within the first segment'); | ||
854 | equal(loader.getMediaIndexForTime_(50 + 100 + 4), 1, 'calculates within the second segment'); | ||
855 | equal(loader.getMediaIndexForTime_(50 + 100 + 4.5), 1, 'calculates within the second segment'); | ||
856 | equal(loader.getMediaIndexForTime_(50 + 100 + 6), 1, 'calculates within the second segment'); | ||
857 | }); | ||
858 | |||
859 | test('prefers precise segment timing when tracking expired time', function() { | ||
860 | var loader = new videojs.Hls.PlaylistLoader('media.m3u8'); | ||
861 | loader.trigger('firstplay'); | ||
862 | requests.shift().respond(200, null, | ||
863 | '#EXTM3U\n' + | ||
864 | '#EXT-X-MEDIA-SEQUENCE:1001\n' + | ||
865 | '#EXTINF:4,\n' + | ||
866 | '1001.ts\n' + | ||
867 | '#EXTINF:5,\n' + | ||
868 | '1002.ts\n'); | ||
869 | // setup the loader with an "imprecise" value as if it had been | ||
870 | // accumulating segment durations as they expire | ||
871 | loader.expired_ = 160; | ||
872 | // annotate the first segment with a start time | ||
873 | // this number would be coming from the Source Buffer in practice | ||
874 | loader.media().segments[0].end = 150; | ||
875 | |||
876 | equal(loader.getMediaIndexForTime_(149), 0, 'prefers the value on the first segment'); | ||
877 | |||
878 | clock.tick(10 * 1000); // trigger a playlist refresh | ||
879 | requests.shift().respond(200, null, | ||
880 | '#EXTM3U\n' + | ||
881 | '#EXT-X-MEDIA-SEQUENCE:1002\n' + | ||
882 | '#EXTINF:5,\n' + | ||
883 | '1002.ts\n'); | ||
884 | equal(loader.getMediaIndexForTime_(150 + 4 + 1), 0, 'tracks precise expired times'); | ||
885 | }); | ||
886 | |||
887 | test('accounts for expired time when calculating media index', function() { | ||
888 | var loader = new videojs.Hls.PlaylistLoader('media.m3u8'); | ||
889 | requests.shift().respond(200, null, | ||
890 | '#EXTM3U\n' + | ||
891 | '#EXT-X-MEDIA-SEQUENCE:1001\n' + | ||
892 | '#EXTINF:4,\n' + | ||
893 | '1001.ts\n' + | ||
894 | '#EXTINF:5,\n' + | ||
895 | '1002.ts\n'); | ||
896 | loader.expired_ = 150; | ||
897 | |||
898 | equal(loader.getMediaIndexForTime_(0), -1, 'expired content returns a negative index'); | ||
899 | equal(loader.getMediaIndexForTime_(75), -1, 'expired content returns a negative index'); | ||
900 | equal(loader.getMediaIndexForTime_(50 + 100), 0, 'calculates the earliest available position'); | ||
901 | equal(loader.getMediaIndexForTime_(50 + 100 + 2), 0, 'calculates within the first segment'); | ||
902 | equal(loader.getMediaIndexForTime_(50 + 100 + 2), 0, 'calculates within the first segment'); | ||
903 | equal(loader.getMediaIndexForTime_(50 + 100 + 4.5), 1, 'calculates within the second segment'); | ||
904 | equal(loader.getMediaIndexForTime_(50 + 100 + 6), 1, 'calculates within the second segment'); | ||
905 | }); | ||
906 | |||
907 | test('does not misintrepret playlists missing newlines at the end', function() { | ||
908 | var loader = new videojs.Hls.PlaylistLoader('media.m3u8'); | ||
909 | requests.shift().respond(200, null, | ||
910 | '#EXTM3U\n' + | ||
911 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
912 | '#EXTINF:10,\n' + | ||
913 | 'low-0.ts\n' + | ||
914 | '#EXT-X-ENDLIST'); // no newline | ||
915 | ok(loader.media().endList, 'flushed the final line of input'); | ||
916 | }); | ||
917 | |||
918 | })(window); |
1 | /* Tests for the playlist utilities */ | 1 | import Playlist from '../src/playlist'; |
2 | (function(window, videojs) { | 2 | import QUnit from 'qunit'; |
3 | 'use strict'; | 3 | QUnit.module('Playlist Duration'); |
4 | var Playlist = videojs.Hls.Playlist; | 4 | |
5 | 5 | QUnit.test('total duration for live playlists is Infinity', function() { | |
6 | module('Playlist Duration'); | 6 | let duration = Playlist.duration({ |
7 | 7 | segments: [{ | |
8 | test('total duration for live playlists is Infinity', function() { | 8 | duration: 4, |
9 | var duration = Playlist.duration({ | 9 | uri: '0.ts' |
10 | segments: [{ | 10 | }] |
11 | duration: 4, | ||
12 | uri: '0.ts' | ||
13 | }] | ||
14 | }); | ||
15 | |||
16 | equal(duration, Infinity, 'duration is infinity'); | ||
17 | }); | 11 | }); |
18 | 12 | ||
19 | module('Playlist Interval Duration'); | 13 | QUnit.equal(duration, Infinity, 'duration is infinity'); |
20 | 14 | }); | |
21 | test('accounts for non-zero starting VOD media sequences', function() { | 15 | |
22 | var duration = Playlist.duration({ | 16 | QUnit.module('Playlist Interval Duration'); |
23 | mediaSequence: 10, | 17 | |
24 | endList: true, | 18 | QUnit.test('accounts for non-zero starting VOD media sequences', function() { |
25 | segments: [{ | 19 | let duration = Playlist.duration({ |
26 | duration: 10, | 20 | mediaSequence: 10, |
27 | uri: '0.ts' | 21 | endList: true, |
28 | }, { | 22 | segments: [{ |
29 | duration: 10, | 23 | duration: 10, |
30 | uri: '1.ts' | 24 | uri: '0.ts' |
31 | }, { | 25 | }, { |
32 | duration: 10, | 26 | duration: 10, |
33 | uri: '2.ts' | 27 | uri: '1.ts' |
34 | }, { | 28 | }, { |
35 | duration: 10, | 29 | duration: 10, |
36 | uri: '3.ts' | 30 | uri: '2.ts' |
37 | }] | 31 | }, { |
38 | }); | 32 | duration: 10, |
39 | 33 | uri: '3.ts' | |
40 | equal(duration, 4 * 10, 'includes only listed segments'); | 34 | }] |
41 | }); | 35 | }); |
42 | 36 | ||
43 | test('uses timeline values when available', function() { | 37 | QUnit.equal(duration, 4 * 10, 'includes only listed segments'); |
44 | var duration = Playlist.duration({ | 38 | }); |
45 | mediaSequence: 0, | 39 | |
46 | endList: true, | 40 | QUnit.test('uses timeline values when available', function() { |
47 | segments: [{ | 41 | let duration = Playlist.duration({ |
48 | start: 0, | 42 | mediaSequence: 0, |
49 | uri: '0.ts' | 43 | endList: true, |
50 | }, { | 44 | segments: [{ |
51 | duration: 10, | 45 | start: 0, |
52 | end: 2 * 10 + 2, | 46 | uri: '0.ts' |
53 | uri: '1.ts' | 47 | }, { |
54 | }, { | 48 | duration: 10, |
55 | duration: 10, | 49 | end: 2 * 10 + 2, |
56 | end: 3 * 10 + 2, | 50 | uri: '1.ts' |
57 | uri: '2.ts' | 51 | }, { |
58 | }, { | 52 | duration: 10, |
59 | duration: 10, | 53 | end: 3 * 10 + 2, |
60 | end: 4 * 10 + 2, | 54 | uri: '2.ts' |
61 | uri: '3.ts' | 55 | }, { |
62 | }] | 56 | duration: 10, |
63 | }, 4); | 57 | end: 4 * 10 + 2, |
64 | 58 | uri: '3.ts' | |
65 | equal(duration, 4 * 10 + 2, 'used timeline values'); | 59 | }] |
60 | }, 4); | ||
61 | |||
62 | QUnit.equal(duration, 4 * 10 + 2, 'used timeline values'); | ||
63 | }); | ||
64 | |||
65 | QUnit.test('works when partial timeline information is available', function() { | ||
66 | let duration = Playlist.duration({ | ||
67 | mediaSequence: 0, | ||
68 | endList: true, | ||
69 | segments: [{ | ||
70 | start: 0, | ||
71 | uri: '0.ts' | ||
72 | }, { | ||
73 | duration: 9, | ||
74 | uri: '1.ts' | ||
75 | }, { | ||
76 | duration: 10, | ||
77 | uri: '2.ts' | ||
78 | }, { | ||
79 | duration: 10, | ||
80 | start: 30.007, | ||
81 | end: 40.002, | ||
82 | uri: '3.ts' | ||
83 | }, { | ||
84 | duration: 10, | ||
85 | end: 50.0002, | ||
86 | uri: '4.ts' | ||
87 | }] | ||
88 | }, 5); | ||
89 | |||
90 | QUnit.equal(duration, 50.0002, 'calculated with mixed intervals'); | ||
91 | }); | ||
92 | |||
93 | QUnit.test('uses timeline values for the expired duration of live playlists', function() { | ||
94 | let playlist = { | ||
95 | mediaSequence: 12, | ||
96 | segments: [{ | ||
97 | duration: 10, | ||
98 | end: 120.5, | ||
99 | uri: '0.ts' | ||
100 | }, { | ||
101 | duration: 9, | ||
102 | uri: '1.ts' | ||
103 | }] | ||
104 | }; | ||
105 | let duration; | ||
106 | |||
107 | duration = Playlist.duration(playlist, playlist.mediaSequence); | ||
108 | QUnit.equal(duration, 110.5, 'used segment end time'); | ||
109 | duration = Playlist.duration(playlist, playlist.mediaSequence + 1); | ||
110 | QUnit.equal(duration, 120.5, 'used segment end time'); | ||
111 | duration = Playlist.duration(playlist, playlist.mediaSequence + 2); | ||
112 | QUnit.equal(duration, 120.5 + 9, 'used segment end time'); | ||
113 | }); | ||
114 | |||
115 | QUnit.test('looks outside the queried interval for live playlist timeline values', | ||
116 | function() { | ||
117 | let playlist = { | ||
118 | mediaSequence: 12, | ||
119 | segments: [{ | ||
120 | duration: 10, | ||
121 | uri: '0.ts' | ||
122 | }, { | ||
123 | duration: 9, | ||
124 | end: 120.5, | ||
125 | uri: '1.ts' | ||
126 | }] | ||
127 | }; | ||
128 | let duration; | ||
129 | |||
130 | duration = Playlist.duration(playlist, playlist.mediaSequence); | ||
131 | QUnit.equal(duration, 120.5 - 9 - 10, 'used segment end time'); | ||
132 | }); | ||
133 | |||
134 | QUnit.test('ignores discontinuity sequences later than the end', function() { | ||
135 | let duration = Playlist.duration({ | ||
136 | mediaSequence: 0, | ||
137 | discontinuityStarts: [1, 3], | ||
138 | segments: [{ | ||
139 | duration: 10, | ||
140 | uri: '0.ts' | ||
141 | }, { | ||
142 | discontinuity: true, | ||
143 | duration: 9, | ||
144 | uri: '1.ts' | ||
145 | }, { | ||
146 | duration: 10, | ||
147 | uri: '2.ts' | ||
148 | }, { | ||
149 | discontinuity: true, | ||
150 | duration: 10, | ||
151 | uri: '3.ts' | ||
152 | }] | ||
153 | }, 2); | ||
154 | |||
155 | QUnit.equal(duration, 19, 'excluded the later segments'); | ||
156 | }); | ||
157 | |||
158 | QUnit.test('handles trailing segments without timeline information', function() { | ||
159 | let duration; | ||
160 | let playlist = { | ||
161 | mediaSequence: 0, | ||
162 | endList: true, | ||
163 | segments: [{ | ||
164 | start: 0, | ||
165 | end: 10.5, | ||
166 | uri: '0.ts' | ||
167 | }, { | ||
168 | duration: 9, | ||
169 | uri: '1.ts' | ||
170 | }, { | ||
171 | duration: 10, | ||
172 | uri: '2.ts' | ||
173 | }, { | ||
174 | start: 29.45, | ||
175 | end: 39.5, | ||
176 | uri: '3.ts' | ||
177 | }] | ||
178 | }; | ||
179 | |||
180 | duration = Playlist.duration(playlist, 3); | ||
181 | QUnit.equal(duration, 29.45, 'calculated duration'); | ||
182 | |||
183 | duration = Playlist.duration(playlist, 2); | ||
184 | QUnit.equal(duration, 19.5, 'calculated duration'); | ||
185 | }); | ||
186 | |||
187 | QUnit.test('uses timeline intervals when segments have them', function() { | ||
188 | let duration; | ||
189 | let playlist = { | ||
190 | mediaSequence: 0, | ||
191 | segments: [{ | ||
192 | start: 0, | ||
193 | end: 10, | ||
194 | uri: '0.ts' | ||
195 | }, { | ||
196 | duration: 9, | ||
197 | uri: '1.ts' | ||
198 | }, { | ||
199 | start: 20.1, | ||
200 | end: 30.1, | ||
201 | duration: 10, | ||
202 | uri: '2.ts' | ||
203 | }] | ||
204 | }; | ||
205 | |||
206 | duration = Playlist.duration(playlist, 2); | ||
207 | QUnit.equal(duration, 20.1, 'used the timeline-based interval'); | ||
208 | |||
209 | duration = Playlist.duration(playlist, 3); | ||
210 | QUnit.equal(duration, 30.1, 'used the timeline-based interval'); | ||
211 | }); | ||
212 | |||
213 | QUnit.test('counts the time between segments as part of the earlier segment\'s duration', | ||
214 | function() { | ||
215 | let duration = Playlist.duration({ | ||
216 | mediaSequence: 0, | ||
217 | endList: true, | ||
218 | segments: [{ | ||
219 | start: 0, | ||
220 | end: 10, | ||
221 | uri: '0.ts' | ||
222 | }, { | ||
223 | start: 10.1, | ||
224 | end: 20.1, | ||
225 | duration: 10, | ||
226 | uri: '1.ts' | ||
227 | }] | ||
228 | }, 1); | ||
229 | |||
230 | QUnit.equal(duration, 10.1, 'included the segment gap'); | ||
231 | }); | ||
232 | |||
233 | QUnit.test('accounts for discontinuities', function() { | ||
234 | let duration = Playlist.duration({ | ||
235 | mediaSequence: 0, | ||
236 | endList: true, | ||
237 | discontinuityStarts: [1], | ||
238 | segments: [{ | ||
239 | duration: 10, | ||
240 | uri: '0.ts' | ||
241 | }, { | ||
242 | discontinuity: true, | ||
243 | duration: 10, | ||
244 | uri: '1.ts' | ||
245 | }] | ||
246 | }, 2); | ||
247 | |||
248 | QUnit.equal(duration, 10 + 10, 'handles discontinuities'); | ||
249 | }); | ||
250 | |||
251 | QUnit.test('a non-positive length interval has zero duration', function() { | ||
252 | let playlist = { | ||
253 | mediaSequence: 0, | ||
254 | discontinuityStarts: [1], | ||
255 | segments: [{ | ||
256 | duration: 10, | ||
257 | uri: '0.ts' | ||
258 | }, { | ||
259 | discontinuity: true, | ||
260 | duration: 10, | ||
261 | uri: '1.ts' | ||
262 | }] | ||
263 | }; | ||
264 | |||
265 | QUnit.equal(Playlist.duration(playlist, 0), 0, 'zero-length duration is zero'); | ||
266 | QUnit.equal(Playlist.duration(playlist, 0, false), 0, 'zero-length duration is zero'); | ||
267 | QUnit.equal(Playlist.duration(playlist, -1), 0, 'negative length duration is zero'); | ||
268 | }); | ||
269 | |||
270 | QUnit.module('Playlist Seekable'); | ||
271 | |||
272 | QUnit.test('calculates seekable time ranges from the available segments', function() { | ||
273 | let playlist = { | ||
274 | mediaSequence: 0, | ||
275 | segments: [{ | ||
276 | duration: 10, | ||
277 | uri: '0.ts' | ||
278 | }, { | ||
279 | duration: 10, | ||
280 | uri: '1.ts' | ||
281 | }], | ||
282 | endList: true | ||
283 | }; | ||
284 | let seekable = Playlist.seekable(playlist); | ||
285 | |||
286 | QUnit.equal(seekable.length, 1, 'there are seekable ranges'); | ||
287 | QUnit.equal(seekable.start(0), 0, 'starts at zero'); | ||
288 | QUnit.equal(seekable.end(0), Playlist.duration(playlist), 'ends at the duration'); | ||
289 | }); | ||
290 | |||
291 | QUnit.test('master playlists have empty seekable ranges', function() { | ||
292 | let seekable = Playlist.seekable({ | ||
293 | playlists: [{ | ||
294 | uri: 'low.m3u8' | ||
295 | }, { | ||
296 | uri: 'high.m3u8' | ||
297 | }] | ||
66 | }); | 298 | }); |
67 | 299 | ||
68 | test('works when partial timeline information is available', function() { | 300 | QUnit.equal(seekable.length, 0, 'no seekable ranges from a master playlist'); |
69 | var duration = Playlist.duration({ | 301 | }); |
70 | mediaSequence: 0, | 302 | |
71 | endList: true, | 303 | QUnit.test('seekable end is three target durations from the actual end of live playlists', |
72 | segments: [{ | 304 | function() { |
73 | start: 0, | 305 | let seekable = Playlist.seekable({ |
74 | uri: '0.ts' | 306 | mediaSequence: 0, |
75 | }, { | 307 | segments: [{ |
76 | duration: 9, | 308 | duration: 7, |
77 | uri: '1.ts' | 309 | uri: '0.ts' |
78 | }, { | 310 | }, { |
79 | duration: 10, | 311 | duration: 10, |
80 | uri: '2.ts' | 312 | uri: '1.ts' |
81 | }, { | 313 | }, { |
82 | duration: 10, | 314 | duration: 10, |
83 | start: 30.007, | 315 | uri: '2.ts' |
84 | end: 40.002, | 316 | }, { |
85 | uri: '3.ts' | 317 | duration: 10, |
86 | }, { | 318 | uri: '3.ts' |
87 | duration: 10, | 319 | }] |
88 | end: 50.0002, | ||
89 | uri: '4.ts' | ||
90 | }] | ||
91 | }, 5); | ||
92 | |||
93 | equal(duration, 50.0002, 'calculated with mixed intervals'); | ||
94 | }); | 320 | }); |
95 | 321 | ||
96 | test('uses timeline values for the expired duration of live playlists', function() { | 322 | QUnit.equal(seekable.length, 1, 'there are seekable ranges'); |
97 | var playlist = { | 323 | QUnit.equal(seekable.start(0), 0, 'starts at zero'); |
98 | mediaSequence: 12, | 324 | QUnit.equal(seekable.end(0), 7, 'ends three target durations from the last segment'); |
99 | segments: [{ | 325 | }); |
100 | duration: 10, | 326 | |
101 | end: 120.5, | 327 | QUnit.test('only considers available segments', function() { |
102 | uri: '0.ts' | 328 | let seekable = Playlist.seekable({ |
103 | }, { | 329 | mediaSequence: 7, |
104 | duration: 9, | 330 | segments: [{ |
105 | uri: '1.ts' | 331 | uri: '8.ts', |
106 | }] | 332 | duration: 10 |
107 | }, duration; | 333 | }, { |
108 | 334 | uri: '9.ts', | |
109 | duration = Playlist.duration(playlist, playlist.mediaSequence); | 335 | duration: 10 |
110 | equal(duration, 110.5, 'used segment end time'); | 336 | }, { |
111 | duration = Playlist.duration(playlist, playlist.mediaSequence + 1); | 337 | uri: '10.ts', |
112 | equal(duration, 120.5, 'used segment end time'); | 338 | duration: 10 |
113 | duration = Playlist.duration(playlist, playlist.mediaSequence + 2); | 339 | }, { |
114 | equal(duration, 120.5 + 9, 'used segment end time'); | 340 | uri: '11.ts', |
341 | duration: 10 | ||
342 | }] | ||
115 | }); | 343 | }); |
116 | 344 | ||
117 | test('looks outside the queried interval for live playlist timeline values', function() { | 345 | QUnit.equal(seekable.length, 1, 'there are seekable ranges'); |
118 | var playlist = { | 346 | QUnit.equal(seekable.start(0), 0, 'starts at the earliest available segment'); |
119 | mediaSequence: 12, | 347 | QUnit.equal(seekable.end(0), |
120 | segments: [{ | 348 | 10, |
121 | duration: 10, | 349 | 'ends three target durations from the last available segment'); |
122 | uri: '0.ts' | 350 | }); |
123 | }, { | 351 | |
124 | duration: 9, | 352 | QUnit.test('seekable end accounts for non-standard target durations', function() { |
125 | end: 120.5, | 353 | let seekable = Playlist.seekable({ |
126 | uri: '1.ts' | 354 | targetDuration: 2, |
127 | }] | 355 | mediaSequence: 0, |
128 | }, duration; | 356 | segments: [{ |
129 | 357 | duration: 2, | |
130 | duration = Playlist.duration(playlist, playlist.mediaSequence); | 358 | uri: '0.ts' |
131 | equal(duration, 120.5 - 9 - 10, 'used segment end time'); | 359 | }, { |
360 | duration: 2, | ||
361 | uri: '1.ts' | ||
362 | }, { | ||
363 | duration: 1, | ||
364 | uri: '2.ts' | ||
365 | }, { | ||
366 | duration: 2, | ||
367 | uri: '3.ts' | ||
368 | }, { | ||
369 | duration: 2, | ||
370 | uri: '4.ts' | ||
371 | }] | ||
132 | }); | 372 | }); |
133 | 373 | ||
134 | test('ignores discontinuity sequences later than the end', function() { | 374 | QUnit.equal(seekable.start(0), 0, 'starts at the earliest available segment'); |
135 | var duration = Playlist.duration({ | 375 | QUnit.equal(seekable.end(0), |
136 | mediaSequence: 0, | 376 | 9 - (2 + 2 + 1), |
137 | discontinuityStarts: [1, 3], | 377 | 'allows seeking no further than three segments from the end'); |
138 | segments: [{ | 378 | }); |
139 | duration: 10, | ||
140 | uri: '0.ts' | ||
141 | }, { | ||
142 | discontinuity: true, | ||
143 | duration: 9, | ||
144 | uri: '1.ts' | ||
145 | }, { | ||
146 | duration: 10, | ||
147 | uri: '2.ts' | ||
148 | }, { | ||
149 | discontinuity: true, | ||
150 | duration: 10, | ||
151 | uri: '3.ts' | ||
152 | }] | ||
153 | }, 2); | ||
154 | |||
155 | equal(duration, 19, 'excluded the later segments'); | ||
156 | }); | ||
157 | |||
158 | test('handles trailing segments without timeline information', function() { | ||
159 | var playlist, duration; | ||
160 | playlist = { | ||
161 | mediaSequence: 0, | ||
162 | endList: true, | ||
163 | segments: [{ | ||
164 | start: 0, | ||
165 | end: 10.5, | ||
166 | uri: '0.ts' | ||
167 | }, { | ||
168 | duration: 9, | ||
169 | uri: '1.ts' | ||
170 | }, { | ||
171 | duration: 10, | ||
172 | uri: '2.ts' | ||
173 | }, { | ||
174 | start: 29.45, | ||
175 | end: 39.5, | ||
176 | uri: '3.ts' | ||
177 | }] | ||
178 | }; | ||
179 | |||
180 | duration = Playlist.duration(playlist, 3); | ||
181 | equal(duration, 29.45, 'calculated duration'); | ||
182 | |||
183 | duration = Playlist.duration(playlist, 2); | ||
184 | equal(duration, 19.5, 'calculated duration'); | ||
185 | }); | ||
186 | |||
187 | test('uses timeline intervals when segments have them', function() { | ||
188 | var playlist, duration; | ||
189 | playlist = { | ||
190 | mediaSequence: 0, | ||
191 | segments: [{ | ||
192 | start: 0, | ||
193 | end: 10, | ||
194 | uri: '0.ts' | ||
195 | }, { | ||
196 | duration: 9, | ||
197 | uri: '1.ts' | ||
198 | },{ | ||
199 | start: 20.1, | ||
200 | end: 30.1, | ||
201 | duration: 10, | ||
202 | uri: '2.ts' | ||
203 | }] | ||
204 | }; | ||
205 | duration = Playlist.duration(playlist, 2); | ||
206 | |||
207 | equal(duration, 20.1, 'used the timeline-based interval'); | ||
208 | |||
209 | duration = Playlist.duration(playlist, 3); | ||
210 | equal(duration, 30.1, 'used the timeline-based interval'); | ||
211 | }); | ||
212 | |||
213 | test('counts the time between segments as part of the earlier segment\'s duration', function() { | ||
214 | var duration = Playlist.duration({ | ||
215 | mediaSequence: 0, | ||
216 | endList: true, | ||
217 | segments: [{ | ||
218 | start: 0, | ||
219 | end: 10, | ||
220 | uri: '0.ts' | ||
221 | }, { | ||
222 | start: 10.1, | ||
223 | end: 20.1, | ||
224 | duration: 10, | ||
225 | uri: '1.ts' | ||
226 | }] | ||
227 | }, 1); | ||
228 | |||
229 | equal(duration, 10.1, 'included the segment gap'); | ||
230 | }); | ||
231 | |||
232 | test('accounts for discontinuities', function() { | ||
233 | var duration = Playlist.duration({ | ||
234 | mediaSequence: 0, | ||
235 | endList: true, | ||
236 | discontinuityStarts: [1], | ||
237 | segments: [{ | ||
238 | duration: 10, | ||
239 | uri: '0.ts' | ||
240 | }, { | ||
241 | discontinuity: true, | ||
242 | duration: 10, | ||
243 | uri: '1.ts' | ||
244 | }] | ||
245 | }, 2); | ||
246 | |||
247 | equal(duration, 10 + 10, 'handles discontinuities'); | ||
248 | }); | ||
249 | |||
250 | test('a non-positive length interval has zero duration', function() { | ||
251 | var playlist = { | ||
252 | mediaSequence: 0, | ||
253 | discontinuityStarts: [1], | ||
254 | segments: [{ | ||
255 | duration: 10, | ||
256 | uri: '0.ts' | ||
257 | }, { | ||
258 | discontinuity: true, | ||
259 | duration: 10, | ||
260 | uri: '1.ts' | ||
261 | }] | ||
262 | }; | ||
263 | |||
264 | equal(Playlist.duration(playlist, 0), 0, 'zero-length duration is zero'); | ||
265 | equal(Playlist.duration(playlist, 0, false), 0, 'zero-length duration is zero'); | ||
266 | equal(Playlist.duration(playlist, -1), 0, 'negative length duration is zero'); | ||
267 | }); | ||
268 | |||
269 | module('Playlist Seekable'); | ||
270 | |||
271 | test('calculates seekable time ranges from the available segments', function() { | ||
272 | var playlist = { | ||
273 | mediaSequence: 0, | ||
274 | segments: [{ | ||
275 | duration: 10, | ||
276 | uri: '0.ts' | ||
277 | }, { | ||
278 | duration: 10, | ||
279 | uri: '1.ts' | ||
280 | }], | ||
281 | endList: true | ||
282 | }, seekable = Playlist.seekable(playlist); | ||
283 | |||
284 | equal(seekable.length, 1, 'there are seekable ranges'); | ||
285 | equal(seekable.start(0), 0, 'starts at zero'); | ||
286 | equal(seekable.end(0), Playlist.duration(playlist), 'ends at the duration'); | ||
287 | }); | ||
288 | |||
289 | test('master playlists have empty seekable ranges', function() { | ||
290 | var seekable = Playlist.seekable({ | ||
291 | playlists: [{ | ||
292 | uri: 'low.m3u8' | ||
293 | }, { | ||
294 | uri: 'high.m3u8' | ||
295 | }] | ||
296 | }); | ||
297 | equal(seekable.length, 0, 'no seekable ranges from a master playlist'); | ||
298 | }); | ||
299 | |||
300 | test('seekable end is three target durations from the actual end of live playlists', function() { | ||
301 | var seekable = Playlist.seekable({ | ||
302 | mediaSequence: 0, | ||
303 | segments: [{ | ||
304 | duration: 7, | ||
305 | uri: '0.ts' | ||
306 | }, { | ||
307 | duration: 10, | ||
308 | uri: '1.ts' | ||
309 | }, { | ||
310 | duration: 10, | ||
311 | uri: '2.ts' | ||
312 | }, { | ||
313 | duration: 10, | ||
314 | uri: '3.ts' | ||
315 | }] | ||
316 | }); | ||
317 | equal(seekable.length, 1, 'there are seekable ranges'); | ||
318 | equal(seekable.start(0), 0, 'starts at zero'); | ||
319 | equal(seekable.end(0), 7, 'ends three target durations from the last segment'); | ||
320 | }); | ||
321 | |||
322 | test('only considers available segments', function() { | ||
323 | var seekable = Playlist.seekable({ | ||
324 | mediaSequence: 7, | ||
325 | segments: [{ | ||
326 | uri: '8.ts', | ||
327 | duration: 10 | ||
328 | }, { | ||
329 | uri: '9.ts', | ||
330 | duration: 10 | ||
331 | }, { | ||
332 | uri: '10.ts', | ||
333 | duration: 10 | ||
334 | }, { | ||
335 | uri: '11.ts', | ||
336 | duration: 10 | ||
337 | }] | ||
338 | }); | ||
339 | equal(seekable.length, 1, 'there are seekable ranges'); | ||
340 | equal(seekable.start(0), 0, 'starts at the earliest available segment'); | ||
341 | equal(seekable.end(0), 10, 'ends three target durations from the last available segment'); | ||
342 | }); | ||
343 | |||
344 | test('seekable end accounts for non-standard target durations', function() { | ||
345 | var seekable = Playlist.seekable({ | ||
346 | targetDuration: 2, | ||
347 | mediaSequence: 0, | ||
348 | segments: [{ | ||
349 | duration: 2, | ||
350 | uri: '0.ts' | ||
351 | }, { | ||
352 | duration: 2, | ||
353 | uri: '1.ts' | ||
354 | }, { | ||
355 | duration: 1, | ||
356 | uri: '2.ts' | ||
357 | }, { | ||
358 | duration: 2, | ||
359 | uri: '3.ts' | ||
360 | }, { | ||
361 | duration: 2, | ||
362 | uri: '4.ts' | ||
363 | }] | ||
364 | }); | ||
365 | equal(seekable.start(0), 0, 'starts at the earliest available segment'); | ||
366 | equal(seekable.end(0), | ||
367 | 9 - (2 + 2 + 1), | ||
368 | 'allows seeking no further than three segments from the end'); | ||
369 | }); | ||
370 | |||
371 | })(window, window.videojs); | ... | ... |
test/sanity.test.js
0 → 100644
1 | import document from 'global/document'; | ||
2 | |||
3 | import QUnit from 'qunit'; | ||
4 | import sinon from 'sinon'; | ||
5 | import videojs from 'video.js'; | ||
6 | |||
7 | QUnit.module('videojs-contrib-hls - sanity', { | ||
8 | beforeEach() { | ||
9 | this.fixture = document.getElementById('qunit-fixture'); | ||
10 | this.video = document.createElement('video'); | ||
11 | this.fixture.appendChild(this.video); | ||
12 | this.player = videojs(this.video); | ||
13 | |||
14 | // Mock the environment's timers because certain things - particularly | ||
15 | // player readiness - are asynchronous in video.js 5. | ||
16 | this.clock = sinon.useFakeTimers(); | ||
17 | }, | ||
18 | |||
19 | afterEach() { | ||
20 | |||
21 | // The clock _must_ be restored before disposing the player; otherwise, | ||
22 | // certain timeout listeners that happen inside video.js may throw errors. | ||
23 | this.clock.restore(); | ||
24 | this.player.dispose(); | ||
25 | } | ||
26 | }); | ||
27 | |||
28 | QUnit.test('the environment is sane', function(assert) { | ||
29 | assert.strictEqual(typeof Array.isArray, 'function', 'es5 exists'); | ||
30 | assert.strictEqual(typeof sinon, 'object', 'sinon exists'); | ||
31 | assert.strictEqual(typeof videojs, 'function', 'videojs exists'); | ||
32 | assert.strictEqual(typeof videojs.MediaSource, 'function', 'MediaSource is an object'); | ||
33 | assert.strictEqual(typeof videojs.URL, 'object', 'URL is an object'); | ||
34 | assert.strictEqual(typeof videojs.Hls, 'object', 'Hls is an object'); | ||
35 | assert.strictEqual(typeof videojs.HlsSourceHandler, | ||
36 | 'function', | ||
37 | 'HlsSourceHandler is a function'); | ||
38 | assert.strictEqual(typeof videojs.HlsHandler, 'function', 'HlsHandler is a function'); | ||
39 | }); |
test/videojs-contrib-hls.test.js
0 → 100644
This diff could not be displayed because it is too large.
test/videojs-hls.html
deleted
100644 → 0
1 | <!DOCTYPE html> | ||
2 | <html> | ||
3 | <head> | ||
4 | <meta charset="utf-8"> | ||
5 | <title>video.js HLS Plugin Test Suite</title> | ||
6 | <!-- Load sinon server for fakeXHR --> | ||
7 | <script src="../node_modules/sinon/pkg/sinon.js"></script> | ||
8 | |||
9 | <!-- Load local QUnit. --> | ||
10 | <link rel="stylesheet" href="../node_modules/qunitjs/qunit/qunit.css" media="screen"> | ||
11 | <script src="../node_modules/qunitjs/qunit/qunit.js"></script> | ||
12 | |||
13 | <!-- video.js --> | ||
14 | <script src="../node_modules/video.js/dist/video.js"></script> | ||
15 | <link rel="stylesheet" href="../node_modules/video.js/dist/video-js.css" media="screen"> | ||
16 | <script src="../node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js"></script> | ||
17 | |||
18 | <!-- HLS plugin --> | ||
19 | <script src="../src/videojs-hls.js"></script> | ||
20 | <script src="../src/xhr.js"></script> | ||
21 | <script src="../src/stream.js"></script> | ||
22 | |||
23 | <!-- M3U8 --> | ||
24 | <script src="../src/m3u8/m3u8-parser.js"></script> | ||
25 | <script src="../src/playlist.js"></script> | ||
26 | <script src="../src/playlist-loader.js"></script> | ||
27 | <script src="../node_modules/pkcs7/dist/pkcs7.unpad.js"></script> | ||
28 | <script src="../src/decrypter.js"></script> | ||
29 | <!-- M3U8 TEST DATA --> | ||
30 | <script src="../tmp/manifests.js"></script> | ||
31 | <script src="../tmp/expected.js"></script> | ||
32 | |||
33 | <!-- M3U8 --> | ||
34 | <!-- SEGMENT --> | ||
35 | <script src="tsSegment-bc.js"></script> | ||
36 | <script src="../src/bin-utils.js"></script> | ||
37 | |||
38 | <!-- Test cases --> | ||
39 | <script> | ||
40 | module('environment'); | ||
41 | test('is sane', function() { | ||
42 | expect(1); | ||
43 | ok(true); | ||
44 | }); | ||
45 | </script> | ||
46 | <script src="videojs-hls_test.js"></script> | ||
47 | <script src="m3u8_test.js"></script> | ||
48 | <script src="playlist_test.js"></script> | ||
49 | <script src="playlist-loader_test.js"></script> | ||
50 | <script src="decrypter_test.js"></script> | ||
51 | </head> | ||
52 | <body> | ||
53 | <div id="qunit"></div> | ||
54 | <div id="qunit-fixture"> | ||
55 | <span>test markup</span> | ||
56 | </div> | ||
57 | </body> | ||
58 | </html> |
test/videojs-hls_test.js
deleted
100644 → 0
This diff could not be displayed because it is too large.
No preview for this file type
No preview for this file type
No preview for this file type
File moved
No preview for this file type
No preview for this file type
No preview for this file type
No preview for this file type
No preview for this file type
No preview for this file type
No preview for this file type
No preview for this file type
No preview for this file type
No preview for this file type
... | @@ -20,7 +20,7 @@ if (process.env.SAUCE_USERNAME) { | ... | @@ -20,7 +20,7 @@ if (process.env.SAUCE_USERNAME) { |
20 | config.maxDuration = 300; | 20 | config.maxDuration = 300; |
21 | } | 21 | } |
22 | 22 | ||
23 | config.baseUrl = 'http://127.0.0.1:9999/example.html'; | 23 | config.baseUrl = 'http://127.0.0.1:9999/'; |
24 | config.specs = ['spec.js']; | 24 | config.specs = ['spec.js']; |
25 | 25 | ||
26 | config.framework = 'jasmine2'; | 26 | config.framework = 'jasmine2'; | ... | ... |
test/manifest/extXPlaylistTypeInvalidPlaylist.js
→
utils/manifest/extXPlaylistTypeInvalidPlaylist.js
File moved
File moved
File moved
File moved
File moved
File moved
test/manifest/playlist_allow_cache_template.m3u8
→
utils/manifest/playlist_allow_cache_template.m3u8
File moved
File moved
File moved
File moved
File moved
... | @@ -12,7 +12,7 @@ | ... | @@ -12,7 +12,7 @@ |
12 | 12 | ||
13 | <link rel="stylesheet" href="css/normalize.min.css"> | 13 | <link rel="stylesheet" href="css/normalize.min.css"> |
14 | <link rel="stylesheet" href="css/main.css"> | 14 | <link rel="stylesheet" href="css/main.css"> |
15 | <link rel="stylesheet" href="../../node_modules/video.js/dist/video-js/video-js.css"> | 15 | <link rel="stylesheet" href="../../node_modules/video.js/dist/video-js.css"> |
16 | 16 | ||
17 | <script src="js/vendor/modernizr-2.6.2.min.js"></script> | 17 | <script src="js/vendor/modernizr-2.6.2.min.js"></script> |
18 | </head> | 18 | </head> |
... | @@ -120,13 +120,17 @@ | ... | @@ -120,13 +120,17 @@ |
120 | <script src="../../node_modules/sinon/lib/sinon/util/fake_timers.js"></script> | 120 | <script src="../../node_modules/sinon/lib/sinon/util/fake_timers.js"></script> |
121 | <script src="js/vendor/d3.min.js"></script> | 121 | <script src="js/vendor/d3.min.js"></script> |
122 | 122 | ||
123 | <script src="../../node_modules/video.js/dist/video-js/video.js"></script> | 123 | <script src="/node_modules/video.js/dist/video.js"></script> |
124 | <script src="../../node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js"></script> | 124 | <script src="/node_modules/videojs-contrib-media-sources/dist/videojs-media-sources.js"></script> |
125 | <script src="../../src/videojs-hls.js"></script> | 125 | <script src="/node_modules/pkcs7/dist/pkcs7.unpad.js"></script> |
126 | <script src="../../src/xhr.js"></script> | 126 | <script src="/src/videojs-hls.js"></script> |
127 | <script src="../../src/stream.js"></script> | 127 | <script src="/src/xhr.js"></script> |
128 | <script src="../../src/m3u8/m3u8-parser.js"></script> | 128 | <script src="/src/stream.js"></script> |
129 | <script src="../../src/playlist-loader.js"></script> | 129 | <script src="/src/m3u8/m3u8-parser.js"></script> |
130 | <script src="/src/playlist.js"></script> | ||
131 | <script src="/src/playlist-loader.js"></script> | ||
132 | <script src="/src/decrypter.js"></script> | ||
133 | <script src="/src/bin-utils.js"></script> | ||
130 | 134 | ||
131 | <script src="js/switcher.js"></script> | 135 | <script src="js/switcher.js"></script> |
132 | </body> | 136 | </body> | ... | ... |
... | @@ -142,6 +142,7 @@ | ... | @@ -142,6 +142,7 @@ |
142 | // mock out the environment | 142 | // mock out the environment |
143 | clock = sinon.useFakeTimers(); | 143 | clock = sinon.useFakeTimers(); |
144 | fakeXhr = sinon.useFakeXMLHttpRequest(); | 144 | fakeXhr = sinon.useFakeXMLHttpRequest(); |
145 | videojs.xhr.XMLHttpRequest = fakeXhr; | ||
145 | requests = []; | 146 | requests = []; |
146 | fakeXhr.onCreate = function(xhr) { | 147 | fakeXhr.onCreate = function(xhr) { |
147 | xhr.startTime = +new Date(); | 148 | xhr.startTime = +new Date(); |
... | @@ -156,7 +157,6 @@ | ... | @@ -156,7 +157,6 @@ |
156 | video.controls = true; | 157 | video.controls = true; |
157 | fixture.appendChild(video); | 158 | fixture.appendChild(video); |
158 | player = videojs(video, { | 159 | player = videojs(video, { |
159 | techOrder: ['hls'], | ||
160 | sources: [{ | 160 | sources: [{ |
161 | src: 'http://example.com/master.m3u8', | 161 | src: 'http://example.com/master.m3u8', |
162 | type: 'application/x-mpegurl' | 162 | type: 'application/x-mpegurl' |
... | @@ -295,6 +295,8 @@ | ... | @@ -295,6 +295,8 @@ |
295 | done(null, results); | 295 | done(null, results); |
296 | }, 0); | 296 | }, 0); |
297 | }); | 297 | }); |
298 | /// trigger the ready function through set timeout | ||
299 | clock.tick(1); | ||
298 | }; | 300 | }; |
299 | runButton = document.getElementById('run-simulation'); | 301 | runButton = document.getElementById('run-simulation'); |
300 | runButton.addEventListener('click', function() { | 302 | runButton.addEventListener('click', function() { | ... | ... |
No preview for this file type
File moved
File moved
-
Please register or sign in to post a comment