Multiple audio track support (#681)
* Support for multiple alternate audio tracks * Separated segment loading logic into a reusable piece of functionality so that we can have more than one segment loader and they can manage the segment fetch behavior unique to each playlist they are loading from * Introduced the MasterPlaylistController to coordinate the loading of the master playlist and separate the behavior of the player from videojs-contrib-hls.js leaving the latter to be the glue between HLS and the video element's events * Added the SourceUpdater to manage the asynchronous bahavior of SourceBuffers and MediaSource objects so that we can treat it as a non-blocking work queue * Added parsing for MediaGroups in master playlists * Added support for AudioTrackList objects and events * Add support for every HLS mime type possible (#684) * Flash live fixes (#682)
Showing
93 changed files
with
7273 additions
and
2238 deletions
1 | sudo: false | 1 | sudo: required |
2 | dist: trusty | ||
2 | language: node_js | 3 | language: node_js |
3 | addons: | ||
4 | firefox: "latest" | ||
5 | node_js: | 4 | node_js: |
6 | - "stable" | 5 | - "stable" |
7 | notifications: | 6 | notifications: |
... | @@ -14,6 +13,7 @@ notifications: | ... | @@ -14,6 +13,7 @@ notifications: |
14 | use_notice: true | 13 | use_notice: true |
15 | # Set up a virtual screen for Firefox. | 14 | # Set up a virtual screen for Firefox. |
16 | before_script: | 15 | before_script: |
16 | - export CHROME_BIN=/usr/bin/google-chrome | ||
17 | - export DISPLAY=:99.0 | 17 | - export DISPLAY=:99.0 |
18 | - sh -e /etc/init.d/xvfb start | 18 | - sh -e /etc/init.d/xvfb start |
19 | env: | 19 | env: |
... | @@ -22,4 +22,8 @@ env: | ... | @@ -22,4 +22,8 @@ env: |
22 | - secure: AnduYGXka5ft1x7V3SuVYqvlKLvJGhUaRNFdy4UDJr3ZVuwpQjE4TMDG8REmJIJvXfHbh4qY4N1cFSGnXkZ4bH21Xk0v9DLhsxbarKz+X2BvPgXs+Af9EQ6vLEy/5S1vMLxfT5+y+Ec5bVNGOsdUZby8Y21CRzSg6ADN9kwPGlE= | 22 | - secure: AnduYGXka5ft1x7V3SuVYqvlKLvJGhUaRNFdy4UDJr3ZVuwpQjE4TMDG8REmJIJvXfHbh4qY4N1cFSGnXkZ4bH21Xk0v9DLhsxbarKz+X2BvPgXs+Af9EQ6vLEy/5S1vMLxfT5+y+Ec5bVNGOsdUZby8Y21CRzSg6ADN9kwPGlE= |
23 | addons: | 23 | addons: |
24 | sauce_connect: true | 24 | sauce_connect: true |
25 | firefox: latest | 25 | apt: |
26 | sources: | ||
27 | - google-chrome | ||
28 | packages: | ||
29 | - google-chrome-stable | ... | ... |
... | @@ -9,6 +9,8 @@ Play back HLS with video.js, even where it's not natively supported. | ... | @@ -9,6 +9,8 @@ Play back HLS with video.js, even where it's not natively supported. |
9 | **Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* | 9 | **Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* |
10 | 10 | ||
11 | - [Getting Started](#getting-started) | 11 | - [Getting Started](#getting-started) |
12 | - [Known Issues](#known-issues) | ||
13 | - [IE11](#ie11) | ||
12 | - [Documentation](#documentation) | 14 | - [Documentation](#documentation) |
13 | - [Options](#options) | 15 | - [Options](#options) |
14 | - [withCredentials](#withcredentials) | 16 | - [withCredentials](#withcredentials) |
... | @@ -44,7 +46,7 @@ and include it in your page along with video.js: | ... | @@ -44,7 +46,7 @@ and include it in your page along with video.js: |
44 | type="application/x-mpegURL"> | 46 | type="application/x-mpegURL"> |
45 | </video> | 47 | </video> |
46 | <script src="video.js"></script> | 48 | <script src="video.js"></script> |
47 | <script src="videojs-hls.min.js"></script> | 49 | <script src="videojs-contrib-hls.min.js"></script> |
48 | <script> | 50 | <script> |
49 | var player = videojs('example-video'); | 51 | var player = videojs('example-video'); |
50 | player.play(); | 52 | player.play(); |
... | @@ -53,6 +55,16 @@ player.play(); | ... | @@ -53,6 +55,16 @@ player.play(); |
53 | 55 | ||
54 | Check out our [live example](http://videojs.github.io/videojs-contrib-hls/) if you're having trouble. | 56 | Check out our [live example](http://videojs.github.io/videojs-contrib-hls/) if you're having trouble. |
55 | 57 | ||
58 | ## Known Issues | ||
59 | Issues that are currenty know about with workarounds. If you want to | ||
60 | help find a solution that would be appreciated! | ||
61 | |||
62 | ### IE11 | ||
63 | In some IE11 setups there are issues working with it's native HTML | ||
64 | SourceBuffers functionality. This leads to various issues, such as | ||
65 | videos stopping playback with media decode errors. The known workaround | ||
66 | for this issues is to force the player to use flash when running on IE11. | ||
67 | |||
56 | ## Documentation | 68 | ## Documentation |
57 | [HTTP Live Streaming](https://developer.apple.com/streaming/) (HLS) has | 69 | [HTTP Live Streaming](https://developer.apple.com/streaming/) (HLS) has |
58 | become a de-facto standard for streaming video on mobile devices | 70 | become a de-facto standard for streaming video on mobile devices |
... | @@ -89,8 +101,7 @@ are some highlights: | ... | @@ -89,8 +101,7 @@ are some highlights: |
89 | - mid-segment quality switching | 101 | - mid-segment quality switching |
90 | - AES-128 segment encryption | 102 | - AES-128 segment encryption |
91 | - CEA-608 captions are automatically translated into standard HTML5 | 103 | - CEA-608 captions are automatically translated into standard HTML5 |
92 | [caption text | 104 | [caption text tracks][0] |
93 | tracks](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/track) | ||
94 | - Timed ID3 Metadata is automatically translated into HTML5 metedata | 105 | - Timed ID3 Metadata is automatically translated into HTML5 metedata |
95 | text tracks | 106 | text tracks |
96 | - Highly customizable adaptive bitrate selection | 107 | - Highly customizable adaptive bitrate selection |
... | @@ -98,6 +109,10 @@ are some highlights: | ... | @@ -98,6 +109,10 @@ are some highlights: |
98 | - Cross-domain credentials support with CORS | 109 | - Cross-domain credentials support with CORS |
99 | - Tight integration with video.js and a philosophy of exposing as much | 110 | - Tight integration with video.js and a philosophy of exposing as much |
100 | as possible with standard HTML APIs | 111 | as possible with standard HTML APIs |
112 | - Stream with multiple audio tracks and switching to those audio tracks | ||
113 | (see the docs folder) for info | ||
114 | |||
115 | [0]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/track | ||
101 | 116 | ||
102 | ### Options | 117 | ### Options |
103 | 118 | ||
... | @@ -106,11 +121,22 @@ initialization. You can pass in options just like you would for other | ... | @@ -106,11 +121,22 @@ initialization. You can pass in options just like you would for other |
106 | parts of video.js: | 121 | parts of video.js: |
107 | 122 | ||
108 | ```javascript | 123 | ```javascript |
109 | videojs(video, { | 124 | // html5 for html hls |
125 | videojs(video, {html5: { | ||
110 | hls: { | 126 | hls: { |
111 | withCredentials: true | 127 | withCredentials: true |
112 | } | 128 | } |
113 | }); | 129 | }}); |
130 | |||
131 | // or | ||
132 | |||
133 | // flash for flash hls | ||
134 | videojs(video, {flash: { | ||
135 | hls: { | ||
136 | withCredentials: true | ||
137 | } | ||
138 | }}); | ||
139 | |||
114 | ``` | 140 | ``` |
115 | 141 | ||
116 | #### withCredentials | 142 | #### withCredentials |
... | @@ -289,26 +315,8 @@ and most CDNs should have no trouble turning CORS on for your account. | ... | @@ -289,26 +315,8 @@ and most CDNs should have no trouble turning CORS on for your account. |
289 | 315 | ||
290 | ### Testing | 316 | ### Testing |
291 | 317 | ||
292 | For testing, you can either run `npm test` or use `grunt` directly. | 318 | For testing, you run `npm run test`. This will run tests using any of the |
293 | If you use `npm test`, it will only run the karma and end-to-end tests using chrome. | 319 | browsers that karma-detect-browsers detects on your machine. |
294 | You can specify which browsers you want the tests to run via grunt's `test` task. | ||
295 | You can use either grunt-style arguments or comma separated arguments: | ||
296 | ``` | ||
297 | grunt test:chrome:firefox # grunt-style | ||
298 | grunt test:chrome,firefox # comma-separated | ||
299 | ``` | ||
300 | Possible options are: | ||
301 | * `chromecanary` | ||
302 | * `phantomjs` | ||
303 | * `opera` | ||
304 | * `chrome`<sup>1</sup> | ||
305 | * `safari`<sup>1, 2</sup> | ||
306 | * `firefox`<sup>1</sup> | ||
307 | * `ie`<sup>1</sup> | ||
308 | |||
309 | |||
310 | _<sup>1</sup>supported end-to-end browsers_<br /> | ||
311 | _<sup>2</sup>requires the [SafariDriver extension]( https://code.google.com/p/selenium/wiki/SafariDriver) to be installed_ | ||
312 | 320 | ||
313 | ## Release History | 321 | ## Release History |
314 | Check out the [changelog](CHANGELOG.md) for a summary of each release. | 322 | Check out the [changelog](CHANGELOG.md) for a summary of each release. | ... | ... |
docs/multiple-alternative-audio-tracks.md
0 → 100644
1 | # Multiple Alternative Audio Tracks | ||
2 | ## General | ||
3 | m3u8 manifests with multiple audio streams will have those streams added to `video.js` in an `AudioTrackList`. The `AudioTrackList` can be accessed using `player.audioTracks()` or `tech.audioTracks()`. | ||
4 | |||
5 | ## Mapping m3u8 metadata to AudioTracks | ||
6 | The mapping between `AudioTrack` and the parsed m3u8 file is fairly straight forward. The table below shows the mapping | ||
7 | |||
8 | | m3u8 | AudioTrack | | ||
9 | |---------|------------| | ||
10 | | label | label | | ||
11 | | lang | language | | ||
12 | | default | enabled | | ||
13 | | ??? | kind | | ||
14 | | ??? | id | | ||
15 | |||
16 | As you can see m3u8's do not have a property for `AudioTrack.id`, which means that we let `video.js` randomly generate the id for `AudioTrack`s. This will have no real impact on any part of the system as we do not use the `id` anywhere. | ||
17 | |||
18 | The other property that does not have a mapping in the m3u8 is `AudioTrack.kind`. It was decided that we would set the `kind` to `main` when `default` is set to `true` and in all other cases we set it to `alternative` | ||
19 | |||
20 | Below is a basic example of a mapping | ||
21 | m3u8 layout | ||
22 | ``` JavaScript | ||
23 | { | ||
24 | 'media-group-1': [{ | ||
25 | 'audio-track-1': { | ||
26 | default: true, | ||
27 | lang: 'eng' | ||
28 | }, | ||
29 | 'audio-track-2': { | ||
30 | default: true, | ||
31 | lang: 'fr' | ||
32 | } | ||
33 | }] | ||
34 | } | ||
35 | ``` | ||
36 | |||
37 | Corresponding AudioTrackList when media-group-1 is used (before any tracks have been changed) | ||
38 | ``` JavaScript | ||
39 | [{ | ||
40 | label: 'audio-tracks-1', | ||
41 | enabled: true, | ||
42 | language: 'eng', | ||
43 | kind: 'main', | ||
44 | id: 'random' | ||
45 | }, { | ||
46 | label: 'audio-tracks-2', | ||
47 | enabled: false, | ||
48 | language: 'fr', | ||
49 | kind: 'alternative', | ||
50 | id: 'random' | ||
51 | }] | ||
52 | ``` | ||
53 | |||
54 | ## Startup (how tracks are added and used) | ||
55 | > AudioTrack & AudioTrackList live in video.js | ||
56 | |||
57 | 1. `HLS` creates a `MasterPlaylistController` and watches for the `loadedmetadata` event | ||
58 | 1. `HLS` parses the m3u8 using the `MasterPlaylistController` | ||
59 | 1. `MasterPlaylistController` creates a `PlaylistLoader` for the master m3u8 | ||
60 | 1. `MasterPlaylistController` creates `PlaylistLoader`s for every audio playlist | ||
61 | 1. `MasterPlaylistController` creates a `SegmentLoader` for the main m3u8 | ||
62 | 1. `MasterPlaylistController` creates a `SegmentLoader` for a potential audio playlist | ||
63 | 1. `HLS` sees the `loadedmetadata` and finds the currently selected MediaGroup and all the metadata | ||
64 | 1. `HLS` removes all `AudioTrack`s from the `AudioTrackList` | ||
65 | 1. `HLS` created `AudioTrack`s for the MediaGroup and adds them to the `AudioTrackList` | ||
66 | 1. `HLS` calls `MasterPlaylistController`s `useAudio` with no arguments (causes it to use the currently enabled audio) | ||
67 | 1. `MasterPlaylistController` turns off the current audio `PlaylistLoader` if it is on | ||
68 | 1. `MasterPlaylistController` maps the `label` to the `PlaylistLoader` containing the audio | ||
69 | 1. `MasterPlaylistController` turns on that `PlaylistLoader` and the Corresponding `SegmentLoader` (master or audio only) | ||
70 | 1. `MediaSource`/`mux.js` determine how to mux | ||
71 | |||
72 | ## How tracks are switched | ||
73 | > AudioTrack & AudioTrackList live in video.js | ||
74 | |||
75 | 1. `HLS` is setup to watch for the `changed` event on the `AudioTrackList` | ||
76 | 1. User selects a new `AudioTrack` from a menu (where only one track can be enabled) | ||
77 | 1. `AudioTrackList` enables the new `Audiotrack` and disables all others | ||
78 | 1. `AudioTrackList` triggers a `changed` event | ||
79 | 1. `HLS` sees the `changed` event and finds the newly enabled `AudioTrack` | ||
80 | 1. `HLS` sends the `label` for the new `AudioTrack` to `MasterPlaylistController`s `useAudio` function | ||
81 | 1. `MasterPlaylistController` turns off the current audio `PlaylistLoader` if it is on | ||
82 | 1. `MasterPlaylistController` maps the `label` to the `PlaylistLoader` containing the audio | ||
83 | 1. `MasterPlaylistController` maps the `label` to the `PlaylistLoader` containing the audio | ||
84 | 1. `MasterPlaylistController` turns on that `PlaylistLoader` and the Corresponding `SegmentLoader` (master or audio only) | ||
85 | 1. `MediaSource`/`mux.js` determine how to mux | ||
86 |
docs/segment-loader-states.png
0 → 100644
58.2 KB
examples/index.html
0 → 100644
1 | <!DOCTYPE html> | ||
2 | <html> | ||
3 | <head> | ||
4 | <meta charset="utf-8"> | ||
5 | <title>Multiple Alternative Audio Tracks - Example</title> | ||
6 | <link href="/node_modules/video.js/dist/video-js.css" rel="stylesheet"> | ||
7 | </head> | ||
8 | <body> | ||
9 | <h1>Multiple Alternative Audio Tracks</h1> | ||
10 | <p>Check the source of this page and the console for detailed information on this example</p> | ||
11 | <video id="maat-player" class="video-js vjs-default-skin" controls> | ||
12 | <source src="https://s3.amazonaws.com/_bc_dml/example-content/bipbop-advanced/bipbop_16x9_variant.m3u8" type="application/x-mpegURL"> | ||
13 | </video> | ||
14 | <div id="audioTrackChoice"> | ||
15 | <select id="enabled-audio-track" name="enabled-audio-track"> | ||
16 | </select> | ||
17 | </div> | ||
18 | <script src="/node_modules/video.js/dist/video.js"></script> | ||
19 | <script src="/dist/videojs-contrib-hls.js"></script> | ||
20 | <script> | ||
21 | (function(window, videojs) { | ||
22 | var player = window.player = videojs('maat-player'); | ||
23 | var audioTrackList = player.audioTracks(); | ||
24 | var audioTrackSelect = document.getElementById("enabled-audio-track"); | ||
25 | // watch for a change on the select element | ||
26 | // then change the enabled audio track | ||
27 | // only one can be enabled at a time, but video.js will | ||
28 | // handle that for us, all we need to do is enable the new | ||
29 | // track | ||
30 | audioTrackSelect.addEventListener('change', function() { | ||
31 | var track = audioTrackList[this.selectedIndex]; | ||
32 | console.log('User switched to track ' + track.label); | ||
33 | track.enabled = true; | ||
34 | }); | ||
35 | |||
36 | // watch for changes that will be triggered by any change | ||
37 | // to enabled on any audio track. Manually or through the | ||
38 | // select element | ||
39 | audioTrackList.on('change', function() { | ||
40 | for (var i = 0; i < audioTrackList.length; i++) { | ||
41 | var track = audioTrackList[i]; | ||
42 | if (track.enabled) { | ||
43 | console.log('A new ' + track.label + ' has been enabled!'); | ||
44 | } | ||
45 | } | ||
46 | }); | ||
47 | |||
48 | // will be fired twice in this example | ||
49 | audioTrackList.on('addtrack', function() { | ||
50 | console.log('a track has been added to the audio track list'); | ||
51 | }); | ||
52 | |||
53 | // will not be fired at all unless you call | ||
54 | // audioTrackList.removeTrack(trackObj) | ||
55 | // we typically will not need to do this unless we have to load | ||
56 | // another video for some reason | ||
57 | audioTrackList.on('removetrack', function() { | ||
58 | console.log('a track has been removed from the audio track list'); | ||
59 | }); | ||
60 | |||
61 | // getting all the possible audio tracks from the track list | ||
62 | // get all of thier properties | ||
63 | // add each track to the select on the page | ||
64 | // this is all filled out by HLS when it parses the m3u8 | ||
65 | player.on('loadeddata', function() { | ||
66 | console.log('There are ' + audioTrackList.length + ' audio tracks'); | ||
67 | for (var i = 0; i < audioTrackList.length; i++) { | ||
68 | var track = audioTrackList[i]; | ||
69 | var option = document.createElement("option"); | ||
70 | option.text = track.label; | ||
71 | if (track.enabled) { | ||
72 | option.selected = true; | ||
73 | } | ||
74 | audioTrackSelect.add(option, i); | ||
75 | console.log('Track ' + (i + 1)); | ||
76 | ['label', 'enabled', 'language', 'id', 'kind'].forEach(function(prop) { | ||
77 | console.log(" " + prop + ": " + track[prop]); | ||
78 | }); | ||
79 | } | ||
80 | }); | ||
81 | }(window, window.videojs)); | ||
82 | </script> | ||
83 | </body> | ||
84 | </html> |
... | @@ -43,6 +43,7 @@ | ... | @@ -43,6 +43,7 @@ |
43 | <ul> | 43 | <ul> |
44 | <li><a href="/test/">Run unit tests in browser.</a></li> | 44 | <li><a href="/test/">Run unit tests in browser.</a></li> |
45 | <li><a href="/docs/api/">Read generated docs.</a></li> | 45 | <li><a href="/docs/api/">Read generated docs.</a></li> |
46 | <li><a href="/examples">Browse Examples</a></li> | ||
46 | </ul> | 47 | </ul> |
47 | 48 | ||
48 | <script src="/node_modules/video.js/dist/video.js"></script> | 49 | <script src="/node_modules/video.js/dist/video.js"></script> |
... | @@ -50,6 +51,7 @@ | ... | @@ -50,6 +51,7 @@ |
50 | <script> | 51 | <script> |
51 | (function(window, videojs) { | 52 | (function(window, videojs) { |
52 | var player = window.player = videojs('videojs-contrib-hls-player'); | 53 | var player = window.player = videojs('videojs-contrib-hls-player'); |
54 | |||
53 | // hook up the video switcher | 55 | // hook up the video switcher |
54 | var loadUrl = document.getElementById('load-url'); | 56 | var loadUrl = document.getElementById('load-url'); |
55 | var url = document.getElementById('url'); | 57 | var url = document.getElementById('url'); | ... | ... |
... | @@ -28,8 +28,7 @@ | ... | @@ -28,8 +28,7 @@ |
28 | "docs:api": "jsdoc src -r -d docs/api", | 28 | "docs:api": "jsdoc src -r -d docs/api", |
29 | "docs:toc": "doctoc README.md", | 29 | "docs:toc": "doctoc README.md", |
30 | "lint": "vjsstandard", | 30 | "lint": "vjsstandard", |
31 | "prestart": "npm-run-all docs build", | 31 | "start": "npm-run-all -p watch start:*", |
32 | "start": "npm-run-all -p start:* watch:*", | ||
33 | "start:serve": "babel-node scripts/server.js", | 32 | "start:serve": "babel-node scripts/server.js", |
34 | "pretest": "npm-run-all lint build", | 33 | "pretest": "npm-run-all lint build", |
35 | "test": "karma start test/karma/detected.js", | 34 | "test": "karma start test/karma/detected.js", |
... | @@ -40,7 +39,10 @@ | ... | @@ -40,7 +39,10 @@ |
40 | "preversion": "npm test", | 39 | "preversion": "npm test", |
41 | "version": "npm run build", | 40 | "version": "npm run build", |
42 | "watch": "npm-run-all -p watch:*", | 41 | "watch": "npm-run-all -p watch:*", |
43 | "watch:js": "watchify src/videojs-contrib-hls.js -t babelify -v -o dist/videojs-contrib-hls.js", | 42 | "watch:docs": "nodemon --watch src/ --exec npm run docs", |
43 | "watch:js": "npm-run-all -p watch:js:babel watch:js:browserify", | ||
44 | "watch:js:babel": "npm run build:js:babel -- --watch", | ||
45 | "watch:js:browserify": "watchify . -v -o dist/videojs-contrib-hls.js", | ||
44 | "watch:test": "npm-run-all -p watch:test:*", | 46 | "watch:test": "npm-run-all -p watch:test:*", |
45 | "watch:test:js": "node scripts/watch-test.js", | 47 | "watch:test:js": "node scripts/watch-test.js", |
46 | "watch:test:manifest": "node -e \"var b=require('./scripts/manifest-data.js'); b.watch();\"", | 48 | "watch:test:manifest": "node -e \"var b=require('./scripts/manifest-data.js'); b.watch();\"", |
... | @@ -73,21 +75,19 @@ | ... | @@ -73,21 +75,19 @@ |
73 | }, | 75 | }, |
74 | "files": [ | 76 | "files": [ |
75 | "CONTRIBUTING.md", | 77 | "CONTRIBUTING.md", |
76 | "dist-test/", | ||
77 | "dist/", | 78 | "dist/", |
78 | "docs/", | 79 | "docs/", |
79 | "es5/", | 80 | "es5/", |
80 | "index.html", | 81 | "index.html", |
81 | "scripts/", | 82 | "scripts/", |
82 | "src/", | 83 | "src/", |
83 | "test/", | 84 | "test/" |
84 | "utils/" | ||
85 | ], | 85 | ], |
86 | "dependencies": { | 86 | "dependencies": { |
87 | "pkcs7": "^0.2.2", | 87 | "pkcs7": "^0.2.2", |
88 | "video.js": "^5.2.1", | 88 | "video.js": "^5.10.1", |
89 | "videojs-contrib-media-sources": "^3.0.0", | 89 | "videojs-contrib-media-sources": "^3.1.0", |
90 | "videojs-swf": "^5.0.0" | 90 | "videojs-swf": "^5.0.2" |
91 | }, | 91 | }, |
92 | "devDependencies": { | 92 | "devDependencies": { |
93 | "babel": "^5.8.0", | 93 | "babel": "^5.8.0", |
... | @@ -111,6 +111,7 @@ | ... | @@ -111,6 +111,7 @@ |
111 | "karma-safari-launcher": "^0.1.0", | 111 | "karma-safari-launcher": "^0.1.0", |
112 | "lodash-compat": "^3.10.0", | 112 | "lodash-compat": "^3.10.0", |
113 | "minimist": "^1.2.0", | 113 | "minimist": "^1.2.0", |
114 | "nodemon": "^1.9.1", | ||
114 | "npm-run-all": "^1.2.0", | 115 | "npm-run-all": "^1.2.0", |
115 | "portscanner": "^1.0.0", | 116 | "portscanner": "^1.0.0", |
116 | "qunitjs": "^1.18.0", | 117 | "qunitjs": "^1.18.0", | ... | ... |
... | @@ -17,7 +17,7 @@ glob('test/**/*.test.js', function(err, files) { | ... | @@ -17,7 +17,7 @@ glob('test/**/*.test.js', function(err, files) { |
17 | }; | 17 | }; |
18 | 18 | ||
19 | b.on('log', function(msg) { | 19 | b.on('log', function(msg) { |
20 | process.stdout.write(msg + '\n'); | 20 | process.stdout.write(msg + ' dist-test/videojs-contrib-hls.js\n'); |
21 | }); | 21 | }); |
22 | 22 | ||
23 | b.on('update', bundle); | 23 | b.on('update', bundle); | ... | ... |
1 | /** | ||
2 | * @file bin-utils.js | ||
3 | */ | ||
4 | |||
5 | /** | ||
6 | * convert a TimeRange to text | ||
7 | * | ||
8 | * @param {TimeRange} range the timerange to use for conversion | ||
9 | * @param {Number} i the iterator on the range to convert | ||
10 | */ | ||
1 | const textRange = function(range, i) { | 11 | const textRange = function(range, i) { |
2 | return range.start(i) + '-' + range.end(i); | 12 | return range.start(i) + '-' + range.end(i); |
3 | }; | 13 | }; |
4 | 14 | ||
15 | /** | ||
16 | * format a number as hex string | ||
17 | * | ||
18 | * @param {Number} e The number | ||
19 | * @param {Number} i the iterator | ||
20 | */ | ||
5 | const formatHexString = function(e, i) { | 21 | const formatHexString = function(e, i) { |
6 | let value = e.toString(16); | 22 | let value = e.toString(16); |
7 | 23 | ||
... | @@ -14,6 +30,9 @@ const formatAsciiString = function(e) { | ... | @@ -14,6 +30,9 @@ const formatAsciiString = function(e) { |
14 | return '.'; | 30 | return '.'; |
15 | }; | 31 | }; |
16 | 32 | ||
33 | /** | ||
34 | * utils to help dump binary data to the console | ||
35 | */ | ||
17 | const utils = { | 36 | const utils = { |
18 | hexDump(data) { | 37 | hexDump(data) { |
19 | let bytes = Array.prototype.slice.call(data); | 38 | let bytes = Array.prototype.slice.call(data); | ... | ... |
1 | /* | 1 | /** |
2 | * aes.js | 2 | * @file decrypter/aes.js |
3 | * | 3 | * |
4 | * This file contains an adaptation of the AES decryption algorithm | 4 | * This file contains an adaptation of the AES decryption algorithm |
5 | * from the Standford Javascript Cryptography Library. That work is | 5 | * from the Standford Javascript Cryptography Library. That work is |
... | @@ -96,7 +96,7 @@ let aesTables = null; | ... | @@ -96,7 +96,7 @@ let aesTables = null; |
96 | * Schedule out an AES key for both encryption and decryption. This | 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. | 97 | * is a low-level class. Use a cipher mode to do bulk encryption. |
98 | * | 98 | * |
99 | * @constructor | 99 | * @class AES |
100 | * @param key {Array} The key as an array of 4, 6 or 8 words. | 100 | * @param key {Array} The key as an array of 4, 6 or 8 words. |
101 | */ | 101 | */ |
102 | export default class AES { | 102 | export default class AES { |
... | @@ -184,13 +184,14 @@ export default class AES { | ... | @@ -184,13 +184,14 @@ export default class AES { |
184 | 184 | ||
185 | /** | 185 | /** |
186 | * Decrypt 16 bytes, specified as four 32-bit words. | 186 | * Decrypt 16 bytes, specified as four 32-bit words. |
187 | * @param encrypted0 {number} the first word to decrypt | 187 | * |
188 | * @param encrypted1 {number} the second word to decrypt | 188 | * @param {Number} encrypted0 the first word to decrypt |
189 | * @param encrypted2 {number} the third word to decrypt | 189 | * @param {Number} encrypted1 the second word to decrypt |
190 | * @param encrypted3 {number} the fourth word to decrypt | 190 | * @param {Number} encrypted2 the third word to decrypt |
191 | * @param out {Int32Array} the array to write the decrypted words | 191 | * @param {Number} encrypted3 the fourth word to decrypt |
192 | * @param {Int32Array} out the array to write the decrypted words | ||
192 | * into | 193 | * into |
193 | * @param offset {number} the offset into the output array to start | 194 | * @param {Number} offset the offset into the output array to start |
194 | * writing results | 195 | * writing results |
195 | * @return {Array} The plaintext. | 196 | * @return {Array} The plaintext. |
196 | */ | 197 | */ | ... | ... |
1 | /** | ||
2 | * @file decrypter/async-stream.js | ||
3 | */ | ||
1 | import Stream from '../stream'; | 4 | import Stream from '../stream'; |
2 | 5 | ||
3 | /** | 6 | /** |
4 | * A wrapper around the Stream class to use setTiemout | 7 | * A wrapper around the Stream class to use setTiemout |
5 | * and run stream "jobs" Asynchronously | 8 | * and run stream "jobs" Asynchronously |
9 | * | ||
10 | * @class AsyncStream | ||
11 | * @extends Stream | ||
6 | */ | 12 | */ |
7 | export default class AsyncStream extends Stream { | 13 | export default class AsyncStream extends Stream { |
8 | constructor() { | 14 | constructor() { |
... | @@ -11,6 +17,12 @@ export default class AsyncStream extends Stream { | ... | @@ -11,6 +17,12 @@ export default class AsyncStream extends Stream { |
11 | this.delay = 1; | 17 | this.delay = 1; |
12 | this.timeout_ = null; | 18 | this.timeout_ = null; |
13 | } | 19 | } |
20 | |||
21 | /** | ||
22 | * process an async job | ||
23 | * | ||
24 | * @private | ||
25 | */ | ||
14 | processJob_() { | 26 | processJob_() { |
15 | this.jobs.shift()(); | 27 | this.jobs.shift()(); |
16 | if (this.jobs.length) { | 28 | if (this.jobs.length) { |
... | @@ -20,6 +32,12 @@ export default class AsyncStream extends Stream { | ... | @@ -20,6 +32,12 @@ export default class AsyncStream extends Stream { |
20 | this.timeout_ = null; | 32 | this.timeout_ = null; |
21 | } | 33 | } |
22 | } | 34 | } |
35 | |||
36 | /** | ||
37 | * push a job into the stream | ||
38 | * | ||
39 | * @param {Function} job the job to push into the stream | ||
40 | */ | ||
23 | push(job) { | 41 | push(job) { |
24 | this.jobs.push(job); | 42 | this.jobs.push(job); |
25 | if (!this.timeout_) { | 43 | if (!this.timeout_) { | ... | ... |
1 | /* | 1 | /** |
2 | * decrypter.js | 2 | * @file decrypter/decrypter.js |
3 | * | 3 | * |
4 | * An asynchronous implementation of AES-128 CBC decryption with | 4 | * An asynchronous implementation of AES-128 CBC decryption with |
5 | * PKCS#7 padding. | 5 | * PKCS#7 padding. |
... | @@ -20,12 +20,12 @@ const ntoh = function(word) { | ... | @@ -20,12 +20,12 @@ const ntoh = function(word) { |
20 | (word >>> 24); | 20 | (word >>> 24); |
21 | }; | 21 | }; |
22 | 22 | ||
23 | /* eslint-disable max-len */ | ||
24 | /** | 23 | /** |
25 | * Decrypt bytes using AES-128 with CBC and PKCS#7 padding. | 24 | * Decrypt bytes using AES-128 with CBC and PKCS#7 padding. |
26 | * @param encrypted {Uint8Array} the encrypted bytes | 25 | * |
27 | * @param key {Uint32Array} the bytes of the decryption key | 26 | * @param {Uint8Array} encrypted the encrypted bytes |
28 | * @param initVector {Uint32Array} the initialization vector (IV) to | 27 | * @param {Uint32Array} key the bytes of the decryption key |
28 | * @param {Uint32Array} initVector the initialization vector (IV) to | ||
29 | * use for the first round of CBC. | 29 | * use for the first round of CBC. |
30 | * @return {Uint8Array} the decrypted bytes | 30 | * @return {Uint8Array} the decrypted bytes |
31 | * | 31 | * |
... | @@ -33,7 +33,6 @@ const ntoh = function(word) { | ... | @@ -33,7 +33,6 @@ const ntoh = function(word) { |
33 | * @see http://en.wikipedia.org/wiki/Block_cipher_mode_of_operation#Cipher_Block_Chaining_.28CBC.29 | 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 | 34 | * @see https://tools.ietf.org/html/rfc2315 |
35 | */ | 35 | */ |
36 | /* eslint-enable max-len */ | ||
37 | export const decrypt = function(encrypted, key, initVector) { | 36 | export const decrypt = function(encrypted, key, initVector) { |
38 | // word-level access to the encrypted bytes | 37 | // word-level access to the encrypted bytes |
39 | let encrypted32 = new Int32Array(encrypted.buffer, | 38 | let encrypted32 = new Int32Array(encrypted.buffer, |
... | @@ -106,6 +105,12 @@ export const decrypt = function(encrypted, key, initVector) { | ... | @@ -106,6 +105,12 @@ export const decrypt = function(encrypted, key, initVector) { |
106 | * The `Decrypter` class that manages decryption of AES | 105 | * The `Decrypter` class that manages decryption of AES |
107 | * data through `AsyncStream` objects and the `decrypt` | 106 | * data through `AsyncStream` objects and the `decrypt` |
108 | * function | 107 | * function |
108 | * | ||
109 | * @param {Uint8Array} encrypted the encrypted bytes | ||
110 | * @param {Uint32Array} key the bytes of the decryption key | ||
111 | * @param {Uint32Array} initVector the initialization vector (IV) to | ||
112 | * @param {Function} done the function to run when done | ||
113 | * @class Decrypter | ||
109 | */ | 114 | */ |
110 | export class Decrypter { | 115 | export class Decrypter { |
111 | constructor(encrypted, key, initVector, done) { | 116 | constructor(encrypted, key, initVector, done) { |
... | @@ -137,6 +142,20 @@ export class Decrypter { | ... | @@ -137,6 +142,20 @@ export class Decrypter { |
137 | done(null, unpad(decrypted)); | 142 | done(null, unpad(decrypted)); |
138 | }); | 143 | }); |
139 | } | 144 | } |
145 | |||
146 | /** | ||
147 | * a getter for step the maximum number of bytes to process at one time | ||
148 | * | ||
149 | * @return {Number} the value of step 32000 | ||
150 | */ | ||
151 | static get STEP() { | ||
152 | // 4 * 8000; | ||
153 | return 32000; | ||
154 | } | ||
155 | |||
156 | /** | ||
157 | * @private | ||
158 | */ | ||
140 | decryptChunk_(encrypted, key, initVector, decrypted) { | 159 | decryptChunk_(encrypted, key, initVector, decrypted) { |
141 | return function() { | 160 | return function() { |
142 | let bytes = decrypt(encrypted, key, initVector); | 161 | let bytes = decrypt(encrypted, key, initVector); |
... | @@ -146,10 +165,6 @@ export class Decrypter { | ... | @@ -146,10 +165,6 @@ export class Decrypter { |
146 | } | 165 | } |
147 | } | 166 | } |
148 | 167 | ||
149 | // the maximum number of bytes to process at one time | ||
150 | // 4 * 8000; | ||
151 | Decrypter.STEP = 32000; | ||
152 | |||
153 | export default { | 168 | export default { |
154 | Decrypter, | 169 | Decrypter, |
155 | decrypt | 170 | decrypt | ... | ... |
src/hls-audio-track.js
0 → 100644
1 | /** | ||
2 | * @file hls-audio-track.js | ||
3 | */ | ||
4 | import {AudioTrack} from 'video.js'; | ||
5 | import PlaylistLoader from './playlist-loader'; | ||
6 | |||
7 | /** | ||
8 | * HlsAudioTrack extends video.js audio tracks but adds HLS | ||
9 | * specific data storage such as playlist loaders, mediaGroups | ||
10 | * and default/autoselect | ||
11 | * | ||
12 | * @param {Object} options options to create HlsAudioTrack with | ||
13 | * @class HlsAudioTrack | ||
14 | * @extends AudioTrack | ||
15 | */ | ||
16 | export default class HlsAudioTrack extends AudioTrack { | ||
17 | constructor(options) { | ||
18 | super({ | ||
19 | kind: options.default ? 'main' : 'alternative', | ||
20 | enabled: options.default || false, | ||
21 | language: options.language, | ||
22 | label: options.label | ||
23 | }); | ||
24 | |||
25 | this.hls = options.hls; | ||
26 | this.autoselect = options.autoselect || false; | ||
27 | this.default = options.default || false; | ||
28 | this.withCredentials = options.withCredentials || false; | ||
29 | this.mediaGroups_ = []; | ||
30 | this.addLoader(options.mediaGroup, options.resolvedUri); | ||
31 | } | ||
32 | |||
33 | /** | ||
34 | * get a PlaylistLoader from this track given a mediaGroup name | ||
35 | * | ||
36 | * @param {String} mediaGroup the mediaGroup to get the loader for | ||
37 | * @return {PlaylistLoader|Null} the PlaylistLoader or null | ||
38 | */ | ||
39 | getLoader(mediaGroup) { | ||
40 | for (let i = 0; i < this.mediaGroups_.length; i++) { | ||
41 | let mgl = this.mediaGroups_[i]; | ||
42 | |||
43 | if (mgl.mediaGroup === mediaGroup) { | ||
44 | return mgl.loader; | ||
45 | } | ||
46 | } | ||
47 | } | ||
48 | |||
49 | /** | ||
50 | * add a PlaylistLoader given a mediaGroup, and a uri. for a combined track | ||
51 | * we store null for the playlistloader | ||
52 | * | ||
53 | * @param {String} mediaGroup the mediaGroup to get the loader for | ||
54 | * @param {String} uri the uri to get the audio track/mediaGroup from | ||
55 | */ | ||
56 | addLoader(mediaGroup, uri = null) { | ||
57 | let loader = null; | ||
58 | |||
59 | if (uri) { | ||
60 | // TODO: this should probably happen upstream in Master Playlist | ||
61 | // Controller when we can switch PlaylistLoader sources | ||
62 | // then we can just store the uri here instead | ||
63 | loader = new PlaylistLoader(uri, this.hls, this.withCredentials); | ||
64 | } | ||
65 | this.mediaGroups_.push({mediaGroup, loader}); | ||
66 | } | ||
67 | |||
68 | /** | ||
69 | * remove a playlist loader from a track given the mediaGroup | ||
70 | * | ||
71 | * @param {String} mediaGroup the mediaGroup to remove | ||
72 | */ | ||
73 | removeLoader(mediaGroup) { | ||
74 | for (let i = 0; i < this.mediaGroups_.length; i++) { | ||
75 | let mgl = this.mediaGroups_[i]; | ||
76 | |||
77 | if (mgl.mediaGroup === mediaGroup) { | ||
78 | if (mgl.loader) { | ||
79 | mgl.loader.dispose(); | ||
80 | } | ||
81 | this.mediaGroups_.splice(i, 1); | ||
82 | return; | ||
83 | } | ||
84 | } | ||
85 | } | ||
86 | |||
87 | /** | ||
88 | * Dispose of this audio track and | ||
89 | * the playlist loader that it holds inside | ||
90 | */ | ||
91 | dispose() { | ||
92 | let i = this.mediaGroups_.length; | ||
93 | |||
94 | while (i--) { | ||
95 | this.removeLoader(this.mediaGroups_[i].mediaGroup); | ||
96 | } | ||
97 | } | ||
98 | } |
1 | /** | 1 | /** |
2 | * @file m3u8/index.js | ||
3 | * | ||
2 | * Utilities for parsing M3U8 files. If the entire manifest is available, | 4 | * Utilities for parsing M3U8 files. If the entire manifest is available, |
3 | * `Parser` will create an object representation with enough detail for managing | 5 | * `Parser` will create an object representation with enough detail for managing |
4 | * playback. `ParseStream` and `LineStream` are lower-level parsing primitives | 6 | * playback. `ParseStream` and `LineStream` are lower-level parsing primitives | ... | ... |
1 | /** | ||
2 | * @file m3u8/line-stream.js | ||
3 | */ | ||
1 | import Stream from '../stream'; | 4 | import Stream from '../stream'; |
5 | |||
2 | /** | 6 | /** |
3 | * A stream that buffers string input and generates a `data` event for each | 7 | * A stream that buffers string input and generates a `data` event for each |
4 | * line. | 8 | * line. |
9 | * | ||
10 | * @class LineStream | ||
11 | * @extends Stream | ||
5 | */ | 12 | */ |
6 | export default class LineStream extends Stream { | 13 | export default class LineStream extends Stream { |
7 | constructor() { | 14 | constructor() { |
... | @@ -11,7 +18,8 @@ export default class LineStream extends Stream { | ... | @@ -11,7 +18,8 @@ export default class LineStream extends Stream { |
11 | 18 | ||
12 | /** | 19 | /** |
13 | * Add new data to be parsed. | 20 | * Add new data to be parsed. |
14 | * @param data {string} the text to process | 21 | * |
22 | * @param {String} data the text to process | ||
15 | */ | 23 | */ |
16 | push(data) { | 24 | push(data) { |
17 | let nextNewline; | 25 | let nextNewline; | ... | ... |
1 | /** | ||
2 | * @file m3u8/parse-stream.js | ||
3 | */ | ||
1 | import Stream from '../stream'; | 4 | import Stream from '../stream'; |
2 | 5 | ||
3 | // "forgiving" attribute list psuedo-grammar: | 6 | /** |
4 | // attributes -> keyvalue (',' keyvalue)* | 7 | * "forgiving" attribute list psuedo-grammar: |
5 | // keyvalue -> key '=' value | 8 | * attributes -> keyvalue (',' keyvalue)* |
6 | // key -> [^=]* | 9 | * keyvalue -> key '=' value |
7 | // value -> '"' [^"]* '"' | [^,]* | 10 | * key -> [^=]* |
11 | * value -> '"' [^"]* '"' | [^,]* | ||
12 | */ | ||
8 | const attributeSeparator = function() { | 13 | const attributeSeparator = function() { |
9 | let key = '[^=]*'; | 14 | let key = '[^=]*'; |
10 | let value = '"[^"]*"|[^,]*'; | 15 | let value = '"[^"]*"|[^,]*'; |
... | @@ -13,6 +18,11 @@ const attributeSeparator = function() { | ... | @@ -13,6 +18,11 @@ const attributeSeparator = function() { |
13 | return new RegExp('(?:^|,)(' + keyvalue + ')'); | 18 | return new RegExp('(?:^|,)(' + keyvalue + ')'); |
14 | }; | 19 | }; |
15 | 20 | ||
21 | /** | ||
22 | * Parse attributes from a line given the seperator | ||
23 | * | ||
24 | * @param {String} attributes the attibute line to parse | ||
25 | */ | ||
16 | const parseAttributes = function(attributes) { | 26 | const parseAttributes = function(attributes) { |
17 | // split the string using attributes as the separator | 27 | // split the string using attributes as the separator |
18 | let attrs = attributes.split(attributeSeparator()); | 28 | let attrs = attributes.split(attributeSeparator()); |
... | @@ -57,6 +67,9 @@ const parseAttributes = function(attributes) { | ... | @@ -57,6 +67,9 @@ const parseAttributes = function(attributes) { |
57 | * `#EXT-X-MEDIA-SEQUENCE` becomes `media-sequence` when parsed. Unrecognized | 67 | * `#EXT-X-MEDIA-SEQUENCE` becomes `media-sequence` when parsed. Unrecognized |
58 | * tags are given the tag type `unknown` and a single additional property | 68 | * tags are given the tag type `unknown` and a single additional property |
59 | * `data` with the remainder of the input. | 69 | * `data` with the remainder of the input. |
70 | * | ||
71 | * @class ParseStream | ||
72 | * @extends Stream | ||
60 | */ | 73 | */ |
61 | export default class ParseStream extends Stream { | 74 | export default class ParseStream extends Stream { |
62 | constructor() { | 75 | constructor() { |
... | @@ -65,7 +78,8 @@ export default class ParseStream extends Stream { | ... | @@ -65,7 +78,8 @@ export default class ParseStream extends Stream { |
65 | 78 | ||
66 | /** | 79 | /** |
67 | * Parses an additional line of input. | 80 | * Parses an additional line of input. |
68 | * @param line {string} a single line of an M3U8 file to parse | 81 | * |
82 | * @param {String} line a single line of an M3U8 file to parse | ||
69 | */ | 83 | */ |
70 | push(line) { | 84 | push(line) { |
71 | let match; | 85 | let match; |
... | @@ -254,6 +268,18 @@ export default class ParseStream extends Stream { | ... | @@ -254,6 +268,18 @@ export default class ParseStream extends Stream { |
254 | this.trigger('data', event); | 268 | this.trigger('data', event); |
255 | return; | 269 | return; |
256 | } | 270 | } |
271 | match = (/^#EXT-X-MEDIA:?(.*)$/).exec(line); | ||
272 | if (match) { | ||
273 | event = { | ||
274 | type: 'tag', | ||
275 | tagType: 'media' | ||
276 | }; | ||
277 | if (match[1]) { | ||
278 | event.attributes = parseAttributes(match[1]); | ||
279 | } | ||
280 | this.trigger('data', event); | ||
281 | return; | ||
282 | } | ||
257 | match = (/^#EXT-X-ENDLIST/).exec(line); | 283 | match = (/^#EXT-X-ENDLIST/).exec(line); |
258 | if (match) { | 284 | if (match) { |
259 | this.trigger('data', { | 285 | this.trigger('data', { | ... | ... |
1 | /** | ||
2 | * @file m3u8/parser.js | ||
3 | */ | ||
1 | import Stream from '../stream' ; | 4 | import Stream from '../stream' ; |
2 | import LineStream from './line-stream'; | 5 | import LineStream from './line-stream'; |
3 | import ParseStream from './parse-stream'; | 6 | import ParseStream from './parse-stream'; |
... | @@ -20,6 +23,9 @@ import {mergeOptions} from 'video.js'; | ... | @@ -20,6 +23,9 @@ import {mergeOptions} from 'video.js'; |
20 | * underlying input is somewhat nonsensical. It emits `info` and `warning` | 23 | * underlying input is somewhat nonsensical. It emits `info` and `warning` |
21 | * events during the parse if it encounters input that seems invalid or | 24 | * events during the parse if it encounters input that seems invalid or |
22 | * requires some property of the manifest object to be defaulted. | 25 | * requires some property of the manifest object to be defaulted. |
26 | * | ||
27 | * @class Parser | ||
28 | * @extends Stream | ||
23 | */ | 29 | */ |
24 | export default class Parser extends Stream { | 30 | export default class Parser extends Stream { |
25 | constructor() { | 31 | constructor() { |
... | @@ -34,6 +40,14 @@ export default class Parser extends Stream { | ... | @@ -34,6 +40,14 @@ export default class Parser extends Stream { |
34 | let currentUri = {}; | 40 | let currentUri = {}; |
35 | let key; | 41 | let key; |
36 | let noop = function() {}; | 42 | let noop = function() {}; |
43 | let defaultMediaGroups = { | ||
44 | 'AUDIO': {}, | ||
45 | 'VIDEO': {}, | ||
46 | 'CLOSED-CAPTIONS': {}, | ||
47 | 'SUBTITLES': {} | ||
48 | }; | ||
49 | // group segments into numbered timelines delineated by discontinuities | ||
50 | let currentTimeline = 0; | ||
37 | 51 | ||
38 | // the manifest is empty until the parse stream begins delivering data | 52 | // the manifest is empty until the parse stream begins delivering data |
39 | this.manifest = { | 53 | this.manifest = { |
... | @@ -43,6 +57,9 @@ export default class Parser extends Stream { | ... | @@ -43,6 +57,9 @@ export default class Parser extends Stream { |
43 | 57 | ||
44 | // update the manifest with the m3u8 entry from the parse stream | 58 | // update the manifest with the m3u8 entry from the parse stream |
45 | this.parseStream.on('data', function(entry) { | 59 | this.parseStream.on('data', function(entry) { |
60 | let mediaGroup; | ||
61 | let rendition; | ||
62 | |||
46 | ({ | 63 | ({ |
47 | tag() { | 64 | tag() { |
48 | // switch based on the tag type | 65 | // switch based on the tag type |
... | @@ -96,7 +113,6 @@ export default class Parser extends Stream { | ... | @@ -96,7 +113,6 @@ export default class Parser extends Stream { |
96 | } | 113 | } |
97 | 114 | ||
98 | this.manifest.segments = uris; | 115 | this.manifest.segments = uris; |
99 | |||
100 | }, | 116 | }, |
101 | key() { | 117 | key() { |
102 | if (!entry.attributes) { | 118 | if (!entry.attributes) { |
... | @@ -149,6 +165,7 @@ export default class Parser extends Stream { | ... | @@ -149,6 +165,7 @@ export default class Parser extends Stream { |
149 | return; | 165 | return; |
150 | } | 166 | } |
151 | this.manifest.discontinuitySequence = entry.number; | 167 | this.manifest.discontinuitySequence = entry.number; |
168 | currentTimeline = entry.number; | ||
152 | }, | 169 | }, |
153 | 'playlist-type'() { | 170 | 'playlist-type'() { |
154 | if (!(/VOD|EVENT/).test(entry.playlistType)) { | 171 | if (!(/VOD|EVENT/).test(entry.playlistType)) { |
... | @@ -161,6 +178,8 @@ export default class Parser extends Stream { | ... | @@ -161,6 +178,8 @@ export default class Parser extends Stream { |
161 | }, | 178 | }, |
162 | 'stream-inf'() { | 179 | 'stream-inf'() { |
163 | this.manifest.playlists = uris; | 180 | this.manifest.playlists = uris; |
181 | this.manifest.mediaGroups = | ||
182 | this.manifest.mediaGroups || defaultMediaGroups; | ||
164 | 183 | ||
165 | if (!entry.attributes) { | 184 | if (!entry.attributes) { |
166 | this.trigger('warn', { | 185 | this.trigger('warn', { |
... | @@ -175,7 +194,48 @@ export default class Parser extends Stream { | ... | @@ -175,7 +194,48 @@ export default class Parser extends Stream { |
175 | currentUri.attributes = mergeOptions(currentUri.attributes, | 194 | currentUri.attributes = mergeOptions(currentUri.attributes, |
176 | entry.attributes); | 195 | entry.attributes); |
177 | }, | 196 | }, |
197 | media() { | ||
198 | this.manifest.mediaGroups = | ||
199 | this.manifest.mediaGroups || defaultMediaGroups; | ||
200 | |||
201 | if (!(entry.attributes && | ||
202 | entry.attributes.TYPE && | ||
203 | entry.attributes['GROUP-ID'] && | ||
204 | entry.attributes.NAME)) { | ||
205 | this.trigger('warn', { | ||
206 | message: 'ignoring incomplete or missing media group' | ||
207 | }); | ||
208 | return; | ||
209 | } | ||
210 | |||
211 | // find the media group, creating defaults as necessary | ||
212 | let mediaGroupType = this.manifest.mediaGroups[entry.attributes.TYPE]; | ||
213 | |||
214 | mediaGroupType[entry.attributes['GROUP-ID']] = | ||
215 | mediaGroupType[entry.attributes['GROUP-ID']] || {}; | ||
216 | mediaGroup = mediaGroupType[entry.attributes['GROUP-ID']]; | ||
217 | |||
218 | // collect the rendition metadata | ||
219 | rendition = { | ||
220 | default: (/yes/i).test(entry.attributes.DEFAULT) | ||
221 | }; | ||
222 | if (rendition.default) { | ||
223 | rendition.autoselect = true; | ||
224 | } else { | ||
225 | rendition.autoselect = (/yes/i).test(entry.attributes.AUTOSELECT); | ||
226 | } | ||
227 | if (entry.attributes.LANGUAGE) { | ||
228 | rendition.language = entry.attributes.LANGUAGE; | ||
229 | } | ||
230 | if (entry.attributes.URI) { | ||
231 | rendition.uri = entry.attributes.URI; | ||
232 | } | ||
233 | |||
234 | // insert the new rendition | ||
235 | mediaGroup[entry.attributes.NAME] = rendition; | ||
236 | }, | ||
178 | discontinuity() { | 237 | discontinuity() { |
238 | currentTimeline += 1; | ||
179 | currentUri.discontinuity = true; | 239 | currentUri.discontinuity = true; |
180 | this.manifest.discontinuityStarts.push(uris.length); | 240 | this.manifest.discontinuityStarts.push(uris.length); |
181 | }, | 241 | }, |
... | @@ -215,6 +275,7 @@ export default class Parser extends Stream { | ... | @@ -215,6 +275,7 @@ export default class Parser extends Stream { |
215 | if (key) { | 275 | if (key) { |
216 | currentUri.key = key; | 276 | currentUri.key = key; |
217 | } | 277 | } |
278 | currentUri.timeline = currentTimeline; | ||
218 | 279 | ||
219 | // prepare for the next URI | 280 | // prepare for the next URI |
220 | currentUri = {}; | 281 | currentUri = {}; |
... | @@ -229,7 +290,8 @@ export default class Parser extends Stream { | ... | @@ -229,7 +290,8 @@ export default class Parser extends Stream { |
229 | 290 | ||
230 | /** | 291 | /** |
231 | * Parse the input string and update the manifest object. | 292 | * Parse the input string and update the manifest object. |
232 | * @param chunk {string} a potentially incomplete portion of the manifest | 293 | * |
294 | * @param {String} chunk a potentially incomplete portion of the manifest | ||
233 | */ | 295 | */ |
234 | push(chunk) { | 296 | push(chunk) { |
235 | this.lineStream.push(chunk); | 297 | this.lineStream.push(chunk); |
... | @@ -246,4 +308,3 @@ export default class Parser extends Stream { | ... | @@ -246,4 +308,3 @@ export default class Parser extends Stream { |
246 | } | 308 | } |
247 | 309 | ||
248 | } | 310 | } |
249 | ... | ... |
src/master-playlist-controller.js
0 → 100644
1 | /** | ||
2 | * @file master-playlist-controller.js | ||
3 | */ | ||
4 | import PlaylistLoader from './playlist-loader'; | ||
5 | import SegmentLoader from './segment-loader'; | ||
6 | import Ranges from './ranges'; | ||
7 | import videojs from 'video.js'; | ||
8 | import HlsAudioTrack from './hls-audio-track'; | ||
9 | |||
10 | // 5 minute blacklist | ||
11 | const BLACKLIST_DURATION = 5 * 60 * 1000; | ||
12 | let Hls; | ||
13 | |||
14 | const parseCodecs = function(codecs) { | ||
15 | let result = { | ||
16 | codecCount: 0, | ||
17 | videoCodec: null, | ||
18 | audioProfile: null | ||
19 | }; | ||
20 | |||
21 | result.codecCount = codecs.split(',').length; | ||
22 | result.codecCount = result.codecCount || 2; | ||
23 | |||
24 | // parse the video codec but ignore the version | ||
25 | result.videoCodec = (/(^|\s|,)+(avc1)[^ ,]*/i).exec(codecs); | ||
26 | result.videoCodec = result.videoCodec && result.videoCodec[2]; | ||
27 | |||
28 | // parse the last field of the audio codec | ||
29 | result.audioProfile = (/(^|\s|,)+mp4a.\d+\.(\d+)/i).exec(codecs); | ||
30 | result.audioProfile = result.audioProfile && result.audioProfile[2]; | ||
31 | |||
32 | return result; | ||
33 | }; | ||
34 | |||
35 | /** | ||
36 | * the master playlist controller controller all interactons | ||
37 | * between playlists and segmentloaders. At this time this mainly | ||
38 | * involves a master playlist and a series of audio playlists | ||
39 | * if they are available | ||
40 | * | ||
41 | * @class MasterPlaylistController | ||
42 | * @extends videojs.EventTarget | ||
43 | */ | ||
44 | export default class MasterPlaylistController extends videojs.EventTarget { | ||
45 | constructor({ | ||
46 | url, | ||
47 | withCredentials, | ||
48 | mode, | ||
49 | tech, | ||
50 | bandwidth, | ||
51 | externHls | ||
52 | }) { | ||
53 | super(); | ||
54 | |||
55 | Hls = externHls; | ||
56 | |||
57 | this.withCredentials = withCredentials; | ||
58 | this.tech_ = tech; | ||
59 | this.hls_ = tech.hls; | ||
60 | this.mode_ = mode; | ||
61 | this.audioTracks_ = []; | ||
62 | |||
63 | this.mediaSource = new videojs.MediaSource({ mode }); | ||
64 | this.mediaSource.on('audioinfo', (e) => this.trigger(e)); | ||
65 | // load the media source into the player | ||
66 | this.mediaSource.addEventListener('sourceopen', this.handleSourceOpen_.bind(this)); | ||
67 | |||
68 | let segmentLoaderOptions = { | ||
69 | hls: this.hls_, | ||
70 | mediaSource: this.mediaSource, | ||
71 | currentTime: this.tech_.currentTime.bind(this.tech_), | ||
72 | withCredentials: this.withCredentials, | ||
73 | seekable: () => this.seekable(), | ||
74 | seeking: () => this.tech_.seeking(), | ||
75 | setCurrentTime: (a) => this.setCurrentTime(a), | ||
76 | hasPlayed: () => this.tech_.played().length !== 0, | ||
77 | bandwidth | ||
78 | }; | ||
79 | |||
80 | // combined audio/video or just video when alternate audio track is selected | ||
81 | this.mainSegmentLoader_ = new SegmentLoader(segmentLoaderOptions); | ||
82 | // alternate audio track | ||
83 | this.audioSegmentLoader_ = new SegmentLoader(segmentLoaderOptions); | ||
84 | |||
85 | if (!url) { | ||
86 | throw new Error('A non-empty playlist URL is required'); | ||
87 | } | ||
88 | |||
89 | this.masterPlaylistLoader_ = new PlaylistLoader(url, this.hls_, this.withCredentials); | ||
90 | |||
91 | this.masterPlaylistLoader_.on('loadedmetadata', () => { | ||
92 | let media = this.masterPlaylistLoader_.media(); | ||
93 | |||
94 | // if this isn't a live video and preload permits, start | ||
95 | // downloading segments | ||
96 | if (media.endList && this.tech_.preload() !== 'none') { | ||
97 | this.mainSegmentLoader_.playlist(media); | ||
98 | this.mainSegmentLoader_.expired(this.masterPlaylistLoader_.expired_); | ||
99 | this.mainSegmentLoader_.load(); | ||
100 | } | ||
101 | |||
102 | this.setupSourceBuffer_(); | ||
103 | this.setupFirstPlay(); | ||
104 | this.useAudio(); | ||
105 | }); | ||
106 | |||
107 | this.masterPlaylistLoader_.on('loadedplaylist', () => { | ||
108 | let updatedPlaylist = this.masterPlaylistLoader_.media(); | ||
109 | let seekable; | ||
110 | |||
111 | if (!updatedPlaylist) { | ||
112 | // select the initial variant | ||
113 | this.initialMedia_ = this.selectPlaylist(); | ||
114 | this.masterPlaylistLoader_.media(this.initialMedia_); | ||
115 | this.fillAudioTracks_(); | ||
116 | |||
117 | this.trigger('selectedinitialmedia'); | ||
118 | return; | ||
119 | } | ||
120 | |||
121 | this.mainSegmentLoader_.playlist(updatedPlaylist); | ||
122 | this.mainSegmentLoader_.expired(this.masterPlaylistLoader_.expired_); | ||
123 | this.updateDuration(); | ||
124 | |||
125 | // update seekable | ||
126 | seekable = this.seekable(); | ||
127 | if (!updatedPlaylist.endList && seekable.length !== 0) { | ||
128 | this.mediaSource.addSeekableRange_(seekable.start(0), seekable.end(0)); | ||
129 | } | ||
130 | }); | ||
131 | |||
132 | this.masterPlaylistLoader_.on('error', () => { | ||
133 | this.blacklistCurrentPlaylist(this.masterPlaylistLoader_.error); | ||
134 | }); | ||
135 | |||
136 | this.masterPlaylistLoader_.on('mediachanging', () => { | ||
137 | this.mainSegmentLoader_.pause(); | ||
138 | }); | ||
139 | |||
140 | this.masterPlaylistLoader_.on('mediachange', () => { | ||
141 | this.mainSegmentLoader_.abort(); | ||
142 | this.mainSegmentLoader_.load(); | ||
143 | this.tech_.trigger({ | ||
144 | type: 'mediachange', | ||
145 | bubbles: true | ||
146 | }); | ||
147 | }); | ||
148 | |||
149 | this.mainSegmentLoader_.on('progress', () => { | ||
150 | // figure out what stream the next segment should be downloaded from | ||
151 | // with the updated bandwidth information | ||
152 | this.masterPlaylistLoader_.media(this.selectPlaylist()); | ||
153 | |||
154 | this.trigger('progress'); | ||
155 | }); | ||
156 | |||
157 | this.mainSegmentLoader_.on('error', () => { | ||
158 | this.blacklistCurrentPlaylist(this.mainSegmentLoader_.error()); | ||
159 | }); | ||
160 | |||
161 | this.audioSegmentLoader_.on('error', () => { | ||
162 | videojs.log.warn('Problem encountered with the current alternate audio track' + | ||
163 | '. Switching back to default.'); | ||
164 | this.audioSegmentLoader_.abort(); | ||
165 | this.audioPlaylistLoader_ = null; | ||
166 | this.useAudio(); | ||
167 | }); | ||
168 | |||
169 | this.masterPlaylistLoader_.load(); | ||
170 | } | ||
171 | |||
172 | /** | ||
173 | * fill our internal list of HlsAudioTracks with data from | ||
174 | * the master playlist or use a default | ||
175 | * | ||
176 | * @private | ||
177 | */ | ||
178 | fillAudioTracks_() { | ||
179 | let master = this.master(); | ||
180 | let mediaGroups = master.mediaGroups || {}; | ||
181 | |||
182 | // force a default if we have none or we are not | ||
183 | // in html5 mode (the only mode to support more than one | ||
184 | // audio track) | ||
185 | if (!mediaGroups || | ||
186 | !mediaGroups.AUDIO || | ||
187 | Object.keys(mediaGroups.AUDIO).length === 0 || | ||
188 | this.mode_ !== 'html5') { | ||
189 | // "main" audio group, track name "default" | ||
190 | mediaGroups = videojs.mergeOptions(mediaGroups, {AUDIO: { | ||
191 | main: {default: {default: true}}} | ||
192 | }); | ||
193 | } | ||
194 | |||
195 | let tracks = {}; | ||
196 | |||
197 | for (let mediaGroup in mediaGroups.AUDIO) { | ||
198 | for (let label in mediaGroups.AUDIO[mediaGroup]) { | ||
199 | let properties = mediaGroups.AUDIO[mediaGroup][label]; | ||
200 | |||
201 | // if the track already exists add a new "location" | ||
202 | // since tracks in different mediaGroups are actually the same | ||
203 | // track with different locations to download them from | ||
204 | if (tracks[label]) { | ||
205 | tracks[label].addLoader(mediaGroup, properties.resolvedUri); | ||
206 | continue; | ||
207 | } | ||
208 | |||
209 | let track = new HlsAudioTrack(videojs.mergeOptions(properties, { | ||
210 | hls: this.hls_, | ||
211 | withCredentials: this.withCredential, | ||
212 | mediaGroup, | ||
213 | label | ||
214 | })); | ||
215 | |||
216 | tracks[label] = track; | ||
217 | this.audioTracks_.push(track); | ||
218 | } | ||
219 | } | ||
220 | } | ||
221 | |||
222 | /** | ||
223 | * Call load on our SegmentLoaders | ||
224 | */ | ||
225 | load() { | ||
226 | this.mainSegmentLoader_.load(); | ||
227 | if (this.audioPlaylistLoader_) { | ||
228 | this.audioSegmentLoader_.load(); | ||
229 | } | ||
230 | } | ||
231 | |||
232 | /** | ||
233 | * Get the current active Media Group for Audio | ||
234 | * given the selected playlist and its attributes | ||
235 | */ | ||
236 | activeAudioGroup() { | ||
237 | let media = this.masterPlaylistLoader_.media(); | ||
238 | let mediaGroup = 'main'; | ||
239 | |||
240 | if (media && media.attributes && media.attributes.AUDIO) { | ||
241 | mediaGroup = media.attributes.AUDIO; | ||
242 | } | ||
243 | |||
244 | return mediaGroup; | ||
245 | } | ||
246 | |||
247 | /** | ||
248 | * Use any audio track that we have, and start to load it | ||
249 | */ | ||
250 | useAudio() { | ||
251 | let track; | ||
252 | |||
253 | this.audioTracks_.forEach((t) => { | ||
254 | if (!track && t.enabled) { | ||
255 | track = t; | ||
256 | } | ||
257 | }); | ||
258 | |||
259 | // called too early or no track is enabled | ||
260 | if (!track) { | ||
261 | return; | ||
262 | } | ||
263 | |||
264 | // Pause any alternative audio | ||
265 | if (this.audioPlaylistLoader_) { | ||
266 | this.audioPlaylistLoader_.pause(); | ||
267 | this.audioPlaylistLoader_ = null; | ||
268 | this.audioSegmentLoader_.pause(); | ||
269 | } | ||
270 | |||
271 | // If the audio track for the active audio group has | ||
272 | // a playlist loader than it is an alterative audio track | ||
273 | // otherwise it is a part of the mainSegmenLoader | ||
274 | let loader = track.getLoader(this.activeAudioGroup()); | ||
275 | |||
276 | if (!loader) { | ||
277 | this.mainSegmentLoader_.clearBuffer(); | ||
278 | return; | ||
279 | } | ||
280 | |||
281 | // TODO: it may be better to create the playlist loader here | ||
282 | // when we can change an audioPlaylistLoaders src | ||
283 | this.audioPlaylistLoader_ = loader; | ||
284 | |||
285 | if (this.audioPlaylistLoader_.started) { | ||
286 | this.audioPlaylistLoader_.load(); | ||
287 | this.audioSegmentLoader_.load(); | ||
288 | this.audioSegmentLoader_.clearBuffer(); | ||
289 | return; | ||
290 | } | ||
291 | |||
292 | this.audioPlaylistLoader_.on('loadedmetadata', () => { | ||
293 | /* eslint-disable no-shadow */ | ||
294 | let media = this.audioPlaylistLoader_.media(); | ||
295 | /* eslint-enable no-shadow */ | ||
296 | |||
297 | this.audioSegmentLoader_.playlist(media); | ||
298 | this.addMimeType_(this.audioSegmentLoader_, 'mp4a.40.2', media); | ||
299 | |||
300 | // if the video is already playing, or if this isn't a live video and preload | ||
301 | // permits, start downloading segments | ||
302 | if (!this.tech_.paused() || | ||
303 | (media.endList && this.tech_.preload() !== 'none')) { | ||
304 | this.audioSegmentLoader_.load(); | ||
305 | } | ||
306 | |||
307 | if (!media.endList) { | ||
308 | // trigger the playlist loader to start "expired time"-tracking | ||
309 | this.audioPlaylistLoader_.trigger('firstplay'); | ||
310 | } | ||
311 | }); | ||
312 | |||
313 | this.audioPlaylistLoader_.on('loadedplaylist', () => { | ||
314 | let updatedPlaylist; | ||
315 | |||
316 | if (this.audioPlaylistLoader_) { | ||
317 | updatedPlaylist = this.audioPlaylistLoader_.media(); | ||
318 | } | ||
319 | |||
320 | if (!updatedPlaylist) { | ||
321 | // only one playlist to select | ||
322 | this.audioPlaylistLoader_.media( | ||
323 | this.audioPlaylistLoader_.playlists.master.playlists[0]); | ||
324 | return; | ||
325 | } | ||
326 | |||
327 | this.audioSegmentLoader_.playlist(updatedPlaylist); | ||
328 | }); | ||
329 | |||
330 | this.audioPlaylistLoader_.on('error', () => { | ||
331 | videojs.log.warn('Problem encountered loading the alternate audio track' + | ||
332 | '. Switching back to default.'); | ||
333 | this.audioSegmentLoader_.abort(); | ||
334 | this.audioPlaylistLoader_ = null; | ||
335 | this.useAudio(); | ||
336 | }); | ||
337 | |||
338 | this.audioSegmentLoader_.clearBuffer(); | ||
339 | this.audioPlaylistLoader_.start(); | ||
340 | } | ||
341 | |||
342 | /** | ||
343 | * Re-tune playback quality level for the current player | ||
344 | * conditions. This method may perform destructive actions, like | ||
345 | * removing already buffered content, to readjust the currently | ||
346 | * active playlist quickly. | ||
347 | * | ||
348 | * @private | ||
349 | */ | ||
350 | fastQualityChange_() { | ||
351 | let media = this.selectPlaylist(); | ||
352 | |||
353 | if (media !== this.masterPlaylistLoader_.media()) { | ||
354 | this.masterPlaylistLoader_.media(media); | ||
355 | this.mainSegmentLoader_.sourceUpdater_.remove(this.currentTimeFunc() + 5, Infinity); | ||
356 | } | ||
357 | } | ||
358 | |||
359 | /** | ||
360 | * Begin playback. | ||
361 | */ | ||
362 | play() { | ||
363 | if (this.setupFirstPlay()) { | ||
364 | return; | ||
365 | } | ||
366 | |||
367 | if (this.tech_.ended()) { | ||
368 | this.tech_.setCurrentTime(0); | ||
369 | } | ||
370 | |||
371 | this.load(); | ||
372 | |||
373 | // if the viewer has paused and we fell out of the live window, | ||
374 | // seek forward to the earliest available position | ||
375 | if (this.tech_.duration() === Infinity) { | ||
376 | if (this.tech_.currentTime() < this.tech_.seekable().start(0)) { | ||
377 | return this.tech_.setCurrentTime(this.tech_.seekable().start(0)); | ||
378 | } | ||
379 | } | ||
380 | |||
381 | } | ||
382 | |||
383 | /** | ||
384 | * Seek to the latest media position if this is a live video and the | ||
385 | * player and video are loaded and initialized. | ||
386 | */ | ||
387 | setupFirstPlay() { | ||
388 | let seekable; | ||
389 | let media = this.masterPlaylistLoader_.media(); | ||
390 | |||
391 | // check that everything is ready to begin buffering | ||
392 | // 1) the active media playlist is available | ||
393 | if (media && | ||
394 | // 2) the video is a live stream | ||
395 | !media.endList && | ||
396 | |||
397 | // 3) the player is not paused | ||
398 | !this.tech_.paused() && | ||
399 | |||
400 | // 4) the player has not started playing | ||
401 | !this.hasPlayed_) { | ||
402 | |||
403 | this.load(); | ||
404 | |||
405 | // trigger the playlist loader to start "expired time"-tracking | ||
406 | this.masterPlaylistLoader_.trigger('firstplay'); | ||
407 | this.hasPlayed_ = true; | ||
408 | |||
409 | // seek to the latest media position for live videos | ||
410 | seekable = this.seekable(); | ||
411 | if (seekable.length) { | ||
412 | this.tech_.setCurrentTime(seekable.end(0)); | ||
413 | } | ||
414 | |||
415 | return true; | ||
416 | } | ||
417 | return false; | ||
418 | } | ||
419 | |||
420 | /** | ||
421 | * handle the sourceopen event on the MediaSource | ||
422 | * | ||
423 | * @private | ||
424 | */ | ||
425 | handleSourceOpen_() { | ||
426 | // Only attempt to create the source buffer if none already exist. | ||
427 | // handleSourceOpen is also called when we are "re-opening" a source buffer | ||
428 | // after `endOfStream` has been called (in response to a seek for instance) | ||
429 | this.setupSourceBuffer_(); | ||
430 | |||
431 | // if autoplay is enabled, begin playback. This is duplicative of | ||
432 | // code in video.js but is required because play() must be invoked | ||
433 | // *after* the media source has opened. | ||
434 | if (this.tech_.autoplay()) { | ||
435 | this.tech_.play(); | ||
436 | } | ||
437 | |||
438 | this.trigger('sourceopen'); | ||
439 | } | ||
440 | |||
441 | /** | ||
442 | * Blacklists a playlist when an error occurs for a set amount of time | ||
443 | * making it unavailable for selection by the rendition selection algorithm | ||
444 | * and then forces a new playlist (rendition) selection. | ||
445 | * | ||
446 | * @param {Object=} error an optional error that may include the playlist | ||
447 | * to blacklist | ||
448 | */ | ||
449 | blacklistCurrentPlaylist(error = {}) { | ||
450 | let currentPlaylist; | ||
451 | let nextPlaylist; | ||
452 | |||
453 | // If the `error` was generated by the playlist loader, it will contain | ||
454 | // the playlist we were trying to load (but failed) and that should be | ||
455 | // blacklisted instead of the currently selected playlist which is likely | ||
456 | // out-of-date in this scenario | ||
457 | currentPlaylist = error.playlist || this.masterPlaylistLoader_.media(); | ||
458 | |||
459 | // If there is no current playlist, then an error occurred while we were | ||
460 | // trying to load the master OR while we were disposing of the tech | ||
461 | if (!currentPlaylist) { | ||
462 | this.error = error; | ||
463 | return this.mediaSource.endOfStream('network'); | ||
464 | } | ||
465 | |||
466 | // Blacklist this playlist | ||
467 | currentPlaylist.excludeUntil = Date.now() + BLACKLIST_DURATION; | ||
468 | |||
469 | // Select a new playlist | ||
470 | nextPlaylist = this.selectPlaylist(); | ||
471 | |||
472 | if (nextPlaylist) { | ||
473 | videojs.log.warn('Problem encountered with the current ' + | ||
474 | 'HLS playlist. Switching to another playlist.'); | ||
475 | |||
476 | return this.masterPlaylistLoader_.media(nextPlaylist); | ||
477 | } | ||
478 | videojs.log.warn('Problem encountered with the current ' + | ||
479 | 'HLS playlist. No suitable alternatives found.'); | ||
480 | // We have no more playlists we can select so we must fail | ||
481 | this.error = error; | ||
482 | return this.mediaSource.endOfStream('network'); | ||
483 | } | ||
484 | |||
485 | /** | ||
486 | * Pause all segment loaders | ||
487 | */ | ||
488 | pauseLoading() { | ||
489 | this.mainSegmentLoader_.pause(); | ||
490 | if (this.audioPlaylistLoader_) { | ||
491 | this.audioSegmentLoader_.pause(); | ||
492 | } | ||
493 | } | ||
494 | |||
495 | /** | ||
496 | * set the current time on all segment loaders | ||
497 | * | ||
498 | * @param {TimeRange} currentTime the current time to set | ||
499 | * @return {TimeRange} the current time | ||
500 | */ | ||
501 | setCurrentTime(currentTime) { | ||
502 | let buffered = Ranges.findRange(this.tech_.buffered(), currentTime); | ||
503 | |||
504 | if (!(this.masterPlaylistLoader_ && this.masterPlaylistLoader_.media())) { | ||
505 | // return immediately if the metadata is not ready yet | ||
506 | return 0; | ||
507 | } | ||
508 | |||
509 | // it's clearly an edge-case but don't thrown an error if asked to | ||
510 | // seek within an empty playlist | ||
511 | if (!this.masterPlaylistLoader_.media().segments) { | ||
512 | return 0; | ||
513 | } | ||
514 | |||
515 | // if the seek location is already buffered, continue buffering as | ||
516 | // usual | ||
517 | if (buffered && buffered.length) { | ||
518 | return currentTime; | ||
519 | } | ||
520 | |||
521 | // cancel outstanding requests so we begin buffering at the new | ||
522 | // location | ||
523 | this.mainSegmentLoader_.abort(); | ||
524 | if (this.audioPlaylistLoader_) { | ||
525 | this.audioSegmentLoader_.abort(); | ||
526 | } | ||
527 | |||
528 | if (!this.tech_.paused()) { | ||
529 | this.mainSegmentLoader_.load(); | ||
530 | if (this.audioPlaylistLoader_) { | ||
531 | this.audioSegmentLoader_.load(); | ||
532 | } | ||
533 | } | ||
534 | } | ||
535 | |||
536 | /** | ||
537 | * get the current duration | ||
538 | * | ||
539 | * @return {TimeRange} the duration | ||
540 | */ | ||
541 | duration() { | ||
542 | if (!this.masterPlaylistLoader_) { | ||
543 | return 0; | ||
544 | } | ||
545 | |||
546 | if (this.mediaSource) { | ||
547 | return this.mediaSource.duration; | ||
548 | } | ||
549 | |||
550 | return Hls.Playlist.duration(this.masterPlaylistLoader_.media()); | ||
551 | } | ||
552 | |||
553 | /** | ||
554 | * check the seekable range | ||
555 | * | ||
556 | * @return {TimeRange} the seekable range | ||
557 | */ | ||
558 | seekable() { | ||
559 | let media; | ||
560 | let mainSeekable; | ||
561 | let audioSeekable; | ||
562 | |||
563 | if (!this.masterPlaylistLoader_) { | ||
564 | return videojs.createTimeRanges(); | ||
565 | } | ||
566 | media = this.masterPlaylistLoader_.media(); | ||
567 | if (!media) { | ||
568 | return videojs.createTimeRanges(); | ||
569 | } | ||
570 | |||
571 | mainSeekable = Hls.Playlist.seekable(media, | ||
572 | this.masterPlaylistLoader_.expired_); | ||
573 | if (mainSeekable.length === 0) { | ||
574 | return mainSeekable; | ||
575 | } | ||
576 | |||
577 | if (this.audioPlaylistLoader_) { | ||
578 | audioSeekable = Hls.Playlist.seekable(this.audioPlaylistLoader_.media(), | ||
579 | this.audioPlaylistLoader_.expired_); | ||
580 | if (audioSeekable.length === 0) { | ||
581 | return audioSeekable; | ||
582 | } | ||
583 | } | ||
584 | |||
585 | if (!audioSeekable) { | ||
586 | // seekable has been calculated based on buffering video data so it | ||
587 | // can be returned directly | ||
588 | return mainSeekable; | ||
589 | } | ||
590 | |||
591 | return videojs.createTimeRanges([[ | ||
592 | (audioSeekable.start(0) > mainSeekable.start(0)) ? audioSeekable.start(0) : | ||
593 | mainSeekable.start(0), | ||
594 | (audioSeekable.end(0) < mainSeekable.end(0)) ? audioSeekable.end(0) : | ||
595 | mainSeekable.end(0) | ||
596 | ]]); | ||
597 | } | ||
598 | |||
599 | /** | ||
600 | * Update the player duration | ||
601 | */ | ||
602 | updateDuration() { | ||
603 | let oldDuration = this.mediaSource.duration; | ||
604 | let newDuration = Hls.Playlist.duration(this.masterPlaylistLoader_.media()); | ||
605 | let buffered = this.tech_.buffered(); | ||
606 | let setDuration = () => { | ||
607 | this.mediaSource.duration = newDuration; | ||
608 | this.tech_.trigger('durationchange'); | ||
609 | |||
610 | this.mediaSource.removeEventListener('sourceopen', setDuration); | ||
611 | }; | ||
612 | |||
613 | if (buffered.length > 0) { | ||
614 | newDuration = Math.max(newDuration, buffered.end(buffered.length - 1)); | ||
615 | } | ||
616 | |||
617 | // if the duration has changed, invalidate the cached value | ||
618 | if (oldDuration !== newDuration) { | ||
619 | // update the duration | ||
620 | if (this.mediaSource.readyState !== 'open') { | ||
621 | this.mediaSource.addEventListener('sourceopen', setDuration); | ||
622 | } else { | ||
623 | setDuration(); | ||
624 | } | ||
625 | } | ||
626 | } | ||
627 | |||
628 | /** | ||
629 | * dispose of the MasterPlaylistController and everything | ||
630 | * that it controls | ||
631 | */ | ||
632 | dispose() { | ||
633 | this.masterPlaylistLoader_.dispose(); | ||
634 | this.audioTracks_.forEach((track) => { | ||
635 | track.dispose(); | ||
636 | }); | ||
637 | this.audioTracks_.length = 0; | ||
638 | this.mainSegmentLoader_.dispose(); | ||
639 | this.audioSegmentLoader_.dispose(); | ||
640 | } | ||
641 | |||
642 | /** | ||
643 | * return the master playlist object if we have one | ||
644 | * | ||
645 | * @return {Object} the master playlist object that we parsed | ||
646 | */ | ||
647 | master() { | ||
648 | return this.masterPlaylistLoader_.master; | ||
649 | } | ||
650 | |||
651 | /** | ||
652 | * return the currently selected playlist | ||
653 | * | ||
654 | * @return {Object} the currently selected playlist object that we parsed | ||
655 | */ | ||
656 | media() { | ||
657 | // playlist loader will not return media if it has not been fully loaded | ||
658 | return this.masterPlaylistLoader_.media() || this.initialMedia_; | ||
659 | } | ||
660 | |||
661 | /** | ||
662 | * setup our internal source buffers on our segment Loaders | ||
663 | * | ||
664 | * @private | ||
665 | */ | ||
666 | setupSourceBuffer_() { | ||
667 | let media = this.masterPlaylistLoader_.media(); | ||
668 | |||
669 | // wait until a media playlist is available and the Media Source is | ||
670 | // attached | ||
671 | if (!media || this.mediaSource.readyState !== 'open') { | ||
672 | return; | ||
673 | } | ||
674 | |||
675 | this.addMimeType_(this.mainSegmentLoader_, 'avc1.4d400d, mp4a.40.2', media); | ||
676 | |||
677 | // exclude any incompatible variant streams from future playlist | ||
678 | // selection | ||
679 | this.excludeIncompatibleVariants_(media); | ||
680 | } | ||
681 | |||
682 | /** | ||
683 | * add a time type to a segmentLoader | ||
684 | * | ||
685 | * @param {SegmentLoader} segmentLoader the segmentloader to work on | ||
686 | * @param {String} codecs to use by default | ||
687 | * @param {Object} the parsed media object | ||
688 | * @private | ||
689 | */ | ||
690 | addMimeType_(segmentLoader, defaultCodecs, media) { | ||
691 | let mimeType = 'video/mp2t'; | ||
692 | |||
693 | // if the codecs were explicitly specified, pass them along to the | ||
694 | // source buffer | ||
695 | if (media.attributes && media.attributes.CODECS) { | ||
696 | mimeType += '; codecs="' + media.attributes.CODECS + '"'; | ||
697 | } else { | ||
698 | mimeType += '; codecs="' + defaultCodecs + '"'; | ||
699 | } | ||
700 | segmentLoader.mimeType(mimeType); | ||
701 | } | ||
702 | |||
703 | /** | ||
704 | * Blacklist playlists that are known to be codec or | ||
705 | * stream-incompatible with the SourceBuffer configuration. For | ||
706 | * instance, Media Source Extensions would cause the video element to | ||
707 | * stall waiting for video data if you switched from a variant with | ||
708 | * video and audio to an audio-only one. | ||
709 | * | ||
710 | * @param {Object} media a media playlist compatible with the current | ||
711 | * set of SourceBuffers. Variants in the current master playlist that | ||
712 | * do not appear to have compatible codec or stream configurations | ||
713 | * will be excluded from the default playlist selection algorithm | ||
714 | * indefinitely. | ||
715 | * @private | ||
716 | */ | ||
717 | excludeIncompatibleVariants_(media) { | ||
718 | let master = this.masterPlaylistLoader_.master; | ||
719 | let codecCount = 2; | ||
720 | let videoCodec = null; | ||
721 | let audioProfile = null; | ||
722 | let codecs; | ||
723 | |||
724 | if (media.attributes && media.attributes.CODECS) { | ||
725 | codecs = parseCodecs(media.attributes.CODECS); | ||
726 | videoCodec = codecs.videoCodec; | ||
727 | audioProfile = codecs.audioProfile; | ||
728 | codecCount = codecs.codecCount; | ||
729 | } | ||
730 | master.playlists.forEach(function(variant) { | ||
731 | let variantCodecs = { | ||
732 | codecCount: 2, | ||
733 | videoCodec: null, | ||
734 | audioProfile: null | ||
735 | }; | ||
736 | |||
737 | if (variant.attributes && variant.attributes.CODECS) { | ||
738 | variantCodecs = parseCodecs(variant.attributes.CODECS); | ||
739 | } | ||
740 | |||
741 | // if the streams differ in the presence or absence of audio or | ||
742 | // video, they are incompatible | ||
743 | if (variantCodecs.codecCount !== codecCount) { | ||
744 | variant.excludeUntil = Infinity; | ||
745 | } | ||
746 | |||
747 | // if h.264 is specified on the current playlist, some flavor of | ||
748 | // it must be specified on all compatible variants | ||
749 | if (variantCodecs.videoCodec !== videoCodec) { | ||
750 | variant.excludeUntil = Infinity; | ||
751 | } | ||
752 | // HE-AAC ("mp4a.40.5") is incompatible with all other versions of | ||
753 | // AAC audio in Chrome 46. Don't mix the two. | ||
754 | if ((variantCodecs.audioProfile === '5' && audioProfile !== '5') || | ||
755 | (audioProfile === '5' && variantCodecs.audioProfile !== '5')) { | ||
756 | variant.excludeUntil = Infinity; | ||
757 | } | ||
758 | }); | ||
759 | } | ||
760 | } |
1 | /** | 1 | /** |
2 | * playlist-loader | 2 | * @file playlist-loader.js |
3 | * | 3 | * |
4 | * A state machine that manages the loading, caching, and updating of | 4 | * A state machine that manages the loading, caching, and updating of |
5 | * M3U8 playlists. | 5 | * M3U8 playlists. |
... | @@ -11,21 +11,53 @@ import Stream from './stream'; | ... | @@ -11,21 +11,53 @@ import Stream from './stream'; |
11 | import m3u8 from './m3u8'; | 11 | import m3u8 from './m3u8'; |
12 | 12 | ||
13 | /** | 13 | /** |
14 | * Returns a new master playlist that is the result of merging an | 14 | * Returns a new array of segments that is the result of merging |
15 | * updated media playlist into the original version. If the | 15 | * properties from an older list of segments onto an updated |
16 | * updated media playlist does not match any of the playlist | 16 | * list. No properties on the updated playlist will be overridden. |
17 | * entries in the original master playlist, null is returned. | 17 | * |
18 | * @param master {object} a parsed master M3U8 object | 18 | * @param {Array} original the outdated list of segments |
19 | * @param media {object} a parsed media M3U8 object | 19 | * @param {Array} update the updated list of segments |
20 | * @return {object} a new object that represents the original | 20 | * @param {Number=} offset the index of the first update |
21 | * master playlist with the updated media playlist merged in, or | 21 | * segment in the original segment list. For non-live playlists, |
22 | * null if the merge produced no change. | 22 | * this should always be zero and does not need to be |
23 | */ | 23 | * specified. For live playlists, it should be the difference |
24 | * between the media sequence numbers in the original and updated | ||
25 | * playlists. | ||
26 | * @return a list of merged segment objects | ||
27 | */ | ||
28 | const updateSegments = function(original, update, offset) { | ||
29 | let result = update.slice(); | ||
30 | let length; | ||
31 | let i; | ||
32 | |||
33 | offset = offset || 0; | ||
34 | length = Math.min(original.length, update.length + offset); | ||
35 | |||
36 | for (i = offset; i < length; i++) { | ||
37 | result[i - offset] = mergeOptions(original[i], result[i - offset]); | ||
38 | } | ||
39 | return result; | ||
40 | }; | ||
41 | |||
42 | /** | ||
43 | * Returns a new master playlist that is the result of merging an | ||
44 | * updated media playlist into the original version. If the | ||
45 | * updated media playlist does not match any of the playlist | ||
46 | * entries in the original master playlist, null is returned. | ||
47 | * | ||
48 | * @param {Object} master a parsed master M3U8 object | ||
49 | * @param {Object} media a parsed media M3U8 object | ||
50 | * @return {Object} a new object that represents the original | ||
51 | * master playlist with the updated media playlist merged in, or | ||
52 | * null if the merge produced no change. | ||
53 | */ | ||
24 | const updateMaster = function(master, media) { | 54 | const updateMaster = function(master, media) { |
25 | let changed = false; | 55 | let changed = false; |
26 | let result = mergeOptions(master, {}); | 56 | let result = mergeOptions(master, {}); |
27 | let i = master.playlists.length; | 57 | let i = master.playlists.length; |
28 | let playlist; | 58 | let playlist; |
59 | let segment; | ||
60 | let j; | ||
29 | 61 | ||
30 | while (i--) { | 62 | while (i--) { |
31 | playlist = result.playlists[i]; | 63 | playlist = result.playlists[i]; |
... | @@ -45,10 +77,25 @@ const updateMaster = function(master, media) { | ... | @@ -45,10 +77,25 @@ const updateMaster = function(master, media) { |
45 | // if the update could overlap existing segment information, | 77 | // if the update could overlap existing segment information, |
46 | // merge the two lists | 78 | // merge the two lists |
47 | if (playlist.segments) { | 79 | if (playlist.segments) { |
48 | result.playlists[i].segments = updateSegments(playlist.segments, | 80 | result.playlists[i].segments = updateSegments( |
49 | media.segments, | 81 | playlist.segments, |
50 | media.mediaSequence - | 82 | media.segments, |
51 | playlist.mediaSequence); | 83 | media.mediaSequence - playlist.mediaSequence |
84 | ); | ||
85 | } | ||
86 | // resolve any missing segment and key URIs | ||
87 | j = 0; | ||
88 | if (result.playlists[i].segments) { | ||
89 | j = result.playlists[i].segments.length; | ||
90 | } | ||
91 | while (j--) { | ||
92 | segment = result.playlists[i].segments[j]; | ||
93 | if (!segment.resolvedUri) { | ||
94 | segment.resolvedUri = resolveUrl(playlist.resolvedUri, segment.uri); | ||
95 | } | ||
96 | if (segment.key && !segment.key.resolvedUri) { | ||
97 | segment.key.resolvedUri = resolveUrl(playlist.resolvedUri, segment.key.uri); | ||
98 | } | ||
52 | } | 99 | } |
53 | changed = true; | 100 | changed = true; |
54 | } | 101 | } |
... | @@ -57,258 +104,297 @@ const updateMaster = function(master, media) { | ... | @@ -57,258 +104,297 @@ const updateMaster = function(master, media) { |
57 | }; | 104 | }; |
58 | 105 | ||
59 | /** | 106 | /** |
60 | * Returns a new array of segments that is the result of merging | 107 | * Load a playlist from a remote loacation |
61 | * properties from an older list of segments onto an updated | 108 | * |
62 | * list. No properties on the updated playlist will be overridden. | 109 | * @class PlaylistLoader |
63 | * @param original {array} the outdated list of segments | 110 | * @extends Stream |
64 | * @param update {array} the updated list of segments | 111 | * @param {String} srcUrl the url to start with |
65 | * @param offset {number} (optional) the index of the first update | 112 | * @param {Boolean} withCredentials the withCredentials xhr option |
66 | * segment in the original segment list. For non-live playlists, | 113 | * @constructor |
67 | * this should always be zero and does not need to be | ||
68 | * specified. For live playlists, it should be the difference | ||
69 | * between the media sequence numbers in the original and updated | ||
70 | * playlists. | ||
71 | * @return a list of merged segment objects | ||
72 | */ | 114 | */ |
73 | const updateSegments = function(original, update, offset) { | 115 | const PlaylistLoader = function(srcUrl, hls, withCredentials) { |
74 | let result = update.slice(); | 116 | /* eslint-disable consistent-this */ |
75 | let length; | 117 | let loader = this; |
76 | let i; | 118 | /* eslint-enable consistent-this */ |
119 | let dispose; | ||
120 | let mediaUpdateTimeout; | ||
121 | let request; | ||
122 | let playlistRequestError; | ||
123 | let haveMetadata; | ||
124 | |||
125 | PlaylistLoader.prototype.constructor.call(this); | ||
126 | |||
127 | this.hls_ = hls; | ||
128 | |||
129 | // a flag that disables "expired time"-tracking this setting has | ||
130 | // no effect when not playing a live stream | ||
131 | this.trackExpiredTime_ = false; | ||
132 | |||
133 | if (!srcUrl) { | ||
134 | throw new Error('A non-empty playlist URL is required'); | ||
135 | } | ||
77 | 136 | ||
78 | offset = offset || 0; | 137 | playlistRequestError = function(xhr, url, startingState) { |
79 | length = Math.min(original.length, update.length + offset); | 138 | loader.setBandwidth(request || xhr); |
80 | 139 | ||
81 | for (i = offset; i < length; i++) { | 140 | // any in-flight request is now finished |
82 | result[i - offset] = mergeOptions(original[i], result[i - offset]); | 141 | request = null; |
83 | } | ||
84 | return result; | ||
85 | }; | ||
86 | 142 | ||
87 | export default class PlaylistLoader extends Stream { | 143 | if (startingState) { |
88 | constructor(srcUrl, hls, withCredentials) { | 144 | loader.state = startingState; |
89 | super(); | 145 | } |
90 | let loader = this; | ||
91 | let dispose; | ||
92 | let mediaUpdateTimeout; | ||
93 | let request; | ||
94 | let playlistRequestError; | ||
95 | let haveMetadata; | ||
96 | 146 | ||
97 | this.hls_ = hls; | 147 | loader.error = { |
148 | playlist: loader.master.playlists[url], | ||
149 | status: xhr.status, | ||
150 | message: 'HLS playlist request error at URL: ' + url, | ||
151 | responseText: xhr.responseText, | ||
152 | code: (xhr.status >= 500) ? 4 : 2 | ||
153 | }; | ||
98 | 154 | ||
99 | // a flag that disables "expired time"-tracking this setting has | 155 | loader.trigger('error'); |
100 | // no effect when not playing a live stream | 156 | }; |
101 | this.trackExpiredTime_ = false; | ||
102 | 157 | ||
103 | if (!srcUrl) { | 158 | // update the playlist loader's state in response to a new or |
104 | throw new Error('A non-empty playlist URL is required'); | 159 | // updated playlist. |
105 | } | 160 | haveMetadata = function(xhr, url) { |
161 | let parser; | ||
162 | let refreshDelay; | ||
163 | let update; | ||
106 | 164 | ||
107 | playlistRequestError = function(xhr, url, startingState) { | 165 | loader.setBandwidth(request || xhr); |
108 | loader.setBandwidth(request || xhr); | ||
109 | 166 | ||
110 | // any in-flight request is now finished | 167 | // any in-flight request is now finished |
111 | request = null; | 168 | request = null; |
112 | 169 | ||
113 | if (startingState) { | 170 | loader.state = 'HAVE_METADATA'; |
114 | loader.state = startingState; | ||
115 | } | ||
116 | 171 | ||
117 | loader.error = { | 172 | parser = new m3u8.Parser(); |
118 | playlist: loader.master.playlists[url], | 173 | parser.push(xhr.responseText); |
119 | status: xhr.status, | 174 | parser.end(); |
120 | message: 'HLS playlist request error at URL: ' + url, | 175 | parser.manifest.uri = url; |
121 | responseText: xhr.responseText, | ||
122 | code: (xhr.status >= 500) ? 4 : 2 | ||
123 | }; | ||
124 | loader.trigger('error'); | ||
125 | }; | ||
126 | 176 | ||
127 | // update the playlist loader's state in response to a new or | 177 | // merge this playlist into the master |
128 | // updated playlist. | 178 | update = updateMaster(loader.master, parser.manifest); |
179 | refreshDelay = (parser.manifest.targetDuration || 10) * 1000; | ||
180 | if (update) { | ||
181 | loader.master = update; | ||
182 | loader.updateMediaPlaylist_(parser.manifest); | ||
183 | } else { | ||
184 | // if the playlist is unchanged since the last reload, | ||
185 | // try again after half the target duration | ||
186 | refreshDelay /= 2; | ||
187 | } | ||
129 | 188 | ||
130 | haveMetadata = function(xhr, url) { | 189 | // refresh live playlists after a target duration passes |
131 | let parser; | 190 | if (!loader.media().endList) { |
132 | let refreshDelay; | 191 | window.clearTimeout(mediaUpdateTimeout); |
133 | let update; | 192 | mediaUpdateTimeout = window.setTimeout(function() { |
193 | loader.trigger('mediaupdatetimeout'); | ||
194 | }, refreshDelay); | ||
195 | } | ||
134 | 196 | ||
135 | loader.setBandwidth(request || xhr); | 197 | loader.trigger('loadedplaylist'); |
198 | }; | ||
136 | 199 | ||
137 | // any in-flight request is now finished | 200 | // initialize the loader state |
138 | request = null; | 201 | loader.state = 'HAVE_NOTHING'; |
139 | loader.state = 'HAVE_METADATA'; | ||
140 | 202 | ||
141 | parser = new m3u8.Parser(); | 203 | // track the time that has expired from the live window |
142 | parser.push(xhr.responseText); | 204 | // this allows the seekable start range to be calculated even if |
143 | parser.end(); | 205 | // all segments with timing information have expired |
144 | parser.manifest.uri = url; | 206 | this.expired_ = 0; |
145 | |||
146 | // merge this playlist into the master | ||
147 | update = updateMaster(loader.master, parser.manifest); | ||
148 | refreshDelay = (parser.manifest.targetDuration || 10) * 1000; | ||
149 | if (update) { | ||
150 | loader.master = update; | ||
151 | loader.updateMediaPlaylist_(parser.manifest); | ||
152 | } else { | ||
153 | // if the playlist is unchanged since the last reload, | ||
154 | // try again after half the target duration | ||
155 | refreshDelay /= 2; | ||
156 | } | ||
157 | 207 | ||
158 | // refresh live playlists after a target duration passes | 208 | // capture the prototype dispose function |
159 | if (!loader.media().endList) { | 209 | dispose = this.dispose; |
160 | window.clearTimeout(mediaUpdateTimeout); | ||
161 | mediaUpdateTimeout = window.setTimeout(function() { | ||
162 | loader.trigger('mediaupdatetimeout'); | ||
163 | }, refreshDelay); | ||
164 | } | ||
165 | 210 | ||
166 | loader.trigger('loadedplaylist'); | 211 | /** |
167 | }; | 212 | * Abort any outstanding work and clean up. |
213 | */ | ||
214 | loader.dispose = function() { | ||
215 | loader.stopRequest(); | ||
216 | window.clearTimeout(mediaUpdateTimeout); | ||
217 | dispose.call(this); | ||
218 | }; | ||
168 | 219 | ||
169 | // initialize the loader state | 220 | loader.stopRequest = () => { |
170 | loader.state = 'HAVE_NOTHING'; | 221 | if (request) { |
222 | let oldRequest = request; | ||
171 | 223 | ||
172 | // track the time that has expired from the live window | 224 | request = null; |
173 | // this allows the seekable start range to be calculated even if | 225 | oldRequest.onreadystatechange = null; |
174 | // all segments with timing information have expired | 226 | oldRequest.abort(); |
175 | this.expired_ = 0; | 227 | } |
228 | }; | ||
229 | |||
230 | /** | ||
231 | * When called without any arguments, returns the currently | ||
232 | * active media playlist. When called with a single argument, | ||
233 | * triggers the playlist loader to asynchronously switch to the | ||
234 | * specified media playlist. Calling this method while the | ||
235 | * loader is in the HAVE_NOTHING causes an error to be emitted | ||
236 | * but otherwise has no effect. | ||
237 | * | ||
238 | * @param {Object=} playlis tthe parsed media playlist | ||
239 | * object to switch to | ||
240 | * @return {Playlist} the current loaded media | ||
241 | */ | ||
242 | loader.media = function(playlist) { | ||
243 | let startingState = loader.state; | ||
244 | let mediaChange; | ||
245 | |||
246 | // getter | ||
247 | if (!playlist) { | ||
248 | return loader.media_; | ||
249 | } | ||
176 | 250 | ||
177 | // capture the prototype dispose function | 251 | // setter |
178 | dispose = this.dispose; | 252 | if (loader.state === 'HAVE_NOTHING') { |
253 | throw new Error('Cannot switch media playlist from ' + loader.state); | ||
254 | } | ||
179 | 255 | ||
180 | /** | 256 | // find the playlist object if the target playlist has been |
181 | * Abort any outstanding work and clean up. | 257 | // specified by URI |
182 | */ | 258 | if (typeof playlist === 'string') { |
183 | loader.dispose = function() { | 259 | if (!loader.master.playlists[playlist]) { |
260 | throw new Error('Unknown playlist URI: ' + playlist); | ||
261 | } | ||
262 | playlist = loader.master.playlists[playlist]; | ||
263 | } | ||
264 | |||
265 | mediaChange = !loader.media_ || playlist.uri !== loader.media_.uri; | ||
266 | |||
267 | // switch to fully loaded playlists immediately | ||
268 | if (loader.master.playlists[playlist.uri].endList) { | ||
269 | // abort outstanding playlist requests | ||
184 | if (request) { | 270 | if (request) { |
185 | request.onreadystatechange = null; | 271 | request.onreadystatechange = null; |
186 | request.abort(); | 272 | request.abort(); |
187 | request = null; | 273 | request = null; |
188 | } | 274 | } |
189 | window.clearTimeout(mediaUpdateTimeout); | 275 | loader.state = 'HAVE_METADATA'; |
190 | dispose.call(this); | 276 | loader.media_ = playlist; |
191 | }; | ||
192 | |||
193 | /** | ||
194 | * When called without any arguments, returns the currently | ||
195 | * active media playlist. When called with a single argument, | ||
196 | * triggers the playlist loader to asynchronously switch to the | ||
197 | * specified media playlist. Calling this method while the | ||
198 | * loader is in the HAVE_NOTHING causes an error to be emitted | ||
199 | * but otherwise has no effect. | ||
200 | * @param playlist (optional) {object} the parsed media playlist | ||
201 | * object to switch to | ||
202 | */ | ||
203 | loader.media = function(playlist) { | ||
204 | let startingState = loader.state; | ||
205 | let mediaChange; | ||
206 | // getter | ||
207 | if (!playlist) { | ||
208 | return loader.media_; | ||
209 | } | ||
210 | 277 | ||
211 | // setter | 278 | // trigger media change if the active media has been updated |
212 | if (loader.state === 'HAVE_NOTHING') { | 279 | if (mediaChange) { |
213 | throw new Error('Cannot switch media playlist from ' + loader.state); | 280 | loader.trigger('mediachanging'); |
214 | } | 281 | loader.trigger('mediachange'); |
215 | |||
216 | // find the playlist object if the target playlist has been | ||
217 | // specified by URI | ||
218 | if (typeof playlist === 'string') { | ||
219 | if (!loader.master.playlists[playlist]) { | ||
220 | throw new Error('Unknown playlist URI: ' + playlist); | ||
221 | } | ||
222 | playlist = loader.master.playlists[playlist]; | ||
223 | } | 282 | } |
283 | return; | ||
284 | } | ||
224 | 285 | ||
225 | mediaChange = !loader.media_ || playlist.uri !== loader.media_.uri; | 286 | // switching to the active playlist is a no-op |
287 | if (!mediaChange) { | ||
288 | return; | ||
289 | } | ||
226 | 290 | ||
227 | // switch to fully loaded playlists immediately | 291 | loader.state = 'SWITCHING_MEDIA'; |
228 | if (loader.master.playlists[playlist.uri].endList) { | ||
229 | // abort outstanding playlist requests | ||
230 | if (request) { | ||
231 | request.onreadystatechange = null; | ||
232 | request.abort(); | ||
233 | request = null; | ||
234 | } | ||
235 | loader.state = 'HAVE_METADATA'; | ||
236 | loader.media_ = playlist; | ||
237 | 292 | ||
238 | // trigger media change if the active media has been updated | 293 | // there is already an outstanding playlist request |
239 | if (mediaChange) { | 294 | if (request) { |
240 | loader.trigger('mediachange'); | 295 | if (resolveUrl(loader.master.uri, playlist.uri) === request.url) { |
241 | } | 296 | // requesting to switch to the same playlist multiple times |
297 | // has no effect after the first | ||
242 | return; | 298 | return; |
243 | } | 299 | } |
300 | request.onreadystatechange = null; | ||
301 | request.abort(); | ||
302 | request = null; | ||
303 | } | ||
244 | 304 | ||
245 | // switching to the active playlist is a no-op | 305 | // request the new playlist |
246 | if (!mediaChange) { | 306 | if (this.media_) { |
307 | this.trigger('mediachanging'); | ||
308 | } | ||
309 | request = this.hls_.xhr({ | ||
310 | uri: resolveUrl(loader.master.uri, playlist.uri), | ||
311 | withCredentials | ||
312 | }, function(error, req) { | ||
313 | // disposed | ||
314 | if (!request) { | ||
247 | return; | 315 | return; |
248 | } | 316 | } |
249 | 317 | ||
250 | loader.state = 'SWITCHING_MEDIA'; | 318 | if (error) { |
251 | 319 | return playlistRequestError(request, playlist.uri, startingState); | |
252 | // there is already an outstanding playlist request | ||
253 | if (request) { | ||
254 | if (resolveUrl(loader.master.uri, playlist.uri) === request.url) { | ||
255 | // requesting to switch to the same playlist multiple times | ||
256 | // has no effect after the first | ||
257 | return; | ||
258 | } | ||
259 | request.onreadystatechange = null; | ||
260 | request.abort(); | ||
261 | request = null; | ||
262 | } | 320 | } |
263 | 321 | ||
264 | // request the new playlist | 322 | haveMetadata(req, playlist.uri); |
265 | request = this.hls_.xhr({ | ||
266 | uri: resolveUrl(loader.master.uri, playlist.uri), | ||
267 | withCredentials | ||
268 | }, function(error, request) { | ||
269 | if (error) { | ||
270 | return playlistRequestError(request, playlist.uri, startingState); | ||
271 | } | ||
272 | 323 | ||
273 | haveMetadata(request, playlist.uri); | 324 | // fire loadedmetadata the first time a media playlist is loaded |
325 | if (startingState === 'HAVE_MASTER') { | ||
326 | loader.trigger('loadedmetadata'); | ||
327 | } else { | ||
328 | loader.trigger('mediachange'); | ||
329 | } | ||
330 | }); | ||
331 | }; | ||
274 | 332 | ||
275 | // fire loadedmetadata the first time a media playlist is loaded | 333 | /** |
276 | if (startingState === 'HAVE_MASTER') { | 334 | * set the bandwidth on an xhr to the bandwidth on the playlist |
277 | loader.trigger('loadedmetadata'); | 335 | */ |
278 | } else { | 336 | loader.setBandwidth = function(xhr) { |
279 | loader.trigger('mediachange'); | 337 | loader.bandwidth = xhr.bandwidth; |
280 | } | 338 | }; |
281 | }); | 339 | |
282 | }; | 340 | // In a live playlist, don't keep track of the expired time |
341 | // until HLS tells us that "first play" has commenced | ||
342 | loader.on('firstplay', function() { | ||
343 | this.trackExpiredTime_ = true; | ||
344 | }); | ||
345 | |||
346 | // live playlist staleness timeout | ||
347 | loader.on('mediaupdatetimeout', function() { | ||
348 | if (loader.state !== 'HAVE_METADATA') { | ||
349 | // only refresh the media playlist if no other activity is going on | ||
350 | return; | ||
351 | } | ||
283 | 352 | ||
284 | loader.setBandwidth = function(xhr) { | 353 | loader.state = 'HAVE_CURRENT_METADATA'; |
285 | loader.bandwidth = xhr.bandwidth; | 354 | request = this.hls_.xhr({ |
286 | }; | 355 | uri: resolveUrl(loader.master.uri, loader.media().uri), |
356 | withCredentials | ||
357 | }, function(error, req) { | ||
358 | // disposed | ||
359 | if (!request) { | ||
360 | return; | ||
361 | } | ||
287 | 362 | ||
288 | // In a live list, don't keep track of the expired time until | 363 | if (error) { |
289 | // HLS tells us that "first play" has commenced | 364 | return playlistRequestError(request, loader.media().uri); |
290 | loader.on('firstplay', function() { | 365 | } |
291 | this.trackExpiredTime_ = true; | 366 | haveMetadata(request, loader.media().uri); |
292 | }); | 367 | }); |
368 | }); | ||
293 | 369 | ||
294 | // live playlist staleness timeout | 370 | /** |
295 | loader.on('mediaupdatetimeout', function() { | 371 | * pause loading of the playlist |
296 | if (loader.state !== 'HAVE_METADATA') { | 372 | */ |
297 | // only refresh the media playlist if no other activity is going on | 373 | loader.pause = () => { |
298 | return; | 374 | loader.stopRequest(); |
375 | window.clearTimeout(mediaUpdateTimeout); | ||
376 | }; | ||
377 | |||
378 | /** | ||
379 | * start loading of the playlist | ||
380 | */ | ||
381 | loader.load = () => { | ||
382 | if (loader.started) { | ||
383 | if (!loader.media().endList) { | ||
384 | loader.trigger('mediaupdatetimeout'); | ||
385 | } else { | ||
386 | loader.trigger('loadedplaylist'); | ||
299 | } | 387 | } |
388 | } else { | ||
389 | loader.start(); | ||
390 | } | ||
391 | }; | ||
300 | 392 | ||
301 | loader.state = 'HAVE_CURRENT_METADATA'; | 393 | /** |
302 | request = this.hls_.xhr({ | 394 | * start loading of the playlist |
303 | uri: resolveUrl(loader.master.uri, loader.media().uri), | 395 | */ |
304 | withCredentials | 396 | loader.start = () => { |
305 | }, function(error, request) { | 397 | loader.started = true; |
306 | if (error) { | ||
307 | return playlistRequestError(request, loader.media().uri); | ||
308 | } | ||
309 | haveMetadata(request, loader.media().uri); | ||
310 | }); | ||
311 | }); | ||
312 | 398 | ||
313 | // request the specified URL | 399 | // request the specified URL |
314 | request = this.hls_.xhr({ | 400 | request = this.hls_.xhr({ |
... | @@ -316,8 +402,14 @@ export default class PlaylistLoader extends Stream { | ... | @@ -316,8 +402,14 @@ export default class PlaylistLoader extends Stream { |
316 | withCredentials | 402 | withCredentials |
317 | }, function(error, req) { | 403 | }, function(error, req) { |
318 | let parser; | 404 | let parser; |
405 | let playlist; | ||
319 | let i; | 406 | let i; |
320 | 407 | ||
408 | // disposed | ||
409 | if (!request) { | ||
410 | return; | ||
411 | } | ||
412 | |||
321 | // clear the loader's request reference | 413 | // clear the loader's request reference |
322 | request = null; | 414 | request = null; |
323 | 415 | ||
... | @@ -344,10 +436,23 @@ export default class PlaylistLoader extends Stream { | ... | @@ -344,10 +436,23 @@ export default class PlaylistLoader extends Stream { |
344 | if (parser.manifest.playlists) { | 436 | if (parser.manifest.playlists) { |
345 | loader.master = parser.manifest; | 437 | loader.master = parser.manifest; |
346 | 438 | ||
347 | // setup by-URI lookups | 439 | // setup by-URI lookups and resolve media playlist URIs |
348 | i = loader.master.playlists.length; | 440 | i = loader.master.playlists.length; |
349 | while (i--) { | 441 | while (i--) { |
350 | loader.master.playlists[loader.master.playlists[i].uri] = loader.master.playlists[i]; | 442 | playlist = loader.master.playlists[i]; |
443 | loader.master.playlists[playlist.uri] = playlist; | ||
444 | playlist.resolvedUri = resolveUrl(loader.master.uri, playlist.uri); | ||
445 | } | ||
446 | |||
447 | // resolve any media group URIs | ||
448 | for (let groupKey in loader.master.mediaGroups.AUDIO) { | ||
449 | for (let labelKey in loader.master.mediaGroups.AUDIO[groupKey]) { | ||
450 | let alternateAudio = loader.master.mediaGroups.AUDIO[groupKey][labelKey]; | ||
451 | if (alternateAudio.uri) { | ||
452 | alternateAudio.resolvedUri = | ||
453 | resolveUrl(loader.master.uri, alternateAudio.uri); | ||
454 | } | ||
455 | } | ||
351 | } | 456 | } |
352 | 457 | ||
353 | loader.trigger('loadedplaylist'); | 458 | loader.trigger('loadedplaylist'); |
... | @@ -368,200 +473,82 @@ export default class PlaylistLoader extends Stream { | ... | @@ -368,200 +473,82 @@ export default class PlaylistLoader extends Stream { |
368 | }] | 473 | }] |
369 | }; | 474 | }; |
370 | loader.master.playlists[srcUrl] = loader.master.playlists[0]; | 475 | loader.master.playlists[srcUrl] = loader.master.playlists[0]; |
476 | loader.master.playlists[0].resolvedUri = srcUrl; | ||
371 | haveMetadata(req, srcUrl); | 477 | haveMetadata(req, srcUrl); |
372 | return loader.trigger('loadedmetadata'); | 478 | return loader.trigger('loadedmetadata'); |
373 | }); | 479 | }); |
374 | } | 480 | }; |
375 | /** | 481 | }; |
376 | * Update the PlaylistLoader state to reflect the changes in an | ||
377 | * update to the current media playlist. | ||
378 | * @param update {object} the updated media playlist object | ||
379 | */ | ||
380 | updateMediaPlaylist_(update) { | ||
381 | let outdated; | ||
382 | let i; | ||
383 | let segment; | ||
384 | |||
385 | outdated = this.media_; | ||
386 | this.media_ = this.master.playlists[update.uri]; | ||
387 | |||
388 | if (!outdated) { | ||
389 | return; | ||
390 | } | ||
391 | 482 | ||
392 | // don't track expired time until this flag is truthy | 483 | PlaylistLoader.prototype = new Stream(); |
393 | if (!this.trackExpiredTime_) { | ||
394 | return; | ||
395 | } | ||
396 | 484 | ||
397 | // if the update was the result of a rendition switch do not | 485 | /** |
398 | // attempt to calculate expired_ since media-sequences need not | 486 | * Update the PlaylistLoader state to reflect the changes in an |
399 | // correlate between renditions/variants | 487 | * update to the current media playlist. |
400 | if (update.uri !== outdated.uri) { | 488 | * |
401 | return; | 489 | * @param {Object} update the updated media playlist object |
402 | } | 490 | */ |
491 | PlaylistLoader.prototype.updateMediaPlaylist_ = function(update) { | ||
492 | let outdated; | ||
493 | let i; | ||
494 | let segment; | ||
403 | 495 | ||
404 | // try using precise timing from first segment of the updated | 496 | outdated = this.media_; |
405 | // playlist | 497 | this.media_ = this.master.playlists[update.uri]; |
406 | if (update.segments.length) { | ||
407 | if (update.segments[0].start !== undefined) { | ||
408 | this.expired_ = update.segments[0].start; | ||
409 | return; | ||
410 | } else if (update.segments[0].end !== undefined) { | ||
411 | this.expired_ = update.segments[0].end - update.segments[0].duration; | ||
412 | return; | ||
413 | } | ||
414 | } | ||
415 | 498 | ||
416 | // calculate expired by walking the outdated playlist | 499 | if (!outdated) { |
417 | i = update.mediaSequence - outdated.mediaSequence - 1; | 500 | return; |
501 | } | ||
418 | 502 | ||
419 | for (; i >= 0; i--) { | 503 | // don't track expired time until this flag is truthy |
420 | segment = outdated.segments[i]; | 504 | if (!this.trackExpiredTime_) { |
505 | return; | ||
506 | } | ||
421 | 507 | ||
422 | if (!segment) { | 508 | // if the update was the result of a rendition switch do not |
423 | // we missed information on this segment completely between | 509 | // attempt to calculate expired_ since media-sequences need not |
424 | // playlist updates so we'll have to take an educated guess | 510 | // correlate between renditions/variants |
425 | // once we begin buffering again, any error we introduce can | 511 | if (update.uri !== outdated.uri) { |
426 | // be corrected | 512 | return; |
427 | this.expired_ += outdated.targetDuration || 10; | 513 | } |
428 | continue; | ||
429 | } | ||
430 | 514 | ||
431 | if (segment.end !== undefined) { | 515 | // try using precise timing from first segment of the updated |
432 | this.expired_ = segment.end; | 516 | // playlist |
433 | return; | 517 | if (update.segments.length) { |
434 | } | 518 | if (typeof update.segments[0].start !== 'undefined') { |
435 | if (segment.start !== undefined) { | 519 | this.expired_ = update.segments[0].start; |
436 | this.expired_ = segment.start + segment.duration; | 520 | return; |
437 | return; | 521 | } else if (typeof update.segments[0].end !== 'undefined') { |
438 | } | 522 | this.expired_ = update.segments[0].end - update.segments[0].duration; |
439 | this.expired_ += segment.duration; | 523 | return; |
440 | } | 524 | } |
441 | } | 525 | } |
442 | 526 | ||
443 | /** | 527 | // calculate expired by walking the outdated playlist |
444 | * Determine the index of the segment that contains a specified | 528 | i = update.mediaSequence - outdated.mediaSequence - 1; |
445 | * playback position in the current media playlist. Early versions | ||
446 | * of the HLS specification require segment durations to be rounded | ||
447 | * to the nearest integer which means it may not be possible to | ||
448 | * determine the correct segment for a playback position if that | ||
449 | * position is within .5 seconds of the segment duration. This | ||
450 | * function will always return the lower of the two possible indices | ||
451 | * in those cases. | ||
452 | * | ||
453 | * @param time {number} The number of seconds since the earliest | ||
454 | * possible position to determine the containing segment for | ||
455 | * @returns {number} The number of the media segment that contains | ||
456 | * that time position. If the specified playback position is outside | ||
457 | * the time range of the current set of media segments, the return | ||
458 | * value will be clamped to the index of the segment containing the | ||
459 | * closest playback position that is currently available. | ||
460 | */ | ||
461 | getMediaIndexForTime_(time) { | ||
462 | let i; | ||
463 | let segment; | ||
464 | let originalTime = time; | ||
465 | let numSegments = this.media_.segments.length; | ||
466 | let lastSegment = numSegments - 1; | ||
467 | let startIndex; | ||
468 | let endIndex; | ||
469 | let knownStart; | ||
470 | let knownEnd; | ||
471 | |||
472 | if (!this.media_) { | ||
473 | return 0; | ||
474 | } | ||
475 | 529 | ||
476 | // when the requested position is earlier than the current set of | 530 | for (; i >= 0; i--) { |
477 | // segments, return the earliest segment index | 531 | segment = outdated.segments[i]; |
478 | if (time < 0) { | ||
479 | return 0; | ||
480 | } | ||
481 | 532 | ||
482 | // find segments with known timing information that bound the | 533 | if (!segment) { |
483 | // target time | 534 | // we missed information on this segment completely between |
484 | for (i = 0; i < numSegments; i++) { | 535 | // playlist updates so we'll have to take an educated guess |
485 | segment = this.media_.segments[i]; | 536 | // once we begin buffering again, any error we introduce can |
486 | if (segment.end) { | 537 | // be corrected |
487 | if (segment.end > time) { | 538 | this.expired_ += outdated.targetDuration || 10; |
488 | knownEnd = segment.end; | 539 | continue; |
489 | endIndex = i; | ||
490 | break; | ||
491 | } else { | ||
492 | knownStart = segment.end; | ||
493 | startIndex = i + 1; | ||
494 | } | ||
495 | } | ||
496 | } | 540 | } |
497 | 541 | ||
498 | // use the bounds we just found and playlist information to | 542 | if (typeof segment.end !== 'undefined') { |
499 | // estimate the segment that contains the time we are looking for | 543 | this.expired_ = segment.end; |
500 | if (startIndex !== undefined) { | 544 | return; |
501 | // We have a known-start point that is before our desired time so | 545 | } |
502 | // walk from that point forwards | 546 | if (typeof segment.start !== 'undefined') { |
503 | time = time - knownStart; | 547 | this.expired_ = segment.start + segment.duration; |
504 | for (i = startIndex; i < (endIndex || numSegments); i++) { | 548 | return; |
505 | segment = this.media_.segments[i]; | ||
506 | time -= segment.duration; | ||
507 | |||
508 | if (time < 0) { | ||
509 | return i; | ||
510 | } | ||
511 | } | ||
512 | |||
513 | if (i >= endIndex) { | ||
514 | // We haven't found a segment but we did hit a known end point | ||
515 | // so fallback to interpolating between the segment index | ||
516 | // based on the known span of the timeline we are dealing with | ||
517 | // and the number of segments inside that span | ||
518 | return startIndex + Math.floor( | ||
519 | ((originalTime - knownStart) / (knownEnd - knownStart)) * | ||
520 | (endIndex - startIndex)); | ||
521 | } | ||
522 | |||
523 | // We _still_ haven't found a segment so load the last one | ||
524 | return lastSegment; | ||
525 | } else if (endIndex !== undefined) { | ||
526 | // We _only_ have a known-end point that is after our desired time so | ||
527 | // walk from that point backwards | ||
528 | time = knownEnd - time; | ||
529 | for (i = endIndex; i >= 0; i--) { | ||
530 | segment = this.media_.segments[i]; | ||
531 | time -= segment.duration; | ||
532 | |||
533 | if (time < 0) { | ||
534 | return i; | ||
535 | } | ||
536 | } | ||
537 | |||
538 | // We haven't found a segment so load the first one if time is zero | ||
539 | if (time === 0) { | ||
540 | return 0; | ||
541 | } else { | ||
542 | return -1; | ||
543 | } | ||
544 | } else { | ||
545 | // We known nothing so walk from the front of the playlist, | ||
546 | // subtracting durations until we find a segment that contains | ||
547 | // time and return it | ||
548 | time = time - this.expired_; | ||
549 | |||
550 | if (time < 0) { | ||
551 | return -1; | ||
552 | } | ||
553 | |||
554 | for (i = 0; i < numSegments; i++) { | ||
555 | segment = this.media_.segments[i]; | ||
556 | time -= segment.duration; | ||
557 | if (time < 0) { | ||
558 | return i; | ||
559 | } | ||
560 | } | ||
561 | // We are out of possible candidates so load the last one... | ||
562 | // The last one is the least likely to overlap a buffer and therefore | ||
563 | // the one most likely to tell us something about the timeline | ||
564 | return lastSegment; | ||
565 | } | 549 | } |
550 | this.expired_ += segment.duration; | ||
566 | } | 551 | } |
567 | } | 552 | }; |
553 | |||
554 | export default PlaylistLoader; | ... | ... |
1 | /** | 1 | /** |
2 | * @file playlist.js | ||
3 | * | ||
2 | * Playlist related utilities. | 4 | * Playlist related utilities. |
3 | */ | 5 | */ |
4 | import {createTimeRange} from 'video.js'; | 6 | import {createTimeRange} from 'video.js'; |
... | @@ -13,6 +15,14 @@ let Playlist = { | ... | @@ -13,6 +15,14 @@ let Playlist = { |
13 | UNSAFE_LIVE_SEGMENTS: 3 | 15 | UNSAFE_LIVE_SEGMENTS: 3 |
14 | }; | 16 | }; |
15 | 17 | ||
18 | /** | ||
19 | * walk backward until we find a duration we can use | ||
20 | * or return a failure | ||
21 | * | ||
22 | * @param {Playlist} playlist the playlist to walk through | ||
23 | * @param {Number} endSequence the mediaSequence to stop walking on | ||
24 | */ | ||
25 | |||
16 | const backwardDuration = function(playlist, endSequence) { | 26 | const backwardDuration = function(playlist, endSequence) { |
17 | let result = 0; | 27 | let result = 0; |
18 | let i = endSequence - playlist.mediaSequence; | 28 | let i = endSequence - playlist.mediaSequence; |
... | @@ -48,6 +58,13 @@ const backwardDuration = function(playlist, endSequence) { | ... | @@ -48,6 +58,13 @@ const backwardDuration = function(playlist, endSequence) { |
48 | return { result, precise: false }; | 58 | return { result, precise: false }; |
49 | }; | 59 | }; |
50 | 60 | ||
61 | /** | ||
62 | * walk forward until we find a duration we can use | ||
63 | * or return a failure | ||
64 | * | ||
65 | * @param {Playlist} playlist the playlist to walk through | ||
66 | * @param {Number} endSequence the mediaSequence to stop walking on | ||
67 | */ | ||
51 | const forwardDuration = function(playlist, endSequence) { | 68 | const forwardDuration = function(playlist, endSequence) { |
52 | let result = 0; | 69 | let result = 0; |
53 | let segment; | 70 | let segment; |
... | @@ -83,13 +100,15 @@ const forwardDuration = function(playlist, endSequence) { | ... | @@ -83,13 +100,15 @@ const forwardDuration = function(playlist, endSequence) { |
83 | * playlist. The duration of a subinterval of the available segments | 100 | * playlist. The duration of a subinterval of the available segments |
84 | * may be calculated by specifying an end index. | 101 | * may be calculated by specifying an end index. |
85 | * | 102 | * |
86 | * @param playlist {object} a media playlist object | 103 | * @param {Object} playlist a media playlist object |
87 | * @param endSequence {number} (optional) an exclusive upper boundary | 104 | * @param {Number=} endSequence an exclusive upper boundary |
88 | * for the playlist. Defaults to playlist length. | 105 | * for the playlist. Defaults to playlist length. |
89 | * @return {number} the duration between the first available segment | 106 | * @param {Number} expired the amount of time that has dropped |
107 | * off the front of the playlist in a live scenario | ||
108 | * @return {Number} the duration between the first available segment | ||
90 | * and end index. | 109 | * and end index. |
91 | */ | 110 | */ |
92 | const intervalDuration = function(playlist, endSequence) { | 111 | const intervalDuration = function(playlist, endSequence, expired) { |
93 | let backward; | 112 | let backward; |
94 | let forward; | 113 | let forward; |
95 | 114 | ||
... | @@ -120,7 +139,7 @@ const intervalDuration = function(playlist, endSequence) { | ... | @@ -120,7 +139,7 @@ const intervalDuration = function(playlist, endSequence) { |
120 | } | 139 | } |
121 | 140 | ||
122 | // return the less-precise, playlist-based duration estimate | 141 | // return the less-precise, playlist-based duration estimate |
123 | return backward.result; | 142 | return backward.result + expired; |
124 | }; | 143 | }; |
125 | 144 | ||
126 | /** | 145 | /** |
... | @@ -128,23 +147,23 @@ const intervalDuration = function(playlist, endSequence) { | ... | @@ -128,23 +147,23 @@ const intervalDuration = function(playlist, endSequence) { |
128 | * are specified, the duration will be for the subset of the media | 147 | * are specified, the duration will be for the subset of the media |
129 | * timeline between those two indices. The total duration for live | 148 | * timeline between those two indices. The total duration for live |
130 | * playlists is always Infinity. | 149 | * playlists is always Infinity. |
131 | * @param playlist {object} a media playlist object | 150 | * |
132 | * @param endSequence {number} (optional) an exclusive upper | 151 | * @param {Object} playlist a media playlist object |
133 | * boundary for the playlist. Defaults to the playlist media | 152 | * @param {Number=} endSequence an exclusive upper |
153 | * boundary for the playlist. Defaults to the playlist media | ||
134 | * sequence number plus its length. | 154 | * sequence number plus its length. |
135 | * @param includeTrailingTime {boolean} (optional) if false, the | 155 | * @param {Number=} expired the amount of time that has |
136 | * interval between the final segment and the subsequent segment | 156 | * dropped off the front of the playlist in a live scenario |
137 | * will not be included in the result | 157 | * @return {Number} the duration between the start index and end |
138 | * @return {number} the duration between the start index and end | ||
139 | * index. | 158 | * index. |
140 | */ | 159 | */ |
141 | export const duration = function(playlist, endSequence, includeTrailingTime) { | 160 | export const duration = function(playlist, endSequence, expired) { |
142 | if (!playlist) { | 161 | if (!playlist) { |
143 | return 0; | 162 | return 0; |
144 | } | 163 | } |
145 | 164 | ||
146 | if (typeof includeTrailingTime === 'undefined') { | 165 | if (typeof expired !== 'number') { |
147 | includeTrailingTime = true; | 166 | expired = 0; |
148 | } | 167 | } |
149 | 168 | ||
150 | // if a slice of the total duration is not requested, use | 169 | // if a slice of the total duration is not requested, use |
... | @@ -164,7 +183,7 @@ export const duration = function(playlist, endSequence, includeTrailingTime) { | ... | @@ -164,7 +183,7 @@ export const duration = function(playlist, endSequence, includeTrailingTime) { |
164 | // calculate the total duration based on the segment durations | 183 | // calculate the total duration based on the segment durations |
165 | return intervalDuration(playlist, | 184 | return intervalDuration(playlist, |
166 | endSequence, | 185 | endSequence, |
167 | includeTrailingTime); | 186 | expired); |
168 | }; | 187 | }; |
169 | 188 | ||
170 | /** | 189 | /** |
... | @@ -174,16 +193,24 @@ export const duration = function(playlist, endSequence, includeTrailingTime) { | ... | @@ -174,16 +193,24 @@ export const duration = function(playlist, endSequence, includeTrailingTime) { |
174 | * seekable implementation for live streams would need to offset | 193 | * seekable implementation for live streams would need to offset |
175 | * these values by the duration of content that has expired from the | 194 | * these values by the duration of content that has expired from the |
176 | * stream. | 195 | * stream. |
177 | * @param playlist {object} a media playlist object | 196 | * |
197 | * @param {Object} playlist a media playlist object | ||
198 | * @param {Number=} expired the amount of time that has | ||
199 | * dropped off the front of the playlist in a live scenario | ||
178 | * @return {TimeRanges} the periods of time that are valid targets | 200 | * @return {TimeRanges} the periods of time that are valid targets |
179 | * for seeking | 201 | * for seeking |
180 | */ | 202 | */ |
181 | export const seekable = function(playlist) { | 203 | export const seekable = function(playlist, expired) { |
182 | let start; | 204 | let start; |
183 | let end; | 205 | let end; |
206 | let endSequence; | ||
207 | |||
208 | if (typeof expired !== 'number') { | ||
209 | expired = 0; | ||
210 | } | ||
184 | 211 | ||
185 | // without segments, there are no seekable ranges | 212 | // without segments, there are no seekable ranges |
186 | if (!playlist.segments) { | 213 | if (!playlist || !playlist.segments) { |
187 | return createTimeRange(); | 214 | return createTimeRange(); |
188 | } | 215 | } |
189 | // when the playlist is complete, the entire duration is seekable | 216 | // when the playlist is complete, the entire duration is seekable |
... | @@ -194,15 +221,142 @@ export const seekable = function(playlist) { | ... | @@ -194,15 +221,142 @@ export const seekable = function(playlist) { |
194 | // live playlists should not expose three segment durations worth | 221 | // live playlists should not expose three segment durations worth |
195 | // of content from the end of the playlist | 222 | // of content from the end of the playlist |
196 | // https://tools.ietf.org/html/draft-pantos-http-live-streaming-16#section-6.3.3 | 223 | // https://tools.ietf.org/html/draft-pantos-http-live-streaming-16#section-6.3.3 |
197 | start = intervalDuration(playlist, playlist.mediaSequence); | 224 | start = intervalDuration(playlist, playlist.mediaSequence, expired); |
225 | endSequence = Math.max(0, playlist.segments.length - Playlist.UNSAFE_LIVE_SEGMENTS); | ||
198 | end = intervalDuration(playlist, | 226 | end = intervalDuration(playlist, |
199 | playlist.mediaSequence + | 227 | playlist.mediaSequence + endSequence, |
200 | Math.max(0, playlist.segments.length - Playlist.UNSAFE_LIVE_SEGMENTS)); | 228 | expired); |
201 | return createTimeRange(start, end); | 229 | return createTimeRange(start, end); |
202 | }; | 230 | }; |
203 | 231 | ||
232 | /** | ||
233 | * Determine the index of the segment that contains a specified | ||
234 | * playback position in a media playlist. | ||
235 | * | ||
236 | * @param {Object} playlist the media playlist to query | ||
237 | * @param {Number} time The number of seconds since the earliest | ||
238 | * possible position to determine the containing segment for | ||
239 | * @param {Number=} expired the duration of content, in | ||
240 | * seconds, that has been removed from this playlist because it | ||
241 | * expired | ||
242 | * @return {Number} The number of the media segment that contains | ||
243 | * that time position. | ||
244 | */ | ||
245 | export const getMediaIndexForTime_ = function(playlist, time, expired) { | ||
246 | let i; | ||
247 | let segment; | ||
248 | let originalTime = time; | ||
249 | let numSegments = playlist.segments.length; | ||
250 | let lastSegment = numSegments - 1; | ||
251 | let startIndex; | ||
252 | let endIndex; | ||
253 | let knownStart; | ||
254 | let knownEnd; | ||
255 | |||
256 | if (!playlist) { | ||
257 | return 0; | ||
258 | } | ||
259 | |||
260 | // when the requested position is earlier than the current set of | ||
261 | // segments, return the earliest segment index | ||
262 | if (time < 0) { | ||
263 | return 0; | ||
264 | } | ||
265 | |||
266 | expired = expired || 0; | ||
267 | |||
268 | // find segments with known timing information that bound the | ||
269 | // target time | ||
270 | for (i = 0; i < numSegments; i++) { | ||
271 | segment = playlist.segments[i]; | ||
272 | if (segment.end) { | ||
273 | if (segment.end > time) { | ||
274 | knownEnd = segment.end; | ||
275 | endIndex = i; | ||
276 | break; | ||
277 | } else { | ||
278 | knownStart = segment.end; | ||
279 | startIndex = i + 1; | ||
280 | } | ||
281 | } | ||
282 | } | ||
283 | |||
284 | // time was equal to or past the end of the last segment in the playlist | ||
285 | if (startIndex === numSegments) { | ||
286 | return numSegments; | ||
287 | } | ||
288 | |||
289 | // use the bounds we just found and playlist information to | ||
290 | // estimate the segment that contains the time we are looking for | ||
291 | if (typeof startIndex !== 'undefined') { | ||
292 | // We have a known-start point that is before our desired time so | ||
293 | // walk from that point forwards | ||
294 | time = time - knownStart; | ||
295 | for (i = startIndex; i < (endIndex || numSegments); i++) { | ||
296 | segment = playlist.segments[i]; | ||
297 | time -= segment.duration; | ||
298 | |||
299 | if (time < 0) { | ||
300 | return i; | ||
301 | } | ||
302 | } | ||
303 | |||
304 | if (i >= endIndex) { | ||
305 | // We haven't found a segment but we did hit a known end point | ||
306 | // so fallback to interpolating between the segment index | ||
307 | // based on the known span of the timeline we are dealing with | ||
308 | // and the number of segments inside that span | ||
309 | return startIndex + Math.floor( | ||
310 | ((originalTime - knownStart) / (knownEnd - knownStart)) * | ||
311 | (endIndex - startIndex)); | ||
312 | } | ||
313 | |||
314 | // We _still_ haven't found a segment so load the last one | ||
315 | return lastSegment; | ||
316 | } else if (typeof endIndex !== 'undefined') { | ||
317 | // We _only_ have a known-end point that is after our desired time so | ||
318 | // walk from that point backwards | ||
319 | time = knownEnd - time; | ||
320 | for (i = endIndex; i >= 0; i--) { | ||
321 | segment = playlist.segments[i]; | ||
322 | time -= segment.duration; | ||
323 | |||
324 | if (time < 0) { | ||
325 | return i; | ||
326 | } | ||
327 | } | ||
328 | |||
329 | // We haven't found a segment so load the first one if time is zero | ||
330 | if (time === 0) { | ||
331 | return 0; | ||
332 | } | ||
333 | return -1; | ||
334 | } | ||
335 | // We known nothing so walk from the front of the playlist, | ||
336 | // subtracting durations until we find a segment that contains | ||
337 | // time and return it | ||
338 | time = time - expired; | ||
339 | |||
340 | if (time < 0) { | ||
341 | return -1; | ||
342 | } | ||
343 | |||
344 | for (i = 0; i < numSegments; i++) { | ||
345 | segment = playlist.segments[i]; | ||
346 | time -= segment.duration; | ||
347 | if (time < 0) { | ||
348 | return i; | ||
349 | } | ||
350 | } | ||
351 | // We are out of possible candidates so load the last one... | ||
352 | // The last one is the least likely to overlap a buffer and therefore | ||
353 | // the one most likely to tell us something about the timeline | ||
354 | return lastSegment; | ||
355 | }; | ||
356 | |||
204 | Playlist.duration = duration; | 357 | Playlist.duration = duration; |
205 | Playlist.seekable = seekable; | 358 | Playlist.seekable = seekable; |
359 | Playlist.getMediaIndexForTime_ = getMediaIndexForTime_; | ||
206 | 360 | ||
207 | // exports | 361 | // exports |
208 | export default Playlist; | 362 | export default Playlist; | ... | ... |
src/ranges.js
0 → 100644
1 | /** | ||
2 | * ranges | ||
3 | * | ||
4 | * Utilities for working with TimeRanges. | ||
5 | * | ||
6 | */ | ||
7 | |||
8 | import videojs from 'video.js'; | ||
9 | |||
10 | // Fudge factor to account for TimeRanges rounding | ||
11 | const TIME_FUDGE_FACTOR = 1 / 30; | ||
12 | |||
13 | const filterRanges = function(timeRanges, predicate) { | ||
14 | let results = []; | ||
15 | let i; | ||
16 | |||
17 | if (timeRanges && timeRanges.length) { | ||
18 | // Search for ranges that match the predicate | ||
19 | for (i = 0; i < timeRanges.length; i++) { | ||
20 | if (predicate(timeRanges.start(i), timeRanges.end(i))) { | ||
21 | results.push([timeRanges.start(i), timeRanges.end(i)]); | ||
22 | } | ||
23 | } | ||
24 | } | ||
25 | |||
26 | return videojs.createTimeRanges(results); | ||
27 | }; | ||
28 | |||
29 | /** | ||
30 | * Attempts to find the buffered TimeRange that contains the specified | ||
31 | * time. | ||
32 | * @param {TimeRanges} buffered - the TimeRanges object to query | ||
33 | * @param {number} time - the time to filter on. | ||
34 | * @returns {TimeRanges} a new TimeRanges object | ||
35 | */ | ||
36 | const findRange = function(buffered, time) { | ||
37 | return filterRanges(buffered, function(start, end) { | ||
38 | return start - TIME_FUDGE_FACTOR <= time && | ||
39 | end + TIME_FUDGE_FACTOR >= time; | ||
40 | }); | ||
41 | }; | ||
42 | |||
43 | /** | ||
44 | * Returns the TimeRanges that begin at or later than the specified | ||
45 | * time. | ||
46 | * @param {TimeRanges} timeRanges - the TimeRanges object to query | ||
47 | * @param {number} time - the time to filter on. | ||
48 | * @returns {TimeRanges} a new TimeRanges object. | ||
49 | */ | ||
50 | const findNextRange = function(timeRanges, time) { | ||
51 | return filterRanges(timeRanges, function(start) { | ||
52 | return start - TIME_FUDGE_FACTOR >= time; | ||
53 | }); | ||
54 | }; | ||
55 | |||
56 | /** | ||
57 | * Search for a likely end time for the segment that was just appened | ||
58 | * based on the state of the `buffered` property before and after the | ||
59 | * append. If we fin only one such uncommon end-point return it. | ||
60 | * @param {TimeRanges} original - the buffered time ranges before the update | ||
61 | * @param {TimeRanges} update - the buffered time ranges after the update | ||
62 | * @returns {Number|null} the end time added between `original` and `update`, | ||
63 | * or null if one cannot be unambiguously determined. | ||
64 | */ | ||
65 | const findSoleUncommonTimeRangesEnd = function(original, update) { | ||
66 | let i; | ||
67 | let start; | ||
68 | let end; | ||
69 | let result = []; | ||
70 | let edges = []; | ||
71 | |||
72 | // In order to qualify as a possible candidate, the end point must: | ||
73 | // 1) Not have already existed in the `original` ranges | ||
74 | // 2) Not result from the shrinking of a range that already existed | ||
75 | // in the `original` ranges | ||
76 | // 3) Not be contained inside of a range that existed in `original` | ||
77 | const overlapsCurrentEnd = function(span) { | ||
78 | return (span[0] <= end && span[1] >= end); | ||
79 | }; | ||
80 | |||
81 | if (original) { | ||
82 | // Save all the edges in the `original` TimeRanges object | ||
83 | for (i = 0; i < original.length; i++) { | ||
84 | start = original.start(i); | ||
85 | end = original.end(i); | ||
86 | |||
87 | edges.push([start, end]); | ||
88 | } | ||
89 | } | ||
90 | |||
91 | if (update) { | ||
92 | // Save any end-points in `update` that are not in the `original` | ||
93 | // TimeRanges object | ||
94 | for (i = 0; i < update.length; i++) { | ||
95 | start = update.start(i); | ||
96 | end = update.end(i); | ||
97 | |||
98 | if (edges.some(overlapsCurrentEnd)) { | ||
99 | continue; | ||
100 | } | ||
101 | |||
102 | // at this point it must be a unique non-shrinking end edge | ||
103 | result.push(end); | ||
104 | } | ||
105 | } | ||
106 | |||
107 | // we err on the side of caution and return null if didn't find | ||
108 | // exactly *one* differing end edge in the search above | ||
109 | if (result.length !== 1) { | ||
110 | return null; | ||
111 | } | ||
112 | |||
113 | return result[0]; | ||
114 | }; | ||
115 | |||
116 | /** | ||
117 | * Calculate the intersection of two TimeRanges | ||
118 | * @param {TimeRanges} bufferA | ||
119 | * @param {TimeRanges} bufferB | ||
120 | * @returns {TimeRanges} The interesection of `bufferA` with `bufferB` | ||
121 | */ | ||
122 | const bufferIntersection = function(bufferA, bufferB) { | ||
123 | let start = null; | ||
124 | let end = null; | ||
125 | let arity = 0; | ||
126 | let extents = []; | ||
127 | let ranges = []; | ||
128 | |||
129 | if (!bufferA || !bufferA.length || !bufferB || !bufferB.length) { | ||
130 | return videojs.createTimeRange(); | ||
131 | } | ||
132 | |||
133 | // Handle the case where we have both buffers and create an | ||
134 | // intersection of the two | ||
135 | let count = bufferA.length; | ||
136 | |||
137 | // A) Gather up all start and end times | ||
138 | while (count--) { | ||
139 | extents.push({time: bufferA.start(count), type: 'start'}); | ||
140 | extents.push({time: bufferA.end(count), type: 'end'}); | ||
141 | } | ||
142 | count = bufferB.length; | ||
143 | while (count--) { | ||
144 | extents.push({time: bufferB.start(count), type: 'start'}); | ||
145 | extents.push({time: bufferB.end(count), type: 'end'}); | ||
146 | } | ||
147 | // B) Sort them by time | ||
148 | extents.sort(function(a, b) { | ||
149 | return a.time - b.time; | ||
150 | }); | ||
151 | |||
152 | // C) Go along one by one incrementing arity for start and decrementing | ||
153 | // arity for ends | ||
154 | for (count = 0; count < extents.length; count++) { | ||
155 | if (extents[count].type === 'start') { | ||
156 | arity++; | ||
157 | |||
158 | // D) If arity is ever incremented to 2 we are entering an | ||
159 | // overlapping range | ||
160 | if (arity === 2) { | ||
161 | start = extents[count].time; | ||
162 | } | ||
163 | } else if (extents[count].type === 'end') { | ||
164 | arity--; | ||
165 | |||
166 | // E) If arity is ever decremented to 1 we leaving an | ||
167 | // overlapping range | ||
168 | if (arity === 1) { | ||
169 | end = extents[count].time; | ||
170 | } | ||
171 | } | ||
172 | |||
173 | // F) Record overlapping ranges | ||
174 | if (start !== null && end !== null) { | ||
175 | ranges.push([start, end]); | ||
176 | start = null; | ||
177 | end = null; | ||
178 | } | ||
179 | } | ||
180 | |||
181 | return videojs.createTimeRanges(ranges); | ||
182 | }; | ||
183 | |||
184 | /** | ||
185 | * Calculates the percentage of `segmentRange` that overlaps the | ||
186 | * `buffered` time ranges. | ||
187 | * @param {TimeRanges} segmentRange - the time range that the segment covers | ||
188 | * @param {TimeRanges} buffered - the currently buffered time ranges | ||
189 | * @returns {Number} percent of the segment currently buffered | ||
190 | */ | ||
191 | const calculateBufferedPercent = function(segmentRange, buffered) { | ||
192 | let segmentDuration = segmentRange.end(0) - segmentRange.start(0); | ||
193 | let intersection = bufferIntersection(segmentRange, buffered); | ||
194 | let overlapDuration = 0; | ||
195 | let count = intersection.length; | ||
196 | |||
197 | while (count--) { | ||
198 | overlapDuration += intersection.end(count) - intersection.start(count); | ||
199 | } | ||
200 | |||
201 | return (overlapDuration / segmentDuration) * 100; | ||
202 | }; | ||
203 | |||
204 | export default { | ||
205 | findRange, | ||
206 | findNextRange, | ||
207 | findSoleUncommonTimeRangesEnd, | ||
208 | calculateBufferedPercent, | ||
209 | TIME_FUDGE_FACTOR | ||
210 | }; |
1 | /** | ||
2 | * @file resolve-url.js | ||
3 | */ | ||
1 | import document from 'global/document'; | 4 | import document from 'global/document'; |
2 | /* eslint-disable max-len */ | ||
3 | /** | 5 | /** |
4 | * Constructs a new URI by interpreting a path relative to another | 6 | * Constructs a new URI by interpreting a path relative to another |
5 | * URI. | 7 | * URI. |
6 | * @param basePath {string} a relative or absolute URI | 8 | * |
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 | 9 | * @see http://stackoverflow.com/questions/470832/getting-an-absolute-url-from-a-relative-one-ie6-issue |
10 | * @param {String} basePath a relative or absolute URI | ||
11 | * @param {String} path a path part to combine with the base | ||
12 | * @return {String} a URI that is equivalent to composing `base` | ||
13 | * with `path` | ||
11 | */ | 14 | */ |
12 | /* eslint-enable max-len */ | ||
13 | const resolveUrl = function(basePath, path) { | 15 | const resolveUrl = function(basePath, path) { |
14 | // use the base element to get the browser to handle URI resolution | 16 | // use the base element to get the browser to handle URI resolution |
15 | let oldBase = document.querySelector('base'); | 17 | let oldBase = document.querySelector('base'); | ... | ... |
src/segment-loader.js
0 → 100644
1 | /** | ||
2 | * @file segment-loader.js | ||
3 | */ | ||
4 | import Ranges from './ranges'; | ||
5 | import {getMediaIndexForTime_ as getMediaIndexForTime, duration} from './playlist'; | ||
6 | import videojs from 'video.js'; | ||
7 | import SourceUpdater from './source-updater'; | ||
8 | import {Decrypter} from './decrypter'; | ||
9 | |||
10 | // in ms | ||
11 | const CHECK_BUFFER_DELAY = 500; | ||
12 | |||
13 | // the desired length of video to maintain in the buffer, in seconds | ||
14 | export const GOAL_BUFFER_LENGTH = 30; | ||
15 | |||
16 | /** | ||
17 | * Updates segment with information about its end-point in time and, optionally, | ||
18 | * the segment duration if we have enough information to determine a segment duration | ||
19 | * accurately. | ||
20 | * | ||
21 | * @param {Object} playlist a media playlist object | ||
22 | * @param {Number} segmentIndex the index of segment we last appended | ||
23 | * @param {Number} segmentEnd the known of the segment referenced by segmentIndex | ||
24 | */ | ||
25 | const updateSegmentMetadata = function(playlist, segmentIndex, segmentEnd) { | ||
26 | if (!playlist) { | ||
27 | return false; | ||
28 | } | ||
29 | |||
30 | let segment = playlist.segments[segmentIndex]; | ||
31 | let previousSegment = playlist.segments[segmentIndex - 1]; | ||
32 | |||
33 | if (segmentEnd && segment) { | ||
34 | segment.end = segmentEnd; | ||
35 | |||
36 | // fix up segment durations based on segment end data | ||
37 | if (!previousSegment) { | ||
38 | // first segment is always has a start time of 0 making its duration | ||
39 | // equal to the segment end | ||
40 | segment.duration = segment.end; | ||
41 | } else if (previousSegment.end) { | ||
42 | segment.duration = segment.end - previousSegment.end; | ||
43 | } | ||
44 | return true; | ||
45 | } | ||
46 | return false; | ||
47 | }; | ||
48 | |||
49 | /** | ||
50 | * Determines if we should call endOfStream on the media source based | ||
51 | * on the state of the buffer or if appened segment was the final | ||
52 | * segment in the playlist. | ||
53 | * | ||
54 | * @param {Object} playlist a media playlist object | ||
55 | * @param {Object} mediaSource the MediaSource object | ||
56 | * @param {Number} segmentIndex the index of segment we last appended | ||
57 | * @param {Object} currentBuffered buffered region that currentTime resides in | ||
58 | * @returns {Boolean} do we need to call endOfStream on the MediaSource | ||
59 | */ | ||
60 | const detectEndOfStream = function(playlist, mediaSource, segmentIndex, currentBuffered) { | ||
61 | if (!playlist) { | ||
62 | return false; | ||
63 | } | ||
64 | |||
65 | let segments = playlist.segments; | ||
66 | |||
67 | // determine a few boolean values to help make the branch below easier | ||
68 | // to read | ||
69 | let appendedLastSegment = (segmentIndex === segments.length - 1); | ||
70 | let bufferedToEnd = (currentBuffered.length && | ||
71 | segments[segments.length - 1].end <= currentBuffered.end(0)); | ||
72 | |||
73 | // if we've buffered to the end of the video, we need to call endOfStream | ||
74 | // so that MediaSources can trigger the `ended` event when it runs out of | ||
75 | // buffered data instead of waiting for me | ||
76 | return playlist.endList && | ||
77 | mediaSource.readyState === 'open' && | ||
78 | (appendedLastSegment || bufferedToEnd); | ||
79 | }; | ||
80 | |||
81 | /* Turns segment byterange into a string suitable for use in | ||
82 | * HTTP Range requests | ||
83 | */ | ||
84 | const byterangeStr = function(byterange) { | ||
85 | let byterangeStart; | ||
86 | let byterangeEnd; | ||
87 | |||
88 | // `byterangeEnd` is one less than `offset + length` because the HTTP range | ||
89 | // header uses inclusive ranges | ||
90 | byterangeEnd = byterange.offset + byterange.length - 1; | ||
91 | byterangeStart = byterange.offset; | ||
92 | return 'bytes=' + byterangeStart + '-' + byterangeEnd; | ||
93 | }; | ||
94 | |||
95 | /* Defines headers for use in the xhr request for a particular segment. | ||
96 | */ | ||
97 | const segmentXhrHeaders = function(segment) { | ||
98 | let headers = {}; | ||
99 | |||
100 | if ('byterange' in segment) { | ||
101 | headers.Range = byterangeStr(segment.byterange); | ||
102 | } | ||
103 | return headers; | ||
104 | }; | ||
105 | |||
106 | /** | ||
107 | * An object that manages segment loading and appending. | ||
108 | * | ||
109 | * @class SegmentLoader | ||
110 | * @param {Object} options required and optional options | ||
111 | * @extends videojs.EventTarget | ||
112 | */ | ||
113 | export default class SegmentLoader extends videojs.EventTarget { | ||
114 | constructor(options) { | ||
115 | super(); | ||
116 | let settings; | ||
117 | |||
118 | // check pre-conditions | ||
119 | if (!options) { | ||
120 | throw new TypeError('Initialization options are required'); | ||
121 | } | ||
122 | if (typeof options.currentTime !== 'function') { | ||
123 | throw new TypeError('No currentTime getter specified'); | ||
124 | } | ||
125 | if (!options.mediaSource) { | ||
126 | throw new TypeError('No MediaSource specified'); | ||
127 | } | ||
128 | settings = videojs.mergeOptions(videojs.options.hls, options); | ||
129 | |||
130 | // public properties | ||
131 | this.state = 'INIT'; | ||
132 | this.bandwidth = settings.bandwidth; | ||
133 | this.roundTrip = NaN; | ||
134 | this.bytesReceived = 0; | ||
135 | |||
136 | // private properties | ||
137 | this.hasPlayed_ = settings.hasPlayed; | ||
138 | this.currentTime_ = settings.currentTime; | ||
139 | this.seekable_ = settings.seekable; | ||
140 | this.seeking_ = settings.seeking; | ||
141 | this.setCurrentTime_ = settings.setCurrentTime; | ||
142 | this.mediaSource_ = settings.mediaSource; | ||
143 | this.withCredentials_ = settings.withCredentials; | ||
144 | this.checkBufferTimeout_ = null; | ||
145 | this.error_ = void 0; | ||
146 | this.expired_ = 0; | ||
147 | this.timeCorrection_ = 0; | ||
148 | this.currentTimeline_ = -1; | ||
149 | this.xhr_ = null; | ||
150 | this.pendingSegment_ = null; | ||
151 | this.sourceUpdater_ = null; | ||
152 | this.hls_ = settings.hls; | ||
153 | } | ||
154 | |||
155 | /** | ||
156 | * dispose of the SegmentLoader and reset to the default state | ||
157 | */ | ||
158 | dispose() { | ||
159 | this.state = 'DISPOSED'; | ||
160 | this.abort_(); | ||
161 | if (this.sourceUpdater_) { | ||
162 | this.sourceUpdater_.dispose(); | ||
163 | } | ||
164 | } | ||
165 | |||
166 | /** | ||
167 | * abort anything that is currently doing on with the SegmentLoader | ||
168 | * and reset to a default state | ||
169 | */ | ||
170 | abort() { | ||
171 | if (this.state !== 'WAITING') { | ||
172 | return; | ||
173 | } | ||
174 | |||
175 | this.abort_(); | ||
176 | |||
177 | // don't wait for buffer check timeouts to begin fetching the | ||
178 | // next segment | ||
179 | if (!this.paused()) { | ||
180 | this.state = 'READY'; | ||
181 | this.fillBuffer_(); | ||
182 | } | ||
183 | } | ||
184 | |||
185 | /** | ||
186 | * set an error on the segment loader and null out any pending segements | ||
187 | * | ||
188 | * @param {Error} error the error to set on the SegmentLoader | ||
189 | * @return {Error} the error that was set or that is currently set | ||
190 | */ | ||
191 | error(error) { | ||
192 | if (typeof error !== 'undefined') { | ||
193 | this.error_ = error; | ||
194 | } | ||
195 | |||
196 | this.pendingSegment_ = null; | ||
197 | return this.error_; | ||
198 | } | ||
199 | |||
200 | /** | ||
201 | * load a playlist and start to fill the buffer | ||
202 | */ | ||
203 | load() { | ||
204 | this.monitorBuffer_(); | ||
205 | |||
206 | // if we don't have a playlist yet, keep waiting for one to be | ||
207 | // specified | ||
208 | if (!this.playlist_) { | ||
209 | return; | ||
210 | } | ||
211 | |||
212 | // if we're in the middle of processing a segment already, don't | ||
213 | // kick off an additional segment request | ||
214 | if (!this.sourceUpdater_ || | ||
215 | (this.state !== 'READY' && | ||
216 | this.state !== 'INIT')) { | ||
217 | return; | ||
218 | } | ||
219 | |||
220 | this.state = 'READY'; | ||
221 | this.fillBuffer_(); | ||
222 | } | ||
223 | |||
224 | /** | ||
225 | * set a playlist on the segment loader | ||
226 | * | ||
227 | * @param {PlaylistLoader} media the playlist to set on the segment loader | ||
228 | */ | ||
229 | playlist(media) { | ||
230 | this.playlist_ = media; | ||
231 | // if we were unpaused but waiting for a playlist, start | ||
232 | // buffering now | ||
233 | if (this.sourceUpdater_ && | ||
234 | media && | ||
235 | this.state === 'INIT' && | ||
236 | !this.paused()) { | ||
237 | this.state = 'READY'; | ||
238 | return this.fillBuffer_(); | ||
239 | } | ||
240 | } | ||
241 | |||
242 | /** | ||
243 | * Prevent the loader from fetching additional segments. If there | ||
244 | * is a segment request outstanding, it will finish processing | ||
245 | * before the loader halts. A segment loader can be unpaused by | ||
246 | * calling load(). | ||
247 | */ | ||
248 | pause() { | ||
249 | if (this.checkBufferTimeout_) { | ||
250 | window.clearTimeout(this.checkBufferTimeout_); | ||
251 | |||
252 | this.checkBufferTimeout_ = null; | ||
253 | } | ||
254 | } | ||
255 | |||
256 | /** | ||
257 | * Returns whether the segment loader is fetching additional | ||
258 | * segments when given the opportunity. This property can be | ||
259 | * modified through calls to pause() and load(). | ||
260 | */ | ||
261 | paused() { | ||
262 | return this.checkBufferTimeout_ === null; | ||
263 | } | ||
264 | |||
265 | /** | ||
266 | * setter for expired time on the SegmentLoader | ||
267 | * | ||
268 | * @param {Number} expired the exired time to set | ||
269 | */ | ||
270 | expired(expired) { | ||
271 | this.expired_ = expired; | ||
272 | } | ||
273 | |||
274 | /** | ||
275 | * create/set the following mimetype on the SourceBuffer through a | ||
276 | * SourceUpdater | ||
277 | * | ||
278 | * @param {String} mimeType the mime type string to use | ||
279 | */ | ||
280 | mimeType(mimeType) { | ||
281 | // TODO Allow source buffers to be re-created with different mime-types | ||
282 | if (!this.sourceUpdater_) { | ||
283 | this.sourceUpdater_ = new SourceUpdater(this.mediaSource_, mimeType); | ||
284 | this.clearBuffer(); | ||
285 | |||
286 | // if we were unpaused but waiting for a sourceUpdater, start | ||
287 | // buffering now | ||
288 | if (this.playlist_ && | ||
289 | this.state === 'INIT' && | ||
290 | !this.paused()) { | ||
291 | this.state = 'READY'; | ||
292 | return this.fillBuffer_(); | ||
293 | } | ||
294 | } | ||
295 | } | ||
296 | |||
297 | /** | ||
298 | * asynchronously/recursively monitor the buffer | ||
299 | * | ||
300 | * @private | ||
301 | */ | ||
302 | monitorBuffer_() { | ||
303 | if (this.state === 'READY') { | ||
304 | this.fillBuffer_(); | ||
305 | } | ||
306 | this.checkBufferTimeout_ = window.setTimeout(this.monitorBuffer_.bind(this), | ||
307 | CHECK_BUFFER_DELAY); | ||
308 | } | ||
309 | |||
310 | /** | ||
311 | * Return the amount of a segment specified by the mediaIndex overlaps | ||
312 | * the current buffered content. | ||
313 | * | ||
314 | * @param {Object} playlist the playlist object to fetch segments from | ||
315 | * @param {Number} mediaIndex the index of the segment in the playlist | ||
316 | * @param {TimeRanges} buffered the state of the buffer | ||
317 | * @returns {Number} percentage of the segment's time range that is | ||
318 | * already in `buffered` | ||
319 | */ | ||
320 | getSegmentBufferedPercent_(playlist, mediaIndex, currentTime, buffered) { | ||
321 | let segment = playlist.segments[mediaIndex]; | ||
322 | let startOfSegment = duration(playlist, | ||
323 | playlist.mediaSequence + mediaIndex, | ||
324 | this.expired_); | ||
325 | let segmentRange = videojs.createTimeRanges([[ | ||
326 | Math.max(currentTime, startOfSegment), | ||
327 | startOfSegment + segment.duration | ||
328 | ]]); | ||
329 | |||
330 | return Ranges.calculateBufferedPercent(segmentRange, buffered); | ||
331 | } | ||
332 | |||
333 | /** | ||
334 | * Determines what segment request should be made, given current | ||
335 | * playback state. | ||
336 | * | ||
337 | * @param {TimeRanges} buffered - the state of the buffer | ||
338 | * @param {Object} playlist - the playlist object to fetch segments from | ||
339 | * @param {Number} currentTime - the playback position in seconds | ||
340 | * @returns {Object} a segment info object that describes the | ||
341 | * request that should be made or null if no request is necessary | ||
342 | */ | ||
343 | checkBuffer_(buffered, playlist, currentTime) { | ||
344 | let currentBuffered = Ranges.findRange(buffered, currentTime); | ||
345 | |||
346 | // There are times when MSE reports the first segment as starting a | ||
347 | // little after 0-time so add a fudge factor to try and fix those cases | ||
348 | // or we end up fetching the same first segment over and over | ||
349 | if (currentBuffered.length === 0 && currentTime === 0) { | ||
350 | currentBuffered = Ranges.findRange(buffered, | ||
351 | currentTime + Ranges.TIME_FUDGE_FACTOR); | ||
352 | } | ||
353 | |||
354 | let bufferedTime; | ||
355 | let currentBufferedEnd; | ||
356 | let timestampOffset = this.sourceUpdater_.timestampOffset(); | ||
357 | let segment; | ||
358 | let mediaIndex; | ||
359 | |||
360 | if (!playlist.segments.length) { | ||
361 | return; | ||
362 | } | ||
363 | |||
364 | if (currentBuffered.length === 0) { | ||
365 | // find the segment containing currentTime | ||
366 | mediaIndex = getMediaIndexForTime(playlist, | ||
367 | currentTime, | ||
368 | this.expired_ + this.timeCorrection_); | ||
369 | } else { | ||
370 | // find the segment adjacent to the end of the current | ||
371 | // buffered region | ||
372 | currentBufferedEnd = currentBuffered.end(0); | ||
373 | bufferedTime = Math.max(0, currentBufferedEnd - currentTime); | ||
374 | |||
375 | // if the video has not yet played only, and we already have | ||
376 | // one segment downloaded do nothing | ||
377 | if (!this.hasPlayed_() && bufferedTime >= 1) { | ||
378 | return null; | ||
379 | } | ||
380 | |||
381 | // if there is plenty of content buffered, and the video has | ||
382 | // been played before relax for awhile | ||
383 | if (this.hasPlayed_() && bufferedTime >= GOAL_BUFFER_LENGTH) { | ||
384 | return null; | ||
385 | } | ||
386 | mediaIndex = getMediaIndexForTime(playlist, | ||
387 | currentBufferedEnd, | ||
388 | this.expired_ + this.timeCorrection_); | ||
389 | } | ||
390 | |||
391 | if (mediaIndex < 0 || mediaIndex === playlist.segments.length) { | ||
392 | return null; | ||
393 | } | ||
394 | |||
395 | // Sanity check the segment-index determining logic above but calcuating | ||
396 | // the percentage of the chosen segment that is buffered. If more than 90% | ||
397 | // of the segment is buffered then fetching it will likely not help in any | ||
398 | // way | ||
399 | let percentBuffered = this.getSegmentBufferedPercent_(playlist, | ||
400 | mediaIndex, | ||
401 | currentTime, | ||
402 | buffered); | ||
403 | |||
404 | if (percentBuffered >= 90) { | ||
405 | // Retry the buffered calculation with the next segment if there is another | ||
406 | // segment after the currently selected segment | ||
407 | if (mediaIndex + 1 < playlist.segments.length) { | ||
408 | percentBuffered = this.getSegmentBufferedPercent_(playlist, | ||
409 | mediaIndex + 1, | ||
410 | currentTime, | ||
411 | buffered); | ||
412 | } | ||
413 | |||
414 | // If both checks failed return and don't load anything | ||
415 | if (percentBuffered >= 90) { | ||
416 | return; | ||
417 | } | ||
418 | |||
419 | // Otherwise, continue with the next segment | ||
420 | mediaIndex += 1; | ||
421 | } | ||
422 | |||
423 | segment = playlist.segments[mediaIndex]; | ||
424 | let startOfSegment = duration(playlist, | ||
425 | playlist.mediaSequence + mediaIndex, | ||
426 | this.expired_); | ||
427 | |||
428 | // We will need to change timestampOffset of the sourceBuffer if either of | ||
429 | // the following conditions are true: | ||
430 | // - The segment.timeline !== this.currentTimeline | ||
431 | // (we are crossing a discontinuity somehow) | ||
432 | // - The "timestampOffset" for the start of this segment is less than | ||
433 | // the currently set timestampOffset | ||
434 | if (segment.timeline !== this.currentTimeline_ || | ||
435 | startOfSegment < this.sourceUpdater_.timestampOffset()) { | ||
436 | timestampOffset = startOfSegment; | ||
437 | } | ||
438 | |||
439 | return { | ||
440 | // resolve the segment URL relative to the playlist | ||
441 | uri: segment.resolvedUri, | ||
442 | // the segment's mediaIndex at the time it was requested | ||
443 | mediaIndex, | ||
444 | // the segment's playlist | ||
445 | playlist, | ||
446 | // unencrypted bytes of the segment | ||
447 | bytes: null, | ||
448 | // when a key is defined for this segment, the encrypted bytes | ||
449 | encryptedBytes: null, | ||
450 | // the state of the buffer before a segment is appended will be | ||
451 | // stored here so that the actual segment duration can be | ||
452 | // determined after it has been appended | ||
453 | buffered: null, | ||
454 | // The target timestampOffset for this segment when we append it | ||
455 | // to the source buffer | ||
456 | timestampOffset, | ||
457 | // The timeline that the segment is in | ||
458 | timeline: segment.timeline | ||
459 | }; | ||
460 | } | ||
461 | |||
462 | /** | ||
463 | * abort all pending xhr requests and null any pending segements | ||
464 | * | ||
465 | * @private | ||
466 | */ | ||
467 | abort_() { | ||
468 | if (this.xhr_) { | ||
469 | this.xhr_.abort(); | ||
470 | } | ||
471 | |||
472 | // clear out the segment being processed | ||
473 | this.pendingSegment_ = null; | ||
474 | } | ||
475 | |||
476 | /** | ||
477 | * fill the buffer with segements unless the | ||
478 | * sourceBuffers are currently updating | ||
479 | * | ||
480 | * @private | ||
481 | */ | ||
482 | fillBuffer_() { | ||
483 | if (this.sourceUpdater_.updating()) { | ||
484 | return; | ||
485 | } | ||
486 | |||
487 | // see if we need to begin loading immediately | ||
488 | let request = this.checkBuffer_(this.sourceUpdater_.buffered(), | ||
489 | this.playlist_, | ||
490 | this.currentTime_(), | ||
491 | this.timestampOffset_); | ||
492 | |||
493 | if (request) { | ||
494 | this.loadSegment_(request); | ||
495 | } | ||
496 | } | ||
497 | |||
498 | /** | ||
499 | * load a specific segment from a request into the buffer | ||
500 | * | ||
501 | * @private | ||
502 | */ | ||
503 | loadSegment_(segmentInfo) { | ||
504 | let segment; | ||
505 | let requestTimeout; | ||
506 | let keyXhr; | ||
507 | let segmentXhr; | ||
508 | let seekable = this.seekable_(); | ||
509 | let currentTime = this.currentTime_(); | ||
510 | let removeToTime = 0; | ||
511 | |||
512 | // Chrome has a hard limit of 150mb of | ||
513 | // buffer and a very conservative "garbage collector" | ||
514 | // We manually clear out the old buffer to ensure | ||
515 | // we don't trigger the QuotaExceeded error | ||
516 | // on the source buffer during subsequent appends | ||
517 | |||
518 | // If we have a seekable range use that as the limit for what can be removed safely | ||
519 | // otherwise remove anything older than 1 minute before the current play head | ||
520 | if (seekable.length && | ||
521 | seekable.start(0) > 0 && | ||
522 | seekable.start(0) < currentTime) { | ||
523 | removeToTime = seekable.start(0); | ||
524 | } else { | ||
525 | removeToTime = currentTime - 60; | ||
526 | } | ||
527 | |||
528 | if (removeToTime > 0) { | ||
529 | this.sourceUpdater_.remove(0, removeToTime); | ||
530 | } | ||
531 | |||
532 | segment = segmentInfo.playlist.segments[segmentInfo.mediaIndex]; | ||
533 | // Set xhr timeout to 150% of the segment duration to allow us | ||
534 | // some time to switch renditions in the event of a catastrophic | ||
535 | // decrease in network performance or a server issue. | ||
536 | requestTimeout = (segment.duration * 1.5) * 1000; | ||
537 | |||
538 | if (segment.key) { | ||
539 | keyXhr = this.hls_.xhr({ | ||
540 | uri: segment.key.resolvedUri, | ||
541 | responseType: 'arraybuffer', | ||
542 | withCredentials: this.withCredentials_, | ||
543 | timeout: requestTimeout | ||
544 | }, this.handleResponse_.bind(this)); | ||
545 | } | ||
546 | this.pendingSegment_ = segmentInfo; | ||
547 | segmentXhr = this.hls_.xhr({ | ||
548 | uri: segmentInfo.uri, | ||
549 | responseType: 'arraybuffer', | ||
550 | withCredentials: this.withCredentials_, | ||
551 | timeout: requestTimeout, | ||
552 | headers: segmentXhrHeaders(segment) | ||
553 | }, this.handleResponse_.bind(this)); | ||
554 | |||
555 | this.xhr_ = { | ||
556 | keyXhr, | ||
557 | segmentXhr, | ||
558 | abort() { | ||
559 | if (this.segmentXhr) { | ||
560 | // Prevent error handler from running. | ||
561 | this.segmentXhr.onreadystatechange = null; | ||
562 | this.segmentXhr.abort(); | ||
563 | this.segmentXhr = null; | ||
564 | } | ||
565 | if (this.keyXhr) { | ||
566 | // Prevent error handler from running. | ||
567 | this.keyXhr.onreadystatechange = null; | ||
568 | this.keyXhr.abort(); | ||
569 | this.keyXhr = null; | ||
570 | } | ||
571 | } | ||
572 | }; | ||
573 | |||
574 | this.state = 'WAITING'; | ||
575 | } | ||
576 | |||
577 | /** | ||
578 | * triggered when a segment response is received | ||
579 | * | ||
580 | * @private | ||
581 | */ | ||
582 | handleResponse_(error, request) { | ||
583 | let segmentInfo; | ||
584 | let segment; | ||
585 | let keyXhrRequest; | ||
586 | let view; | ||
587 | |||
588 | // timeout of previously aborted request | ||
589 | if (!this.xhr_ || | ||
590 | (request !== this.xhr_.segmentXhr && request !== this.xhr_.keyXhr)) { | ||
591 | return; | ||
592 | } | ||
593 | |||
594 | segmentInfo = this.pendingSegment_; | ||
595 | segment = segmentInfo.playlist.segments[segmentInfo.mediaIndex]; | ||
596 | |||
597 | // if a request times out, reset bandwidth tracking | ||
598 | if (request.timedout) { | ||
599 | this.abort_(); | ||
600 | this.bandwidth = 1; | ||
601 | this.roundTrip = NaN; | ||
602 | this.state = 'READY'; | ||
603 | return this.trigger('progress'); | ||
604 | } | ||
605 | |||
606 | // trigger an event for other errors | ||
607 | if (!request.aborted && error) { | ||
608 | // abort will clear xhr_ | ||
609 | keyXhrRequest = this.xhr_.keyXhr; | ||
610 | this.abort_(); | ||
611 | this.error({ | ||
612 | status: request.status, | ||
613 | message: request === keyXhrRequest ? | ||
614 | 'HLS key request error at URL: ' + segment.key.uri : | ||
615 | 'HLS segment request error at URL: ' + segmentInfo.uri, | ||
616 | code: 2, | ||
617 | xhr: request | ||
618 | }); | ||
619 | this.state = 'READY'; | ||
620 | this.pause(); | ||
621 | return this.trigger('error'); | ||
622 | } | ||
623 | |||
624 | // stop processing if the request was aborted | ||
625 | if (!request.response) { | ||
626 | this.abort_(); | ||
627 | return; | ||
628 | } | ||
629 | |||
630 | if (request === this.xhr_.segmentXhr) { | ||
631 | // the segment request is no longer outstanding | ||
632 | this.xhr_.segmentXhr = null; | ||
633 | |||
634 | // calculate the download bandwidth based on segment request | ||
635 | this.roundTrip = request.roundTripTime; | ||
636 | this.bandwidth = request.bandwidth; | ||
637 | this.bytesReceived += request.bytesReceived || 0; | ||
638 | |||
639 | if (segment.key) { | ||
640 | segmentInfo.encryptedBytes = new Uint8Array(request.response); | ||
641 | } else { | ||
642 | segmentInfo.bytes = new Uint8Array(request.response); | ||
643 | } | ||
644 | } | ||
645 | |||
646 | if (request === this.xhr_.keyXhr) { | ||
647 | keyXhrRequest = this.xhr_.segmentXhr; | ||
648 | // the key request is no longer outstanding | ||
649 | this.xhr_.keyXhr = null; | ||
650 | |||
651 | if (request.response.byteLength !== 16) { | ||
652 | this.abort_(); | ||
653 | this.error({ | ||
654 | status: request.status, | ||
655 | message: 'Invalid HLS key at URL: ' + segment.key.uri, | ||
656 | code: 2, | ||
657 | xhr: request | ||
658 | }); | ||
659 | this.state = 'READY'; | ||
660 | this.pause(); | ||
661 | return this.trigger('error'); | ||
662 | } | ||
663 | |||
664 | view = new DataView(request.response); | ||
665 | segment.key.bytes = new Uint32Array([ | ||
666 | view.getUint32(0), | ||
667 | view.getUint32(4), | ||
668 | view.getUint32(8), | ||
669 | view.getUint32(12) | ||
670 | ]); | ||
671 | |||
672 | // if the media sequence is greater than 2^32, the IV will be incorrect | ||
673 | // assuming 10s segments, that would be about 1300 years | ||
674 | segment.key.iv = segment.key.iv || new Uint32Array([ | ||
675 | 0, 0, 0, segmentInfo.mediaIndex + segmentInfo.playlist.mediaSequence | ||
676 | ]); | ||
677 | } | ||
678 | |||
679 | if (!this.xhr_.segmentXhr && !this.xhr_.keyXhr) { | ||
680 | this.xhr_ = null; | ||
681 | this.processResponse_(); | ||
682 | } | ||
683 | } | ||
684 | |||
685 | /** | ||
686 | * clear anything that is currently in the buffer and throw it away | ||
687 | */ | ||
688 | clearBuffer() { | ||
689 | if (this.sourceUpdater_ && | ||
690 | this.sourceUpdater_.buffered().length) { | ||
691 | this.sourceUpdater_.remove(0, Infinity); | ||
692 | } | ||
693 | } | ||
694 | |||
695 | /** | ||
696 | * Decrypt the segment that is being loaded if necessary | ||
697 | * | ||
698 | * @private | ||
699 | */ | ||
700 | processResponse_() { | ||
701 | let segmentInfo; | ||
702 | let segment; | ||
703 | |||
704 | this.state = 'DECRYPTING'; | ||
705 | |||
706 | segmentInfo = this.pendingSegment_; | ||
707 | segment = segmentInfo.playlist.segments[segmentInfo.mediaIndex]; | ||
708 | |||
709 | if (segment.key) { | ||
710 | // this is an encrypted segment | ||
711 | // incrementally decrypt the segment | ||
712 | /* eslint-disable no-new, handle-callback-err */ | ||
713 | new Decrypter(segmentInfo.encryptedBytes, | ||
714 | segment.key.bytes, | ||
715 | segment.key.iv, | ||
716 | (function(err, bytes) { | ||
717 | // err always null | ||
718 | segmentInfo.bytes = bytes; | ||
719 | this.handleSegment_(); | ||
720 | }).bind(this)); | ||
721 | /* eslint-enable */ | ||
722 | } else { | ||
723 | this.handleSegment_(); | ||
724 | } | ||
725 | } | ||
726 | |||
727 | /** | ||
728 | * append a decrypted segement to the SourceBuffer through a SourceUpdater | ||
729 | * | ||
730 | * @private | ||
731 | */ | ||
732 | handleSegment_() { | ||
733 | let segmentInfo; | ||
734 | |||
735 | this.state = 'APPENDING'; | ||
736 | segmentInfo = this.pendingSegment_; | ||
737 | segmentInfo.buffered = this.sourceUpdater_.buffered(); | ||
738 | this.currentTimeline_ = segmentInfo.timeline; | ||
739 | |||
740 | if (segmentInfo.timestampOffset !== this.sourceUpdater_.timestampOffset()) { | ||
741 | this.sourceUpdater_.timestampOffset(segmentInfo.timestampOffset); | ||
742 | } | ||
743 | |||
744 | this.sourceUpdater_.appendBuffer(segmentInfo.bytes, | ||
745 | this.handleUpdateEnd_.bind(this)); | ||
746 | } | ||
747 | |||
748 | /** | ||
749 | * callback to run when appendBuffer is finished. detects if we are | ||
750 | * in a good state to do things with the data we got, or if we need | ||
751 | * to wait for more | ||
752 | * | ||
753 | * @private | ||
754 | */ | ||
755 | handleUpdateEnd_() { | ||
756 | let segmentInfo = this.pendingSegment_; | ||
757 | let currentTime = this.currentTime_(); | ||
758 | |||
759 | this.pendingSegment_ = null; | ||
760 | // add segment timeline information if we're still using the | ||
761 | // same playlist | ||
762 | if (segmentInfo && segmentInfo.playlist.uri === this.playlist_.uri) { | ||
763 | this.updateTimeline_(segmentInfo); | ||
764 | this.trigger('progress'); | ||
765 | } | ||
766 | |||
767 | let currentMediaIndex = segmentInfo.mediaIndex; | ||
768 | |||
769 | currentMediaIndex += | ||
770 | segmentInfo.playlist.mediaSequence - this.playlist_.mediaSequence; | ||
771 | |||
772 | let currentBuffered = Ranges.findRange(this.sourceUpdater_.buffered(), currentTime); | ||
773 | |||
774 | // any time an update finishes and the last segment is in the | ||
775 | // buffer, end the stream. this ensures the "ended" event will | ||
776 | // fire if playback reaches that point. | ||
777 | let isEndOfStream = detectEndOfStream(segmentInfo.playlist, | ||
778 | this.mediaSource_, | ||
779 | currentMediaIndex, | ||
780 | currentBuffered); | ||
781 | |||
782 | if (isEndOfStream) { | ||
783 | this.mediaSource_.endOfStream(); | ||
784 | } | ||
785 | |||
786 | // when seeking to the beginning of the seekable range, it's | ||
787 | // possible that imprecise timing information may cause the seek to | ||
788 | // end up earlier than the start of the range | ||
789 | // in that case, seek again | ||
790 | let seekable = this.seekable_(); | ||
791 | let next = Ranges.findNextRange(this.sourceUpdater_.buffered(), currentTime); | ||
792 | |||
793 | if (this.seeking_() && | ||
794 | currentBuffered.length === 0) { | ||
795 | if (seekable.length && | ||
796 | currentTime < seekable.start(0)) { | ||
797 | |||
798 | if (next.length) { | ||
799 | videojs.log('tried seeking to', currentTime, | ||
800 | 'but that was too early, retrying at', next.start(0)); | ||
801 | this.setCurrentTime_(next.start(0) + Ranges.TIME_FUDGE_FACTOR); | ||
802 | } | ||
803 | } | ||
804 | } | ||
805 | |||
806 | this.state = 'READY'; | ||
807 | |||
808 | if (!this.paused()) { | ||
809 | this.fillBuffer_(); | ||
810 | } | ||
811 | } | ||
812 | |||
813 | /** | ||
814 | * annotate the segment with any start and end time information | ||
815 | * added by the media processing | ||
816 | * | ||
817 | * @private | ||
818 | * @param {Object} segmentInfo annotate a segment with time info | ||
819 | */ | ||
820 | updateTimeline_(segmentInfo) { | ||
821 | let segment; | ||
822 | let timelineUpdate; | ||
823 | let playlist = segmentInfo.playlist; | ||
824 | let currentMediaIndex = segmentInfo.mediaIndex; | ||
825 | |||
826 | currentMediaIndex += playlist.mediaSequence - this.playlist_.mediaSequence; | ||
827 | segment = playlist.segments[currentMediaIndex]; | ||
828 | |||
829 | if (!segment) { | ||
830 | return; | ||
831 | } | ||
832 | |||
833 | timelineUpdate = Ranges.findSoleUncommonTimeRangesEnd(segmentInfo.buffered, | ||
834 | this.sourceUpdater_.buffered()); | ||
835 | |||
836 | // Update segment meta-data (duration and end-point) based on timeline | ||
837 | let timelineUpdated = updateSegmentMetadata(playlist, | ||
838 | currentMediaIndex, | ||
839 | timelineUpdate); | ||
840 | |||
841 | // the last segment append must have been entirely in the | ||
842 | // already buffered time ranges. adjust the timeCorrection | ||
843 | // offset to fetch forward until we find a segment that adds | ||
844 | // to the buffered time ranges and improves subsequent media | ||
845 | // index calculations. | ||
846 | if (!timelineUpdated) { | ||
847 | this.timeCorrection_ -= segment.duration; | ||
848 | } else { | ||
849 | this.timeCorrection_ = 0; | ||
850 | } | ||
851 | } | ||
852 | } |
src/source-updater.js
0 → 100644
1 | /** | ||
2 | * @file source-updater.js | ||
3 | */ | ||
4 | import videojs from 'video.js'; | ||
5 | |||
6 | /** | ||
7 | * A queue of callbacks to be serialized and applied when a | ||
8 | * MediaSource and its associated SourceBuffers are not in the | ||
9 | * updating state. It is used by the segment loader to update the | ||
10 | * underlying SourceBuffers when new data is loaded, for instance. | ||
11 | * | ||
12 | * @class SourceUpdater | ||
13 | * @param {MediaSource} mediaSource the MediaSource to create the | ||
14 | * SourceBuffer from | ||
15 | * @param {String} mimeType the desired MIME type of the underlying | ||
16 | * SourceBuffer | ||
17 | */ | ||
18 | export default class SourceUpdater { | ||
19 | constructor(mediaSource, mimeType) { | ||
20 | let createSourceBuffer = () => { | ||
21 | this.sourceBuffer_ = mediaSource.addSourceBuffer(mimeType); | ||
22 | |||
23 | // run completion handlers and process callbacks as updateend | ||
24 | // events fire | ||
25 | this.sourceBuffer_.addEventListener('updateend', () => { | ||
26 | let pendingCallback = this.pendingCallback_; | ||
27 | |||
28 | this.pendingCallback_ = null; | ||
29 | |||
30 | if (pendingCallback) { | ||
31 | pendingCallback(); | ||
32 | } | ||
33 | }); | ||
34 | this.sourceBuffer_.addEventListener('updateend', | ||
35 | this.runCallback_.bind(this)); | ||
36 | |||
37 | this.runCallback_(); | ||
38 | }; | ||
39 | |||
40 | this.callbacks_ = []; | ||
41 | this.pendingCallback_ = null; | ||
42 | this.timestampOffset_ = 0; | ||
43 | this.mediaSource = mediaSource; | ||
44 | |||
45 | if (mediaSource.readyState === 'closed') { | ||
46 | mediaSource.addEventListener('sourceopen', createSourceBuffer); | ||
47 | } else { | ||
48 | createSourceBuffer(); | ||
49 | } | ||
50 | } | ||
51 | |||
52 | /** | ||
53 | * Aborts the current segment and resets the segment parser. | ||
54 | * | ||
55 | * @param {Function} done function to call when done | ||
56 | * @see http://w3c.github.io/media-source/#widl-SourceBuffer-abort-void | ||
57 | */ | ||
58 | abort(done) { | ||
59 | this.queueCallback_(() => { | ||
60 | this.sourceBuffer_.abort(); | ||
61 | }, done); | ||
62 | } | ||
63 | |||
64 | /** | ||
65 | * Queue an update to append an ArrayBuffer. | ||
66 | * | ||
67 | * @param {ArrayBuffer} bytes | ||
68 | * @param {Function} done the function to call when done | ||
69 | * @see http://www.w3.org/TR/media-source/#widl-SourceBuffer-appendBuffer-void-ArrayBuffer-data | ||
70 | */ | ||
71 | appendBuffer(bytes, done) { | ||
72 | this.queueCallback_(() => { | ||
73 | this.sourceBuffer_.appendBuffer(bytes); | ||
74 | }, done); | ||
75 | } | ||
76 | |||
77 | /** | ||
78 | * Indicates what TimeRanges are buffered in the managed SourceBuffer. | ||
79 | * | ||
80 | * @see http://www.w3.org/TR/media-source/#widl-SourceBuffer-buffered | ||
81 | */ | ||
82 | buffered() { | ||
83 | if (!this.sourceBuffer_) { | ||
84 | return videojs.createTimeRanges(); | ||
85 | } | ||
86 | return this.sourceBuffer_.buffered; | ||
87 | } | ||
88 | |||
89 | /** | ||
90 | * Queue an update to set the duration. | ||
91 | * | ||
92 | * @param {Double} duration what to set the duration to | ||
93 | * @see http://www.w3.org/TR/media-source/#widl-MediaSource-duration | ||
94 | */ | ||
95 | duration(duration) { | ||
96 | this.queueCallback_(() => { | ||
97 | this.sourceBuffer_.duration = duration; | ||
98 | }); | ||
99 | } | ||
100 | |||
101 | /** | ||
102 | * Queue an update to remove a time range from the buffer. | ||
103 | * | ||
104 | * @param {Number} start where to start the removal | ||
105 | * @param {Number} end where to end the removal | ||
106 | * @see http://www.w3.org/TR/media-source/#widl-SourceBuffer-remove-void-double-start-unrestricted-double-end | ||
107 | */ | ||
108 | remove(start, end) { | ||
109 | this.queueCallback_(() => { | ||
110 | this.sourceBuffer_.remove(start, end); | ||
111 | }); | ||
112 | } | ||
113 | |||
114 | /** | ||
115 | * wether the underlying sourceBuffer is updating or not | ||
116 | * | ||
117 | * @return {Boolean} the updating status of the SourceBuffer | ||
118 | */ | ||
119 | updating() { | ||
120 | return !this.sourceBuffer_ || this.sourceBuffer_.updating; | ||
121 | } | ||
122 | |||
123 | /** | ||
124 | * Set/get the timestampoffset on the SourceBuffer | ||
125 | * | ||
126 | * @return {Number} the timestamp offset | ||
127 | */ | ||
128 | timestampOffset(offset) { | ||
129 | if (typeof offset !== 'undefined') { | ||
130 | this.queueCallback_(() => { | ||
131 | this.sourceBuffer_.timestampOffset = offset; | ||
132 | }); | ||
133 | this.timestampOffset_ = offset; | ||
134 | } | ||
135 | return this.timestampOffset_; | ||
136 | } | ||
137 | |||
138 | /** | ||
139 | * que a callback to run | ||
140 | */ | ||
141 | queueCallback_(callback, done) { | ||
142 | this.callbacks_.push([callback.bind(this), done]); | ||
143 | this.runCallback_(); | ||
144 | } | ||
145 | |||
146 | /** | ||
147 | * run a queued callback | ||
148 | */ | ||
149 | runCallback_() { | ||
150 | let callbacks; | ||
151 | |||
152 | if (this.sourceBuffer_ && | ||
153 | !this.sourceBuffer_.updating && | ||
154 | this.callbacks_.length) { | ||
155 | callbacks = this.callbacks_.shift(); | ||
156 | this.pendingCallback_ = callbacks[1]; | ||
157 | callbacks[0](); | ||
158 | } | ||
159 | } | ||
160 | |||
161 | /** | ||
162 | * dispose of the source updater and the underlying sourceBuffer | ||
163 | */ | ||
164 | dispose() { | ||
165 | if (this.sourceBuffer_ && this.mediaSource.readyState === 'open') { | ||
166 | this.sourceBuffer_.abort(); | ||
167 | } | ||
168 | } | ||
169 | } |
1 | /** | 1 | /** |
2 | * @file stream.js | ||
3 | */ | ||
4 | /** | ||
2 | * A lightweight readable stream implemention that handles event dispatching. | 5 | * A lightweight readable stream implemention that handles event dispatching. |
6 | * | ||
7 | * @class Stream | ||
3 | */ | 8 | */ |
4 | export default class Stream { | 9 | export default class Stream { |
5 | constructor() { | 10 | constructor() { |
... | @@ -8,8 +13,9 @@ export default class Stream { | ... | @@ -8,8 +13,9 @@ export default class Stream { |
8 | 13 | ||
9 | /** | 14 | /** |
10 | * Add a listener for a specified event type. | 15 | * Add a listener for a specified event type. |
11 | * @param type {string} the event name | 16 | * |
12 | * @param listener {function} the callback to be invoked when an event of | 17 | * @param {String} type the event name |
18 | * @param {Function} listener the callback to be invoked when an event of | ||
13 | * the specified type occurs | 19 | * the specified type occurs |
14 | */ | 20 | */ |
15 | on(type, listener) { | 21 | on(type, listener) { |
... | @@ -21,9 +27,11 @@ export default class Stream { | ... | @@ -21,9 +27,11 @@ export default class Stream { |
21 | 27 | ||
22 | /** | 28 | /** |
23 | * Remove a listener for a specified event type. | 29 | * Remove a listener for a specified event type. |
24 | * @param type {string} the event name | 30 | * |
25 | * @param listener {function} a function previously registered for this | 31 | * @param {String} type the event name |
32 | * @param {Function} listener a function previously registered for this | ||
26 | * type of event through `on` | 33 | * type of event through `on` |
34 | * @return {Boolean} if we could turn it off or not | ||
27 | */ | 35 | */ |
28 | off(type, listener) { | 36 | off(type, listener) { |
29 | let index; | 37 | let index; |
... | @@ -39,7 +47,8 @@ export default class Stream { | ... | @@ -39,7 +47,8 @@ export default class Stream { |
39 | /** | 47 | /** |
40 | * Trigger an event of the specified type on this stream. Any additional | 48 | * Trigger an event of the specified type on this stream. Any additional |
41 | * arguments to this function are passed as parameters to event listeners. | 49 | * arguments to this function are passed as parameters to event listeners. |
42 | * @param type {string} the event name | 50 | * |
51 | * @param {String} type the event name | ||
43 | */ | 52 | */ |
44 | trigger(type) { | 53 | trigger(type) { |
45 | let callbacks; | 54 | let callbacks; |
... | @@ -79,7 +88,8 @@ export default class Stream { | ... | @@ -79,7 +88,8 @@ export default class Stream { |
79 | * Forwards all `data` events on this stream to the destination stream. The | 88 | * Forwards all `data` events on this stream to the destination stream. The |
80 | * destination stream should provide a method `push` to receive the data | 89 | * destination stream should provide a method `push` to receive the data |
81 | * events as they arrive. | 90 | * events as they arrive. |
82 | * @param destination {stream} the stream that will receive all `data` events | 91 | * |
92 | * @param {Stream} destination the stream that will receive all `data` events | ||
83 | * @see http://nodejs.org/api/stream.html#stream_readable_pipe_destination_options | 93 | * @see http://nodejs.org/api/stream.html#stream_readable_pipe_destination_options |
84 | */ | 94 | */ |
85 | pipe(destination) { | 95 | pipe(destination) { | ... | ... |
1 | /** | 1 | /** |
2 | * videojs-hls | 2 | * @file videojs-contrib-hls.js |
3 | * | ||
3 | * The main file for the HLS project. | 4 | * The main file for the HLS project. |
4 | * License: https://github.com/videojs/videojs-contrib-hls/blob/master/LICENSE | 5 | * License: https://github.com/videojs/videojs-contrib-hls/blob/master/LICENSE |
5 | */ | 6 | */ |
7 | import document from 'global/document'; | ||
6 | import PlaylistLoader from './playlist-loader'; | 8 | import PlaylistLoader from './playlist-loader'; |
7 | import Playlist from './playlist'; | 9 | import Playlist from './playlist'; |
8 | import xhrFactory from './xhr'; | 10 | import xhrFactory from './xhr'; |
... | @@ -11,7 +13,34 @@ import utils from './bin-utils'; | ... | @@ -11,7 +13,34 @@ import utils from './bin-utils'; |
11 | import {MediaSource, URL} from 'videojs-contrib-media-sources'; | 13 | import {MediaSource, URL} from 'videojs-contrib-media-sources'; |
12 | import m3u8 from './m3u8'; | 14 | import m3u8 from './m3u8'; |
13 | import videojs from 'video.js'; | 15 | import videojs from 'video.js'; |
14 | import resolveUrl from './resolve-url'; | 16 | import MasterPlaylistController from './master-playlist-controller'; |
17 | |||
18 | /** | ||
19 | * determine if an object a is differnt from | ||
20 | * and object b. both only having one dimensional | ||
21 | * properties | ||
22 | * | ||
23 | * @param {Object} a object one | ||
24 | * @param {Object} b object two | ||
25 | * @return {Boolean} if the object has changed or not | ||
26 | */ | ||
27 | const objectChanged = function(a, b) { | ||
28 | if (typeof a !== typeof b) { | ||
29 | return true; | ||
30 | } | ||
31 | // if we have a different number of elements | ||
32 | // something has changed | ||
33 | if (Object.keys(a).length !== Object.keys(b).length) { | ||
34 | return true; | ||
35 | } | ||
36 | |||
37 | for (let prop in a) { | ||
38 | if (!b[prop] || a[prop] !== b[prop]) { | ||
39 | return true; | ||
40 | } | ||
41 | } | ||
42 | return false; | ||
43 | }; | ||
15 | 44 | ||
16 | const Hls = { | 45 | const Hls = { |
17 | PlaylistLoader, | 46 | PlaylistLoader, |
... | @@ -26,183 +55,19 @@ const Hls = { | ... | @@ -26,183 +55,19 @@ const Hls = { |
26 | // the desired length of video to maintain in the buffer, in seconds | 55 | // the desired length of video to maintain in the buffer, in seconds |
27 | Hls.GOAL_BUFFER_LENGTH = 30; | 56 | Hls.GOAL_BUFFER_LENGTH = 30; |
28 | 57 | ||
29 | // HLS is a source handler, not a tech. Make sure attempts to use it | ||
30 | // as one do not cause exceptions. | ||
31 | Hls.canPlaySource = function() { | ||
32 | return videojs.log.warn('HLS is no longer a tech. Please remove it from ' + | ||
33 | 'your player\'s techOrder.'); | ||
34 | }; | ||
35 | |||
36 | // Search for a likely end time for the segment that was just appened | ||
37 | // based on the state of the `buffered` property before and after the | ||
38 | // append. | ||
39 | // If we found only one such uncommon end-point return it. | ||
40 | Hls.findSoleUncommonTimeRangesEnd_ = function(original, update) { | ||
41 | let i; | ||
42 | let start; | ||
43 | let end; | ||
44 | let result = []; | ||
45 | let edges = []; | ||
46 | |||
47 | // In order to qualify as a possible candidate, the end point must: | ||
48 | // 1) Not have already existed in the `original` ranges | ||
49 | // 2) Not result from the shrinking of a range that already existed | ||
50 | // in the `original` ranges | ||
51 | // 3) Not be contained inside of a range that existed in `original` | ||
52 | let overlapsCurrentEnd = function(span) { | ||
53 | return (span[0] <= end && span[1] >= end); | ||
54 | }; | ||
55 | |||
56 | if (original) { | ||
57 | // Save all the edges in the `original` TimeRanges object | ||
58 | for (i = 0; i < original.length; i++) { | ||
59 | start = original.start(i); | ||
60 | end = original.end(i); | ||
61 | |||
62 | edges.push([start, end]); | ||
63 | } | ||
64 | } | ||
65 | |||
66 | if (update) { | ||
67 | // Save any end-points in `update` that are not in the `original` | ||
68 | // TimeRanges object | ||
69 | for (i = 0; i < update.length; i++) { | ||
70 | start = update.start(i); | ||
71 | end = update.end(i); | ||
72 | |||
73 | if (edges.some(overlapsCurrentEnd)) { | ||
74 | continue; | ||
75 | } | ||
76 | |||
77 | // at this point it must be a unique non-shrinking end edge | ||
78 | result.push(end); | ||
79 | } | ||
80 | } | ||
81 | |||
82 | // we err on the side of caution and return null if didn't find | ||
83 | // exactly *one* differing end edge in the search above | ||
84 | if (result.length !== 1) { | ||
85 | return null; | ||
86 | } | ||
87 | |||
88 | return result[0]; | ||
89 | }; | ||
90 | |||
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 | 58 | // A fudge factor to apply to advertised playlist bitrates to account for |
182 | // temporary flucations in client bandwidth | 59 | // temporary flucations in client bandwidth |
183 | const bandwidthVariance = 1.2; | 60 | const BANDWIDTH_VARIANCE = 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 | 61 | ||
200 | /** | 62 | /** |
201 | * Returns the CSS value for the specified property on an element | 63 | * Returns the CSS value for the specified property on an element |
202 | * using `getComputedStyle`. Firefox has a long-standing issue where | 64 | * using `getComputedStyle`. Firefox has a long-standing issue where |
203 | * getComputedStyle() may return null when running in an iframe with | 65 | * getComputedStyle() may return null when running in an iframe with |
204 | * `display: none`. | 66 | * `display: none`. |
67 | * | ||
205 | * @see https://bugzilla.mozilla.org/show_bug.cgi?id=548397 | 68 | * @see https://bugzilla.mozilla.org/show_bug.cgi?id=548397 |
69 | * @param {HTMLElement} el the htmlelement to work on | ||
70 | * @param {string} the proprety to get the style for | ||
206 | */ | 71 | */ |
207 | const safeGetComputedStyle = function(el, property) { | 72 | const safeGetComputedStyle = function(el, property) { |
208 | let result; | 73 | let result; |
... | @@ -220,137 +85,195 @@ const safeGetComputedStyle = function(el, property) { | ... | @@ -220,137 +85,195 @@ const safeGetComputedStyle = function(el, property) { |
220 | }; | 85 | }; |
221 | 86 | ||
222 | /** | 87 | /** |
223 | * Updates segment with information about its end-point in time and, optionally, | 88 | * Chooses the appropriate media playlist based on the current |
224 | * the segment duration if we have enough information to determine a segment duration | 89 | * bandwidth estimate and the player size. |
225 | * accurately. | 90 | * |
226 | * @param playlist {object} a media playlist object | 91 | * @return {Playlist} the highest bitrate playlist less than the currently detected |
227 | * @param segmentIndex {number} the index of segment we last appended | 92 | * bandwidth, accounting for some amount of bandwidth variance |
228 | * @param segmentEnd {number} the known of the segment referenced by segmentIndex | ||
229 | */ | 93 | */ |
230 | const updateSegmentMetadata = function(playlist, segmentIndex, segmentEnd) { | 94 | Hls.STANDARD_PLAYLIST_SELECTOR = function() { |
231 | if (!playlist) { | 95 | let effectiveBitrate; |
232 | return; | 96 | let sortedPlaylists = this.playlists.master.playlists.slice(); |
233 | } | 97 | let bandwidthPlaylists = []; |
98 | let now = +new Date(); | ||
99 | let i; | ||
100 | let variant; | ||
101 | let bandwidthBestVariant; | ||
102 | let resolutionPlusOne; | ||
103 | let resolutionPlusOneAttribute; | ||
104 | let resolutionBestVariant; | ||
105 | let width; | ||
106 | let height; | ||
107 | |||
108 | sortedPlaylists.sort(Hls.comparePlaylistBandwidth); | ||
109 | |||
110 | // filter out any playlists that have been excluded due to | ||
111 | // incompatible configurations or playback errors | ||
112 | sortedPlaylists = sortedPlaylists.filter((localVariant) => { | ||
113 | if (typeof localVariant.excludeUntil !== 'undefined') { | ||
114 | return now >= localVariant.excludeUntil; | ||
115 | } | ||
116 | return true; | ||
117 | }); | ||
118 | |||
119 | // filter out any variant that has greater effective bitrate | ||
120 | // than the current estimated bandwidth | ||
121 | i = sortedPlaylists.length; | ||
122 | while (i--) { | ||
123 | variant = sortedPlaylists[i]; | ||
124 | |||
125 | // ignore playlists without bandwidth information | ||
126 | if (!variant.attributes || !variant.attributes.BANDWIDTH) { | ||
127 | continue; | ||
128 | } | ||
234 | 129 | ||
235 | let segment = playlist.segments[segmentIndex]; | 130 | effectiveBitrate = variant.attributes.BANDWIDTH * BANDWIDTH_VARIANCE; |
236 | let previousSegment = playlist.segments[segmentIndex - 1]; | ||
237 | 131 | ||
238 | if (segmentEnd && segment) { | 132 | if (effectiveBitrate < this.bandwidth) { |
239 | segment.end = segmentEnd; | 133 | bandwidthPlaylists.push(variant); |
240 | 134 | ||
241 | // fix up segment durations based on segment end data | 135 | // since the playlists are sorted in ascending order by |
242 | if (!previousSegment) { | 136 | // bandwidth, the first viable variant is the best |
243 | // first segment is always has a start time of 0 making its duration | 137 | if (!bandwidthBestVariant) { |
244 | // equal to the segment end | 138 | bandwidthBestVariant = variant; |
245 | segment.duration = segment.end; | 139 | } |
246 | } else if (previousSegment.end) { | ||
247 | segment.duration = segment.end - previousSegment.end; | ||
248 | } | 140 | } |
249 | } | 141 | } |
250 | }; | ||
251 | 142 | ||
252 | /** | 143 | i = bandwidthPlaylists.length; |
253 | * Determines if we should call endOfStream on the media source based on the state | ||
254 | * of the buffer or if appened segment was the final segment in the playlist. | ||
255 | * @param playlist {object} a media playlist object | ||
256 | * @param mediaSource {object} the MediaSource object | ||
257 | * @param segmentIndex {number} the index of segment we last appended | ||
258 | * @param currentBuffered {object} the buffered region that currentTime resides in | ||
259 | * @return {boolean} whether the calling function should call endOfStream on the MediaSource | ||
260 | */ | ||
261 | const detectEndOfStream = function(playlist, mediaSource, segmentIndex, currentBuffered) { | ||
262 | if (!playlist) { | ||
263 | return false; | ||
264 | } | ||
265 | 144 | ||
266 | let segments = playlist.segments; | 145 | // sort variants by resolution |
146 | bandwidthPlaylists.sort(Hls.comparePlaylistResolution); | ||
267 | 147 | ||
268 | // determine a few boolean values to help make the branch below easier | 148 | // forget our old variant from above, |
269 | // to read | 149 | // or we might choose that in high-bandwidth scenarios |
270 | let appendedLastSegment = (segmentIndex === segments.length - 1); | 150 | // (this could be the lowest bitrate rendition as we go through all of them above) |
271 | let bufferedToEnd = (currentBuffered.length && | 151 | variant = null; |
272 | segments[segments.length - 1].end <= currentBuffered.end(0)); | ||
273 | 152 | ||
274 | // if we've buffered to the end of the video, we need to call endOfStream | 153 | width = parseInt(safeGetComputedStyle(this.tech_.el(), 'width'), 10); |
275 | // so that MediaSources can trigger the `ended` event when it runs out of | 154 | height = parseInt(safeGetComputedStyle(this.tech_.el(), 'height'), 10); |
276 | // buffered data instead of waiting for me | ||
277 | return playlist.endList && | ||
278 | mediaSource.readyState === 'open' && | ||
279 | (appendedLastSegment || bufferedToEnd); | ||
280 | }; | ||
281 | 155 | ||
282 | const parseCodecs = function(codecs) { | 156 | // iterate through the bandwidth-filtered playlists and find |
283 | let result = { | 157 | // best rendition by player dimension |
284 | codecCount: 0, | 158 | while (i--) { |
285 | videoCodec: null, | 159 | variant = bandwidthPlaylists[i]; |
286 | audioProfile: null | ||
287 | }; | ||
288 | 160 | ||
289 | result.codecCount = codecs.split(',').length; | 161 | // ignore playlists without resolution information |
290 | result.codecCount = result.codecCount || 2; | 162 | if (!variant.attributes || |
163 | !variant.attributes.RESOLUTION || | ||
164 | !variant.attributes.RESOLUTION.width || | ||
165 | !variant.attributes.RESOLUTION.height) { | ||
166 | continue; | ||
167 | } | ||
168 | |||
169 | // since the playlists are sorted, the first variant that has | ||
170 | // dimensions less than or equal to the player size is the best | ||
171 | let variantResolution = variant.attributes.RESOLUTION; | ||
291 | 172 | ||
292 | // parse the video codec but ignore the version | 173 | if (variantResolution.width === width && |
293 | result.videoCodec = (/(^|\s|,)+(avc1)[^ ,]*/i).exec(codecs); | 174 | variantResolution.height === height) { |
294 | result.videoCodec = result.videoCodec && result.videoCodec[2]; | 175 | // if we have the exact resolution as the player use it |
176 | resolutionPlusOne = null; | ||
177 | resolutionBestVariant = variant; | ||
178 | break; | ||
179 | } else if (variantResolution.width < width && | ||
180 | variantResolution.height < height) { | ||
181 | // if both dimensions are less than the player use the | ||
182 | // previous (next-largest) variant | ||
183 | break; | ||
184 | } else if (!resolutionPlusOne || | ||
185 | (variantResolution.width < resolutionPlusOneAttribute.width && | ||
186 | variantResolution.height < resolutionPlusOneAttribute.height)) { | ||
187 | // If we still haven't found a good match keep a | ||
188 | // reference to the previous variant for the next loop | ||
189 | // iteration | ||
295 | 190 | ||
296 | // parse the last field of the audio codec | 191 | // By only saving variants if they are smaller than the |
297 | result.audioProfile = (/(^|\s|,)+mp4a.\d+\.(\d+)/i).exec(codecs); | 192 | // previously saved variant, we ensure that we also pick |
298 | result.audioProfile = result.audioProfile && result.audioProfile[2]; | 193 | // the highest bandwidth variant that is just-larger-than |
194 | // the video player | ||
195 | resolutionPlusOne = variant; | ||
196 | resolutionPlusOneAttribute = resolutionPlusOne.attributes.RESOLUTION; | ||
197 | } | ||
198 | } | ||
299 | 199 | ||
300 | return result; | 200 | // fallback chain of variants |
201 | return resolutionPlusOne || | ||
202 | resolutionBestVariant || | ||
203 | bandwidthBestVariant || | ||
204 | sortedPlaylists[0]; | ||
301 | }; | 205 | }; |
302 | 206 | ||
303 | const filterBufferedRanges = function(predicate) { | 207 | // HLS is a source handler, not a tech. Make sure attempts to use it |
304 | return function(time) { | 208 | // as one do not cause exceptions. |
305 | let i; | 209 | Hls.canPlaySource = function() { |
306 | let ranges = []; | 210 | return videojs.log.warn('HLS is no longer a tech. Please remove it from ' + |
307 | let tech = this.tech_; | 211 | 'your player\'s techOrder.'); |
308 | 212 | }; | |
309 | // !!The order of the next two assignments is important!! | ||
310 | // `currentTime` must be equal-to or greater-than the start of the | ||
311 | // buffered range. Flash executes out-of-process so, every value can | ||
312 | // change behind the scenes from line-to-line. By reading `currentTime` | ||
313 | // after `buffered`, we ensure that it is always a current or later | ||
314 | // value during playback. | ||
315 | let buffered = tech.buffered(); | ||
316 | |||
317 | if (typeof time === 'undefined') { | ||
318 | time = tech.currentTime(); | ||
319 | } | ||
320 | 213 | ||
321 | // IE 11 has a bug where it will report a the video as fully buffered | 214 | /** |
322 | // before any data has been loaded. This is a work around where we | 215 | * Whether the browser has built-in HLS support. |
323 | // report a fully empty buffer until SourceBuffers have been created | 216 | */ |
324 | // which is after a segment has been loaded and transmuxed. | 217 | Hls.supportsNativeHls = (function() { |
325 | if (!this.mediaSource || | 218 | let video = document.createElement('video'); |
326 | (this.mediaSource.mediaSource_ && | ||
327 | !this.mediaSource.mediaSource_.sourceBuffers.length)) { | ||
328 | return videojs.createTimeRanges([]); | ||
329 | } | ||
330 | 219 | ||
331 | if (buffered && buffered.length) { | 220 | // native HLS is definitely not supported if HTML5 video isn't |
332 | // Search for a range containing the play-head | 221 | if (!videojs.getComponent('Html5').isSupported()) { |
333 | for (i = 0; i < buffered.length; i++) { | 222 | return false; |
334 | if (predicate(buffered.start(i), buffered.end(i), time)) { | 223 | } |
335 | ranges.push([buffered.start(i), buffered.end(i)]); | ||
336 | } | ||
337 | } | ||
338 | } | ||
339 | 224 | ||
340 | return videojs.createTimeRanges(ranges); | 225 | // HLS manifests can go by many mime-types |
341 | }; | 226 | let canPlay = [ |
227 | // Apple santioned | ||
228 | 'application/vnd.apple.mpegurl', | ||
229 | // Apple sanctioned for backwards compatibility | ||
230 | 'audio/mpegurl', | ||
231 | // Very common | ||
232 | 'audio/x-mpegurl', | ||
233 | // Very common | ||
234 | 'application/x-mpegurl', | ||
235 | // Included for completeness | ||
236 | 'video/x-mpegurl', | ||
237 | 'video/mpegurl', | ||
238 | 'application/mpegurl' | ||
239 | ]; | ||
240 | |||
241 | return canPlay.some(function(canItPlay) { | ||
242 | return (/maybe|probably/i).test(video.canPlayType(canItPlay)); | ||
243 | }); | ||
244 | }()); | ||
245 | |||
246 | /** | ||
247 | * HLS is a source handler, not a tech. Make sure attempts to use it | ||
248 | * as one do not cause exceptions. | ||
249 | */ | ||
250 | Hls.isSupported = function() { | ||
251 | return videojs.log.warn('HLS is no longer a tech. Please remove it from ' + | ||
252 | 'your player\'s techOrder.'); | ||
342 | }; | 253 | }; |
343 | 254 | ||
344 | export default class HlsHandler extends Component { | 255 | const Component = videojs.getComponent('Component'); |
345 | constructor(tech, options) { | 256 | |
257 | /** | ||
258 | * The Hls Handler object, where we orchestrate all of the parts | ||
259 | * of HLS to interact with video.js | ||
260 | * | ||
261 | * @class HlsHandler | ||
262 | * @extends videojs.Component | ||
263 | * @param {Object} source the soruce object | ||
264 | * @param {Tech} tech the parent tech object | ||
265 | * @param {Object} options optional and required options | ||
266 | */ | ||
267 | class HlsHandler extends Component { | ||
268 | constructor(source, tech, options) { | ||
346 | super(tech); | 269 | super(tech); |
347 | let _player; | ||
348 | 270 | ||
349 | // tech.player() is deprecated but setup a reference to HLS for | 271 | // tech.player() is deprecated but setup a reference to HLS for |
350 | // backwards-compatibility | 272 | // backwards-compatibility |
351 | if (tech.options_ && tech.options_.playerId) { | 273 | if (tech.options_ && tech.options_.playerId) { |
352 | _player = videojs(tech.options_.playerId); | 274 | let _player = videojs(tech.options_.playerId); |
353 | if (!_player.hls) { | 275 | |
276 | if (!_player.hasOwnProperty('hls')) { | ||
354 | Object.defineProperty(_player, 'hls', { | 277 | Object.defineProperty(_player, 'hls', { |
355 | get: () => { | 278 | get: () => { |
356 | videojs.log.warn('player.hls is deprecated. Use player.tech.hls instead.'); | 279 | videojs.log.warn('player.hls is deprecated. Use player.tech.hls instead.'); |
... | @@ -359,1188 +282,228 @@ export default class HlsHandler extends Component { | ... | @@ -359,1188 +282,228 @@ export default class HlsHandler extends Component { |
359 | }); | 282 | }); |
360 | } | 283 | } |
361 | } | 284 | } |
285 | |||
286 | this.options_ = videojs.mergeOptions(videojs.options.hls || {}, options.hls); | ||
362 | this.tech_ = tech; | 287 | this.tech_ = tech; |
363 | this.source_ = options.source; | 288 | this.source_ = 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 | 289 | ||
369 | // start playlist selection at a reasonable bandwidth for | 290 | // start playlist selection at a reasonable bandwidth for |
370 | // broadband internet | 291 | // broadband internet |
371 | // 0.5 Mbps | 292 | // 0.5 Mbps |
372 | this.bandwidth = options.bandwidth || 4194304; | 293 | this.bandwidth = this.options_.bandwidth || 4194304; |
373 | this.bytesReceived = 0; | 294 | this.bytesReceived = 0; |
374 | 295 | ||
375 | // loadingState_ tracks how far along the buffering process we | 296 | // listen for fullscreenchange events for this player so that we |
376 | // have been given permission to proceed. There are three possible | 297 | // can adjust our quality selection quickly |
377 | // values: | 298 | this.on(document, [ |
378 | // - none: do not load playlists or segments | 299 | 'fullscreenchange', 'webkitfullscreenchange', |
379 | // - meta: load playlists but not segments | 300 | 'mozfullscreenchange', 'MSFullscreenChange' |
380 | // - segments: load everything | 301 | ], (event) => { |
381 | this.loadingState_ = 'none'; | 302 | let fullscreenElement = document.fullscreenElement || |
382 | if (this.tech_.preload() !== 'none') { | 303 | document.webkitFullscreenElement || |
383 | this.loadingState_ = 'meta'; | 304 | document.mozFullScreenElement || |
384 | } | 305 | document.msFullscreenElement; |
385 | 306 | ||
386 | // periodically check if new data needs to be downloaded or | 307 | if (fullscreenElement && fullscreenElement.contains(this.tech_.el())) { |
387 | // buffered data should be appended to the source buffer | 308 | this.masterPlaylistController_.fastQualityChange_(); |
388 | this.startCheckingBuffer_(); | 309 | } |
310 | }); | ||
389 | 311 | ||
390 | this.on(this.tech_, 'seeking', function() { | 312 | this.on(this.tech_, 'seeking', function() { |
391 | this.setCurrentTime(this.tech_.currentTime()); | 313 | this.setCurrentTime(this.tech_.currentTime()); |
392 | }); | 314 | }); |
393 | this.on(this.tech_, 'error', function() { | 315 | this.on(this.tech_, 'error', function() { |
394 | this.stopCheckingBuffer_(); | 316 | if (this.masterPlaylistController_) { |
317 | this.masterPlaylistController_.pauseLoading(); | ||
318 | } | ||
395 | }); | 319 | }); |
396 | 320 | ||
321 | this.audioTrackChange_ = () => { | ||
322 | this.masterPlaylistController_.useAudio(); | ||
323 | }; | ||
324 | |||
397 | this.on(this.tech_, 'play', this.play); | 325 | this.on(this.tech_, 'play', this.play); |
398 | } | 326 | } |
399 | src(src) { | ||
400 | let oldMediaPlaylist; | ||
401 | 327 | ||
328 | /** | ||
329 | * called when player.src gets called, handle a new source | ||
330 | * | ||
331 | * @param {Object} src the source object to handle | ||
332 | */ | ||
333 | src(src) { | ||
402 | // do nothing if the src is falsey | 334 | // do nothing if the src is falsey |
403 | if (!src) { | 335 | if (!src) { |
404 | return; | 336 | return; |
405 | } | 337 | } |
406 | 338 | ||
407 | this.mediaSource = new videojs.MediaSource({ mode: this.mode_ }); | 339 | ['withCredentials', 'bandwidth'].forEach((option) => { |
408 | 340 | if (typeof this.source_[option] !== 'undefined') { | |
409 | // load the MediaSource into the player | 341 | this.options_[option] = this.source_[option]; |
410 | this.mediaSource.addEventListener('sourceopen', this.handleSourceOpen.bind(this)); | ||
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.tech_.hls, | ||
420 | this.options_.withCredentials); | ||
421 | |||
422 | this.tech_.one('canplay', this.setupFirstPlay.bind(this)); | ||
423 | |||
424 | this.playlists.on('loadedmetadata', () => { | ||
425 | oldMediaPlaylist = this.playlists.media(); | ||
426 | |||
427 | // if this isn't a live video and preload permits, start | ||
428 | // downloading segments | ||
429 | if (oldMediaPlaylist.endList && | ||
430 | this.tech_.preload() !== 'metadata' && | ||
431 | this.tech_.preload() !== 'none') { | ||
432 | this.loadingState_ = 'segments'; | ||
433 | } | 342 | } |
434 | |||
435 | this.setupSourceBuffer_(); | ||
436 | this.setupFirstPlay(); | ||
437 | this.fillBuffer(); | ||
438 | this.tech_.trigger('loadedmetadata'); | ||
439 | }); | 343 | }); |
440 | 344 | this.options_.url = this.source_.src; | |
441 | this.playlists.on('error', () => { | 345 | this.options_.tech = this.tech_; |
442 | this.blacklistCurrentPlaylist_(this.playlists.error); | 346 | this.options_.externHls = Hls; |
347 | this.options_.bandwidth = this.bandwidth; | ||
348 | this.masterPlaylistController_ = new MasterPlaylistController(this.options_); | ||
349 | // `this` in selectPlaylist should be the HlsHandler for backwards | ||
350 | // compatibility with < v2 | ||
351 | this.masterPlaylistController_.selectPlaylist = | ||
352 | Hls.STANDARD_PLAYLIST_SELECTOR.bind(this); | ||
353 | |||
354 | // re-expose some internal objects for backwards compatibility with < v2 | ||
355 | this.playlists = this.masterPlaylistController_.masterPlaylistLoader_; | ||
356 | this.mediaSource = this.masterPlaylistController_.mediaSource; | ||
357 | |||
358 | // Proxy assignment of some properties to the master playlist | ||
359 | // controller. Using a custom property for backwards compatibility | ||
360 | // with < v2 | ||
361 | Object.defineProperties(this, { | ||
362 | selectPlaylist: { | ||
363 | get() { | ||
364 | return this.masterPlaylistController_.selectPlaylist; | ||
365 | }, | ||
366 | set(selectPlaylist) { | ||
367 | this.masterPlaylistController_.selectPlaylist = selectPlaylist.bind(this); | ||
368 | } | ||
369 | }, | ||
370 | bandwidth: { | ||
371 | get() { | ||
372 | return this.masterPlaylistController_.mainSegmentLoader_.bandwidth; | ||
373 | }, | ||
374 | set(bandwidth) { | ||
375 | this.masterPlaylistController_.mainSegmentLoader_.bandwidth = bandwidth; | ||
376 | } | ||
377 | } | ||
443 | }); | 378 | }); |
444 | 379 | ||
445 | this.playlists.on('loadedplaylist', () => { | 380 | this.tech_.one('canplay', |
446 | let updatedPlaylist = this.playlists.media(); | 381 | this.masterPlaylistController_.setupFirstPlay.bind(this.masterPlaylistController_)); |
447 | let seekable; | 382 | |
383 | this.masterPlaylistController_.on('sourceopen', () => { | ||
384 | this.tech_.audioTracks().addEventListener('change', this.audioTrackChange_); | ||
385 | }); | ||
448 | 386 | ||
449 | if (!updatedPlaylist) { | 387 | this.masterPlaylistController_.on('audioinfo', (e) => { |
450 | // select the initial variant | 388 | if (!videojs.browser.IS_FIREFOX || |
451 | this.playlists.media(this.selectPlaylist()); | 389 | !this.audioInfo_ || |
390 | !objectChanged(this.audioInfo_, e.info)) { | ||
391 | this.audioInfo_ = e.info; | ||
452 | return; | 392 | return; |
453 | } | 393 | } |
454 | 394 | ||
455 | this.updateDuration(this.playlists.media()); | 395 | let error = 'had different audio properties (channels, sample rate, etc.) ' + |
396 | 'or changed in some other way. This behavior is currently ' + | ||
397 | 'unsupported in Firefox due to an issue: \n\n' + | ||
398 | 'https://bugzilla.mozilla.org/show_bug.cgi?id=1247138\n\n'; | ||
399 | |||
400 | let enabledTrack; | ||
401 | let defaultTrack; | ||
402 | |||
403 | this.masterPlaylistController_.audioTracks_.forEach((t) => { | ||
404 | if (!defaultTrack && t.default) { | ||
405 | defaultTrack = t; | ||
406 | } | ||
407 | |||
408 | if (!enabledTrack && t.enabled) { | ||
409 | enabledTrack = t; | ||
410 | } | ||
411 | }); | ||
456 | 412 | ||
457 | // update seekable | 413 | // they did not switch audiotracks |
458 | seekable = this.seekable(); | 414 | // blacklist the current playlist |
459 | if (this.duration() === Infinity && | 415 | if (!enabledTrack.getLoader(this.activeAudioGroup_())) { |
460 | seekable.length !== 0) { | 416 | error = `The rendition that we tried to switch to ${error}` + |
461 | this.mediaSource.addSeekableRange_(seekable.start(0), seekable.end(0)); | 417 | 'Unfortunately that means we will have to blacklist ' + |
418 | 'the current playlist and switch to another. Sorry!'; | ||
419 | this.masterPlaylistController_.blacklistCurrentPlaylist(); | ||
420 | } else { | ||
421 | error = `The audio track '${enabledTrack.label}' that we tried to ` + | ||
422 | `switch to ${error} Unfortunately this means we will have to ` + | ||
423 | `return you to the main track '${defaultTrack.label}'. Sorry!`; | ||
424 | defaultTrack.enabled = true; | ||
425 | this.tech_.audioTracks().removeTrack(enabledTrack); | ||
462 | } | 426 | } |
463 | 427 | ||
464 | oldMediaPlaylist = updatedPlaylist; | 428 | videojs.log.warn(error); |
429 | this.masterPlaylistController_.useAudio(); | ||
465 | }); | 430 | }); |
466 | 431 | this.masterPlaylistController_.on('selectedinitialmedia', () => { | |
467 | this.playlists.on('mediachange', () => { | 432 | // clear current audioTracks |
468 | this.tech_.trigger({ | 433 | this.tech_.clearTracks('audio'); |
469 | type: 'mediachange', | 434 | this.masterPlaylistController_.audioTracks_.forEach((track) => { |
470 | bubbles: true | 435 | this.tech_.audioTracks().addTrack(track); |
471 | }); | 436 | }); |
472 | }); | 437 | }); |
473 | 438 | ||
439 | // the bandwidth of the primary segment loader is our best | ||
440 | // estimate of overall bandwidth | ||
441 | this.on(this.masterPlaylistController_, 'progress', function() { | ||
442 | this.bandwidth = this.masterPlaylistController_.mainSegmentLoader_.bandwidth; | ||
443 | this.tech_.trigger('progress'); | ||
444 | }); | ||
445 | |||
474 | // do nothing if the tech has been disposed already | 446 | // do nothing if the tech has been disposed already |
475 | // this can occur if someone sets the src in player.ready(), for instance | 447 | // this can occur if someone sets the src in player.ready(), for instance |
476 | if (!this.tech_.el()) { | 448 | if (!this.tech_.el()) { |
477 | return; | 449 | return; |
478 | } | 450 | } |
479 | 451 | ||
480 | this.tech_.src(videojs.URL.createObjectURL(this.mediaSource)); | 452 | this.tech_.src(videojs.URL.createObjectURL( |
481 | } | 453 | this.masterPlaylistController_.mediaSource)); |
482 | handleSourceOpen() { | ||
483 | // Only attempt to create the source buffer if none already exist. | ||
484 | // handleSourceOpen is also called when we are "re-opening" a source buffer | ||
485 | // after `endOfStream` has been called (in response to a seek for instance) | ||
486 | if (!this.sourceBuffer) { | ||
487 | this.setupSourceBuffer_(); | ||
488 | } | ||
489 | |||
490 | // if autoplay is enabled, begin playback. This is duplicative of | ||
491 | // code in video.js but is required because play() must be invoked | ||
492 | // *after* the media source has opened. | ||
493 | // NOTE: moving this invocation of play() after | ||
494 | // sourceBuffer.appendBuffer() below caused live streams with | ||
495 | // autoplay to stall | ||
496 | if (this.tech_.autoplay()) { | ||
497 | this.play(); | ||
498 | } | ||
499 | } | 454 | } |
500 | 455 | ||
501 | /** | 456 | /** |
502 | * Blacklist playlists that are known to be codec or | 457 | * a helper for grabbing the active audio group from MasterPlaylistController |
503 | * stream-incompatible with the SourceBuffer configuration. For | ||
504 | * instance, Media Source Extensions would cause the video element to | ||
505 | * stall waiting for video data if you switched from a variant with | ||
506 | * video and audio to an audio-only one. | ||
507 | * | 458 | * |
508 | * @param media {object} a media playlist compatible with the current | 459 | * @private |
509 | * set of SourceBuffers. Variants in the current master playlist that | ||
510 | * do not appear to have compatible codec or stream configurations | ||
511 | * will be excluded from the default playlist selection algorithm | ||
512 | * indefinitely. | ||
513 | */ | 460 | */ |
514 | excludeIncompatibleVariants_(media) { | 461 | activeAudioGroup_() { |
515 | let master = this.playlists.master; | 462 | return this.masterPlaylistController_.activeAudioGroup(); |
516 | let codecCount = 2; | ||
517 | let videoCodec = null; | ||
518 | let audioProfile = null; | ||
519 | let codecs; | ||
520 | |||
521 | if (media.attributes && media.attributes.CODECS) { | ||
522 | codecs = parseCodecs(media.attributes.CODECS); | ||
523 | videoCodec = codecs.videoCodec; | ||
524 | audioProfile = codecs.audioProfile; | ||
525 | codecCount = codecs.codecCount; | ||
526 | } | ||
527 | master.playlists.forEach(function(variant) { | ||
528 | let variantCodecs = { | ||
529 | codecCount: 2, | ||
530 | videoCodec: null, | ||
531 | audioProfile: null | ||
532 | }; | ||
533 | |||
534 | if (variant.attributes && variant.attributes.CODECS) { | ||
535 | variantCodecs = parseCodecs(variant.attributes.CODECS); | ||
536 | } | ||
537 | |||
538 | // if the streams differ in the presence or absence of audio or | ||
539 | // video, they are incompatible | ||
540 | if (variantCodecs.codecCount !== codecCount) { | ||
541 | variant.excludeUntil = Infinity; | ||
542 | } | ||
543 | |||
544 | // if h.264 is specified on the current playlist, some flavor of | ||
545 | // it must be specified on all compatible variants | ||
546 | if (variantCodecs.videoCodec !== videoCodec) { | ||
547 | variant.excludeUntil = Infinity; | ||
548 | } | ||
549 | // HE-AAC ("mp4a.40.5") is incompatible with all other versions of | ||
550 | // AAC audio in Chrome 46. Don't mix the two. | ||
551 | if ((variantCodecs.audioProfile === '5' && audioProfile !== '5') || | ||
552 | (audioProfile === '5' && variantCodecs.audioProfile !== '5')) { | ||
553 | variant.excludeUntil = Infinity; | ||
554 | } | ||
555 | }); | ||
556 | } | 463 | } |
557 | 464 | ||
558 | setupSourceBuffer_() { | 465 | /** |
559 | let media = this.playlists.media(); | 466 | * Begin playing the video. |
560 | let mimeType; | 467 | */ |
561 | 468 | play() { | |
562 | // wait until a media playlist is available and the Media Source is | 469 | this.masterPlaylistController_.play(); |
563 | // attached | ||
564 | if (!media || this.mediaSource.readyState !== 'open') { | ||
565 | return; | ||
566 | } | ||
567 | |||
568 | // if the codecs were explicitly specified, pass them along to the | ||
569 | // source buffer | ||
570 | mimeType = 'video/mp2t'; | ||
571 | if (media.attributes && media.attributes.CODECS) { | ||
572 | mimeType += '; codecs="' + media.attributes.CODECS + '"'; | ||
573 | } | ||
574 | this.sourceBuffer = this.mediaSource.addSourceBuffer(mimeType); | ||
575 | |||
576 | // exclude any incompatible variant streams from future playlist | ||
577 | // selection | ||
578 | this.excludeIncompatibleVariants_(media); | ||
579 | |||
580 | // transition the sourcebuffer to the ended state if we've hit the end of | ||
581 | // the playlist | ||
582 | this.sourceBuffer.addEventListener('updateend', this.updateEndHandler_.bind(this)); | ||
583 | } | 470 | } |
584 | 471 | ||
585 | /** | 472 | /** |
586 | * Seek to the latest media position if this is a live video and the | 473 | * a wrapper around the function in MasterPlaylistController |
587 | * player and video are loaded and initialized. | ||
588 | */ | 474 | */ |
589 | setupFirstPlay() { | 475 | setCurrentTime(currentTime) { |
590 | let seekable; | 476 | this.masterPlaylistController_.setCurrentTime(currentTime); |
591 | let media = this.playlists.media(); | ||
592 | |||
593 | // check that everything is ready to begin buffering | ||
594 | |||
595 | // 1) the video is a live stream of unknown duration | ||
596 | if (this.duration() === Infinity && | ||
597 | |||
598 | // 2) the player has not played before and is not paused | ||
599 | this.tech_.played().length === 0 && | ||
600 | !this.tech_.paused() && | ||
601 | |||
602 | // 3) the Media Source and Source Buffers are ready | ||
603 | this.sourceBuffer && | ||
604 | |||
605 | // 4) the active media playlist is available | ||
606 | media && | ||
607 | |||
608 | // 5) the video element or flash player is in a readyState of | ||
609 | // at least HAVE_FUTURE_DATA | ||
610 | this.tech_.readyState() >= 1) { | ||
611 | |||
612 | // trigger the playlist loader to start "expired time"-tracking | ||
613 | this.playlists.trigger('firstplay'); | ||
614 | |||
615 | // seek to the latest media position for live videos | ||
616 | seekable = this.seekable(); | ||
617 | if (seekable.length) { | ||
618 | this.tech_.setCurrentTime(seekable.end(0)); | ||
619 | } | ||
620 | } | ||
621 | } | 477 | } |
622 | 478 | ||
623 | /** | 479 | /** |
624 | * Begin playing the video. | 480 | * a wrapper around the function in MasterPlaylistController |
625 | */ | 481 | */ |
626 | play() { | 482 | duration() { |
627 | this.loadingState_ = 'segments'; | 483 | return this.masterPlaylistController_.duration(); |
484 | } | ||
628 | 485 | ||
629 | if (this.tech_.ended()) { | 486 | /** |
630 | this.tech_.setCurrentTime(0); | 487 | * a wrapper around the function in MasterPlaylistController |
631 | } | 488 | */ |
489 | seekable() { | ||
490 | return this.masterPlaylistController_.seekable(); | ||
491 | } | ||
632 | 492 | ||
633 | if (this.tech_.played().length === 0) { | 493 | /** |
634 | return this.setupFirstPlay(); | 494 | * Abort all outstanding work and cleanup. |
495 | */ | ||
496 | dispose() { | ||
497 | if (this.masterPlaylistController_) { | ||
498 | this.masterPlaylistController_.dispose(); | ||
635 | } | 499 | } |
500 | this.tech_.audioTracks().removeEventListener('change', this.audioTrackChange_); | ||
636 | 501 | ||
637 | // if the viewer has paused and we fell out of the live window, | 502 | super.dispose(); |
638 | // seek forward to the earliest available position | ||
639 | if (this.duration() === Infinity) { | ||
640 | if (this.tech_.currentTime() < this.seekable().start(0)) { | ||
641 | this.tech_.setCurrentTime(this.seekable().start(0)); | ||
642 | } | ||
643 | } | ||
644 | } | ||
645 | |||
646 | setCurrentTime(currentTime) { | ||
647 | let buffered = this.findBufferedRange_(); | ||
648 | |||
649 | if (!(this.playlists && this.playlists.media())) { | ||
650 | // return immediately if the metadata is not ready yet | ||
651 | return 0; | ||
652 | } | ||
653 | |||
654 | // it's clearly an edge-case but don't thrown an error if asked to | ||
655 | // seek within an empty playlist | ||
656 | if (!this.playlists.media().segments) { | ||
657 | return 0; | ||
658 | } | ||
659 | |||
660 | // if the seek location is already buffered, continue buffering as | ||
661 | // usual | ||
662 | if (buffered && buffered.length) { | ||
663 | return currentTime; | ||
664 | } | ||
665 | |||
666 | // if we are in the middle of appending a segment, let it finish up | ||
667 | if (this.pendingSegment_ && this.pendingSegment_.buffered) { | ||
668 | return currentTime; | ||
669 | } | ||
670 | |||
671 | this.lastSegmentLoaded_ = null; | ||
672 | |||
673 | // cancel outstanding requests and buffer appends | ||
674 | this.cancelSegmentXhr(); | ||
675 | |||
676 | // abort outstanding key requests, if necessary | ||
677 | if (this.keyXhr_) { | ||
678 | this.keyXhr_.aborted = true; | ||
679 | this.cancelKeyXhr(); | ||
680 | } | ||
681 | |||
682 | // begin filling the buffer at the new position | ||
683 | this.fillBuffer(this.playlists.getMediaIndexForTime_(currentTime)); | ||
684 | } | ||
685 | |||
686 | duration() { | ||
687 | let playlists = this.playlists; | ||
688 | |||
689 | if (!playlists) { | ||
690 | return 0; | ||
691 | } | ||
692 | |||
693 | if (this.mediaSource) { | ||
694 | return this.mediaSource.duration; | ||
695 | } | ||
696 | |||
697 | return Hls.Playlist.duration(playlists.media()); | ||
698 | } | ||
699 | |||
700 | seekable() { | ||
701 | let media; | ||
702 | let seekable; | ||
703 | |||
704 | if (!this.playlists) { | ||
705 | return videojs.createTimeRanges(); | ||
706 | } | ||
707 | media = this.playlists.media(); | ||
708 | if (!media) { | ||
709 | return videojs.createTimeRanges(); | ||
710 | } | ||
711 | |||
712 | seekable = Hls.Playlist.seekable(media); | ||
713 | if (seekable.length === 0) { | ||
714 | return seekable; | ||
715 | } | ||
716 | |||
717 | // if the seekable start is zero, it may be because the player has | ||
718 | // been paused for a long time and stopped buffering. in that case, | ||
719 | // fall back to the playlist loader's running estimate of expired | ||
720 | // time | ||
721 | if (seekable.start(0) === 0) { | ||
722 | return videojs.createTimeRanges([[this.playlists.expired_, | ||
723 | this.playlists.expired_ + seekable.end(0)]]); | ||
724 | } | ||
725 | |||
726 | // seekable has been calculated based on buffering video data so it | ||
727 | // can be returned directly | ||
728 | return seekable; | ||
729 | } | ||
730 | |||
731 | /** | ||
732 | * Update the player duration | ||
733 | */ | ||
734 | updateDuration(playlist) { | ||
735 | let oldDuration = this.mediaSource.duration; | ||
736 | let newDuration = Hls.Playlist.duration(playlist); | ||
737 | let buffered = this.tech_.buffered(); | ||
738 | let setDuration = () => { | ||
739 | this.mediaSource.duration = newDuration; | ||
740 | this.tech_.trigger('durationchange'); | ||
741 | |||
742 | this.mediaSource.removeEventListener('sourceopen', setDuration); | ||
743 | }; | ||
744 | |||
745 | if (buffered.length > 0) { | ||
746 | newDuration = Math.max(newDuration, buffered.end(buffered.length - 1)); | ||
747 | } | ||
748 | |||
749 | // if the duration has changed, invalidate the cached value | ||
750 | if (oldDuration !== newDuration) { | ||
751 | // update the duration | ||
752 | if (this.mediaSource.readyState !== 'open') { | ||
753 | this.mediaSource.addEventListener('sourceopen', setDuration); | ||
754 | } else if (!this.sourceBuffer || !this.sourceBuffer.updating) { | ||
755 | this.mediaSource.duration = newDuration; | ||
756 | this.tech_.trigger('durationchange'); | ||
757 | } | ||
758 | } | ||
759 | } | ||
760 | |||
761 | /** | ||
762 | * Clear all buffers and reset any state relevant to the current | ||
763 | * source. After this function is called, the tech should be in a | ||
764 | * state suitable for switching to a different video. | ||
765 | */ | ||
766 | resetSrc_() { | ||
767 | this.cancelSegmentXhr(); | ||
768 | this.cancelKeyXhr(); | ||
769 | |||
770 | if (this.sourceBuffer && this.mediaSource.readyState === 'open') { | ||
771 | this.sourceBuffer.abort(); | ||
772 | } | ||
773 | } | ||
774 | |||
775 | cancelKeyXhr() { | ||
776 | if (this.keyXhr_) { | ||
777 | this.keyXhr_.onreadystatechange = null; | ||
778 | this.keyXhr_.abort(); | ||
779 | this.keyXhr_ = null; | ||
780 | } | ||
781 | } | ||
782 | |||
783 | cancelSegmentXhr() { | ||
784 | if (this.segmentXhr_) { | ||
785 | // Prevent error handler from running. | ||
786 | this.segmentXhr_.onreadystatechange = null; | ||
787 | this.segmentXhr_.abort(); | ||
788 | this.segmentXhr_ = null; | ||
789 | } | ||
790 | |||
791 | // clear out the segment being processed | ||
792 | this.pendingSegment_ = null; | ||
793 | } | ||
794 | |||
795 | /** | ||
796 | * Abort all outstanding work and cleanup. | ||
797 | */ | ||
798 | dispose() { | ||
799 | this.stopCheckingBuffer_(); | ||
800 | |||
801 | if (this.playlists) { | ||
802 | this.playlists.dispose(); | ||
803 | } | ||
804 | |||
805 | this.resetSrc_(); | ||
806 | super.dispose(); | ||
807 | } | ||
808 | |||
809 | /** | ||
810 | * Chooses the appropriate media playlist based on the current | ||
811 | * bandwidth estimate and the player size. | ||
812 | * @return the highest bitrate playlist less than the currently detected | ||
813 | * bandwidth, accounting for some amount of bandwidth variance | ||
814 | */ | ||
815 | selectPlaylist() { | ||
816 | let effectiveBitrate; | ||
817 | let sortedPlaylists = this.playlists.master.playlists.slice(); | ||
818 | let bandwidthPlaylists = []; | ||
819 | let now = +new Date(); | ||
820 | let i; | ||
821 | let variant; | ||
822 | let bandwidthBestVariant; | ||
823 | let resolutionPlusOne; | ||
824 | let resolutionPlusOneAttribute; | ||
825 | let resolutionBestVariant; | ||
826 | let width; | ||
827 | let height; | ||
828 | |||
829 | sortedPlaylists.sort(Hls.comparePlaylistBandwidth); | ||
830 | |||
831 | // filter out any playlists that have been excluded due to | ||
832 | // incompatible configurations or playback errors | ||
833 | sortedPlaylists = sortedPlaylists.filter((localVariant) => { | ||
834 | if (typeof localVariant.excludeUntil !== 'undefined') { | ||
835 | return now >= localVariant.excludeUntil; | ||
836 | } | ||
837 | return true; | ||
838 | }); | ||
839 | |||
840 | // filter out any variant that has greater effective bitrate | ||
841 | // than the current estimated bandwidth | ||
842 | i = sortedPlaylists.length; | ||
843 | while (i--) { | ||
844 | variant = sortedPlaylists[i]; | ||
845 | |||
846 | // ignore playlists without bandwidth information | ||
847 | if (!variant.attributes || !variant.attributes.BANDWIDTH) { | ||
848 | continue; | ||
849 | } | ||
850 | |||
851 | effectiveBitrate = variant.attributes.BANDWIDTH * bandwidthVariance; | ||
852 | |||
853 | if (effectiveBitrate < this.bandwidth) { | ||
854 | bandwidthPlaylists.push(variant); | ||
855 | |||
856 | // since the playlists are sorted in ascending order by | ||
857 | // bandwidth, the first viable variant is the best | ||
858 | if (!bandwidthBestVariant) { | ||
859 | bandwidthBestVariant = variant; | ||
860 | } | ||
861 | } | ||
862 | } | ||
863 | |||
864 | i = bandwidthPlaylists.length; | ||
865 | |||
866 | // sort variants by resolution | ||
867 | bandwidthPlaylists.sort(Hls.comparePlaylistResolution); | ||
868 | |||
869 | // forget our old variant from above, | ||
870 | // or we might choose that in high-bandwidth scenarios | ||
871 | // (this could be the lowest bitrate rendition as we go through all of them above) | ||
872 | variant = null; | ||
873 | |||
874 | width = parseInt(safeGetComputedStyle(this.tech_.el(), 'width'), 10); | ||
875 | height = parseInt(safeGetComputedStyle(this.tech_.el(), 'height'), 10); | ||
876 | |||
877 | // iterate through the bandwidth-filtered playlists and find | ||
878 | // best rendition by player dimension | ||
879 | while (i--) { | ||
880 | variant = bandwidthPlaylists[i]; | ||
881 | |||
882 | // ignore playlists without resolution information | ||
883 | if (!variant.attributes || | ||
884 | !variant.attributes.RESOLUTION || | ||
885 | !variant.attributes.RESOLUTION.width || | ||
886 | !variant.attributes.RESOLUTION.height) { | ||
887 | continue; | ||
888 | } | ||
889 | |||
890 | // since the playlists are sorted, the first variant that has | ||
891 | // dimensions less than or equal to the player size is the best | ||
892 | |||
893 | let variantResolution = variant.attributes.RESOLUTION; | ||
894 | |||
895 | if (variantResolution.width === width && | ||
896 | variantResolution.height === height) { | ||
897 | // if we have the exact resolution as the player use it | ||
898 | resolutionPlusOne = null; | ||
899 | resolutionBestVariant = variant; | ||
900 | break; | ||
901 | } else if (variantResolution.width < width && | ||
902 | variantResolution.height < height) { | ||
903 | // if both dimensions are less than the player use the | ||
904 | // previous (next-largest) variant | ||
905 | break; | ||
906 | } else if (!resolutionPlusOne || | ||
907 | (variantResolution.width < resolutionPlusOneAttribute.width && | ||
908 | variantResolution.height < resolutionPlusOneAttribute.height)) { | ||
909 | // If we still haven't found a good match keep a | ||
910 | // reference to the previous variant for the next loop | ||
911 | // iteration | ||
912 | |||
913 | // By only saving variants if they are smaller than the | ||
914 | // previously saved variant, we ensure that we also pick | ||
915 | // the highest bandwidth variant that is just-larger-than | ||
916 | // the video player | ||
917 | resolutionPlusOne = variant; | ||
918 | resolutionPlusOneAttribute = resolutionPlusOne.attributes.RESOLUTION; | ||
919 | } | ||
920 | } | ||
921 | |||
922 | // fallback chain of variants | ||
923 | return resolutionPlusOne || | ||
924 | resolutionBestVariant || | ||
925 | bandwidthBestVariant || | ||
926 | sortedPlaylists[0]; | ||
927 | } | ||
928 | |||
929 | /** | ||
930 | * Periodically request new segments and append video data. | ||
931 | */ | ||
932 | checkBuffer_() { | ||
933 | // calling this method directly resets any outstanding buffer checks | ||
934 | if (this.checkBufferTimeout_) { | ||
935 | window.clearTimeout(this.checkBufferTimeout_); | ||
936 | this.checkBufferTimeout_ = null; | ||
937 | } | ||
938 | |||
939 | this.fillBuffer(); | ||
940 | this.drainBuffer(); | ||
941 | |||
942 | // wait awhile and try again | ||
943 | this.checkBufferTimeout_ = window.setTimeout((this.checkBuffer_).bind(this), | ||
944 | bufferCheckInterval); | ||
945 | } | ||
946 | |||
947 | /** | ||
948 | * Setup a periodic task to request new segments if necessary and | ||
949 | * append bytes into the SourceBuffer. | ||
950 | */ | ||
951 | startCheckingBuffer_() { | ||
952 | this.checkBuffer_(); | ||
953 | } | ||
954 | |||
955 | /** | ||
956 | * Stop the periodic task requesting new segments and feeding the | ||
957 | * SourceBuffer. | ||
958 | */ | ||
959 | stopCheckingBuffer_() { | ||
960 | if (this.checkBufferTimeout_) { | ||
961 | window.clearTimeout(this.checkBufferTimeout_); | ||
962 | this.checkBufferTimeout_ = null; | ||
963 | } | ||
964 | } | ||
965 | |||
966 | /** | ||
967 | * Determines whether there is enough video data currently in the buffer | ||
968 | * and downloads a new segment if the buffered time is less than the goal. | ||
969 | * @param seekToTime (optional) {number} the offset into the downloaded segment | ||
970 | * to seek to, in seconds | ||
971 | */ | ||
972 | fillBuffer(mediaIndex) { | ||
973 | let tech = this.tech_; | ||
974 | let currentTime = tech.currentTime(); | ||
975 | let hasBufferedContent = (this.tech_.buffered().length !== 0); | ||
976 | let currentBuffered = this.findBufferedRange_(); | ||
977 | let outsideBufferedRanges = !(currentBuffered && currentBuffered.length); | ||
978 | let currentBufferedEnd = 0; | ||
979 | let bufferedTime = 0; | ||
980 | let segment; | ||
981 | let segmentInfo; | ||
982 | let segmentTimestampOffset; | ||
983 | |||
984 | // if preload is set to "none", do not download segments until playback is requested | ||
985 | if (this.loadingState_ !== 'segments') { | ||
986 | return; | ||
987 | } | ||
988 | |||
989 | // if a video has not been specified, do nothing | ||
990 | if (!tech.currentSrc() || !this.playlists) { | ||
991 | return; | ||
992 | } | ||
993 | |||
994 | // if there is a request already in flight, do nothing | ||
995 | if (this.segmentXhr_) { | ||
996 | return; | ||
997 | } | ||
998 | |||
999 | // wait until the buffer is up to date | ||
1000 | if (this.pendingSegment_) { | ||
1001 | return; | ||
1002 | } | ||
1003 | |||
1004 | // if no segments are available, do nothing | ||
1005 | if (this.playlists.state === 'HAVE_NOTHING' || | ||
1006 | !this.playlists.media() || | ||
1007 | !this.playlists.media().segments) { | ||
1008 | return; | ||
1009 | } | ||
1010 | |||
1011 | // if a playlist switch is in progress, wait for it to finish | ||
1012 | if (this.playlists.state === 'SWITCHING_MEDIA') { | ||
1013 | return; | ||
1014 | } | ||
1015 | |||
1016 | if (typeof mediaIndex === 'undefined') { | ||
1017 | if (currentBuffered && currentBuffered.length) { | ||
1018 | currentBufferedEnd = currentBuffered.end(0); | ||
1019 | mediaIndex = this.playlists.getMediaIndexForTime_(currentBufferedEnd); | ||
1020 | bufferedTime = Math.max(0, currentBufferedEnd - currentTime); | ||
1021 | |||
1022 | // if there is plenty of content in the buffer and we're not | ||
1023 | // seeking, relax for awhile | ||
1024 | if (bufferedTime >= Hls.GOAL_BUFFER_LENGTH) { | ||
1025 | return; | ||
1026 | } | ||
1027 | } else { | ||
1028 | mediaIndex = this.playlists.getMediaIndexForTime_(this.tech_.currentTime()); | ||
1029 | } | ||
1030 | } | ||
1031 | segment = this.playlists.media().segments[mediaIndex]; | ||
1032 | |||
1033 | // if the video has finished downloading | ||
1034 | if (!segment) { | ||
1035 | return; | ||
1036 | } | ||
1037 | |||
1038 | // we have entered a state where we are fetching the same segment, | ||
1039 | // try to walk forward | ||
1040 | if (this.lastSegmentLoaded_ && | ||
1041 | this.playlistUriToUrl(this.lastSegmentLoaded_.uri) === this.playlistUriToUrl(segment.uri) && | ||
1042 | this.lastSegmentLoaded_.byterange === segment.byterange) { | ||
1043 | return this.fillBuffer(mediaIndex + 1); | ||
1044 | } | ||
1045 | |||
1046 | // package up all the work to append the segment | ||
1047 | segmentInfo = { | ||
1048 | // resolve the segment URL relative to the playlist | ||
1049 | uri: this.playlistUriToUrl(segment.uri), | ||
1050 | // the segment's mediaIndex & mediaSequence at the time it was requested | ||
1051 | mediaIndex, | ||
1052 | mediaSequence: this.playlists.media().mediaSequence, | ||
1053 | // the segment's playlist | ||
1054 | playlist: this.playlists.media(), | ||
1055 | // The state of the buffer when this segment was requested | ||
1056 | currentBufferedEnd, | ||
1057 | // unencrypted bytes of the segment | ||
1058 | bytes: null, | ||
1059 | // when a key is defined for this segment, the encrypted bytes | ||
1060 | encryptedBytes: null, | ||
1061 | // optionally, the decrypter that is unencrypting the segment | ||
1062 | decrypter: null, | ||
1063 | // the state of the buffer before a segment is appended will be | ||
1064 | // stored here so that the actual segment duration can be | ||
1065 | // determined after it has been appended | ||
1066 | buffered: null, | ||
1067 | // The target timestampOffset for this segment when we append it | ||
1068 | // to the source buffer | ||
1069 | timestampOffset: null | ||
1070 | }; | ||
1071 | |||
1072 | if (mediaIndex > 0) { | ||
1073 | segmentTimestampOffset = Hls.Playlist.duration(segmentInfo.playlist, | ||
1074 | segmentInfo.playlist.mediaSequence + mediaIndex) + this.playlists.expired_; | ||
1075 | } | ||
1076 | |||
1077 | if (this.tech_.seeking() && outsideBufferedRanges) { | ||
1078 | // If there are discontinuities in the playlist, we can't be sure of anything | ||
1079 | // related to time so we reset the timestamp offset and start appending data | ||
1080 | // anew on every seek | ||
1081 | if (segmentInfo.playlist.discontinuityStarts.length) { | ||
1082 | segmentInfo.timestampOffset = segmentTimestampOffset; | ||
1083 | } | ||
1084 | } else if (segment.discontinuity && currentBuffered.length) { | ||
1085 | // If we aren't seeking and are crossing a discontinuity, we should set | ||
1086 | // timestampOffset for new segments to be appended the end of the current | ||
1087 | // buffered time-range | ||
1088 | segmentInfo.timestampOffset = currentBuffered.end(0); | ||
1089 | } else if (!hasBufferedContent && this.tech_.currentTime() > 0.05) { | ||
1090 | // If we are trying to play at a position that is not zero but we aren't | ||
1091 | // currently seeking according to the video element | ||
1092 | segmentInfo.timestampOffset = segmentTimestampOffset; | ||
1093 | } | ||
1094 | |||
1095 | this.loadSegment(segmentInfo); | ||
1096 | } | ||
1097 | |||
1098 | playlistUriToUrl(segmentRelativeUrl) { | ||
1099 | let playListUrl; | ||
1100 | |||
1101 | // resolve the segment URL relative to the playlist | ||
1102 | if (this.playlists.media().uri === this.source_.src) { | ||
1103 | playListUrl = resolveUrl(this.source_.src, segmentRelativeUrl); | ||
1104 | } else { | ||
1105 | playListUrl = | ||
1106 | resolveUrl(resolveUrl(this.source_.src, this.playlists.media().uri || ''), | ||
1107 | segmentRelativeUrl); | ||
1108 | } | ||
1109 | return playListUrl; | ||
1110 | } | ||
1111 | |||
1112 | /* | ||
1113 | * Turns segment byterange into a string suitable for use in | ||
1114 | * HTTP Range requests | ||
1115 | */ | ||
1116 | byterangeStr_(byterange) { | ||
1117 | let byterangeStart; | ||
1118 | let byterangeEnd; | ||
1119 | |||
1120 | // `byterangeEnd` is one less than `offset + length` because the HTTP range | ||
1121 | // header uses inclusive ranges | ||
1122 | byterangeEnd = byterange.offset + byterange.length - 1; | ||
1123 | byterangeStart = byterange.offset; | ||
1124 | return 'bytes=' + byterangeStart + '-' + byterangeEnd; | ||
1125 | } | ||
1126 | |||
1127 | /* | ||
1128 | * Defines headers for use in the xhr request for a particular segment. | ||
1129 | */ | ||
1130 | segmentXhrHeaders_(segment) { | ||
1131 | let headers = {}; | ||
1132 | |||
1133 | if ('byterange' in segment) { | ||
1134 | headers.Range = this.byterangeStr_(segment.byterange); | ||
1135 | } | ||
1136 | return headers; | ||
1137 | } | ||
1138 | |||
1139 | /* | ||
1140 | * Sets `bandwidth`, `segmentXhrTime`, and appends to the `bytesReceived. | ||
1141 | * Expects an object with: | ||
1142 | * * `roundTripTime` - the round trip time for the request we're setting the time for | ||
1143 | * * `bandwidth` - the bandwidth we want to set | ||
1144 | * * `bytesReceived` - amount of bytes downloaded | ||
1145 | * `bandwidth` is the only required property. | ||
1146 | */ | ||
1147 | setBandwidth(localXhr) { | ||
1148 | // calculate the download bandwidth | ||
1149 | this.segmentXhrTime = localXhr.roundTripTime; | ||
1150 | this.bandwidth = localXhr.bandwidth; | ||
1151 | this.bytesReceived += localXhr.bytesReceived || 0; | ||
1152 | |||
1153 | this.tech_.trigger('bandwidthupdate'); | ||
1154 | } | ||
1155 | |||
1156 | /* | ||
1157 | * Blacklists a playlist when an error occurs for a set amount of time | ||
1158 | * making it unavailable for selection by the rendition selection algorithm | ||
1159 | * and then forces a new playlist (rendition) selection. | ||
1160 | */ | ||
1161 | blacklistCurrentPlaylist_(error) { | ||
1162 | let currentPlaylist; | ||
1163 | let nextPlaylist; | ||
1164 | |||
1165 | // If the `error` was generated by the playlist loader, it will contain | ||
1166 | // the playlist we were trying to load (but failed) and that should be | ||
1167 | // blacklisted instead of the currently selected playlist which is likely | ||
1168 | // out-of-date in this scenario | ||
1169 | currentPlaylist = error.playlist || this.playlists.media(); | ||
1170 | |||
1171 | // If there is no current playlist, then an error occurred while we were | ||
1172 | // trying to load the master OR while we were disposing of the tech | ||
1173 | if (!currentPlaylist) { | ||
1174 | this.error = error; | ||
1175 | return this.mediaSource.endOfStream('network'); | ||
1176 | } | ||
1177 | |||
1178 | // Blacklist this playlist | ||
1179 | currentPlaylist.excludeUntil = Date.now() + blacklistDuration; | ||
1180 | |||
1181 | // Select a new playlist | ||
1182 | nextPlaylist = this.selectPlaylist(); | ||
1183 | |||
1184 | if (nextPlaylist) { | ||
1185 | videojs.log.warn('Problem encountered with the current ' + | ||
1186 | 'HLS playlist. Switching to another playlist.'); | ||
1187 | |||
1188 | return this.playlists.media(nextPlaylist); | ||
1189 | } | ||
1190 | videojs.log.warn('Problem encountered with the current ' + | ||
1191 | 'HLS playlist. No suitable alternatives found.'); | ||
1192 | // We have no more playlists we can select so we must fail | ||
1193 | this.error = error; | ||
1194 | return this.mediaSource.endOfStream('network'); | ||
1195 | } | ||
1196 | |||
1197 | loadSegment(segmentInfo) { | ||
1198 | let segment = segmentInfo.playlist.segments[segmentInfo.mediaIndex]; | ||
1199 | let removeToTime = 0; | ||
1200 | let seekable = this.seekable(); | ||
1201 | let currentTime = this.tech_.currentTime(); | ||
1202 | |||
1203 | // Chrome has a hard limit of 150mb of | ||
1204 | // buffer and a very conservative "garbage collector" | ||
1205 | // We manually clear out the old buffer to ensure | ||
1206 | // we don't trigger the QuotaExceeded error | ||
1207 | // on the source buffer during subsequent appends | ||
1208 | if (this.sourceBuffer && !this.sourceBuffer.updating) { | ||
1209 | // If we have a seekable range use that as the limit for what can be removed safely | ||
1210 | // otherwise remove anything older than 1 minute before the current play head | ||
1211 | if (seekable.length && seekable.start(0) > 0 && seekable.start(0) < currentTime) { | ||
1212 | removeToTime = seekable.start(0); | ||
1213 | } else { | ||
1214 | removeToTime = currentTime - 60; | ||
1215 | } | ||
1216 | |||
1217 | if (removeToTime > 0) { | ||
1218 | this.sourceBuffer.remove(0, removeToTime); | ||
1219 | } | ||
1220 | } | ||
1221 | |||
1222 | // if the segment is encrypted, request the key | ||
1223 | if (segment.key) { | ||
1224 | this.fetchKey_(segment); | ||
1225 | } | ||
1226 | |||
1227 | // request the next segment | ||
1228 | this.segmentXhr_ = this.tech_.hls.xhr({ | ||
1229 | uri: segmentInfo.uri, | ||
1230 | responseType: 'arraybuffer', | ||
1231 | withCredentials: this.source_.withCredentials, | ||
1232 | // Set xhr timeout to 150% of the segment duration to allow us | ||
1233 | // some time to switch renditions in the event of a catastrophic | ||
1234 | // decrease in network performance or a server issue. | ||
1235 | timeout: (segment.duration * 1.5) * 1000, | ||
1236 | headers: this.segmentXhrHeaders_(segment) | ||
1237 | }, (error, request) => { | ||
1238 | // This is a timeout of a previously aborted segment request | ||
1239 | // so simply ignore it | ||
1240 | if (!this.segmentXhr_ || request !== this.segmentXhr_) { | ||
1241 | return; | ||
1242 | } | ||
1243 | |||
1244 | // the segment request is no longer outstanding | ||
1245 | this.segmentXhr_ = null; | ||
1246 | |||
1247 | // if a segment request times out, we may have better luck with another playlist | ||
1248 | if (request.timedout) { | ||
1249 | this.bandwidth = 1; | ||
1250 | return this.playlists.media(this.selectPlaylist()); | ||
1251 | } | ||
1252 | |||
1253 | // otherwise, trigger a network error | ||
1254 | if (!request.aborted && error) { | ||
1255 | return this.blacklistCurrentPlaylist_({ | ||
1256 | status: request.status, | ||
1257 | message: 'HLS segment request error at URL: ' + segmentInfo.uri, | ||
1258 | code: (request.status >= 500) ? 4 : 2 | ||
1259 | }); | ||
1260 | } | ||
1261 | |||
1262 | // stop processing if the request was aborted | ||
1263 | if (!request.response) { | ||
1264 | return; | ||
1265 | } | ||
1266 | |||
1267 | this.lastSegmentLoaded_ = segment; | ||
1268 | this.setBandwidth(request); | ||
1269 | |||
1270 | if (segment.key) { | ||
1271 | segmentInfo.encryptedBytes = new Uint8Array(request.response); | ||
1272 | } else { | ||
1273 | segmentInfo.bytes = new Uint8Array(request.response); | ||
1274 | } | ||
1275 | |||
1276 | this.pendingSegment_ = segmentInfo; | ||
1277 | |||
1278 | this.tech_.trigger('progress'); | ||
1279 | this.drainBuffer(); | ||
1280 | |||
1281 | // figure out what stream the next segment should be downloaded from | ||
1282 | // with the updated bandwidth information | ||
1283 | this.playlists.media(this.selectPlaylist()); | ||
1284 | }); | ||
1285 | |||
1286 | } | ||
1287 | |||
1288 | drainBuffer() { | ||
1289 | let segmentInfo; | ||
1290 | let mediaIndex; | ||
1291 | let playlist; | ||
1292 | let bytes; | ||
1293 | let segment; | ||
1294 | let decrypter; | ||
1295 | let segIv; | ||
1296 | |||
1297 | // if the buffer is empty or the source buffer hasn't been created | ||
1298 | // yet, do nothing | ||
1299 | if (!this.pendingSegment_ || !this.sourceBuffer) { | ||
1300 | return; | ||
1301 | } | ||
1302 | |||
1303 | // the pending segment has already been appended and we're waiting | ||
1304 | // for updateend to fire | ||
1305 | if (this.pendingSegment_.buffered) { | ||
1306 | return; | ||
1307 | } | ||
1308 | |||
1309 | // we can't append more data if the source buffer is busy processing | ||
1310 | // what we've already sent | ||
1311 | if (this.sourceBuffer.updating) { | ||
1312 | return; | ||
1313 | } | ||
1314 | |||
1315 | segmentInfo = this.pendingSegment_; | ||
1316 | mediaIndex = segmentInfo.mediaIndex; | ||
1317 | playlist = segmentInfo.playlist; | ||
1318 | bytes = segmentInfo.bytes; | ||
1319 | segment = playlist.segments[mediaIndex]; | ||
1320 | |||
1321 | if (segment.key && !bytes) { | ||
1322 | // this is an encrypted segment | ||
1323 | // if the key download failed, we want to skip this segment | ||
1324 | // but if the key hasn't downloaded yet, we want to try again later | ||
1325 | if (keyFailed(segment.key)) { | ||
1326 | return this.blacklistCurrentPlaylist_({ | ||
1327 | message: 'HLS segment key request error.', | ||
1328 | code: 4 | ||
1329 | }); | ||
1330 | } else if (!segment.key.bytes) { | ||
1331 | // waiting for the key bytes, try again later | ||
1332 | return; | ||
1333 | } else if (segmentInfo.decrypter) { | ||
1334 | // decryption is in progress, try again later | ||
1335 | return; | ||
1336 | } | ||
1337 | // if the media sequence is greater than 2^32, the IV will be incorrect | ||
1338 | // assuming 10s segments, that would be about 1300 years | ||
1339 | segIv = segment.key.iv || | ||
1340 | new Uint32Array([0, 0, 0, mediaIndex + playlist.mediaSequence]); | ||
1341 | |||
1342 | // create a decrypter to incrementally decrypt the segment | ||
1343 | decrypter = new Hls.Decrypter(segmentInfo.encryptedBytes, | ||
1344 | segment.key.bytes, | ||
1345 | segIv, | ||
1346 | function(error, localBytes) { | ||
1347 | if (error) { | ||
1348 | videojs.log.warn(error); | ||
1349 | } | ||
1350 | segmentInfo.bytes = localBytes; | ||
1351 | }); | ||
1352 | segmentInfo.decrypter = decrypter; | ||
1353 | return; | ||
1354 | } | ||
1355 | |||
1356 | this.pendingSegment_.buffered = this.tech_.buffered(); | ||
1357 | |||
1358 | if (segmentInfo.timestampOffset !== null) { | ||
1359 | this.sourceBuffer.timestampOffset = segmentInfo.timestampOffset; | ||
1360 | } | ||
1361 | |||
1362 | // the segment is asynchronously added to the current buffered data | ||
1363 | this.sourceBuffer.appendBuffer(bytes); | ||
1364 | } | ||
1365 | |||
1366 | updateEndHandler_() { | ||
1367 | let segmentInfo = this.pendingSegment_; | ||
1368 | let playlist; | ||
1369 | let currentMediaIndex; | ||
1370 | let currentBuffered; | ||
1371 | let seekable; | ||
1372 | let timelineUpdate; | ||
1373 | let isEndOfStream; | ||
1374 | |||
1375 | // stop here if the update errored or was aborted | ||
1376 | if (!segmentInfo) { | ||
1377 | this.pendingSegment_ = null; | ||
1378 | return; | ||
1379 | } | ||
1380 | |||
1381 | // In Firefox, the updateend event is triggered for both removing from the buffer and | ||
1382 | // adding to the buffer. To prevent this code from executing on removals, we wait for | ||
1383 | // segmentInfo to have a filled in buffered value before we continue processing. | ||
1384 | if (!segmentInfo.buffered) { | ||
1385 | return; | ||
1386 | } | ||
1387 | |||
1388 | this.pendingSegment_ = null; | ||
1389 | |||
1390 | playlist = segmentInfo.playlist; | ||
1391 | currentMediaIndex = segmentInfo.mediaIndex + | ||
1392 | (segmentInfo.mediaSequence - playlist.mediaSequence); | ||
1393 | currentBuffered = this.findBufferedRange_(); | ||
1394 | isEndOfStream = detectEndOfStream(playlist, this.mediaSource, currentMediaIndex, currentBuffered); | ||
1395 | |||
1396 | // if we switched renditions don't try to add segment timeline | ||
1397 | // information to the playlist | ||
1398 | if (segmentInfo.playlist.uri !== this.playlists.media().uri) { | ||
1399 | if (isEndOfStream) { | ||
1400 | return this.mediaSource.endOfStream(); | ||
1401 | } | ||
1402 | return this.fillBuffer(); | ||
1403 | } | ||
1404 | |||
1405 | // when seeking to the beginning of the seekable range, it's | ||
1406 | // possible that imprecise timing information may cause the seek to | ||
1407 | // end up earlier than the start of the range | ||
1408 | // in that case, seek again | ||
1409 | seekable = this.seekable(); | ||
1410 | if (this.tech_.seeking() && | ||
1411 | currentBuffered.length === 0) { | ||
1412 | if (seekable.length && | ||
1413 | this.tech_.currentTime() < seekable.start(0)) { | ||
1414 | let next = this.findNextBufferedRange_(); | ||
1415 | |||
1416 | if (next.length) { | ||
1417 | videojs.log('tried seeking to', this.tech_.currentTime(), | ||
1418 | 'but that was too early, retrying at', next.start(0)); | ||
1419 | this.tech_.setCurrentTime(next.start(0) + TIME_FUDGE_FACTOR); | ||
1420 | } | ||
1421 | } | ||
1422 | } | ||
1423 | |||
1424 | timelineUpdate = Hls.findSoleUncommonTimeRangesEnd_(segmentInfo.buffered, | ||
1425 | this.tech_.buffered()); | ||
1426 | |||
1427 | // Update segment meta-data (duration and end-point) based on timeline | ||
1428 | updateSegmentMetadata(playlist, currentMediaIndex, timelineUpdate); | ||
1429 | |||
1430 | // If we decide to signal the end of stream, then we can return instead | ||
1431 | // of trying to fetch more segments | ||
1432 | if (isEndOfStream) { | ||
1433 | return this.mediaSource.endOfStream(); | ||
1434 | } | ||
1435 | |||
1436 | if (timelineUpdate !== null || | ||
1437 | segmentInfo.buffered.length !== this.tech_.buffered().length) { | ||
1438 | this.updateDuration(playlist); | ||
1439 | // check if it's time to download the next segment | ||
1440 | this.fillBuffer(); | ||
1441 | return; | ||
1442 | } | ||
1443 | |||
1444 | // the last segment append must have been entirely in the | ||
1445 | // already buffered time ranges. just buffer forward until we | ||
1446 | // find a segment that adds to the buffered time ranges and | ||
1447 | // improves subsequent media index calculations. | ||
1448 | this.fillBuffer(currentMediaIndex + 1); | ||
1449 | return; | ||
1450 | } | ||
1451 | |||
1452 | /** | ||
1453 | * Attempt to retrieve the key for a particular media segment. | ||
1454 | */ | ||
1455 | fetchKey_(segment) { | ||
1456 | let key; | ||
1457 | let settings; | ||
1458 | let receiveKey; | ||
1459 | |||
1460 | // if there is a pending XHR or no segments, don't do anything | ||
1461 | if (this.keyXhr_) { | ||
1462 | return; | ||
1463 | } | ||
1464 | |||
1465 | settings = this.options_; | ||
1466 | |||
1467 | /** | ||
1468 | * Handle a key XHR response. | ||
1469 | */ | ||
1470 | receiveKey = (keyRecieved) => { | ||
1471 | return (error, request) => { | ||
1472 | let view; | ||
1473 | |||
1474 | this.keyXhr_ = null; | ||
1475 | |||
1476 | if (error || !request.response || request.response.byteLength !== 16) { | ||
1477 | keyRecieved.retries = keyRecieved.retries || 0; | ||
1478 | keyRecieved.retries++; | ||
1479 | if (!request.aborted) { | ||
1480 | // try fetching again | ||
1481 | this.fetchKey_(segment); | ||
1482 | } | ||
1483 | return; | ||
1484 | } | ||
1485 | |||
1486 | view = new DataView(request.response); | ||
1487 | keyRecieved.bytes = new Uint32Array([ | ||
1488 | view.getUint32(0), | ||
1489 | view.getUint32(4), | ||
1490 | view.getUint32(8), | ||
1491 | view.getUint32(12) | ||
1492 | ]); | ||
1493 | |||
1494 | // check to see if this allows us to make progress buffering now | ||
1495 | this.checkBuffer_(); | ||
1496 | }; | ||
1497 | }; | ||
1498 | |||
1499 | key = segment.key; | ||
1500 | |||
1501 | // nothing to do if this segment is unencrypted | ||
1502 | if (!key) { | ||
1503 | return; | ||
1504 | } | ||
1505 | |||
1506 | // request the key if the retry limit hasn't been reached | ||
1507 | if (!key.bytes && !keyFailed(key)) { | ||
1508 | this.keyXhr_ = this.tech_.hls.xhr({ | ||
1509 | uri: this.playlistUriToUrl(key.uri), | ||
1510 | responseType: 'arraybuffer', | ||
1511 | withCredentials: settings.withCredentials | ||
1512 | }, receiveKey(key)); | ||
1513 | return; | ||
1514 | } | ||
1515 | } | 503 | } |
1516 | } | 504 | } |
1517 | 505 | ||
1518 | /** | 506 | /** |
1519 | * Attempts to find the buffered TimeRange that contains the specified | ||
1520 | * time, or where playback is currently happening if no specific time | ||
1521 | * is specified. | ||
1522 | * @param time (optional) {number} the time to filter on. Defaults to | ||
1523 | * currentTime. | ||
1524 | * @return a new TimeRanges object. | ||
1525 | */ | ||
1526 | HlsHandler.prototype.findBufferedRange_ = | ||
1527 | filterBufferedRanges(function(start, end, time) { | ||
1528 | return start - TIME_FUDGE_FACTOR <= time && | ||
1529 | end + TIME_FUDGE_FACTOR >= time; | ||
1530 | }); | ||
1531 | /** | ||
1532 | * Returns the TimeRanges that begin at or later than the specified | ||
1533 | * time. | ||
1534 | * @param time (optional) {number} the time to filter on. Defaults to | ||
1535 | * currentTime. | ||
1536 | * @return a new TimeRanges object. | ||
1537 | */ | ||
1538 | HlsHandler.prototype.findNextBufferedRange_ = | ||
1539 | filterBufferedRanges(function(start, end, time) { | ||
1540 | return start - TIME_FUDGE_FACTOR >= time; | ||
1541 | }); | ||
1542 | |||
1543 | /** | ||
1544 | * The Source Handler object, which informs video.js what additional | 507 | * The Source Handler object, which informs video.js what additional |
1545 | * MIME types are supported and sets up playback. It is registered | 508 | * MIME types are supported and sets up playback. It is registered |
1546 | * automatically to the appropriate tech based on the capabilities of | 509 | * automatically to the appropriate tech based on the capabilities of |
... | @@ -1550,9 +513,16 @@ HlsHandler.prototype.findNextBufferedRange_ = | ... | @@ -1550,9 +513,16 @@ HlsHandler.prototype.findNextBufferedRange_ = |
1550 | const HlsSourceHandler = function(mode) { | 513 | const HlsSourceHandler = function(mode) { |
1551 | return { | 514 | return { |
1552 | canHandleSource(srcObj) { | 515 | canHandleSource(srcObj) { |
516 | // this forces video.js to skip this tech/mode if its not the one we have been | ||
517 | // overriden to use, by returing that we cannot handle the source. | ||
518 | if (videojs.options.hls && | ||
519 | videojs.options.hls.mode && | ||
520 | videojs.options.hls.mode !== mode) { | ||
521 | return false; | ||
522 | } | ||
1553 | return HlsSourceHandler.canPlayType(srcObj.type); | 523 | return HlsSourceHandler.canPlayType(srcObj.type); |
1554 | }, | 524 | }, |
1555 | handleSource(source, tech) { | 525 | handleSource(source, tech, options) { |
1556 | if (mode === 'flash') { | 526 | if (mode === 'flash') { |
1557 | // We need to trigger this asynchronously to give others the chance | 527 | // We need to trigger this asynchronously to give others the chance |
1558 | // to bind to the event when a source is set at player creation | 528 | // to bind to the event when a source is set at player creation |
... | @@ -1560,10 +530,10 @@ const HlsSourceHandler = function(mode) { | ... | @@ -1560,10 +530,10 @@ const HlsSourceHandler = function(mode) { |
1560 | tech.trigger('loadstart'); | 530 | tech.trigger('loadstart'); |
1561 | }, 1); | 531 | }, 1); |
1562 | } | 532 | } |
1563 | tech.hls = new HlsHandler(tech, { | 533 | |
1564 | source, | 534 | let settings = videojs.mergeOptions(options, {hls: {mode}}); |
1565 | mode | 535 | |
1566 | }); | 536 | tech.hls = new HlsHandler(source, tech, settings); |
1567 | 537 | ||
1568 | tech.hls.xhr = xhrFactory(); | 538 | tech.hls.xhr = xhrFactory(); |
1569 | // Use a global `before` function if specified on videojs.Hls.xhr | 539 | // Use a global `before` function if specified on videojs.Hls.xhr |
... | @@ -1576,13 +546,81 @@ const HlsSourceHandler = function(mode) { | ... | @@ -1576,13 +546,81 @@ const HlsSourceHandler = function(mode) { |
1576 | return tech.hls; | 546 | return tech.hls; |
1577 | }, | 547 | }, |
1578 | canPlayType(type) { | 548 | canPlayType(type) { |
1579 | return HlsSourceHandler.canPlayType(type); | 549 | if (HlsSourceHandler.canPlayType(type)) { |
550 | return 'maybe'; | ||
551 | } | ||
552 | return ''; | ||
1580 | } | 553 | } |
1581 | }; | 554 | }; |
1582 | }; | 555 | }; |
1583 | 556 | ||
557 | /** | ||
558 | * A comparator function to sort two playlist object by bandwidth. | ||
559 | * | ||
560 | * @param {Object} left a media playlist object | ||
561 | * @param {Object} right a media playlist object | ||
562 | * @return {Number} Greater than zero if the bandwidth attribute of | ||
563 | * left is greater than the corresponding attribute of right. Less | ||
564 | * than zero if the bandwidth of right is greater than left and | ||
565 | * exactly zero if the two are equal. | ||
566 | */ | ||
567 | Hls.comparePlaylistBandwidth = function(left, right) { | ||
568 | let leftBandwidth; | ||
569 | let rightBandwidth; | ||
570 | |||
571 | if (left.attributes && left.attributes.BANDWIDTH) { | ||
572 | leftBandwidth = left.attributes.BANDWIDTH; | ||
573 | } | ||
574 | leftBandwidth = leftBandwidth || window.Number.MAX_VALUE; | ||
575 | if (right.attributes && right.attributes.BANDWIDTH) { | ||
576 | rightBandwidth = right.attributes.BANDWIDTH; | ||
577 | } | ||
578 | rightBandwidth = rightBandwidth || window.Number.MAX_VALUE; | ||
579 | |||
580 | return leftBandwidth - rightBandwidth; | ||
581 | }; | ||
582 | |||
583 | /** | ||
584 | * A comparator function to sort two playlist object by resolution (width). | ||
585 | * @param {Object} left a media playlist object | ||
586 | * @param {Object} right a media playlist object | ||
587 | * @return {Number} Greater than zero if the resolution.width attribute of | ||
588 | * left is greater than the corresponding attribute of right. Less | ||
589 | * than zero if the resolution.width of right is greater than left and | ||
590 | * exactly zero if the two are equal. | ||
591 | */ | ||
592 | Hls.comparePlaylistResolution = function(left, right) { | ||
593 | let leftWidth; | ||
594 | let rightWidth; | ||
595 | |||
596 | if (left.attributes && | ||
597 | left.attributes.RESOLUTION && | ||
598 | left.attributes.RESOLUTION.width) { | ||
599 | leftWidth = left.attributes.RESOLUTION.width; | ||
600 | } | ||
601 | |||
602 | leftWidth = leftWidth || window.Number.MAX_VALUE; | ||
603 | |||
604 | if (right.attributes && | ||
605 | right.attributes.RESOLUTION && | ||
606 | right.attributes.RESOLUTION.width) { | ||
607 | rightWidth = right.attributes.RESOLUTION.width; | ||
608 | } | ||
609 | |||
610 | rightWidth = rightWidth || window.Number.MAX_VALUE; | ||
611 | |||
612 | // NOTE - Fallback to bandwidth sort as appropriate in cases where multiple renditions | ||
613 | // have the same media dimensions/ resolution | ||
614 | if (leftWidth === rightWidth && | ||
615 | left.attributes.BANDWIDTH && | ||
616 | right.attributes.BANDWIDTH) { | ||
617 | return left.attributes.BANDWIDTH - right.attributes.BANDWIDTH; | ||
618 | } | ||
619 | return leftWidth - rightWidth; | ||
620 | }; | ||
621 | |||
1584 | HlsSourceHandler.canPlayType = function(type) { | 622 | HlsSourceHandler.canPlayType = function(type) { |
1585 | let mpegurlRE = /^application\/(?:x-|vnd\.apple\.)mpegurl/i; | 623 | let mpegurlRE = /^(audio|video|application)\/(x-|vnd\.apple\.)?mpegurl/i; |
1586 | 624 | ||
1587 | // favor native HLS support if it's available | 625 | // favor native HLS support if it's available |
1588 | if (Hls.supportsNativeHls) { | 626 | if (Hls.supportsNativeHls) { |
... | @@ -1609,6 +647,8 @@ videojs.HlsHandler = HlsHandler; | ... | @@ -1609,6 +647,8 @@ videojs.HlsHandler = HlsHandler; |
1609 | videojs.HlsSourceHandler = HlsSourceHandler; | 647 | videojs.HlsSourceHandler = HlsSourceHandler; |
1610 | videojs.Hls = Hls; | 648 | videojs.Hls = Hls; |
1611 | videojs.m3u8 = m3u8; | 649 | videojs.m3u8 = m3u8; |
650 | videojs.registerComponent('Hls', Hls); | ||
651 | videojs.options.hls = videojs.options.hls || {}; | ||
1612 | 652 | ||
1613 | module.exports = { | 653 | module.exports = { |
1614 | Hls, | 654 | Hls, | ... | ... |
1 | /** | 1 | /** |
2 | * @file xhr.js | ||
3 | */ | ||
4 | |||
5 | /** | ||
2 | * A wrapper for videojs.xhr that tracks bandwidth. | 6 | * A wrapper for videojs.xhr that tracks bandwidth. |
7 | * | ||
8 | * @param {Object} options options for the XHR | ||
9 | * @param {Function} callback the callback to call when done | ||
10 | * @return {Request} the xhr request that is going to be made | ||
3 | */ | 11 | */ |
4 | import {xhr as videojsXHR, mergeOptions} from 'video.js'; | 12 | import {xhr as videojsXHR, mergeOptions} from 'video.js'; |
5 | 13 | ... | ... |
test/hls-audio-track.test.js
0 → 100644
1 | import HlsAudioTrack from '../src/hls-audio-track'; | ||
2 | import QUnit from 'qunit'; | ||
3 | |||
4 | // Most of these tests will be done in video.js.AudioTrack unit tests | ||
5 | QUnit.module('HlsAudioTrack - Props'); | ||
6 | |||
7 | QUnit.test('verify that props are readonly and can be set', function() { | ||
8 | let props = { | ||
9 | default: true, | ||
10 | language: 'en', | ||
11 | label: 'English', | ||
12 | autoselect: true, | ||
13 | withCredentials: true, | ||
14 | // below props won't be used, its used for checking | ||
15 | kind: 'main' | ||
16 | }; | ||
17 | |||
18 | let track = new HlsAudioTrack(props); | ||
19 | |||
20 | for (let k in props) { | ||
21 | QUnit.equal(track[k], props[k], `${k} should be stored in track`); | ||
22 | } | ||
23 | }); | ||
24 | |||
25 | QUnit.test('can start with a mediaGroup that has a uri', function() { | ||
26 | let props = { | ||
27 | default: true, | ||
28 | language: 'en', | ||
29 | label: 'English', | ||
30 | autoselect: true, | ||
31 | mediaGroup: 'foo', | ||
32 | withCredentials: true, | ||
33 | resolvedUri: 'http://some.test.url/playlist.m3u8', | ||
34 | // below props won't be used, its used for checking | ||
35 | enabled: true, | ||
36 | kind: 'main' | ||
37 | }; | ||
38 | let track = new HlsAudioTrack(props); | ||
39 | |||
40 | QUnit.equal(track.mediaGroups_.length, 1, 'loader was created'); | ||
41 | let loader = track.getLoader('foo'); | ||
42 | |||
43 | QUnit.ok(loader, 'can getLoader on foo'); | ||
44 | |||
45 | track.dispose(); | ||
46 | QUnit.equal(track.mediaGroups_.length, 0, 'loader disposed'); | ||
47 | }); | ||
48 | |||
49 | QUnit.test('can start with a mediaGroup that has no uri', function() { | ||
50 | let props = { | ||
51 | default: true, | ||
52 | language: 'en', | ||
53 | label: 'English', | ||
54 | autoselect: true, | ||
55 | mediaGroup: 'foo', | ||
56 | withCredentials: true, | ||
57 | // below props won't be used, its used for checking | ||
58 | enabled: true, | ||
59 | kind: 'main' | ||
60 | }; | ||
61 | let track = new HlsAudioTrack(props); | ||
62 | |||
63 | QUnit.equal(track.mediaGroups_.length, 1, 'mediaGroupLoader was created for foo'); | ||
64 | QUnit.ok(!track.getLoader('foo'), 'can getLoader on foo, but it is undefined'); | ||
65 | |||
66 | track.dispose(); | ||
67 | QUnit.equal(track.mediaGroups_.length, 0, 'loaders disposed'); | ||
68 | }); | ||
69 | |||
70 | QUnit.module('HlsAudioTrack - Loader', { | ||
71 | beforeEach() { | ||
72 | this.track = new HlsAudioTrack({ | ||
73 | mediaGroup: 'default', | ||
74 | default: true, | ||
75 | language: 'en', | ||
76 | label: 'English', | ||
77 | autoselect: true, | ||
78 | withCredentials: true | ||
79 | }); | ||
80 | }, | ||
81 | afterEach() { | ||
82 | this.track.dispose(); | ||
83 | QUnit.equal(this.track.mediaGroups_.length, 0, 'zero loaders after dispose'); | ||
84 | } | ||
85 | }); | ||
86 | |||
87 | QUnit.test('can add a playlist loader', function() { | ||
88 | QUnit.equal(this.track.mediaGroups_.length, 1, '1 loader to start'); | ||
89 | |||
90 | this.track.addLoader('foo', 'someurl'); | ||
91 | this.track.addLoader('bar', 'someurl'); | ||
92 | this.track.addLoader('baz', 'someurl'); | ||
93 | |||
94 | QUnit.equal(this.track.mediaGroups_.length, 4, 'now has four loaders'); | ||
95 | }); | ||
96 | |||
97 | QUnit.test('can remove playlist loader', function() { | ||
98 | QUnit.equal(this.track.mediaGroups_.length, 1, 'one loaders to start'); | ||
99 | |||
100 | this.track.addLoader('foo', 'someurl'); | ||
101 | this.track.addLoader('baz', 'someurl'); | ||
102 | |||
103 | QUnit.equal(this.track.mediaGroups_.length, 3, 'now has three loaders'); | ||
104 | |||
105 | this.track.removeLoader('baz'); | ||
106 | QUnit.equal(this.track.mediaGroups_.length, 2, 'now has two loaders'); | ||
107 | |||
108 | }); |
... | @@ -10,13 +10,10 @@ var DEFAULTS = { | ... | @@ -10,13 +10,10 @@ var DEFAULTS = { |
10 | 'node_modules/sinon/pkg/sinon-ie.js', | 10 | 'node_modules/sinon/pkg/sinon-ie.js', |
11 | 'node_modules/video.js/dist/video.js', | 11 | 'node_modules/video.js/dist/video.js', |
12 | 'node_modules/video.js/dist/video-js.css', | 12 | 'node_modules/video.js/dist/video-js.css', |
13 | |||
14 | 'test/**/*.test.js' | 13 | 'test/**/*.test.js' |
15 | ], | 14 | ], |
16 | 15 | ||
17 | exclude: [ | 16 | exclude: [], |
18 | 'test/data/**' | ||
19 | ], | ||
20 | 17 | ||
21 | plugins: [ | 18 | plugins: [ |
22 | 'karma-browserify', | 19 | 'karma-browserify', |
... | @@ -43,6 +40,13 @@ var DEFAULTS = { | ... | @@ -43,6 +40,13 @@ var DEFAULTS = { |
43 | noParse: [ | 40 | noParse: [ |
44 | 'test/data/**', | 41 | 'test/data/**', |
45 | ] | 42 | ] |
43 | }, | ||
44 | |||
45 | customLaunchers: { | ||
46 | travisChrome: { | ||
47 | base: 'Chrome', | ||
48 | flags: ['--no-sandbox'] | ||
49 | } | ||
46 | } | 50 | } |
47 | }; | 51 | }; |
48 | 52 | ... | ... |
... | @@ -4,12 +4,10 @@ var common = require('./common'); | ... | @@ -4,12 +4,10 @@ var common = require('./common'); |
4 | 4 | ||
5 | module.exports = function(config) { | 5 | module.exports = function(config) { |
6 | 6 | ||
7 | // Travis CI should run in its available Firefox headless browser. | ||
8 | if (process.env.TRAVIS) { | 7 | if (process.env.TRAVIS) { |
9 | |||
10 | config.set(common({ | 8 | config.set(common({ |
11 | browsers: ['Firefox'], | 9 | browsers: ['travisChrome'], |
12 | plugins: ['karma-firefox-launcher'] | 10 | plugins: ['karma-chrome-launcher'] |
13 | })) | 11 | })) |
14 | } else { | 12 | } else { |
15 | config.set(common({ | 13 | config.set(common({ | ... | ... |
test/master-playlist-controller.test.js
0 → 100644
1 | import QUnit from 'qunit'; | ||
2 | import videojs from 'video.js'; | ||
3 | import { | ||
4 | useFakeEnvironment, | ||
5 | useFakeMediaSource, | ||
6 | createPlayer, | ||
7 | standardXHRResponse, | ||
8 | openMediaSource | ||
9 | } from './test-helpers.js'; | ||
10 | import MasterPlaylistController from '../src/master-playlist-controller'; | ||
11 | /* eslint-disable no-unused-vars */ | ||
12 | // we need this so that it can register hls with videojs | ||
13 | import { Hls } from '../src/videojs-contrib-hls'; | ||
14 | /* eslint-enable no-unused-vars */ | ||
15 | import Playlist from '../src/playlist'; | ||
16 | |||
17 | QUnit.module('MasterPlaylistController', { | ||
18 | beforeEach() { | ||
19 | this.env = useFakeEnvironment(); | ||
20 | this.clock = this.env.clock; | ||
21 | this.requests = this.env.requests; | ||
22 | this.mse = useFakeMediaSource(); | ||
23 | |||
24 | // force the HLS tech to run | ||
25 | this.origSupportsNativeHls = videojs.Hls.supportsNativeHls; | ||
26 | videojs.Hls.supportsNativeHls = false; | ||
27 | |||
28 | this.player = createPlayer(); | ||
29 | this.player.src({ | ||
30 | src: 'manifest/master.m3u8', | ||
31 | type: 'application/vnd.apple.mpegurl' | ||
32 | }); | ||
33 | this.masterPlaylistController = this.player.tech_.hls.masterPlaylistController_; | ||
34 | }, | ||
35 | afterEach() { | ||
36 | this.env.restore(); | ||
37 | this.mse.restore(); | ||
38 | videojs.Hls.supportsNativeHls = this.origSupportsNativeHls; | ||
39 | this.player.dispose(); | ||
40 | } | ||
41 | }); | ||
42 | |||
43 | QUnit.test('throws error when given an empty URL', function() { | ||
44 | let options = { | ||
45 | url: 'test', | ||
46 | currentTimeFunc: () => {}, | ||
47 | tech: this.player.tech_ | ||
48 | }; | ||
49 | |||
50 | QUnit.ok(new MasterPlaylistController(options), 'can create with options'); | ||
51 | |||
52 | options.url = ''; | ||
53 | QUnit.throws(() => { | ||
54 | new MasterPlaylistController(options); // eslint-disable-line no-new | ||
55 | }, /A non-empty playlist URL is required/, 'requires a non empty url'); | ||
56 | }); | ||
57 | |||
58 | QUnit.test('obeys none preload option', function() { | ||
59 | this.player.preload('none'); | ||
60 | // master | ||
61 | standardXHRResponse(this.requests.shift()); | ||
62 | // playlist | ||
63 | standardXHRResponse(this.requests.shift()); | ||
64 | |||
65 | openMediaSource(this.player, this.clock); | ||
66 | |||
67 | QUnit.equal(this.requests.length, 0, 'no segment requests'); | ||
68 | }); | ||
69 | |||
70 | QUnit.test('obeys auto preload option', function() { | ||
71 | this.player.preload('auto'); | ||
72 | // master | ||
73 | standardXHRResponse(this.requests.shift()); | ||
74 | // playlist | ||
75 | standardXHRResponse(this.requests.shift()); | ||
76 | |||
77 | openMediaSource(this.player, this.clock); | ||
78 | |||
79 | QUnit.equal(this.requests.length, 1, '1 segment request'); | ||
80 | }); | ||
81 | |||
82 | QUnit.test('obeys metadata preload option', function() { | ||
83 | this.player.preload('metadata'); | ||
84 | // master | ||
85 | standardXHRResponse(this.requests.shift()); | ||
86 | // playlist | ||
87 | standardXHRResponse(this.requests.shift()); | ||
88 | |||
89 | openMediaSource(this.player, this.clock); | ||
90 | |||
91 | QUnit.equal(this.requests.length, 1, '1 segment request'); | ||
92 | }); | ||
93 | |||
94 | QUnit.test('clears some of the buffer for a fast quality change', function() { | ||
95 | let removes = []; | ||
96 | |||
97 | // master | ||
98 | standardXHRResponse(this.requests.shift()); | ||
99 | // media | ||
100 | standardXHRResponse(this.requests.shift()); | ||
101 | this.masterPlaylistController.mediaSource.trigger('sourceopen'); | ||
102 | |||
103 | let segmentLoader = this.masterPlaylistController.mainSegmentLoader_; | ||
104 | |||
105 | segmentLoader.sourceUpdater_.remove = function(start, end) { | ||
106 | removes.push({ start, end }); | ||
107 | }; | ||
108 | this.masterPlaylistController.selectPlaylist = () => { | ||
109 | return this.masterPlaylistController.master().playlists[0]; | ||
110 | }; | ||
111 | this.masterPlaylistController.currentTimeFunc = () => 7; | ||
112 | |||
113 | this.masterPlaylistController.fastQualityChange_(); | ||
114 | |||
115 | QUnit.equal(removes.length, 1, 'removed buffered content'); | ||
116 | QUnit.equal(removes[0].start, 7 + 5, 'removed from a bit after current time'); | ||
117 | QUnit.equal(removes[0].end, Infinity, 'removed to the end'); | ||
118 | }); | ||
119 | |||
120 | QUnit.test('does not clear the buffer when no fast quality change occurs', function() { | ||
121 | let removes = []; | ||
122 | |||
123 | // master | ||
124 | standardXHRResponse(this.requests.shift()); | ||
125 | // media | ||
126 | standardXHRResponse(this.requests.shift()); | ||
127 | this.masterPlaylistController.mediaSource.trigger('sourceopen'); | ||
128 | |||
129 | let segmentLoader = this.masterPlaylistController.mainSegmentLoader_; | ||
130 | |||
131 | segmentLoader.sourceUpdater_.remove = function(start, end) { | ||
132 | removes.push({ start, end }); | ||
133 | }; | ||
134 | |||
135 | this.masterPlaylistController.fastQualityChange_(); | ||
136 | |||
137 | QUnit.equal(removes.length, 0, 'did not remove content'); | ||
138 | }); | ||
139 | |||
140 | QUnit.test('if buffered, will request second segment byte range', function() { | ||
141 | this.requests.length = 0; | ||
142 | this.player.src({ | ||
143 | src: 'manifest/playlist.m3u8', | ||
144 | type: 'application/vnd.apple.mpegurl' | ||
145 | }); | ||
146 | this.masterPlaylistController = this.player.tech_.hls.masterPlaylistController_; | ||
147 | |||
148 | // mock that the user has played the video before | ||
149 | this.player.tech_.triggerReady(); | ||
150 | this.clock.tick(1); | ||
151 | this.player.tech_.trigger('play'); | ||
152 | this.player.tech_.played = () => videojs.createTimeRanges([[0, 20]]); | ||
153 | |||
154 | openMediaSource(this.player, this.clock); | ||
155 | // playlist | ||
156 | standardXHRResponse(this.requests[0]); | ||
157 | |||
158 | this.masterPlaylistController.mainSegmentLoader_.sourceUpdater_.buffered = () => { | ||
159 | return videojs.createTimeRanges([[0, 20]]); | ||
160 | }; | ||
161 | |||
162 | // segment | ||
163 | standardXHRResponse(this.requests[1]); | ||
164 | this.masterPlaylistController.mediaSource.sourceBuffers[0].trigger('updateend'); | ||
165 | this.clock.tick(10 * 1000); | ||
166 | QUnit.equal(this.requests[2].headers.Range, 'bytes=1823412-2299991'); | ||
167 | }); | ||
168 | |||
169 | QUnit.test('re-initializes the combined playlist loader when switching sources', | ||
170 | function() { | ||
171 | openMediaSource(this.player, this.clock); | ||
172 | // master | ||
173 | standardXHRResponse(this.requests.shift()); | ||
174 | // playlist | ||
175 | standardXHRResponse(this.requests.shift()); | ||
176 | // segment | ||
177 | standardXHRResponse(this.requests.shift()); | ||
178 | // change the source | ||
179 | this.player.src({ | ||
180 | src: 'manifest/master.m3u8', | ||
181 | type: 'application/vnd.apple.mpegurl' | ||
182 | }); | ||
183 | this.masterPlaylistController = this.player.tech_.hls.masterPlaylistController_; | ||
184 | // maybe not needed if https://github.com/videojs/video.js/issues/2326 gets fixed | ||
185 | this.clock.tick(1); | ||
186 | QUnit.ok(!this.masterPlaylistController.masterPlaylistLoader_.media(), | ||
187 | 'no media playlist'); | ||
188 | QUnit.equal(this.masterPlaylistController.masterPlaylistLoader_.state, | ||
189 | 'HAVE_NOTHING', | ||
190 | 'reset the playlist loader state'); | ||
191 | QUnit.equal(this.requests.length, 1, 'requested the new src'); | ||
192 | |||
193 | // buffer check | ||
194 | this.clock.tick(10 * 1000); | ||
195 | QUnit.equal(this.requests.length, 1, 'did not request a stale segment'); | ||
196 | |||
197 | // sourceopen | ||
198 | openMediaSource(this.player, this.clock); | ||
199 | |||
200 | QUnit.equal(this.requests.length, 1, 'made one request'); | ||
201 | QUnit.ok( | ||
202 | this.requests[0].url.indexOf('master.m3u8') >= 0, | ||
203 | 'requested only the new playlist' | ||
204 | ); | ||
205 | }); | ||
206 | |||
207 | QUnit.test('updates the combined segment loader on live playlist refreshes', function() { | ||
208 | let updates = []; | ||
209 | |||
210 | openMediaSource(this.player, this.clock); | ||
211 | // master | ||
212 | standardXHRResponse(this.requests.shift()); | ||
213 | // media | ||
214 | standardXHRResponse(this.requests.shift()); | ||
215 | |||
216 | this.masterPlaylistController.mainSegmentLoader_.playlist = function(update) { | ||
217 | updates.push(update); | ||
218 | }; | ||
219 | |||
220 | this.masterPlaylistController.masterPlaylistLoader_.trigger('loadedplaylist'); | ||
221 | QUnit.equal(updates.length, 1, 'updated the segment list'); | ||
222 | }); | ||
223 | |||
224 | QUnit.test( | ||
225 | 'fires a progress event after downloading a segment from combined segment loader', | ||
226 | function() { | ||
227 | let progressCount = 0; | ||
228 | |||
229 | openMediaSource(this.player, this.clock); | ||
230 | |||
231 | // master | ||
232 | standardXHRResponse(this.requests.shift()); | ||
233 | // media | ||
234 | standardXHRResponse(this.requests.shift()); | ||
235 | |||
236 | this.player.tech_.on('progress', function() { | ||
237 | progressCount++; | ||
238 | }); | ||
239 | |||
240 | // segment | ||
241 | standardXHRResponse(this.requests.shift()); | ||
242 | this.masterPlaylistController.mainSegmentLoader_.trigger('progress'); | ||
243 | QUnit.equal(progressCount, 1, 'fired a progress event'); | ||
244 | }); | ||
245 | |||
246 | QUnit.test('blacklists switching from video+audio playlists to audio only', function() { | ||
247 | let audioPlaylist; | ||
248 | |||
249 | openMediaSource(this.player, this.clock); | ||
250 | |||
251 | this.player.tech_.hls.bandwidth = 1e10; | ||
252 | |||
253 | // master | ||
254 | this.requests.shift().respond(200, null, | ||
255 | '#EXTM3U\n' + | ||
256 | '#EXT-X-STREAM-INF:BANDWIDTH=1,CODECS="mp4a.40.2"\n' + | ||
257 | 'media.m3u8\n' + | ||
258 | '#EXT-X-STREAM-INF:BANDWIDTH=10,RESOLUTION=1x1\n' + | ||
259 | 'media1.m3u8\n'); | ||
260 | // media1 | ||
261 | standardXHRResponse(this.requests.shift()); | ||
262 | |||
263 | QUnit.equal(this.masterPlaylistController.masterPlaylistLoader_.media(), | ||
264 | this.masterPlaylistController.masterPlaylistLoader_.master.playlists[1], | ||
265 | 'selected video+audio'); | ||
266 | audioPlaylist = this.masterPlaylistController.masterPlaylistLoader_.master.playlists[0]; | ||
267 | QUnit.equal(audioPlaylist.excludeUntil, Infinity, 'excluded incompatible playlist'); | ||
268 | }); | ||
269 | |||
270 | QUnit.test('blacklists switching from audio-only playlists to video+audio', function() { | ||
271 | let videoAudioPlaylist; | ||
272 | |||
273 | openMediaSource(this.player, this.clock); | ||
274 | |||
275 | this.player.tech_.hls.bandwidth = 1; | ||
276 | // master | ||
277 | this.requests.shift().respond(200, null, | ||
278 | '#EXTM3U\n' + | ||
279 | '#EXT-X-STREAM-INF:BANDWIDTH=1,CODECS="mp4a.40.2"\n' + | ||
280 | 'media.m3u8\n' + | ||
281 | '#EXT-X-STREAM-INF:BANDWIDTH=10,RESOLUTION=1x1\n' + | ||
282 | 'media1.m3u8\n'); | ||
283 | |||
284 | // media1 | ||
285 | standardXHRResponse(this.requests.shift()); | ||
286 | QUnit.equal(this.masterPlaylistController.masterPlaylistLoader_.media(), | ||
287 | this.masterPlaylistController.masterPlaylistLoader_.master.playlists[0], | ||
288 | 'selected audio only'); | ||
289 | videoAudioPlaylist = | ||
290 | this.masterPlaylistController.masterPlaylistLoader_.master.playlists[1]; | ||
291 | QUnit.equal(videoAudioPlaylist.excludeUntil, | ||
292 | Infinity, | ||
293 | 'excluded incompatible playlist'); | ||
294 | }); | ||
295 | |||
296 | QUnit.test('blacklists switching from video-only playlists to video+audio', function() { | ||
297 | let videoAudioPlaylist; | ||
298 | |||
299 | openMediaSource(this.player, this.clock); | ||
300 | |||
301 | this.player.tech_.hls.bandwidth = 1; | ||
302 | // master | ||
303 | this.requests.shift() | ||
304 | .respond(200, null, | ||
305 | '#EXTM3U\n' + | ||
306 | '#EXT-X-STREAM-INF:BANDWIDTH=1,CODECS="avc1.4d400d"\n' + | ||
307 | 'media.m3u8\n' + | ||
308 | '#EXT-X-STREAM-INF:BANDWIDTH=10,CODECS="avc1.4d400d,mp4a.40.2"\n' + | ||
309 | 'media1.m3u8\n'); | ||
310 | |||
311 | // media | ||
312 | standardXHRResponse(this.requests.shift()); | ||
313 | QUnit.equal(this.masterPlaylistController.masterPlaylistLoader_.media(), | ||
314 | this.masterPlaylistController.masterPlaylistLoader_.master.playlists[0], | ||
315 | 'selected video only'); | ||
316 | videoAudioPlaylist = | ||
317 | this.masterPlaylistController.masterPlaylistLoader_.master.playlists[1]; | ||
318 | QUnit.equal(videoAudioPlaylist.excludeUntil, | ||
319 | Infinity, | ||
320 | 'excluded incompatible playlist'); | ||
321 | }); | ||
322 | |||
323 | QUnit.test('blacklists switching between playlists with incompatible audio codecs', | ||
324 | function() { | ||
325 | let alternatePlaylist; | ||
326 | |||
327 | openMediaSource(this.player, this.clock); | ||
328 | |||
329 | this.player.tech_.hls.bandwidth = 1; | ||
330 | // master | ||
331 | this.requests.shift() | ||
332 | .respond(200, null, | ||
333 | '#EXTM3U\n' + | ||
334 | '#EXT-X-STREAM-INF:BANDWIDTH=1,CODECS="avc1.4d400d,mp4a.40.5"\n' + | ||
335 | 'media.m3u8\n' + | ||
336 | '#EXT-X-STREAM-INF:BANDWIDTH=10,CODECS="avc1.4d400d,mp4a.40.2"\n' + | ||
337 | 'media1.m3u8\n'); | ||
338 | |||
339 | // media | ||
340 | standardXHRResponse(this.requests.shift()); | ||
341 | QUnit.equal(this.masterPlaylistController.masterPlaylistLoader_.media(), | ||
342 | this.masterPlaylistController.masterPlaylistLoader_.master.playlists[0], | ||
343 | 'selected HE-AAC stream'); | ||
344 | alternatePlaylist = | ||
345 | this.masterPlaylistController.masterPlaylistLoader_.master.playlists[1]; | ||
346 | QUnit.equal(alternatePlaylist.excludeUntil, Infinity, 'excluded incompatible playlist'); | ||
347 | }); | ||
348 | |||
349 | QUnit.test('updates the combined segment loader on media changes', function() { | ||
350 | let updates = []; | ||
351 | |||
352 | this.masterPlaylistController.mediaSource.trigger('sourceopen'); | ||
353 | |||
354 | this.masterPlaylistController.mainSegmentLoader_.bandwidth = 1; | ||
355 | |||
356 | // master | ||
357 | standardXHRResponse(this.requests.shift()); | ||
358 | // media | ||
359 | standardXHRResponse(this.requests.shift()); | ||
360 | |||
361 | this.masterPlaylistController.mainSegmentLoader_.playlist = function(update) { | ||
362 | updates.push(update); | ||
363 | }; | ||
364 | |||
365 | // downloading the new segment will update bandwidth and cause a | ||
366 | // playlist change | ||
367 | // segment 0 | ||
368 | standardXHRResponse(this.requests.shift()); | ||
369 | this.masterPlaylistController.mediaSource.sourceBuffers[0].trigger('updateend'); | ||
370 | // media | ||
371 | standardXHRResponse(this.requests.shift()); | ||
372 | QUnit.equal(updates.length, 1, 'updated the segment list'); | ||
373 | }); | ||
374 | |||
375 | QUnit.test('selects a playlist after main/combined segment downloads', function() { | ||
376 | let calls = 0; | ||
377 | |||
378 | this.masterPlaylistController.selectPlaylist = () => { | ||
379 | calls++; | ||
380 | return this.masterPlaylistController.masterPlaylistLoader_.master.playlists[0]; | ||
381 | }; | ||
382 | this.masterPlaylistController.mediaSource.trigger('sourceopen'); | ||
383 | |||
384 | // master | ||
385 | standardXHRResponse(this.requests.shift()); | ||
386 | // media | ||
387 | standardXHRResponse(this.requests.shift()); | ||
388 | |||
389 | // "downloaded" a segment | ||
390 | this.masterPlaylistController.mainSegmentLoader_.trigger('progress'); | ||
391 | QUnit.strictEqual(calls, 2, 'selects after the initial segment'); | ||
392 | |||
393 | // and another | ||
394 | this.masterPlaylistController.mainSegmentLoader_.trigger('progress'); | ||
395 | QUnit.strictEqual(calls, 3, 'selects after additional segments'); | ||
396 | }); | ||
397 | |||
398 | QUnit.test('updates the duration after switching playlists', function() { | ||
399 | let selectedPlaylist = false; | ||
400 | |||
401 | this.masterPlaylistController.mediaSource.trigger('sourceopen'); | ||
402 | |||
403 | this.masterPlaylistController.bandwidth = 1e20; | ||
404 | |||
405 | // master | ||
406 | standardXHRResponse(this.requests[0]); | ||
407 | // media | ||
408 | standardXHRResponse(this.requests[1]); | ||
409 | |||
410 | this.masterPlaylistController.selectPlaylist = () => { | ||
411 | selectedPlaylist = true; | ||
412 | |||
413 | // this duration should be overwritten by the playlist change | ||
414 | this.masterPlaylistController.mediaSource.duration = 0; | ||
415 | this.masterPlaylistController.mediaSource.readyState = 'open'; | ||
416 | |||
417 | return this.masterPlaylistController.masterPlaylistLoader_.master.playlists[1]; | ||
418 | }; | ||
419 | |||
420 | // segment 0 | ||
421 | standardXHRResponse(this.requests[2]); | ||
422 | this.masterPlaylistController.mediaSource.sourceBuffers[0].trigger('updateend'); | ||
423 | // media1 | ||
424 | standardXHRResponse(this.requests[3]); | ||
425 | QUnit.ok(selectedPlaylist, 'selected playlist'); | ||
426 | QUnit.ok(this.masterPlaylistController.mediaSource.duration !== 0, | ||
427 | 'updates the duration'); | ||
428 | }); | ||
429 | |||
430 | QUnit.test('seekable uses the intersection of alternate audio and combined tracks', | ||
431 | function() { | ||
432 | let origSeekable = Playlist.seekable; | ||
433 | let mainMedia = {}; | ||
434 | let audioMedia = {}; | ||
435 | let mainTimeRanges = []; | ||
436 | let audioTimeRanges = []; | ||
437 | let assertTimeRangesEqual = (left, right, message) => { | ||
438 | if (left.length === 0 && right.length === 0) { | ||
439 | return; | ||
440 | } | ||
441 | |||
442 | QUnit.equal(left.length, 1, message); | ||
443 | QUnit.equal(right.length, 1, message); | ||
444 | |||
445 | QUnit.equal(left.start(0), right.start(0), message); | ||
446 | QUnit.equal(left.end(0), right.end(0), message); | ||
447 | }; | ||
448 | |||
449 | this.masterPlaylistController.masterPlaylistLoader_.media = () => mainMedia; | ||
450 | |||
451 | Playlist.seekable = (media) => { | ||
452 | if (media === mainMedia) { | ||
453 | return videojs.createTimeRanges(mainTimeRanges); | ||
454 | } | ||
455 | return videojs.createTimeRanges(audioTimeRanges); | ||
456 | }; | ||
457 | |||
458 | assertTimeRangesEqual(this.masterPlaylistController.seekable(), | ||
459 | videojs.createTimeRanges(), | ||
460 | 'empty when main empty'); | ||
461 | mainTimeRanges = [[0, 10]]; | ||
462 | assertTimeRangesEqual(this.masterPlaylistController.seekable(), | ||
463 | videojs.createTimeRanges([[0, 10]]), | ||
464 | 'main when no audio'); | ||
465 | |||
466 | this.masterPlaylistController.audioPlaylistLoader_ = { | ||
467 | media: () => audioMedia, | ||
468 | expired_: 0 | ||
469 | }; | ||
470 | |||
471 | assertTimeRangesEqual(this.masterPlaylistController.seekable(), | ||
472 | videojs.createTimeRanges(), | ||
473 | 'empty when both empty'); | ||
474 | mainTimeRanges = [[0, 10]]; | ||
475 | assertTimeRangesEqual(this.masterPlaylistController.seekable(), | ||
476 | videojs.createTimeRanges(), | ||
477 | 'empty when audio empty'); | ||
478 | mainTimeRanges = []; | ||
479 | audioTimeRanges = [[0, 10]]; | ||
480 | assertTimeRangesEqual(this.masterPlaylistController.seekable(), | ||
481 | videojs.createTimeRanges(), | ||
482 | 'empty when main empty'); | ||
483 | mainTimeRanges = [[0, 10]]; | ||
484 | audioTimeRanges = [[0, 10]]; | ||
485 | assertTimeRangesEqual(this.masterPlaylistController.seekable(), | ||
486 | videojs.createTimeRanges([[0, 10]]), | ||
487 | 'ranges equal'); | ||
488 | mainTimeRanges = [[5, 10]]; | ||
489 | assertTimeRangesEqual(this.masterPlaylistController.seekable(), | ||
490 | videojs.createTimeRanges([[5, 10]]), | ||
491 | 'main later start'); | ||
492 | mainTimeRanges = [[0, 10]]; | ||
493 | audioTimeRanges = [[5, 10]]; | ||
494 | assertTimeRangesEqual(this.masterPlaylistController.seekable(), | ||
495 | videojs.createTimeRanges([[5, 10]]), | ||
496 | 'audio later start'); | ||
497 | mainTimeRanges = [[0, 9]]; | ||
498 | audioTimeRanges = [[0, 10]]; | ||
499 | assertTimeRangesEqual(this.masterPlaylistController.seekable(), | ||
500 | videojs.createTimeRanges([[0, 9]]), | ||
501 | 'main earlier end'); | ||
502 | mainTimeRanges = [[0, 10]]; | ||
503 | audioTimeRanges = [[0, 9]]; | ||
504 | assertTimeRangesEqual(this.masterPlaylistController.seekable(), | ||
505 | videojs.createTimeRanges([[0, 9]]), | ||
506 | 'audio earlier end'); | ||
507 | mainTimeRanges = [[1, 10]]; | ||
508 | audioTimeRanges = [[0, 9]]; | ||
509 | assertTimeRangesEqual(this.masterPlaylistController.seekable(), | ||
510 | videojs.createTimeRanges([[1, 9]]), | ||
511 | 'main later start, audio earlier end'); | ||
512 | mainTimeRanges = [[0, 9]]; | ||
513 | audioTimeRanges = [[1, 10]]; | ||
514 | assertTimeRangesEqual(this.masterPlaylistController.seekable(), | ||
515 | videojs.createTimeRanges([[1, 9]]), | ||
516 | 'audio later start, main earlier end'); | ||
517 | mainTimeRanges = [[2, 9]]; | ||
518 | assertTimeRangesEqual(this.masterPlaylistController.seekable(), | ||
519 | videojs.createTimeRanges([[2, 9]]), | ||
520 | 'main later start, main earlier end'); | ||
521 | mainTimeRanges = [[1, 10]]; | ||
522 | audioTimeRanges = [[2, 9]]; | ||
523 | assertTimeRangesEqual(this.masterPlaylistController.seekable(), | ||
524 | videojs.createTimeRanges([[2, 9]]), | ||
525 | 'audio later start, audio earlier end'); | ||
526 | |||
527 | Playlist.seekable = origSeekable; | ||
528 | }); |
1 | import sinon from 'sinon'; | ||
2 | import QUnit from 'qunit'; | 1 | import QUnit from 'qunit'; |
3 | import PlaylistLoader from '../src/playlist-loader'; | 2 | import PlaylistLoader from '../src/playlist-loader'; |
4 | import videojs from 'video.js'; | ||
5 | import xhrFactory from '../src/xhr'; | 3 | import xhrFactory from '../src/xhr'; |
4 | import { useFakeEnvironment } from './test-helpers'; | ||
5 | |||
6 | // Attempts to produce an absolute URL to a given relative path | 6 | // Attempts to produce an absolute URL to a given relative path |
7 | // based on window.location.href | 7 | // based on window.location.href |
8 | const urlTo = function(path) { | 8 | const urlTo = function(path) { |
... | @@ -15,28 +15,15 @@ const urlTo = function(path) { | ... | @@ -15,28 +15,15 @@ const urlTo = function(path) { |
15 | 15 | ||
16 | QUnit.module('Playlist Loader', { | 16 | QUnit.module('Playlist Loader', { |
17 | beforeEach() { | 17 | beforeEach() { |
18 | // fake XHRs | 18 | this.env = useFakeEnvironment(); |
19 | this.oldXHR = videojs.xhr.XMLHttpRequest; | 19 | this.clock = this.env.clock; |
20 | this.sinonXhr = sinon.useFakeXMLHttpRequest(); | 20 | this.requests = this.env.requests; |
21 | this.requests = []; | ||
22 | this.sinonXhr.onCreate = (xhr) => { | ||
23 | // force the XHR2 timeout polyfill | ||
24 | xhr.timeout = null; | ||
25 | this.requests.push(xhr); | ||
26 | }; | ||
27 | |||
28 | // fake timers | ||
29 | this.clock = sinon.useFakeTimers(); | ||
30 | videojs.xhr.XMLHttpRequest = this.sinonXhr; | ||
31 | |||
32 | this.fakeHls = { | 21 | this.fakeHls = { |
33 | xhr: xhrFactory() | 22 | xhr: xhrFactory() |
34 | }; | 23 | }; |
35 | }, | 24 | }, |
36 | afterEach() { | 25 | afterEach() { |
37 | this.sinonXhr.restore(); | 26 | this.env.restore(); |
38 | this.clock.restore(); | ||
39 | videojs.xhr.XMLHttpRequest = this.oldXHR; | ||
40 | } | 27 | } |
41 | }); | 28 | }); |
42 | 29 | ||
... | @@ -52,12 +39,16 @@ QUnit.test('throws if the playlist url is empty or undefined', function() { | ... | @@ -52,12 +39,16 @@ QUnit.test('throws if the playlist url is empty or undefined', function() { |
52 | QUnit.test('starts without any metadata', function() { | 39 | QUnit.test('starts without any metadata', function() { |
53 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); | 40 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); |
54 | 41 | ||
42 | loader.load(); | ||
43 | |||
55 | QUnit.strictEqual(loader.state, 'HAVE_NOTHING', 'no metadata has loaded yet'); | 44 | QUnit.strictEqual(loader.state, 'HAVE_NOTHING', 'no metadata has loaded yet'); |
56 | }); | 45 | }); |
57 | 46 | ||
58 | QUnit.test('starts with no expired time', function() { | 47 | QUnit.test('starts with no expired time', function() { |
59 | let loader = new PlaylistLoader('media.m3u8', this.fakeHls); | 48 | let loader = new PlaylistLoader('media.m3u8', this.fakeHls); |
60 | 49 | ||
50 | loader.load(); | ||
51 | |||
61 | this.requests.pop().respond(200, null, | 52 | this.requests.pop().respond(200, null, |
62 | '#EXTM3U\n' + | 53 | '#EXTM3U\n' + |
63 | '#EXTINF:10,\n' + | 54 | '#EXTINF:10,\n' + |
... | @@ -68,9 +59,9 @@ QUnit.test('starts with no expired time', function() { | ... | @@ -68,9 +59,9 @@ QUnit.test('starts with no expired time', function() { |
68 | }); | 59 | }); |
69 | 60 | ||
70 | QUnit.test('requests the initial playlist immediately', function() { | 61 | QUnit.test('requests the initial playlist immediately', function() { |
71 | /* eslint-disable no-unused-vars */ | ||
72 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); | 62 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); |
73 | /* eslint-enable no-unused-vars */ | 63 | |
64 | loader.load(); | ||
74 | 65 | ||
75 | QUnit.strictEqual(this.requests.length, 1, 'made a request'); | 66 | QUnit.strictEqual(this.requests.length, 1, 'made a request'); |
76 | QUnit.strictEqual(this.requests[0].url, | 67 | QUnit.strictEqual(this.requests[0].url, |
... | @@ -82,6 +73,8 @@ QUnit.test('moves to HAVE_MASTER after loading a master playlist', function() { | ... | @@ -82,6 +73,8 @@ QUnit.test('moves to HAVE_MASTER after loading a master playlist', function() { |
82 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); | 73 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); |
83 | let state; | 74 | let state; |
84 | 75 | ||
76 | loader.load(); | ||
77 | |||
85 | loader.on('loadedplaylist', function() { | 78 | loader.on('loadedplaylist', function() { |
86 | state = loader.state; | 79 | state = loader.state; |
87 | }); | 80 | }); |
... | @@ -97,6 +90,8 @@ QUnit.test('jumps to HAVE_METADATA when initialized with a media playlist', func | ... | @@ -97,6 +90,8 @@ QUnit.test('jumps to HAVE_METADATA when initialized with a media playlist', func |
97 | let loadedmetadatas = 0; | 90 | let loadedmetadatas = 0; |
98 | let loader = new PlaylistLoader('media.m3u8', this.fakeHls); | 91 | let loader = new PlaylistLoader('media.m3u8', this.fakeHls); |
99 | 92 | ||
93 | loader.load(); | ||
94 | |||
100 | loader.on('loadedmetadata', function() { | 95 | loader.on('loadedmetadata', function() { |
101 | loadedmetadatas++; | 96 | loadedmetadatas++; |
102 | }); | 97 | }); |
... | @@ -113,10 +108,156 @@ QUnit.test('jumps to HAVE_METADATA when initialized with a media playlist', func | ... | @@ -113,10 +108,156 @@ QUnit.test('jumps to HAVE_METADATA when initialized with a media playlist', func |
113 | QUnit.strictEqual(loadedmetadatas, 1, 'fired one loadedmetadata'); | 108 | QUnit.strictEqual(loadedmetadatas, 1, 'fired one loadedmetadata'); |
114 | }); | 109 | }); |
115 | 110 | ||
111 | QUnit.test('resolves relative media playlist URIs', function() { | ||
112 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); | ||
113 | |||
114 | loader.load(); | ||
115 | |||
116 | this.requests.shift().respond(200, null, | ||
117 | '#EXTM3U\n' + | ||
118 | '#EXT-X-STREAM-INF:\n' + | ||
119 | 'video/media.m3u8\n'); | ||
120 | QUnit.equal(loader.master.playlists[0].resolvedUri, urlTo('video/media.m3u8'), | ||
121 | 'resolved media URI'); | ||
122 | }); | ||
123 | |||
124 | QUnit.test('recognizes absolute URIs and requests them unmodified', function() { | ||
125 | let loader = new PlaylistLoader('manifest/media.m3u8', this.fakeHls); | ||
126 | |||
127 | loader.load(); | ||
128 | |||
129 | this.requests.shift().respond(200, null, | ||
130 | '#EXTM3U\n' + | ||
131 | '#EXT-X-STREAM-INF:\n' + | ||
132 | 'http://example.com/video/media.m3u8\n'); | ||
133 | QUnit.equal(loader.master.playlists[0].resolvedUri, | ||
134 | 'http://example.com/video/media.m3u8', 'resolved media URI'); | ||
135 | |||
136 | this.requests.shift().respond(200, null, | ||
137 | '#EXTM3U\n' + | ||
138 | '#EXTINF:10,\n' + | ||
139 | 'http://example.com/00001.ts\n' + | ||
140 | '#EXT-X-ENDLIST\n'); | ||
141 | QUnit.equal(loader.media().segments[0].resolvedUri, | ||
142 | 'http://example.com/00001.ts', 'resolved segment URI'); | ||
143 | }); | ||
144 | |||
145 | QUnit.test('recognizes domain-relative URLs', function() { | ||
146 | let loader = new PlaylistLoader('manifest/media.m3u8', this.fakeHls); | ||
147 | |||
148 | loader.load(); | ||
149 | |||
150 | this.requests.shift().respond(200, null, | ||
151 | '#EXTM3U\n' + | ||
152 | '#EXT-X-STREAM-INF:\n' + | ||
153 | '/media.m3u8\n'); | ||
154 | QUnit.equal(loader.master.playlists[0].resolvedUri, | ||
155 | window.location.protocol + '//' + | ||
156 | window.location.host + '/media.m3u8', | ||
157 | 'resolved media URI'); | ||
158 | |||
159 | this.requests.shift().respond(200, null, | ||
160 | '#EXTM3U\n' + | ||
161 | '#EXTINF:10,\n' + | ||
162 | '/00001.ts\n' + | ||
163 | '#EXT-X-ENDLIST\n'); | ||
164 | QUnit.equal(loader.media().segments[0].resolvedUri, | ||
165 | window.location.protocol + '//' + | ||
166 | window.location.host + '/00001.ts', | ||
167 | 'resolved segment URI'); | ||
168 | }); | ||
169 | |||
170 | QUnit.test('recognizes key URLs relative to master and playlist', function() { | ||
171 | let loader = new PlaylistLoader('/video/media-encrypted.m3u8', this.fakeHls); | ||
172 | |||
173 | loader.load(); | ||
174 | |||
175 | this.requests.shift().respond(200, null, | ||
176 | '#EXTM3U\n' + | ||
177 | '#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=17\n' + | ||
178 | 'playlist/playlist.m3u8\n' + | ||
179 | '#EXT-X-ENDLIST\n'); | ||
180 | QUnit.equal(loader.master.playlists[0].resolvedUri, | ||
181 | window.location.protocol + '//' + | ||
182 | window.location.host + '/video/playlist/playlist.m3u8', | ||
183 | 'resolved media URI'); | ||
184 | |||
185 | this.requests.shift().respond(200, null, | ||
186 | '#EXTM3U\n' + | ||
187 | '#EXT-X-TARGETDURATION:15\n' + | ||
188 | '#EXT-X-KEY:METHOD=AES-128,URI="keys/key.php"\n' + | ||
189 | '#EXTINF:2.833,\n' + | ||
190 | 'http://example.com/000001.ts\n' + | ||
191 | '#EXT-X-ENDLIST\n'); | ||
192 | QUnit.equal(loader.media().segments[0].key.resolvedUri, | ||
193 | window.location.protocol + '//' + | ||
194 | window.location.host + '/video/playlist/keys/key.php', | ||
195 | 'resolved multiple relative paths for key URI'); | ||
196 | }); | ||
197 | |||
198 | QUnit.test('trigger an error event when a media playlist 404s', function() { | ||
199 | let count = 0; | ||
200 | let loader = new PlaylistLoader('manifest/master.m3u8', this.fakeHls); | ||
201 | |||
202 | loader.load(); | ||
203 | |||
204 | loader.on('error', function() { | ||
205 | count += 1; | ||
206 | }); | ||
207 | |||
208 | // master | ||
209 | this.requests.shift().respond(200, null, | ||
210 | '#EXTM3U\n' + | ||
211 | '#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=17\n' + | ||
212 | 'playlist/playlist.m3u8\n' + | ||
213 | '#EXT-X-STREAM-INF:PROGRAM-ID=2,BANDWIDTH=170\n' + | ||
214 | 'playlist/playlist2.m3u8\n' + | ||
215 | '#EXT-X-ENDLIST\n'); | ||
216 | QUnit.equal(count, 0, | ||
217 | 'error not triggered before requesting playlist'); | ||
218 | |||
219 | // playlist | ||
220 | this.requests.shift().respond(404); | ||
221 | |||
222 | QUnit.equal(count, 1, | ||
223 | 'error triggered after playlist 404'); | ||
224 | }); | ||
225 | |||
226 | QUnit.test('recognizes absolute key URLs', function() { | ||
227 | let loader = new PlaylistLoader('/video/media-encrypted.m3u8', this.fakeHls); | ||
228 | |||
229 | loader.load(); | ||
230 | |||
231 | this.requests.shift().respond(200, null, | ||
232 | '#EXTM3U\n' + | ||
233 | '#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=17\n' + | ||
234 | 'playlist/playlist.m3u8\n' + | ||
235 | '#EXT-X-ENDLIST\n'); | ||
236 | QUnit.equal(loader.master.playlists[0].resolvedUri, | ||
237 | window.location.protocol + '//' + | ||
238 | window.location.host + '/video/playlist/playlist.m3u8', | ||
239 | 'resolved media URI'); | ||
240 | |||
241 | this.requests.shift().respond( | ||
242 | 200, | ||
243 | null, | ||
244 | '#EXTM3U\n' + | ||
245 | '#EXT-X-TARGETDURATION:15\n' + | ||
246 | '#EXT-X-KEY:METHOD=AES-128,URI="http://example.com/keys/key.php"\n' + | ||
247 | '#EXTINF:2.833,\n' + | ||
248 | 'http://example.com/000001.ts\n' + | ||
249 | '#EXT-X-ENDLIST\n' | ||
250 | ); | ||
251 | QUnit.equal(loader.media().segments[0].key.resolvedUri, | ||
252 | 'http://example.com/keys/key.php', 'resolved absolute path for key URI'); | ||
253 | }); | ||
254 | |||
116 | QUnit.test('jumps to HAVE_METADATA when initialized with a live media playlist', | 255 | QUnit.test('jumps to HAVE_METADATA when initialized with a live media playlist', |
117 | function() { | 256 | function() { |
118 | let loader = new PlaylistLoader('media.m3u8', this.fakeHls); | 257 | let loader = new PlaylistLoader('media.m3u8', this.fakeHls); |
119 | 258 | ||
259 | loader.load(); | ||
260 | |||
120 | this.requests.pop().respond(200, null, | 261 | this.requests.pop().respond(200, null, |
121 | '#EXTM3U\n' + | 262 | '#EXTM3U\n' + |
122 | '#EXTINF:10,\n' + | 263 | '#EXTINF:10,\n' + |
... | @@ -131,6 +272,8 @@ QUnit.test('moves to HAVE_METADATA after loading a media playlist', function() { | ... | @@ -131,6 +272,8 @@ QUnit.test('moves to HAVE_METADATA after loading a media playlist', function() { |
131 | let loadedMetadata = 0; | 272 | let loadedMetadata = 0; |
132 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); | 273 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); |
133 | 274 | ||
275 | loader.load(); | ||
276 | |||
134 | loader.on('loadedplaylist', function() { | 277 | loader.on('loadedplaylist', function() { |
135 | loadedPlaylist++; | 278 | loadedPlaylist++; |
136 | }); | 279 | }); |
... | @@ -164,6 +307,8 @@ QUnit.test('moves to HAVE_METADATA after loading a media playlist', function() { | ... | @@ -164,6 +307,8 @@ QUnit.test('moves to HAVE_METADATA after loading a media playlist', function() { |
164 | QUnit.test('moves to HAVE_CURRENT_METADATA when refreshing the playlist', function() { | 307 | QUnit.test('moves to HAVE_CURRENT_METADATA when refreshing the playlist', function() { |
165 | let loader = new PlaylistLoader('live.m3u8', this.fakeHls); | 308 | let loader = new PlaylistLoader('live.m3u8', this.fakeHls); |
166 | 309 | ||
310 | loader.load(); | ||
311 | |||
167 | this.requests.pop().respond(200, null, | 312 | this.requests.pop().respond(200, null, |
168 | '#EXTM3U\n' + | 313 | '#EXTM3U\n' + |
169 | '#EXTINF:10,\n' + | 314 | '#EXTINF:10,\n' + |
... | @@ -180,6 +325,8 @@ QUnit.test('moves to HAVE_CURRENT_METADATA when refreshing the playlist', functi | ... | @@ -180,6 +325,8 @@ QUnit.test('moves to HAVE_CURRENT_METADATA when refreshing the playlist', functi |
180 | QUnit.test('returns to HAVE_METADATA after refreshing the playlist', function() { | 325 | QUnit.test('returns to HAVE_METADATA after refreshing the playlist', function() { |
181 | let loader = new PlaylistLoader('live.m3u8', this.fakeHls); | 326 | let loader = new PlaylistLoader('live.m3u8', this.fakeHls); |
182 | 327 | ||
328 | loader.load(); | ||
329 | |||
183 | this.requests.pop().respond(200, null, | 330 | this.requests.pop().respond(200, null, |
184 | '#EXTM3U\n' + | 331 | '#EXTM3U\n' + |
185 | '#EXTINF:10,\n' + | 332 | '#EXTINF:10,\n' + |
... | @@ -197,6 +344,8 @@ QUnit.test('does not increment expired seconds before firstplay is triggered', | ... | @@ -197,6 +344,8 @@ QUnit.test('does not increment expired seconds before firstplay is triggered', |
197 | function() { | 344 | function() { |
198 | let loader = new PlaylistLoader('live.m3u8', this.fakeHls); | 345 | let loader = new PlaylistLoader('live.m3u8', this.fakeHls); |
199 | 346 | ||
347 | loader.load(); | ||
348 | |||
200 | this.requests.pop().respond(200, null, | 349 | this.requests.pop().respond(200, null, |
201 | '#EXTM3U\n' + | 350 | '#EXTM3U\n' + |
202 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | 351 | '#EXT-X-MEDIA-SEQUENCE:0\n' + |
... | @@ -227,6 +376,8 @@ function() { | ... | @@ -227,6 +376,8 @@ function() { |
227 | QUnit.test('increments expired seconds after a segment is removed', function() { | 376 | QUnit.test('increments expired seconds after a segment is removed', function() { |
228 | let loader = new PlaylistLoader('live.m3u8', this.fakeHls); | 377 | let loader = new PlaylistLoader('live.m3u8', this.fakeHls); |
229 | 378 | ||
379 | loader.load(); | ||
380 | |||
230 | loader.trigger('firstplay'); | 381 | loader.trigger('firstplay'); |
231 | this.requests.pop().respond(200, null, | 382 | this.requests.pop().respond(200, null, |
232 | '#EXTM3U\n' + | 383 | '#EXTM3U\n' + |
... | @@ -258,6 +409,8 @@ QUnit.test('increments expired seconds after a segment is removed', function() { | ... | @@ -258,6 +409,8 @@ QUnit.test('increments expired seconds after a segment is removed', function() { |
258 | QUnit.test('increments expired seconds after a discontinuity', function() { | 409 | QUnit.test('increments expired seconds after a discontinuity', function() { |
259 | let loader = new PlaylistLoader('live.m3u8', this.fakeHls); | 410 | let loader = new PlaylistLoader('live.m3u8', this.fakeHls); |
260 | 411 | ||
412 | loader.load(); | ||
413 | |||
261 | loader.trigger('firstplay'); | 414 | loader.trigger('firstplay'); |
262 | this.requests.pop().respond(200, null, | 415 | this.requests.pop().respond(200, null, |
263 | '#EXTM3U\n' + | 416 | '#EXTM3U\n' + |
... | @@ -306,6 +459,8 @@ QUnit.test('tracks expired seconds properly when two discontinuities expire at o | ... | @@ -306,6 +459,8 @@ QUnit.test('tracks expired seconds properly when two discontinuities expire at o |
306 | function() { | 459 | function() { |
307 | let loader = new PlaylistLoader('live.m3u8', this.fakeHls); | 460 | let loader = new PlaylistLoader('live.m3u8', this.fakeHls); |
308 | 461 | ||
462 | loader.load(); | ||
463 | |||
309 | loader.trigger('firstplay'); | 464 | loader.trigger('firstplay'); |
310 | this.requests.pop().respond(200, null, | 465 | this.requests.pop().respond(200, null, |
311 | '#EXTM3U\n' + | 466 | '#EXTM3U\n' + |
... | @@ -334,6 +489,8 @@ QUnit.test('estimates expired if an entire window elapses between live playlist | ... | @@ -334,6 +489,8 @@ QUnit.test('estimates expired if an entire window elapses between live playlist |
334 | function() { | 489 | function() { |
335 | let loader = new PlaylistLoader('live.m3u8', this.fakeHls); | 490 | let loader = new PlaylistLoader('live.m3u8', this.fakeHls); |
336 | 491 | ||
492 | loader.load(); | ||
493 | |||
337 | loader.trigger('firstplay'); | 494 | loader.trigger('firstplay'); |
338 | this.requests.pop().respond(200, null, | 495 | this.requests.pop().respond(200, null, |
339 | '#EXTM3U\n' + | 496 | '#EXTM3U\n' + |
... | @@ -361,6 +518,8 @@ QUnit.test('emits an error when an initial playlist request fails', function() { | ... | @@ -361,6 +518,8 @@ QUnit.test('emits an error when an initial playlist request fails', function() { |
361 | let errors = []; | 518 | let errors = []; |
362 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); | 519 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); |
363 | 520 | ||
521 | loader.load(); | ||
522 | |||
364 | loader.on('error', function() { | 523 | loader.on('error', function() { |
365 | errors.push(loader.error); | 524 | errors.push(loader.error); |
366 | }); | 525 | }); |
... | @@ -374,6 +533,8 @@ QUnit.test('errors when an initial media playlist request fails', function() { | ... | @@ -374,6 +533,8 @@ QUnit.test('errors when an initial media playlist request fails', function() { |
374 | let errors = []; | 533 | let errors = []; |
375 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); | 534 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); |
376 | 535 | ||
536 | loader.load(); | ||
537 | |||
377 | loader.on('error', function() { | 538 | loader.on('error', function() { |
378 | errors.push(loader.error); | 539 | errors.push(loader.error); |
379 | }); | 540 | }); |
... | @@ -393,9 +554,9 @@ QUnit.test('errors when an initial media playlist request fails', function() { | ... | @@ -393,9 +554,9 @@ QUnit.test('errors when an initial media playlist request fails', function() { |
393 | // http://tools.ietf.org/html/draft-pantos-http-live-streaming-12#section-6.3.4 | 554 | // http://tools.ietf.org/html/draft-pantos-http-live-streaming-12#section-6.3.4 |
394 | QUnit.test('halves the refresh timeout if a playlist is unchanged since the last reload', | 555 | QUnit.test('halves the refresh timeout if a playlist is unchanged since the last reload', |
395 | function() { | 556 | function() { |
396 | /* eslint-disable no-unused-vars */ | ||
397 | let loader = new PlaylistLoader('live.m3u8', this.fakeHls); | 557 | let loader = new PlaylistLoader('live.m3u8', this.fakeHls); |
398 | /* eslint-enable no-unused-vars */ | 558 | |
559 | loader.load(); | ||
399 | 560 | ||
400 | this.requests.pop().respond(200, null, | 561 | this.requests.pop().respond(200, null, |
401 | '#EXTM3U\n' + | 562 | '#EXTM3U\n' + |
... | @@ -422,6 +583,8 @@ QUnit.test('preserves segment metadata across playlist refreshes', function() { | ... | @@ -422,6 +583,8 @@ QUnit.test('preserves segment metadata across playlist refreshes', function() { |
422 | let loader = new PlaylistLoader('live.m3u8', this.fakeHls); | 583 | let loader = new PlaylistLoader('live.m3u8', this.fakeHls); |
423 | let segment; | 584 | let segment; |
424 | 585 | ||
586 | loader.load(); | ||
587 | |||
425 | this.requests.pop().respond(200, null, | 588 | this.requests.pop().respond(200, null, |
426 | '#EXTM3U\n' + | 589 | '#EXTM3U\n' + |
427 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | 590 | '#EXT-X-MEDIA-SEQUENCE:0\n' + |
... | @@ -454,6 +617,8 @@ QUnit.test('clears the update timeout when switching quality', function() { | ... | @@ -454,6 +617,8 @@ QUnit.test('clears the update timeout when switching quality', function() { |
454 | let loader = new PlaylistLoader('live-master.m3u8', this.fakeHls); | 617 | let loader = new PlaylistLoader('live-master.m3u8', this.fakeHls); |
455 | let refreshes = 0; | 618 | let refreshes = 0; |
456 | 619 | ||
620 | loader.load(); | ||
621 | |||
457 | // track the number of playlist refreshes triggered | 622 | // track the number of playlist refreshes triggered |
458 | loader.on('mediaupdatetimeout', function() { | 623 | loader.on('mediaupdatetimeout', function() { |
459 | refreshes++; | 624 | refreshes++; |
... | @@ -485,9 +650,9 @@ QUnit.test('clears the update timeout when switching quality', function() { | ... | @@ -485,9 +650,9 @@ QUnit.test('clears the update timeout when switching quality', function() { |
485 | }); | 650 | }); |
486 | 651 | ||
487 | QUnit.test('media-sequence updates are considered a playlist change', function() { | 652 | QUnit.test('media-sequence updates are considered a playlist change', function() { |
488 | /* eslint-disable no-unused-vars */ | ||
489 | let loader = new PlaylistLoader('live.m3u8', this.fakeHls); | 653 | let loader = new PlaylistLoader('live.m3u8', this.fakeHls); |
490 | /* eslint-enable no-unused-vars */ | 654 | |
655 | loader.load(); | ||
491 | 656 | ||
492 | this.requests.pop().respond(200, null, | 657 | this.requests.pop().respond(200, null, |
493 | '#EXTM3U\n' + | 658 | '#EXTM3U\n' + |
... | @@ -512,6 +677,8 @@ QUnit.test('emits an error if a media refresh fails', function() { | ... | @@ -512,6 +677,8 @@ QUnit.test('emits an error if a media refresh fails', function() { |
512 | let errorResponseText = 'custom error message'; | 677 | let errorResponseText = 'custom error message'; |
513 | let loader = new PlaylistLoader('live.m3u8', this.fakeHls); | 678 | let loader = new PlaylistLoader('live.m3u8', this.fakeHls); |
514 | 679 | ||
680 | loader.load(); | ||
681 | |||
515 | loader.on('error', function() { | 682 | loader.on('error', function() { |
516 | errors++; | 683 | errors++; |
517 | }); | 684 | }); |
... | @@ -534,6 +701,8 @@ QUnit.test('emits an error if a media refresh fails', function() { | ... | @@ -534,6 +701,8 @@ QUnit.test('emits an error if a media refresh fails', function() { |
534 | QUnit.test('switches media playlists when requested', function() { | 701 | QUnit.test('switches media playlists when requested', function() { |
535 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); | 702 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); |
536 | 703 | ||
704 | loader.load(); | ||
705 | |||
537 | this.requests.pop().respond(200, null, | 706 | this.requests.pop().respond(200, null, |
538 | '#EXTM3U\n' + | 707 | '#EXTM3U\n' + |
539 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | 708 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + |
... | @@ -563,6 +732,8 @@ QUnit.test('switches media playlists when requested', function() { | ... | @@ -563,6 +732,8 @@ QUnit.test('switches media playlists when requested', function() { |
563 | QUnit.test('can switch playlists immediately after the master is downloaded', function() { | 732 | QUnit.test('can switch playlists immediately after the master is downloaded', function() { |
564 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); | 733 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); |
565 | 734 | ||
735 | loader.load(); | ||
736 | |||
566 | loader.on('loadedplaylist', function() { | 737 | loader.on('loadedplaylist', function() { |
567 | loader.media('high.m3u8'); | 738 | loader.media('high.m3u8'); |
568 | }); | 739 | }); |
... | @@ -578,6 +749,8 @@ QUnit.test('can switch playlists immediately after the master is downloaded', fu | ... | @@ -578,6 +749,8 @@ QUnit.test('can switch playlists immediately after the master is downloaded', fu |
578 | QUnit.test('can switch media playlists based on URI', function() { | 749 | QUnit.test('can switch media playlists based on URI', function() { |
579 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); | 750 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); |
580 | 751 | ||
752 | loader.load(); | ||
753 | |||
581 | this.requests.pop().respond(200, null, | 754 | this.requests.pop().respond(200, null, |
582 | '#EXTM3U\n' + | 755 | '#EXTM3U\n' + |
583 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | 756 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + |
... | @@ -607,6 +780,8 @@ QUnit.test('can switch media playlists based on URI', function() { | ... | @@ -607,6 +780,8 @@ QUnit.test('can switch media playlists based on URI', function() { |
607 | QUnit.test('aborts in-flight playlist refreshes when switching', function() { | 780 | QUnit.test('aborts in-flight playlist refreshes when switching', function() { |
608 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); | 781 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); |
609 | 782 | ||
783 | loader.load(); | ||
784 | |||
610 | this.requests.pop().respond(200, null, | 785 | this.requests.pop().respond(200, null, |
611 | '#EXTM3U\n' + | 786 | '#EXTM3U\n' + |
612 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | 787 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + |
... | @@ -629,6 +804,8 @@ QUnit.test('aborts in-flight playlist refreshes when switching', function() { | ... | @@ -629,6 +804,8 @@ QUnit.test('aborts in-flight playlist refreshes when switching', function() { |
629 | QUnit.test('switching to the active playlist is a no-op', function() { | 804 | QUnit.test('switching to the active playlist is a no-op', function() { |
630 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); | 805 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); |
631 | 806 | ||
807 | loader.load(); | ||
808 | |||
632 | this.requests.pop().respond(200, null, | 809 | this.requests.pop().respond(200, null, |
633 | '#EXTM3U\n' + | 810 | '#EXTM3U\n' + |
634 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | 811 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + |
... | @@ -649,6 +826,8 @@ QUnit.test('switching to the active playlist is a no-op', function() { | ... | @@ -649,6 +826,8 @@ QUnit.test('switching to the active playlist is a no-op', function() { |
649 | QUnit.test('switching to the active live playlist is a no-op', function() { | 826 | QUnit.test('switching to the active live playlist is a no-op', function() { |
650 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); | 827 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); |
651 | 828 | ||
829 | loader.load(); | ||
830 | |||
652 | this.requests.pop().respond(200, null, | 831 | this.requests.pop().respond(200, null, |
653 | '#EXTM3U\n' + | 832 | '#EXTM3U\n' + |
654 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | 833 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + |
... | @@ -668,6 +847,8 @@ QUnit.test('switching to the active live playlist is a no-op', function() { | ... | @@ -668,6 +847,8 @@ QUnit.test('switching to the active live playlist is a no-op', function() { |
668 | QUnit.test('switches back to loaded playlists without re-requesting them', function() { | 847 | QUnit.test('switches back to loaded playlists without re-requesting them', function() { |
669 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); | 848 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); |
670 | 849 | ||
850 | loader.load(); | ||
851 | |||
671 | this.requests.pop().respond(200, null, | 852 | this.requests.pop().respond(200, null, |
672 | '#EXTM3U\n' + | 853 | '#EXTM3U\n' + |
673 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | 854 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + |
... | @@ -697,6 +878,8 @@ QUnit.test('aborts outstanding requests if switching back to an already loaded p | ... | @@ -697,6 +878,8 @@ QUnit.test('aborts outstanding requests if switching back to an already loaded p |
697 | function() { | 878 | function() { |
698 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); | 879 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); |
699 | 880 | ||
881 | loader.load(); | ||
882 | |||
700 | this.requests.pop().respond(200, null, | 883 | this.requests.pop().respond(200, null, |
701 | '#EXTM3U\n' + | 884 | '#EXTM3U\n' + |
702 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | 885 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + |
... | @@ -731,6 +914,8 @@ QUnit.test('does not abort requests when the same playlist is re-requested', | ... | @@ -731,6 +914,8 @@ QUnit.test('does not abort requests when the same playlist is re-requested', |
731 | function() { | 914 | function() { |
732 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); | 915 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); |
733 | 916 | ||
917 | loader.load(); | ||
918 | |||
734 | this.requests.pop().respond(200, null, | 919 | this.requests.pop().respond(200, null, |
735 | '#EXTM3U\n' + | 920 | '#EXTM3U\n' + |
736 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | 921 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + |
... | @@ -753,6 +938,8 @@ function() { | ... | @@ -753,6 +938,8 @@ function() { |
753 | QUnit.test('throws an error if a media switch is initiated too early', function() { | 938 | QUnit.test('throws an error if a media switch is initiated too early', function() { |
754 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); | 939 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); |
755 | 940 | ||
941 | loader.load(); | ||
942 | |||
756 | QUnit.throws(function() { | 943 | QUnit.throws(function() { |
757 | loader.media('high.m3u8'); | 944 | loader.media('high.m3u8'); |
758 | }, 'threw an error from HAVE_NOTHING'); | 945 | }, 'threw an error from HAVE_NOTHING'); |
... | @@ -769,6 +956,8 @@ QUnit.test('throws an error if a switch to an unrecognized playlist is requested | ... | @@ -769,6 +956,8 @@ QUnit.test('throws an error if a switch to an unrecognized playlist is requested |
769 | function() { | 956 | function() { |
770 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); | 957 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); |
771 | 958 | ||
959 | loader.load(); | ||
960 | |||
772 | this.requests.pop().respond(200, null, | 961 | this.requests.pop().respond(200, null, |
773 | '#EXTM3U\n' + | 962 | '#EXTM3U\n' + |
774 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | 963 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + |
... | @@ -782,6 +971,8 @@ function() { | ... | @@ -782,6 +971,8 @@ function() { |
782 | QUnit.test('dispose cancels the refresh timeout', function() { | 971 | QUnit.test('dispose cancels the refresh timeout', function() { |
783 | let loader = new PlaylistLoader('live.m3u8', this.fakeHls); | 972 | let loader = new PlaylistLoader('live.m3u8', this.fakeHls); |
784 | 973 | ||
974 | loader.load(); | ||
975 | |||
785 | this.requests.pop().respond(200, null, | 976 | this.requests.pop().respond(200, null, |
786 | '#EXTM3U\n' + | 977 | '#EXTM3U\n' + |
787 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | 978 | '#EXT-X-MEDIA-SEQUENCE:0\n' + |
... | @@ -797,6 +988,8 @@ QUnit.test('dispose cancels the refresh timeout', function() { | ... | @@ -797,6 +988,8 @@ QUnit.test('dispose cancels the refresh timeout', function() { |
797 | QUnit.test('dispose aborts pending refresh requests', function() { | 988 | QUnit.test('dispose aborts pending refresh requests', function() { |
798 | let loader = new PlaylistLoader('live.m3u8', this.fakeHls); | 989 | let loader = new PlaylistLoader('live.m3u8', this.fakeHls); |
799 | 990 | ||
991 | loader.load(); | ||
992 | |||
800 | this.requests.pop().respond(200, null, | 993 | this.requests.pop().respond(200, null, |
801 | '#EXTM3U\n' + | 994 | '#EXTM3U\n' + |
802 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | 995 | '#EXT-X-MEDIA-SEQUENCE:0\n' + |
... | @@ -815,6 +1008,8 @@ QUnit.test('errors if requests take longer than 45s', function() { | ... | @@ -815,6 +1008,8 @@ QUnit.test('errors if requests take longer than 45s', function() { |
815 | let loader = new PlaylistLoader('media.m3u8', this.fakeHls); | 1008 | let loader = new PlaylistLoader('media.m3u8', this.fakeHls); |
816 | let errors = 0; | 1009 | let errors = 0; |
817 | 1010 | ||
1011 | loader.load(); | ||
1012 | |||
818 | loader.on('error', function() { | 1013 | loader.on('error', function() { |
819 | errors++; | 1014 | errors++; |
820 | }); | 1015 | }); |
... | @@ -827,10 +1022,16 @@ QUnit.test('errors if requests take longer than 45s', function() { | ... | @@ -827,10 +1022,16 @@ QUnit.test('errors if requests take longer than 45s', function() { |
827 | QUnit.test('triggers an event when the active media changes', function() { | 1022 | QUnit.test('triggers an event when the active media changes', function() { |
828 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); | 1023 | let loader = new PlaylistLoader('master.m3u8', this.fakeHls); |
829 | let mediaChanges = 0; | 1024 | let mediaChanges = 0; |
1025 | let mediaChangings = 0; | ||
1026 | |||
1027 | loader.load(); | ||
830 | 1028 | ||
831 | loader.on('mediachange', function() { | 1029 | loader.on('mediachange', function() { |
832 | mediaChanges++; | 1030 | mediaChanges++; |
833 | }); | 1031 | }); |
1032 | loader.on('mediachanging', function() { | ||
1033 | mediaChangings++; | ||
1034 | }); | ||
834 | this.requests.pop().respond(200, null, | 1035 | this.requests.pop().respond(200, null, |
835 | '#EXTM3U\n' + | 1036 | '#EXTM3U\n' + |
836 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | 1037 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + |
... | @@ -843,9 +1044,11 @@ QUnit.test('triggers an event when the active media changes', function() { | ... | @@ -843,9 +1044,11 @@ QUnit.test('triggers an event when the active media changes', function() { |
843 | '#EXTINF:10,\n' + | 1044 | '#EXTINF:10,\n' + |
844 | 'low-0.ts\n' + | 1045 | 'low-0.ts\n' + |
845 | '#EXT-X-ENDLIST\n'); | 1046 | '#EXT-X-ENDLIST\n'); |
1047 | QUnit.strictEqual(mediaChangings, 0, 'initial selection is not a media changing'); | ||
846 | QUnit.strictEqual(mediaChanges, 0, 'initial selection is not a media change'); | 1048 | QUnit.strictEqual(mediaChanges, 0, 'initial selection is not a media change'); |
847 | 1049 | ||
848 | loader.media('high.m3u8'); | 1050 | loader.media('high.m3u8'); |
1051 | QUnit.strictEqual(mediaChangings, 1, 'mediachanging fires immediately'); | ||
849 | QUnit.strictEqual(mediaChanges, 0, 'mediachange does not fire immediately'); | 1052 | QUnit.strictEqual(mediaChanges, 0, 'mediachange does not fire immediately'); |
850 | 1053 | ||
851 | this.requests.shift().respond(200, null, | 1054 | this.requests.shift().respond(200, null, |
... | @@ -854,166 +1057,25 @@ QUnit.test('triggers an event when the active media changes', function() { | ... | @@ -854,166 +1057,25 @@ QUnit.test('triggers an event when the active media changes', function() { |
854 | '#EXTINF:10,\n' + | 1057 | '#EXTINF:10,\n' + |
855 | 'high-0.ts\n' + | 1058 | 'high-0.ts\n' + |
856 | '#EXT-X-ENDLIST\n'); | 1059 | '#EXT-X-ENDLIST\n'); |
1060 | QUnit.strictEqual(mediaChangings, 1, 'still one mediachanging'); | ||
857 | QUnit.strictEqual(mediaChanges, 1, 'fired a mediachange'); | 1061 | QUnit.strictEqual(mediaChanges, 1, 'fired a mediachange'); |
858 | 1062 | ||
859 | // switch back to an already loaded playlist | 1063 | // switch back to an already loaded playlist |
860 | loader.media('low.m3u8'); | 1064 | loader.media('low.m3u8'); |
1065 | QUnit.strictEqual(mediaChangings, 2, 'mediachanging fires'); | ||
861 | QUnit.strictEqual(mediaChanges, 2, 'fired a mediachange'); | 1066 | QUnit.strictEqual(mediaChanges, 2, 'fired a mediachange'); |
862 | 1067 | ||
863 | // trigger a no-op switch | 1068 | // trigger a no-op switch |
864 | loader.media('low.m3u8'); | 1069 | loader.media('low.m3u8'); |
1070 | QUnit.strictEqual(mediaChangings, 2, 'mediachanging ignored the no-op'); | ||
865 | QUnit.strictEqual(mediaChanges, 2, 'ignored a no-op media change'); | 1071 | QUnit.strictEqual(mediaChanges, 2, 'ignored a no-op media change'); |
866 | }); | 1072 | }); |
867 | 1073 | ||
868 | QUnit.test('can get media index by playback position for non-live videos', function() { | ||
869 | let loader = new PlaylistLoader('media.m3u8', this.fakeHls); | ||
870 | |||
871 | this.requests.shift().respond(200, null, | ||
872 | '#EXTM3U\n' + | ||
873 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
874 | '#EXTINF:4,\n' + | ||
875 | '0.ts\n' + | ||
876 | '#EXTINF:5,\n' + | ||
877 | '1.ts\n' + | ||
878 | '#EXTINF:6,\n' + | ||
879 | '2.ts\n' + | ||
880 | '#EXT-X-ENDLIST\n'); | ||
881 | |||
882 | QUnit.equal(loader.getMediaIndexForTime_(-1), | ||
883 | 0, | ||
884 | 'the index is never less than zero'); | ||
885 | QUnit.equal(loader.getMediaIndexForTime_(0), 0, 'time zero is index zero'); | ||
886 | QUnit.equal(loader.getMediaIndexForTime_(3), 0, 'time three is index zero'); | ||
887 | QUnit.equal(loader.getMediaIndexForTime_(10), 2, 'time 10 is index 2'); | ||
888 | QUnit.equal(loader.getMediaIndexForTime_(22), | ||
889 | 2, | ||
890 | 'time greater than the length is index 2'); | ||
891 | }); | ||
892 | |||
893 | QUnit.test('returns the lower index when calculating for a segment boundary', function() { | ||
894 | let loader = new PlaylistLoader('media.m3u8', this.fakeHls); | ||
895 | |||
896 | this.requests.shift().respond(200, null, | ||
897 | '#EXTM3U\n' + | ||
898 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
899 | '#EXTINF:4,\n' + | ||
900 | '0.ts\n' + | ||
901 | '#EXTINF:5,\n' + | ||
902 | '1.ts\n' + | ||
903 | '#EXT-X-ENDLIST\n'); | ||
904 | QUnit.equal(loader.getMediaIndexForTime_(4), 1, 'rounds up exact matches'); | ||
905 | QUnit.equal(loader.getMediaIndexForTime_(3.7), 0, 'rounds down'); | ||
906 | QUnit.equal(loader.getMediaIndexForTime_(4.5), 1, 'rounds up at 0.5'); | ||
907 | }); | ||
908 | |||
909 | QUnit.test('accounts for non-zero starting segment time when calculating media index', | ||
910 | function() { | ||
911 | let loader = new PlaylistLoader('media.m3u8', this.fakeHls); | ||
912 | |||
913 | this.requests.shift().respond(200, null, | ||
914 | '#EXTM3U\n' + | ||
915 | '#EXT-X-MEDIA-SEQUENCE:1001\n' + | ||
916 | '#EXTINF:4,\n' + | ||
917 | '1001.ts\n' + | ||
918 | '#EXTINF:5,\n' + | ||
919 | '1002.ts\n'); | ||
920 | loader.media().segments[0].end = 154; | ||
921 | |||
922 | QUnit.equal(loader.getMediaIndexForTime_(0), | ||
923 | -1, | ||
924 | 'the lowest returned value is negative one'); | ||
925 | QUnit.equal(loader.getMediaIndexForTime_(45), | ||
926 | -1, | ||
927 | 'expired content returns negative one'); | ||
928 | QUnit.equal(loader.getMediaIndexForTime_(75), | ||
929 | -1, | ||
930 | 'expired content returns negative one'); | ||
931 | QUnit.equal(loader.getMediaIndexForTime_(50 + 100), | ||
932 | 0, | ||
933 | 'calculates the earliest available position'); | ||
934 | QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 2), | ||
935 | 0, | ||
936 | 'calculates within the first segment'); | ||
937 | QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 4), | ||
938 | 1, | ||
939 | 'calculates within the second segment'); | ||
940 | QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 4.5), | ||
941 | 1, | ||
942 | 'calculates within the second segment'); | ||
943 | QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 6), | ||
944 | 1, | ||
945 | 'calculates within the second segment'); | ||
946 | }); | ||
947 | |||
948 | QUnit.test('prefers precise segment timing when tracking expired time', function() { | ||
949 | let loader = new PlaylistLoader('media.m3u8', this.fakeHls); | ||
950 | |||
951 | loader.trigger('firstplay'); | ||
952 | this.requests.shift().respond(200, null, | ||
953 | '#EXTM3U\n' + | ||
954 | '#EXT-X-MEDIA-SEQUENCE:1001\n' + | ||
955 | '#EXTINF:4,\n' + | ||
956 | '1001.ts\n' + | ||
957 | '#EXTINF:5,\n' + | ||
958 | '1002.ts\n'); | ||
959 | // setup the loader with an "imprecise" value as if it had been | ||
960 | // accumulating segment durations as they expire | ||
961 | loader.expired_ = 160; | ||
962 | // annotate the first segment with a start time | ||
963 | // this number would be coming from the Source Buffer in practice | ||
964 | loader.media().segments[0].end = 150; | ||
965 | |||
966 | QUnit.equal(loader.getMediaIndexForTime_(149), | ||
967 | 0, | ||
968 | 'prefers the value on the first segment'); | ||
969 | |||
970 | // trigger a playlist refresh | ||
971 | this.clock.tick(10 * 1000); | ||
972 | this.requests.shift().respond(200, null, | ||
973 | '#EXTM3U\n' + | ||
974 | '#EXT-X-MEDIA-SEQUENCE:1002\n' + | ||
975 | '#EXTINF:5,\n' + | ||
976 | '1002.ts\n'); | ||
977 | QUnit.equal(loader.getMediaIndexForTime_(150 + 4 + 1), | ||
978 | 0, | ||
979 | 'tracks precise expired times'); | ||
980 | }); | ||
981 | |||
982 | QUnit.test('accounts for expired time when calculating media index', function() { | ||
983 | let loader = new PlaylistLoader('media.m3u8', this.fakeHls); | ||
984 | |||
985 | this.requests.shift().respond(200, null, | ||
986 | '#EXTM3U\n' + | ||
987 | '#EXT-X-MEDIA-SEQUENCE:1001\n' + | ||
988 | '#EXTINF:4,\n' + | ||
989 | '1001.ts\n' + | ||
990 | '#EXTINF:5,\n' + | ||
991 | '1002.ts\n'); | ||
992 | loader.expired_ = 150; | ||
993 | |||
994 | QUnit.equal(loader.getMediaIndexForTime_(0), | ||
995 | -1, | ||
996 | 'expired content returns a negative index'); | ||
997 | QUnit.equal(loader.getMediaIndexForTime_(75), | ||
998 | -1, | ||
999 | 'expired content returns a negative index'); | ||
1000 | QUnit.equal(loader.getMediaIndexForTime_(50 + 100), | ||
1001 | 0, | ||
1002 | 'calculates the earliest available position'); | ||
1003 | QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 2), | ||
1004 | 0, | ||
1005 | 'calculates within the first segment'); | ||
1006 | QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 4.5), | ||
1007 | 1, | ||
1008 | 'calculates within the second segment'); | ||
1009 | QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 6), | ||
1010 | 1, | ||
1011 | 'calculates within the second segment'); | ||
1012 | }); | ||
1013 | |||
1014 | QUnit.test('does not misintrepret playlists missing newlines at the end', function() { | 1074 | QUnit.test('does not misintrepret playlists missing newlines at the end', function() { |
1015 | let loader = new PlaylistLoader('media.m3u8', this.fakeHls); | 1075 | let loader = new PlaylistLoader('media.m3u8', this.fakeHls); |
1016 | 1076 | ||
1077 | loader.load(); | ||
1078 | |||
1017 | // no newline | 1079 | // no newline |
1018 | this.requests.shift().respond(200, null, | 1080 | this.requests.shift().respond(200, null, |
1019 | '#EXTM3U\n' + | 1081 | '#EXTM3U\n' + | ... | ... |
1 | import Playlist from '../src/playlist'; | 1 | import Playlist from '../src/playlist'; |
2 | import PlaylistLoader from '../src/playlist-loader'; | ||
2 | import QUnit from 'qunit'; | 3 | import QUnit from 'qunit'; |
4 | import xhrFactory from '../src/xhr'; | ||
5 | import { useFakeEnvironment } from './test-helpers'; | ||
6 | |||
3 | QUnit.module('Playlist Duration'); | 7 | QUnit.module('Playlist Duration'); |
4 | 8 | ||
5 | QUnit.test('total duration for live playlists is Infinity', function() { | 9 | QUnit.test('total duration for live playlists is Infinity', function() { |
... | @@ -376,3 +380,251 @@ QUnit.test('seekable end accounts for non-standard target durations', function() | ... | @@ -376,3 +380,251 @@ QUnit.test('seekable end accounts for non-standard target durations', function() |
376 | 9 - (2 + 2 + 1), | 380 | 9 - (2 + 2 + 1), |
377 | 'allows seeking no further than three segments from the end'); | 381 | 'allows seeking no further than three segments from the end'); |
378 | }); | 382 | }); |
383 | |||
384 | QUnit.module('Playlist Media Index For Time', { | ||
385 | beforeEach() { | ||
386 | this.env = useFakeEnvironment(); | ||
387 | this.clock = this.env.clock; | ||
388 | this.requests = this.env.requests; | ||
389 | this.fakeHls = { | ||
390 | xhr: xhrFactory() | ||
391 | }; | ||
392 | }, | ||
393 | afterEach() { | ||
394 | this.env.restore(); | ||
395 | } | ||
396 | }); | ||
397 | |||
398 | QUnit.test('can get media index by playback position for non-live videos', function() { | ||
399 | let media; | ||
400 | let loader = new PlaylistLoader('media.m3u8', this.fakeHls); | ||
401 | |||
402 | loader.load(); | ||
403 | |||
404 | this.requests.shift().respond(200, null, | ||
405 | '#EXTM3U\n' + | ||
406 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
407 | '#EXTINF:4,\n' + | ||
408 | '0.ts\n' + | ||
409 | '#EXTINF:5,\n' + | ||
410 | '1.ts\n' + | ||
411 | '#EXTINF:6,\n' + | ||
412 | '2.ts\n' + | ||
413 | '#EXT-X-ENDLIST\n' | ||
414 | ); | ||
415 | |||
416 | media = loader.media(); | ||
417 | |||
418 | QUnit.equal(Playlist.getMediaIndexForTime_(media, -1), 0, | ||
419 | 'the index is never less than zero'); | ||
420 | QUnit.equal(Playlist.getMediaIndexForTime_(media, 0), 0, 'time zero is index zero'); | ||
421 | QUnit.equal(Playlist.getMediaIndexForTime_(media, 3), 0, 'time three is index zero'); | ||
422 | QUnit.equal(Playlist.getMediaIndexForTime_(media, 10), 2, 'time 10 is index 2'); | ||
423 | QUnit.equal(Playlist.getMediaIndexForTime_(media, 22), 2, | ||
424 | 'time greater than the length is index 2'); | ||
425 | }); | ||
426 | |||
427 | QUnit.test('returns the lower index when calculating for a segment boundary', function() { | ||
428 | let media; | ||
429 | let loader = new PlaylistLoader('media.m3u8', this.fakeHls); | ||
430 | |||
431 | loader.load(); | ||
432 | |||
433 | this.requests.shift().respond(200, null, | ||
434 | '#EXTM3U\n' + | ||
435 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
436 | '#EXTINF:4,\n' + | ||
437 | '0.ts\n' + | ||
438 | '#EXTINF:5,\n' + | ||
439 | '1.ts\n' + | ||
440 | '#EXT-X-ENDLIST\n' | ||
441 | ); | ||
442 | |||
443 | media = loader.media(); | ||
444 | |||
445 | QUnit.equal(Playlist.getMediaIndexForTime_(media, 4), 1, 'rounds up exact matches'); | ||
446 | QUnit.equal(Playlist.getMediaIndexForTime_(media, 3.7), 0, 'rounds down'); | ||
447 | QUnit.equal(Playlist.getMediaIndexForTime_(media, 4.5), 1, 'rounds up at 0.5'); | ||
448 | }); | ||
449 | |||
450 | QUnit.test( | ||
451 | 'accounts for non-zero starting segment time when calculating media index', | ||
452 | function() { | ||
453 | let media; | ||
454 | let loader = new PlaylistLoader('media.m3u8', this.fakeHls); | ||
455 | |||
456 | loader.load(); | ||
457 | |||
458 | this.requests.shift().respond(200, null, | ||
459 | '#EXTM3U\n' + | ||
460 | '#EXT-X-MEDIA-SEQUENCE:1001\n' + | ||
461 | '#EXTINF:4,\n' + | ||
462 | '1001.ts\n' + | ||
463 | '#EXTINF:5,\n' + | ||
464 | '1002.ts\n' | ||
465 | ); | ||
466 | loader.media().segments[0].end = 154; | ||
467 | |||
468 | media = loader.media(); | ||
469 | |||
470 | QUnit.equal( | ||
471 | Playlist.getMediaIndexForTime_(media, 0), | ||
472 | -1, | ||
473 | 'the lowest returned value is negative one' | ||
474 | ); | ||
475 | QUnit.equal( | ||
476 | Playlist.getMediaIndexForTime_(media, 45), | ||
477 | -1, | ||
478 | 'expired content returns negative one' | ||
479 | ); | ||
480 | QUnit.equal( | ||
481 | Playlist.getMediaIndexForTime_(media, 75), | ||
482 | -1, | ||
483 | 'expired content returns negative one' | ||
484 | ); | ||
485 | QUnit.equal( | ||
486 | Playlist.getMediaIndexForTime_(media, 50 + 100), | ||
487 | 0, | ||
488 | 'calculates the earliest available position' | ||
489 | ); | ||
490 | QUnit.equal( | ||
491 | Playlist.getMediaIndexForTime_(media, 50 + 100 + 2), | ||
492 | 0, | ||
493 | 'calculates within the first segment' | ||
494 | ); | ||
495 | QUnit.equal( | ||
496 | Playlist.getMediaIndexForTime_(media, 50 + 100 + 2), | ||
497 | 0, | ||
498 | 'calculates within the first segment' | ||
499 | ); | ||
500 | QUnit.equal( | ||
501 | Playlist.getMediaIndexForTime_(media, 50 + 100 + 4), | ||
502 | 1, | ||
503 | 'calculates within the second segment' | ||
504 | ); | ||
505 | QUnit.equal( | ||
506 | Playlist.getMediaIndexForTime_(media, 50 + 100 + 4.5), | ||
507 | 1, | ||
508 | 'calculates within the second segment' | ||
509 | ); | ||
510 | QUnit.equal( | ||
511 | Playlist.getMediaIndexForTime_(media, 50 + 100 + 6), | ||
512 | 1, | ||
513 | 'calculates within the second segment' | ||
514 | ); | ||
515 | |||
516 | loader.media().segments[1].end = 159; | ||
517 | QUnit.equal( | ||
518 | Playlist.getMediaIndexForTime_(media, 159), | ||
519 | 2, | ||
520 | 'returns number of segments when time is equal to end of last segment' | ||
521 | ); | ||
522 | QUnit.equal( | ||
523 | Playlist.getMediaIndexForTime_(media, 159.1), | ||
524 | 2, | ||
525 | 'returns number of segments when time is past end of last segment' | ||
526 | ); | ||
527 | }); | ||
528 | |||
529 | QUnit.test('prefers precise segment timing when tracking expired time', function() { | ||
530 | let media; | ||
531 | let loader = new PlaylistLoader('media.m3u8', this.fakeHls); | ||
532 | |||
533 | loader.load(); | ||
534 | |||
535 | loader.trigger('firstplay'); | ||
536 | this.requests.shift().respond(200, null, | ||
537 | '#EXTM3U\n' + | ||
538 | '#EXT-X-MEDIA-SEQUENCE:1001\n' + | ||
539 | '#EXTINF:4,\n' + | ||
540 | '1001.ts\n' + | ||
541 | '#EXTINF:5,\n' + | ||
542 | '1002.ts\n' | ||
543 | ); | ||
544 | // setup the loader with an "imprecise" value as if it had been | ||
545 | // accumulating segment durations as they expire | ||
546 | loader.expired_ = 160; | ||
547 | // annotate the first segment with a start time | ||
548 | // this number would be coming from the Source Buffer in practice | ||
549 | loader.media().segments[0].end = 150; | ||
550 | |||
551 | media = loader.media(); | ||
552 | |||
553 | QUnit.equal( | ||
554 | Playlist.getMediaIndexForTime_(media, 149), | ||
555 | 0, | ||
556 | 'prefers the value on the first segment' | ||
557 | ); | ||
558 | |||
559 | // trigger a playlist refresh | ||
560 | this.clock.tick(10 * 1000); | ||
561 | this.requests.shift().respond(200, null, | ||
562 | '#EXTM3U\n' + | ||
563 | '#EXT-X-MEDIA-SEQUENCE:1002\n' + | ||
564 | '#EXTINF:5,\n' + | ||
565 | '1002.ts\n' | ||
566 | ); | ||
567 | |||
568 | media = loader.media(); | ||
569 | |||
570 | QUnit.equal( | ||
571 | Playlist.getMediaIndexForTime_(media, 150 + 4 + 1), | ||
572 | 0, | ||
573 | 'tracks precise expired times' | ||
574 | ); | ||
575 | }); | ||
576 | |||
577 | QUnit.test('accounts for expired time when calculating media index', function() { | ||
578 | let media; | ||
579 | let loader = new PlaylistLoader('media.m3u8', this.fakeHls); | ||
580 | let expired = 150; | ||
581 | |||
582 | loader.load(); | ||
583 | |||
584 | this.requests.shift().respond(200, null, | ||
585 | '#EXTM3U\n' + | ||
586 | '#EXT-X-MEDIA-SEQUENCE:1001\n' + | ||
587 | '#EXTINF:4,\n' + | ||
588 | '1001.ts\n' + | ||
589 | '#EXTINF:5,\n' + | ||
590 | '1002.ts\n' | ||
591 | ); | ||
592 | |||
593 | media = loader.media(); | ||
594 | |||
595 | QUnit.equal( | ||
596 | Playlist.getMediaIndexForTime_(media, 0, expired), | ||
597 | -1, | ||
598 | 'expired content returns a negative index' | ||
599 | ); | ||
600 | QUnit.equal( | ||
601 | Playlist.getMediaIndexForTime_(media, 75, expired), | ||
602 | -1, | ||
603 | 'expired content returns a negative index' | ||
604 | ); | ||
605 | QUnit.equal( | ||
606 | Playlist.getMediaIndexForTime_(media, 50 + 100, expired), | ||
607 | 0, | ||
608 | 'calculates the earliest available position' | ||
609 | ); | ||
610 | QUnit.equal( | ||
611 | Playlist.getMediaIndexForTime_(media, 50 + 100 + 2, expired), | ||
612 | 0, | ||
613 | 'calculates within the first segment' | ||
614 | ); | ||
615 | QUnit.equal( | ||
616 | Playlist.getMediaIndexForTime_(media, 50 + 100 + 2, expired), | ||
617 | 0, | ||
618 | 'calculates within the first segment' | ||
619 | ); | ||
620 | QUnit.equal( | ||
621 | Playlist.getMediaIndexForTime_(media, 50 + 100 + 4.5, expired), | ||
622 | 1, | ||
623 | 'calculates within the second segment' | ||
624 | ); | ||
625 | QUnit.equal( | ||
626 | Playlist.getMediaIndexForTime_(media, 50 + 100 + 6, expired), | ||
627 | 1, | ||
628 | 'calculates within the second segment' | ||
629 | ); | ||
630 | }); | ... | ... |
test/ranges.test.js
0 → 100644
1 | import Ranges from '../src/ranges'; | ||
2 | import {createTimeRanges} from 'video.js'; | ||
3 | import QUnit from 'qunit'; | ||
4 | |||
5 | QUnit.module('TimeRanges Utilities'); | ||
6 | |||
7 | QUnit.test('finds the overlapping time range', function() { | ||
8 | let range = Ranges.findRange(createTimeRanges([[0, 5], [6, 12]]), 3); | ||
9 | |||
10 | QUnit.equal(range.length, 1, 'found one range'); | ||
11 | QUnit.equal(range.end(0), 5, 'inside the first buffered region'); | ||
12 | |||
13 | range = Ranges.findRange(createTimeRanges([[0, 5], [6, 12]]), 6); | ||
14 | QUnit.equal(range.length, 1, 'found one range'); | ||
15 | QUnit.equal(range.end(0), 12, 'inside the second buffered region'); | ||
16 | }); | ||
17 | |||
18 | QUnit.module('Buffer Inpsection'); | ||
19 | |||
20 | QUnit.test('detects time range end-point changed by updates', function() { | ||
21 | let edge; | ||
22 | |||
23 | // Single-range changes | ||
24 | edge = Ranges.findSoleUncommonTimeRangesEnd(createTimeRanges([[0, 10]]), | ||
25 | createTimeRanges([[0, 11]])); | ||
26 | QUnit.strictEqual(edge, 11, 'detected a forward addition'); | ||
27 | |||
28 | edge = Ranges.findSoleUncommonTimeRangesEnd(createTimeRanges([[5, 10]]), | ||
29 | createTimeRanges([[0, 10]])); | ||
30 | QUnit.strictEqual(edge, null, 'ignores backward addition'); | ||
31 | |||
32 | edge = Ranges.findSoleUncommonTimeRangesEnd(createTimeRanges([[5, 10]]), | ||
33 | createTimeRanges([[0, 11]])); | ||
34 | QUnit.strictEqual(edge, 11, | ||
35 | 'detected a forward addition & ignores a backward addition'); | ||
36 | |||
37 | edge = Ranges.findSoleUncommonTimeRangesEnd(createTimeRanges([[0, 10]]), | ||
38 | createTimeRanges([[0, 9]])); | ||
39 | QUnit.strictEqual(edge, null, | ||
40 | 'ignores a backwards addition resulting from a shrinking range'); | ||
41 | |||
42 | edge = Ranges.findSoleUncommonTimeRangesEnd(createTimeRanges([[0, 10]]), | ||
43 | createTimeRanges([[2, 7]])); | ||
44 | QUnit.strictEqual(edge, null, | ||
45 | 'ignores a forward & backwards addition resulting from a shrinking ' + | ||
46 | 'range'); | ||
47 | |||
48 | edge = Ranges.findSoleUncommonTimeRangesEnd(createTimeRanges([[2, 10]]), | ||
49 | createTimeRanges([[0, 7]])); | ||
50 | QUnit.strictEqual( | ||
51 | edge, | ||
52 | null, | ||
53 | 'ignores a forward & backwards addition resulting from a range shifted backward' | ||
54 | ); | ||
55 | |||
56 | edge = Ranges.findSoleUncommonTimeRangesEnd(createTimeRanges([[2, 10]]), | ||
57 | createTimeRanges([[5, 15]])); | ||
58 | QUnit.strictEqual(edge, 15, | ||
59 | 'detected a forwards addition resulting from a range shifted foward'); | ||
60 | |||
61 | // Multiple-range changes | ||
62 | edge = Ranges.findSoleUncommonTimeRangesEnd(createTimeRanges([[0, 10]]), | ||
63 | createTimeRanges([[0, 11], [12, 15]])); | ||
64 | QUnit.strictEqual(edge, null, 'ignores multiple new forward additions'); | ||
65 | |||
66 | edge = Ranges.findSoleUncommonTimeRangesEnd(createTimeRanges([[0, 10], [20, 40]]), | ||
67 | createTimeRanges([[20, 50]])); | ||
68 | QUnit.strictEqual(edge, 50, 'detected a forward addition & ignores range removal'); | ||
69 | |||
70 | edge = Ranges.findSoleUncommonTimeRangesEnd(createTimeRanges([[0, 10], [20, 40]]), | ||
71 | createTimeRanges([[0, 50]])); | ||
72 | QUnit.strictEqual(edge, 50, 'detected a forward addition & ignores merges'); | ||
73 | |||
74 | edge = Ranges.findSoleUncommonTimeRangesEnd(createTimeRanges([[0, 10], [20, 40]]), | ||
75 | createTimeRanges([[0, 40]])); | ||
76 | QUnit.strictEqual(edge, null, 'ignores merges'); | ||
77 | |||
78 | // Empty input | ||
79 | edge = Ranges.findSoleUncommonTimeRangesEnd(createTimeRanges(), | ||
80 | createTimeRanges([[0, 11]])); | ||
81 | QUnit.strictEqual(edge, 11, 'handle an empty original TimeRanges object'); | ||
82 | |||
83 | edge = Ranges.findSoleUncommonTimeRangesEnd(createTimeRanges([[0, 11]]), | ||
84 | createTimeRanges()); | ||
85 | QUnit.strictEqual(edge, null, 'handle an empty update TimeRanges object'); | ||
86 | |||
87 | // Null input | ||
88 | edge = Ranges.findSoleUncommonTimeRangesEnd(null, createTimeRanges([[0, 11]])); | ||
89 | QUnit.strictEqual(edge, 11, 'treat null original buffer as an empty TimeRanges object'); | ||
90 | |||
91 | edge = Ranges.findSoleUncommonTimeRangesEnd(createTimeRanges([[0, 11]]), null); | ||
92 | QUnit.strictEqual(edge, null, 'treat null update buffer as an empty TimeRanges object'); | ||
93 | }); |
test/segment-loader.test.js
0 → 100644
1 | import QUnit from 'qunit'; | ||
2 | import {GOAL_BUFFER_LENGTH, default as SegmentLoader} from '../src/segment-loader'; | ||
3 | import videojs from 'video.js'; | ||
4 | import xhrFactory from '../src/xhr'; | ||
5 | import { useFakeEnvironment, useFakeMediaSource } from './test-helpers.js'; | ||
6 | |||
7 | const playlistWithDuration = function(time, conf) { | ||
8 | let result = { | ||
9 | mediaSequence: conf && conf.mediaSequence ? conf.mediaSequence : 0, | ||
10 | discontinuityStarts: [], | ||
11 | segments: [], | ||
12 | endList: true | ||
13 | }; | ||
14 | let count = Math.floor(time / 10); | ||
15 | let remainder = time % 10; | ||
16 | let i; | ||
17 | let isEncrypted = conf && conf.isEncrypted; | ||
18 | |||
19 | for (i = 0; i < count; i++) { | ||
20 | result.segments.push({ | ||
21 | uri: i + '.ts', | ||
22 | resolvedUri: i + '.ts', | ||
23 | duration: 10 | ||
24 | }); | ||
25 | if (isEncrypted) { | ||
26 | result.segments[i].key = { | ||
27 | uri: i + '-key.php', | ||
28 | resolvedUri: i + '-key.php' | ||
29 | }; | ||
30 | } | ||
31 | } | ||
32 | if (remainder) { | ||
33 | result.segments.push({ | ||
34 | uri: i + '.ts', | ||
35 | duration: remainder | ||
36 | }); | ||
37 | } | ||
38 | return result; | ||
39 | }; | ||
40 | |||
41 | let currentTime; | ||
42 | let mediaSource; | ||
43 | let loader; | ||
44 | |||
45 | QUnit.module('Segment Loader', { | ||
46 | beforeEach() { | ||
47 | this.env = useFakeEnvironment(); | ||
48 | this.clock = this.env.clock; | ||
49 | this.requests = this.env.requests; | ||
50 | this.mse = useFakeMediaSource(); | ||
51 | this.seekable = { | ||
52 | length: 0 | ||
53 | }; | ||
54 | this.mimeType = 'video/mp2t'; | ||
55 | this.fakeHls = { | ||
56 | xhr: xhrFactory() | ||
57 | }; | ||
58 | |||
59 | currentTime = 0; | ||
60 | mediaSource = new videojs.MediaSource(); | ||
61 | mediaSource.trigger('sourceopen'); | ||
62 | loader = new SegmentLoader({ | ||
63 | hls: this.fakeHls, | ||
64 | currentTime() { | ||
65 | return currentTime; | ||
66 | }, | ||
67 | seekable: () => this.seekable, | ||
68 | seeking: () => false, | ||
69 | hasPlayed: () => true, | ||
70 | mediaSource | ||
71 | }); | ||
72 | }, | ||
73 | afterEach() { | ||
74 | this.env.restore(); | ||
75 | this.mse.restore(); | ||
76 | } | ||
77 | }); | ||
78 | |||
79 | QUnit.test('fails without required initialization options', function() { | ||
80 | /* eslint-disable no-new */ | ||
81 | QUnit.throws(function() { | ||
82 | new SegmentLoader(); | ||
83 | }, 'requires options'); | ||
84 | QUnit.throws(function() { | ||
85 | new SegmentLoader({}); | ||
86 | }, 'requires a currentTime callback'); | ||
87 | QUnit.throws(function() { | ||
88 | new SegmentLoader({ | ||
89 | currentTime() {} | ||
90 | }); | ||
91 | }, 'requires a media source'); | ||
92 | /* eslint-enable */ | ||
93 | }); | ||
94 | |||
95 | QUnit.test('load waits until a playlist and mime type are specified to proceed', | ||
96 | function() { | ||
97 | loader.load(); | ||
98 | QUnit.equal(loader.state, 'INIT', 'waiting in init'); | ||
99 | QUnit.equal(loader.paused(), false, 'not paused'); | ||
100 | |||
101 | loader.playlist(playlistWithDuration(10)); | ||
102 | QUnit.equal(this.requests.length, 0, 'have not made a request yet'); | ||
103 | loader.mimeType(this.mimeType); | ||
104 | |||
105 | QUnit.equal(this.requests.length, 1, 'made a request'); | ||
106 | QUnit.equal(loader.state, 'WAITING', 'transitioned states'); | ||
107 | }); | ||
108 | |||
109 | QUnit.test('calling mime type and load begins buffering', function() { | ||
110 | QUnit.equal(loader.state, 'INIT', 'starts in the init state'); | ||
111 | loader.playlist(playlistWithDuration(10)); | ||
112 | QUnit.equal(loader.state, 'INIT', 'starts in the init state'); | ||
113 | QUnit.ok(loader.paused(), 'starts paused'); | ||
114 | |||
115 | loader.mimeType(this.mimeType); | ||
116 | QUnit.equal(loader.state, 'INIT', 'still in the init state'); | ||
117 | loader.load(); | ||
118 | |||
119 | QUnit.equal(loader.state, 'WAITING', 'moves to the ready state'); | ||
120 | QUnit.ok(!loader.paused(), 'loading is not paused'); | ||
121 | QUnit.equal(this.requests.length, 1, 'requested a segment'); | ||
122 | }); | ||
123 | |||
124 | QUnit.test('calling load is idempotent', function() { | ||
125 | loader.playlist(playlistWithDuration(20)); | ||
126 | loader.mimeType(this.mimeType); | ||
127 | loader.load(); | ||
128 | QUnit.equal(loader.state, 'WAITING', 'moves to the ready state'); | ||
129 | QUnit.equal(this.requests.length, 1, 'made one request'); | ||
130 | |||
131 | loader.load(); | ||
132 | QUnit.equal(loader.state, 'WAITING', 'still in the ready state'); | ||
133 | QUnit.equal(this.requests.length, 1, 'still one request'); | ||
134 | |||
135 | // some time passes and a response is received | ||
136 | this.clock.tick(100); | ||
137 | this.requests[0].response = new Uint8Array(10).buffer; | ||
138 | this.requests.shift().respond(200, null, ''); | ||
139 | loader.load(); | ||
140 | QUnit.equal(this.requests.length, 0, 'load has no effect'); | ||
141 | }); | ||
142 | |||
143 | QUnit.test('calling load should unpause', function() { | ||
144 | let sourceBuffer; | ||
145 | |||
146 | loader.playlist(playlistWithDuration(20)); | ||
147 | loader.pause(); | ||
148 | |||
149 | loader.mimeType(this.mimeType); | ||
150 | sourceBuffer = mediaSource.sourceBuffers[0]; | ||
151 | |||
152 | loader.load(); | ||
153 | QUnit.equal(loader.paused(), false, 'loading unpauses'); | ||
154 | |||
155 | loader.pause(); | ||
156 | this.clock.tick(1); | ||
157 | this.requests[0].response = new Uint8Array(10).buffer; | ||
158 | this.requests.shift().respond(200, null, ''); | ||
159 | |||
160 | QUnit.equal(loader.paused(), true, 'stayed paused'); | ||
161 | loader.load(); | ||
162 | QUnit.equal(loader.paused(), false, 'unpaused during processing'); | ||
163 | |||
164 | loader.pause(); | ||
165 | sourceBuffer.trigger('updateend'); | ||
166 | QUnit.equal(loader.state, 'READY', 'finished processing'); | ||
167 | QUnit.ok(loader.paused(), 'stayed paused'); | ||
168 | |||
169 | loader.load(); | ||
170 | QUnit.equal(loader.paused(), false, 'unpaused'); | ||
171 | }); | ||
172 | |||
173 | QUnit.test('regularly checks the buffer while unpaused', function() { | ||
174 | let sourceBuffer; | ||
175 | |||
176 | loader.playlist(playlistWithDuration(90)); | ||
177 | loader.mimeType(this.mimeType); | ||
178 | loader.load(); | ||
179 | sourceBuffer = mediaSource.sourceBuffers[0]; | ||
180 | |||
181 | // fill the buffer | ||
182 | this.clock.tick(1); | ||
183 | this.requests[0].response = new Uint8Array(10).buffer; | ||
184 | this.requests.shift().respond(200, null, ''); | ||
185 | sourceBuffer.buffered = videojs.createTimeRanges([[ | ||
186 | 0, GOAL_BUFFER_LENGTH | ||
187 | ]]); | ||
188 | sourceBuffer.trigger('updateend'); | ||
189 | QUnit.equal(this.requests.length, 0, 'no outstanding requests'); | ||
190 | |||
191 | // play some video to drain the buffer | ||
192 | currentTime = GOAL_BUFFER_LENGTH; | ||
193 | this.clock.tick(10 * 1000); | ||
194 | QUnit.equal(this.requests.length, 1, 'requested another segment'); | ||
195 | }); | ||
196 | |||
197 | QUnit.test('does not check the buffer while paused', function() { | ||
198 | let sourceBuffer; | ||
199 | |||
200 | loader.playlist(playlistWithDuration(90)); | ||
201 | loader.mimeType(this.mimeType); | ||
202 | loader.load(); | ||
203 | sourceBuffer = mediaSource.sourceBuffers[0]; | ||
204 | |||
205 | loader.pause(); | ||
206 | this.clock.tick(1); | ||
207 | this.requests[0].response = new Uint8Array(10).buffer; | ||
208 | this.requests.shift().respond(200, null, ''); | ||
209 | sourceBuffer.trigger('updateend'); | ||
210 | |||
211 | this.clock.tick(10 * 1000); | ||
212 | QUnit.equal(this.requests.length, 0, 'did not make a request'); | ||
213 | }); | ||
214 | |||
215 | QUnit.test('calculates bandwidth after downloading a segment', function() { | ||
216 | loader.playlist(playlistWithDuration(10)); | ||
217 | loader.mimeType(this.mimeType); | ||
218 | loader.load(); | ||
219 | |||
220 | // some time passes and a response is received | ||
221 | this.clock.tick(100); | ||
222 | this.requests[0].response = new Uint8Array(10).buffer; | ||
223 | this.requests.shift().respond(200, null, ''); | ||
224 | |||
225 | QUnit.equal(loader.bandwidth, (10 / 100) * 8 * 1000, 'calculated bandwidth'); | ||
226 | QUnit.equal(loader.roundTrip, 100, 'saves request round trip time'); | ||
227 | QUnit.equal(loader.bytesReceived, 10, 'saves bytes received'); | ||
228 | }); | ||
229 | |||
230 | QUnit.test('segment request timeouts reset bandwidth', function() { | ||
231 | loader.playlist(playlistWithDuration(10)); | ||
232 | loader.mimeType(this.mimeType); | ||
233 | loader.load(); | ||
234 | |||
235 | // a lot of time passes so the request times out | ||
236 | this.requests[0].timedout = true; | ||
237 | this.clock.tick(100 * 1000); | ||
238 | |||
239 | QUnit.equal(loader.bandwidth, 1, 'reset bandwidth'); | ||
240 | QUnit.ok(isNaN(loader.roundTrip), 'reset round trip time'); | ||
241 | }); | ||
242 | |||
243 | QUnit.test('appending a segment triggers progress', function() { | ||
244 | let progresses = 0; | ||
245 | |||
246 | loader.on('progress', function() { | ||
247 | progresses++; | ||
248 | }); | ||
249 | loader.playlist(playlistWithDuration(10)); | ||
250 | loader.mimeType(this.mimeType); | ||
251 | loader.load(); | ||
252 | |||
253 | // some time passes and a response is received | ||
254 | this.requests[0].response = new Uint8Array(10).buffer; | ||
255 | this.requests.shift().respond(200, null, ''); | ||
256 | mediaSource.sourceBuffers[0].trigger('updateend'); | ||
257 | |||
258 | QUnit.equal(progresses, 1, 'fired progress'); | ||
259 | }); | ||
260 | |||
261 | QUnit.test('only requests one segment at a time', function() { | ||
262 | loader.playlist(playlistWithDuration(10)); | ||
263 | loader.mimeType(this.mimeType); | ||
264 | loader.load(); | ||
265 | |||
266 | // a bunch of time passes without recieving a response | ||
267 | this.clock.tick(20 * 1000); | ||
268 | QUnit.equal(this.requests.length, 1, 'only one request was made'); | ||
269 | }); | ||
270 | |||
271 | QUnit.test('only appends one segment at a time', function() { | ||
272 | loader.playlist(playlistWithDuration(10)); | ||
273 | loader.mimeType(this.mimeType); | ||
274 | loader.load(); | ||
275 | |||
276 | // some time passes and a segment is received | ||
277 | this.clock.tick(100); | ||
278 | this.requests[0].response = new Uint8Array(10).buffer; | ||
279 | this.requests.shift().respond(200, null, ''); | ||
280 | |||
281 | // a lot of time goes by without "updateend" | ||
282 | this.clock.tick(20 * 1000); | ||
283 | |||
284 | QUnit.equal(mediaSource.sourceBuffers[0].updates_.filter( | ||
285 | update => update.append).length, 1, 'only one append'); | ||
286 | QUnit.equal(this.requests.length, 0, 'only made one request'); | ||
287 | }); | ||
288 | |||
289 | QUnit.test('adjusts the playlist offset if no buffering progress is made', function() { | ||
290 | let sourceBuffer; | ||
291 | let playlist; | ||
292 | |||
293 | playlist = playlistWithDuration(40); | ||
294 | playlist.endList = false; | ||
295 | loader.playlist(playlist); | ||
296 | loader.mimeType(this.mimeType); | ||
297 | loader.load(); | ||
298 | sourceBuffer = mediaSource.sourceBuffers[0]; | ||
299 | |||
300 | // buffer some content and switch playlists on progress | ||
301 | this.clock.tick(1); | ||
302 | this.requests[0].response = new Uint8Array(10).buffer; | ||
303 | this.requests.shift().respond(200, null, ''); | ||
304 | loader.on('progress', function f() { | ||
305 | loader.off('progress', f); | ||
306 | // switch playlists | ||
307 | playlist = playlistWithDuration(40); | ||
308 | playlist.uri = 'alternate.m3u8'; | ||
309 | playlist.endList = false; | ||
310 | loader.playlist(playlist); | ||
311 | }); | ||
312 | sourceBuffer.buffered = videojs.createTimeRanges([[0, 5]]); | ||
313 | sourceBuffer.trigger('updateend'); | ||
314 | |||
315 | // the next segment doesn't increase the buffer at all | ||
316 | QUnit.equal(this.requests[0].url, '0.ts', 'requested the same segment'); | ||
317 | this.clock.tick(1); | ||
318 | this.requests[0].response = new Uint8Array(10).buffer; | ||
319 | this.requests.shift().respond(200, null, ''); | ||
320 | sourceBuffer.trigger('updateend'); | ||
321 | |||
322 | // so the loader should try the next segment | ||
323 | QUnit.equal(this.requests[0].url, '1.ts', 'moved ahead a segment'); | ||
324 | }); | ||
325 | |||
326 | QUnit.test('never attempt to load a segment that ' + | ||
327 | 'is greater than 90% buffered', function() { | ||
328 | let sourceBuffer; | ||
329 | let playlist; | ||
330 | |||
331 | playlist = playlistWithDuration(40); | ||
332 | playlist.endList = false; | ||
333 | loader.playlist(playlist); | ||
334 | loader.mimeType(this.mimeType); | ||
335 | loader.load(); | ||
336 | sourceBuffer = mediaSource.sourceBuffers[0]; | ||
337 | |||
338 | // buffer some content and switch playlists on progress | ||
339 | this.clock.tick(1); | ||
340 | this.requests[0].response = new Uint8Array(10).buffer; | ||
341 | this.requests.shift().respond(200, null, ''); | ||
342 | loader.on('progress', function f() { | ||
343 | loader.off('progress', f); | ||
344 | // switch playlists | ||
345 | playlist = playlistWithDuration(40); | ||
346 | playlist.uri = 'alternate.m3u8'; | ||
347 | playlist.endList = false; | ||
348 | loader.playlist(playlist); | ||
349 | }); | ||
350 | sourceBuffer.buffered = videojs.createTimeRanges([[0, 9.2]]); | ||
351 | sourceBuffer.trigger('updateend'); | ||
352 | |||
353 | // the next segment doesn't increase the buffer at all | ||
354 | QUnit.equal(this.requests[0].url, '1.ts', 'requested the next segment'); | ||
355 | this.clock.tick(1); | ||
356 | this.requests[0].response = new Uint8Array(10).buffer; | ||
357 | this.requests.shift().respond(200, null, ''); | ||
358 | sourceBuffer.trigger('updateend'); | ||
359 | |||
360 | // so the loader should try the next segment | ||
361 | QUnit.equal(this.requests[0].url, '1.ts', 'moved ahead a segment'); | ||
362 | }); | ||
363 | |||
364 | QUnit.test('adjusts the playlist offset if no buffering progress is made', function() { | ||
365 | let sourceBuffer; | ||
366 | let playlist; | ||
367 | |||
368 | playlist = playlistWithDuration(40); | ||
369 | playlist.endList = false; | ||
370 | loader.playlist(playlist); | ||
371 | loader.mimeType(this.mimeType); | ||
372 | loader.load(); | ||
373 | sourceBuffer = mediaSource.sourceBuffers[0]; | ||
374 | |||
375 | // buffer some content and switch playlists on progress | ||
376 | this.clock.tick(1); | ||
377 | this.requests[0].response = new Uint8Array(10).buffer; | ||
378 | this.requests.shift().respond(200, null, ''); | ||
379 | loader.on('progress', function f() { | ||
380 | loader.off('progress', f); | ||
381 | // switch playlists | ||
382 | playlist = playlistWithDuration(40); | ||
383 | playlist.uri = 'alternate.m3u8'; | ||
384 | playlist.endList = false; | ||
385 | loader.playlist(playlist); | ||
386 | }); | ||
387 | sourceBuffer.buffered = videojs.createTimeRanges([[0, 5]]); | ||
388 | sourceBuffer.trigger('updateend'); | ||
389 | |||
390 | // the next segment doesn't increase the buffer at all | ||
391 | QUnit.equal(this.requests[0].url, '0.ts', 'requested the same segment'); | ||
392 | this.clock.tick(1); | ||
393 | this.requests[0].response = new Uint8Array(10).buffer; | ||
394 | this.requests.shift().respond(200, null, ''); | ||
395 | sourceBuffer.trigger('updateend'); | ||
396 | |||
397 | // so the loader should try the next segment | ||
398 | QUnit.equal(this.requests[0].url, '1.ts', 'moved ahead a segment'); | ||
399 | }); | ||
400 | |||
401 | QUnit.test('cancels outstanding requests on abort', function() { | ||
402 | loader.playlist(playlistWithDuration(20)); | ||
403 | loader.mimeType(this.mimeType); | ||
404 | loader.load(); | ||
405 | loader.xhr_.segmentXhr.onreadystatechange = function() { | ||
406 | throw new Error('onreadystatechange should not be called'); | ||
407 | }; | ||
408 | |||
409 | loader.abort(); | ||
410 | QUnit.ok(this.requests[0].aborted, 'aborted the first request'); | ||
411 | QUnit.equal(this.requests.length, 2, 'started a new request'); | ||
412 | QUnit.equal(loader.state, 'WAITING', 'back to the waiting state'); | ||
413 | }); | ||
414 | |||
415 | QUnit.test('abort does not cancel segment processing in progress', function() { | ||
416 | loader.playlist(playlistWithDuration(20)); | ||
417 | loader.mimeType(this.mimeType); | ||
418 | loader.load(); | ||
419 | |||
420 | this.requests[0].response = new Uint8Array(10).buffer; | ||
421 | this.requests.shift().respond(200, null, ''); | ||
422 | |||
423 | loader.abort(); | ||
424 | QUnit.equal(loader.state, 'APPENDING', 'still appending'); | ||
425 | }); | ||
426 | |||
427 | QUnit.test('sets the timestampOffset on timeline change', function() { | ||
428 | let playlist = playlistWithDuration(40); | ||
429 | |||
430 | playlist.discontinuityStarts = [1]; | ||
431 | playlist.segments[1].timeline = 1; | ||
432 | loader.playlist(playlist); | ||
433 | loader.mimeType(this.mimeType); | ||
434 | loader.load(); | ||
435 | |||
436 | // segment 0 | ||
437 | this.requests[0].response = new Uint8Array(10).buffer; | ||
438 | this.requests.shift().respond(200, null, ''); | ||
439 | mediaSource.sourceBuffers[0].buffered = videojs.createTimeRanges([[0, 10]]); | ||
440 | mediaSource.sourceBuffers[0].trigger('updateend'); | ||
441 | |||
442 | // segment 1, discontinuity | ||
443 | this.requests[0].response = new Uint8Array(10).buffer; | ||
444 | this.requests.shift().respond(200, null, ''); | ||
445 | QUnit.equal(mediaSource.sourceBuffers[0].timestampOffset, 10, 'set timestampOffset'); | ||
446 | }); | ||
447 | |||
448 | QUnit.test('tracks segment end times as they are buffered', function() { | ||
449 | let playlist = playlistWithDuration(20); | ||
450 | |||
451 | loader.playlist(playlist); | ||
452 | loader.mimeType(this.mimeType); | ||
453 | loader.load(); | ||
454 | |||
455 | this.requests[0].response = new Uint8Array(10).buffer; | ||
456 | this.requests.shift().respond(200, null, ''); | ||
457 | |||
458 | mediaSource.sourceBuffers[0].buffered = videojs.createTimeRanges([ | ||
459 | [0, 9.5] | ||
460 | ]); | ||
461 | mediaSource.sourceBuffers[0].trigger('updateend'); | ||
462 | QUnit.equal(playlist.segments[0].end, 9.5, 'updated duration'); | ||
463 | }); | ||
464 | |||
465 | QUnit.test('segment 404s should trigger an error', function() { | ||
466 | let errors = []; | ||
467 | |||
468 | loader.playlist(playlistWithDuration(10)); | ||
469 | loader.mimeType(this.mimeType); | ||
470 | loader.load(); | ||
471 | loader.on('error', function(error) { | ||
472 | errors.push(error); | ||
473 | }); | ||
474 | this.requests.shift().respond(404, null, ''); | ||
475 | |||
476 | QUnit.equal(errors.length, 1, 'triggered an error'); | ||
477 | QUnit.equal(loader.error().code, 2, 'triggered MEDIA_ERR_NETWORK'); | ||
478 | QUnit.ok(loader.error().xhr, 'included the request object'); | ||
479 | QUnit.ok(loader.paused(), 'paused the loader'); | ||
480 | QUnit.equal(loader.state, 'READY', 'returned to the ready state'); | ||
481 | }); | ||
482 | |||
483 | QUnit.test('segment 5xx status codes trigger an error', function() { | ||
484 | let errors = []; | ||
485 | |||
486 | loader.playlist(playlistWithDuration(10)); | ||
487 | loader.mimeType(this.mimeType); | ||
488 | loader.load(); | ||
489 | loader.on('error', function(error) { | ||
490 | errors.push(error); | ||
491 | }); | ||
492 | this.requests.shift().respond(500, null, ''); | ||
493 | |||
494 | QUnit.equal(errors.length, 1, 'triggered an error'); | ||
495 | QUnit.equal(loader.error().code, 2, 'triggered MEDIA_ERR_NETWORK'); | ||
496 | QUnit.ok(loader.error().xhr, 'included the request object'); | ||
497 | QUnit.ok(loader.paused(), 'paused the loader'); | ||
498 | QUnit.equal(loader.state, 'READY', 'returned to the ready state'); | ||
499 | }); | ||
500 | |||
501 | QUnit.test('fires ended at the end of a playlist', function() { | ||
502 | let endOfStreams = 0; | ||
503 | |||
504 | loader.playlist(playlistWithDuration(10)); | ||
505 | loader.mimeType(this.mimeType); | ||
506 | loader.load(); | ||
507 | loader.mediaSource_ = { | ||
508 | readyState: 'open', | ||
509 | sourceBuffers: mediaSource.sourceBuffers, | ||
510 | endOfStream() { | ||
511 | endOfStreams++; | ||
512 | } | ||
513 | }; | ||
514 | |||
515 | this.requests[0].response = new Uint8Array(10).buffer; | ||
516 | this.requests.shift().respond(200, null, ''); | ||
517 | mediaSource.sourceBuffers[0].buffered = videojs.createTimeRanges([[0, 10]]); | ||
518 | mediaSource.sourceBuffers[0].trigger('updateend'); | ||
519 | QUnit.equal(endOfStreams, 1, 'triggered ended'); | ||
520 | }); | ||
521 | |||
522 | QUnit.test('live playlists do not trigger ended', function() { | ||
523 | let endOfStreams = 0; | ||
524 | let playlist; | ||
525 | |||
526 | playlist = playlistWithDuration(10); | ||
527 | playlist.endList = false; | ||
528 | loader.playlist(playlist); | ||
529 | loader.mimeType(this.mimeType); | ||
530 | loader.load(); | ||
531 | loader.mediaSource_ = { | ||
532 | readyState: 'open', | ||
533 | sourceBuffers: mediaSource.sourceBuffers, | ||
534 | endOfStream() { | ||
535 | endOfStreams++; | ||
536 | } | ||
537 | }; | ||
538 | |||
539 | this.requests[0].response = new Uint8Array(10).buffer; | ||
540 | this.requests.shift().respond(200, null, ''); | ||
541 | mediaSource.sourceBuffers[0].buffered = videojs.createTimeRanges([[0, 10]]); | ||
542 | mediaSource.sourceBuffers[0].trigger('updateend'); | ||
543 | QUnit.equal(endOfStreams, 0, 'did not trigger ended'); | ||
544 | }); | ||
545 | |||
546 | QUnit.test('respects the global withCredentials option', function() { | ||
547 | let hlsOptions = videojs.options.hls; | ||
548 | |||
549 | videojs.options.hls = { | ||
550 | withCredentials: true | ||
551 | }; | ||
552 | loader = new SegmentLoader({ | ||
553 | hls: this.fakeHls, | ||
554 | currentTime() { | ||
555 | return currentTime; | ||
556 | }, | ||
557 | seekable: () => this.seekable, | ||
558 | mediaSource | ||
559 | }); | ||
560 | loader.playlist(playlistWithDuration(10, {isEncrypted: true})); | ||
561 | loader.mimeType(this.mimeType); | ||
562 | loader.load(); | ||
563 | |||
564 | QUnit.equal(this.requests[0].url, '0-key.php', 'requested the first segment\'s key'); | ||
565 | QUnit.ok(this.requests[0].withCredentials, 'key request used withCredentials'); | ||
566 | QUnit.equal(this.requests[1].url, '0.ts', 'requested the first segment'); | ||
567 | QUnit.ok(this.requests[1].withCredentials, 'segment request used withCredentials'); | ||
568 | videojs.options.hls = hlsOptions; | ||
569 | }); | ||
570 | |||
571 | QUnit.test('respects the withCredentials option', function() { | ||
572 | loader = new SegmentLoader({ | ||
573 | hls: this.fakeHls, | ||
574 | currentTime() { | ||
575 | return currentTime; | ||
576 | }, | ||
577 | seekable: () => this.seekable, | ||
578 | mediaSource, | ||
579 | withCredentials: true | ||
580 | }); | ||
581 | loader.playlist(playlistWithDuration(10, {isEncrypted: true})); | ||
582 | loader.mimeType(this.mimeType); | ||
583 | loader.load(); | ||
584 | |||
585 | QUnit.equal(this.requests[0].url, '0-key.php', 'requested the first segment\'s key'); | ||
586 | QUnit.ok(this.requests[0].withCredentials, 'key request used withCredentials'); | ||
587 | QUnit.equal(this.requests[1].url, '0.ts', 'requested the first segment'); | ||
588 | QUnit.ok(this.requests[1].withCredentials, 'segment request used withCredentials'); | ||
589 | }); | ||
590 | |||
591 | QUnit.test('the withCredentials option overrides the global', function() { | ||
592 | let hlsOptions = videojs.options.hls; | ||
593 | |||
594 | videojs.options.hls = { | ||
595 | withCredentials: true | ||
596 | }; | ||
597 | loader = new SegmentLoader({ | ||
598 | hls: this.fakeHls, | ||
599 | currentTime() { | ||
600 | return currentTime; | ||
601 | }, | ||
602 | mediaSource, | ||
603 | seekable: () => this.seekable, | ||
604 | withCredentials: false | ||
605 | }); | ||
606 | loader.playlist(playlistWithDuration(10, {isEncrypted: true})); | ||
607 | loader.mimeType(this.mimeType); | ||
608 | loader.load(); | ||
609 | |||
610 | QUnit.equal(this.requests[0].url, '0-key.php', 'requested the first segment\'s key'); | ||
611 | QUnit.ok(!this.requests[0].withCredentials, 'overrode key request withCredentials'); | ||
612 | QUnit.equal(this.requests[1].url, '0.ts', 'requested the first segment'); | ||
613 | QUnit.ok(!this.requests[1].withCredentials, 'overrode segment request withCredentials'); | ||
614 | videojs.options.hls = hlsOptions; | ||
615 | }); | ||
616 | |||
617 | QUnit.test('remains ready if there are no segments', function() { | ||
618 | loader.playlist(playlistWithDuration(0)); | ||
619 | loader.mimeType(this.mimeType); | ||
620 | loader.load(); | ||
621 | QUnit.equal(loader.state, 'READY', 'in the ready state'); | ||
622 | }); | ||
623 | |||
624 | QUnit.test('dispose cleans up outstanding work', function() { | ||
625 | loader.playlist(playlistWithDuration(20)); | ||
626 | loader.mimeType(this.mimeType); | ||
627 | loader.load(); | ||
628 | |||
629 | loader.dispose(); | ||
630 | QUnit.ok(this.requests[0].aborted, 'aborted segment request'); | ||
631 | QUnit.equal(this.requests.length, 1, 'did not open another request'); | ||
632 | mediaSource.sourceBuffers.forEach((sourceBuffer, i) => { | ||
633 | let lastOperation = sourceBuffer.updates_.slice(-1)[0]; | ||
634 | |||
635 | QUnit.ok(lastOperation.abort, 'aborted source buffer ' + i); | ||
636 | }); | ||
637 | }); | ||
638 | |||
639 | // ---------- | ||
640 | // Decryption | ||
641 | // ---------- | ||
642 | |||
643 | QUnit.test('calling load with an encrypted segment requests key and segment', function() { | ||
644 | QUnit.equal(loader.state, 'INIT', 'starts in the init state'); | ||
645 | loader.playlist(playlistWithDuration(10, {isEncrypted: true})); | ||
646 | QUnit.equal(loader.state, 'INIT', 'starts in the init state'); | ||
647 | QUnit.ok(loader.paused(), 'starts paused'); | ||
648 | |||
649 | loader.mimeType(this.mimeType); | ||
650 | loader.load(); | ||
651 | QUnit.equal(loader.state, 'WAITING', 'moves to the ready state'); | ||
652 | QUnit.ok(!loader.paused(), 'loading is not paused'); | ||
653 | QUnit.equal(this.requests.length, 2, 'requested a segment and key'); | ||
654 | QUnit.equal(this.requests[0].url, '0-key.php', 'requested the first segment\'s key'); | ||
655 | QUnit.equal(this.requests[1].url, '0.ts', 'requested the first segment'); | ||
656 | }); | ||
657 | |||
658 | QUnit.test('cancels outstanding key request on abort', function() { | ||
659 | loader.playlist(playlistWithDuration(20, {isEncrypted: true})); | ||
660 | loader.mimeType(this.mimeType); | ||
661 | loader.load(); | ||
662 | loader.xhr_.keyXhr.onreadystatechange = function() { | ||
663 | throw new Error('onreadystatechange should not be called'); | ||
664 | }; | ||
665 | |||
666 | QUnit.equal(this.requests.length, 2, 'requested a segment and key'); | ||
667 | loader.abort(); | ||
668 | QUnit.equal(this.requests[0].url, '0-key.php', 'requested the first segment\'s key'); | ||
669 | QUnit.ok(this.requests[0].aborted, 'aborted the first key request'); | ||
670 | QUnit.equal(this.requests.length, 4, 'started a new request'); | ||
671 | QUnit.equal(loader.state, 'WAITING', 'back to the waiting state'); | ||
672 | }); | ||
673 | |||
674 | QUnit.test('dispose cleans up key requests for encrypted segments', function() { | ||
675 | loader.playlist(playlistWithDuration(20, {isEncrypted: true})); | ||
676 | loader.mimeType(this.mimeType); | ||
677 | loader.load(); | ||
678 | |||
679 | loader.dispose(); | ||
680 | QUnit.equal(this.requests.length, 2, 'requested a segment and key'); | ||
681 | QUnit.equal(this.requests[0].url, '0-key.php', 'requested the first segment\'s key'); | ||
682 | QUnit.ok(this.requests[0].aborted, 'aborted the first segment\s key request'); | ||
683 | QUnit.equal(this.requests.length, 2, 'did not open another request'); | ||
684 | }); | ||
685 | |||
686 | QUnit.test('key 404s should trigger an error', function() { | ||
687 | let errors = []; | ||
688 | |||
689 | loader.playlist(playlistWithDuration(10, {isEncrypted: true})); | ||
690 | loader.mimeType(this.mimeType); | ||
691 | loader.load(); | ||
692 | loader.on('error', function(error) { | ||
693 | errors.push(error); | ||
694 | }); | ||
695 | this.requests.shift().respond(404, null, ''); | ||
696 | |||
697 | QUnit.equal(errors.length, 1, 'triggered an error'); | ||
698 | QUnit.equal(loader.error().code, 2, 'triggered MEDIA_ERR_NETWORK'); | ||
699 | QUnit.equal(loader.error().message, 'HLS key request error at URL: 0-key.php', | ||
700 | 'receieved a key error message'); | ||
701 | QUnit.ok(loader.error().xhr, 'included the request object'); | ||
702 | QUnit.ok(loader.paused(), 'paused the loader'); | ||
703 | QUnit.equal(loader.state, 'READY', 'returned to the ready state'); | ||
704 | }); | ||
705 | |||
706 | QUnit.test('key 5xx status codes trigger an error', function() { | ||
707 | let errors = []; | ||
708 | |||
709 | loader.playlist(playlistWithDuration(10, {isEncrypted: true})); | ||
710 | loader.mimeType(this.mimeType); | ||
711 | loader.load(); | ||
712 | loader.on('error', function(error) { | ||
713 | errors.push(error); | ||
714 | }); | ||
715 | this.requests.shift().respond(500, null, ''); | ||
716 | |||
717 | QUnit.equal(errors.length, 1, 'triggered an error'); | ||
718 | QUnit.equal(loader.error().code, 2, 'triggered MEDIA_ERR_NETWORK'); | ||
719 | QUnit.equal(loader.error().message, 'HLS key request error at URL: 0-key.php', | ||
720 | 'receieved a key error message'); | ||
721 | QUnit.ok(loader.error().xhr, 'included the request object'); | ||
722 | QUnit.ok(loader.paused(), 'paused the loader'); | ||
723 | QUnit.equal(loader.state, 'READY', 'returned to the ready state'); | ||
724 | }); | ||
725 | |||
726 | QUnit.test('the key is saved to the segment in the correct format', function() { | ||
727 | let keyRequest; | ||
728 | let segmentRequest; | ||
729 | let segment; | ||
730 | let segmentInfo; | ||
731 | |||
732 | loader.playlist(playlistWithDuration(10, {isEncrypted: true})); | ||
733 | loader.mimeType(this.mimeType); | ||
734 | loader.load(); | ||
735 | |||
736 | // stop processing so we can examine segment info | ||
737 | loader.processResponse_ = function() {}; | ||
738 | |||
739 | keyRequest = this.requests.shift(); | ||
740 | keyRequest.response = new Uint32Array([0, 1, 2, 3]).buffer; | ||
741 | keyRequest.respond(200, null, ''); | ||
742 | |||
743 | segmentRequest = this.requests.shift(); | ||
744 | segmentRequest.response = new Uint8Array(10).buffer; | ||
745 | segmentRequest.respond(200, null, ''); | ||
746 | |||
747 | segmentInfo = loader.pendingSegment_; | ||
748 | segment = segmentInfo.playlist.segments[segmentInfo.mediaIndex]; | ||
749 | |||
750 | QUnit.deepEqual(segment.key.bytes, | ||
751 | new Uint32Array([0, 0x01000000, 0x02000000, 0x03000000]), | ||
752 | 'passed the specified segment key'); | ||
753 | }); | ||
754 | |||
755 | QUnit.test('supplies media sequence of current segment as the IV by default, if no IV ' + | ||
756 | 'is specified', | ||
757 | function() { | ||
758 | let keyRequest; | ||
759 | let segmentRequest; | ||
760 | let segment; | ||
761 | let segmentInfo; | ||
762 | |||
763 | loader.playlist(playlistWithDuration(10, {isEncrypted: true, mediaSequence: 5})); | ||
764 | loader.mimeType(this.mimeType); | ||
765 | loader.load(); | ||
766 | |||
767 | // stop processing so we can examine segment info | ||
768 | loader.processResponse_ = function() {}; | ||
769 | |||
770 | keyRequest = this.requests.shift(); | ||
771 | keyRequest.response = new Uint32Array([0, 0, 0, 0]).buffer; | ||
772 | keyRequest.respond(200, null, ''); | ||
773 | |||
774 | segmentRequest = this.requests.shift(); | ||
775 | segmentRequest.response = new Uint8Array(10).buffer; | ||
776 | segmentRequest.respond(200, null, ''); | ||
777 | |||
778 | segmentInfo = loader.pendingSegment_; | ||
779 | segment = segmentInfo.playlist.segments[segmentInfo.mediaIndex]; | ||
780 | |||
781 | QUnit.deepEqual(segment.key.iv, new Uint32Array([0, 0, 0, 5]), | ||
782 | 'the IV for the segment is the media sequence'); | ||
783 | }); | ||
784 | |||
785 | QUnit.test('segment with key has decrypted bytes appended during processing', function() { | ||
786 | let keyRequest; | ||
787 | let segmentRequest; | ||
788 | |||
789 | // stop processing so we can examine segment info | ||
790 | loader.handleSegment_ = function() {}; | ||
791 | |||
792 | loader.playlist(playlistWithDuration(10, {isEncrypted: true})); | ||
793 | loader.mimeType(this.mimeType); | ||
794 | loader.load(); | ||
795 | |||
796 | segmentRequest = this.requests.pop(); | ||
797 | segmentRequest.response = new Uint8Array(8).buffer; | ||
798 | segmentRequest.respond(200, null, ''); | ||
799 | QUnit.ok(loader.pendingSegment_.encryptedBytes, 'encrypted bytes in segment'); | ||
800 | QUnit.ok(!loader.pendingSegment_.bytes, 'no decrypted bytes in segment'); | ||
801 | |||
802 | keyRequest = this.requests.shift(); | ||
803 | keyRequest.response = new Uint32Array([0, 0, 0, 0]).buffer; | ||
804 | keyRequest.respond(200, null, ''); | ||
805 | |||
806 | // Allow the decrypter to decrypt | ||
807 | this.clock.tick(1); | ||
808 | // Allow the decrypter's async stream to run the callback | ||
809 | this.clock.tick(1); | ||
810 | QUnit.ok(loader.pendingSegment_.bytes, 'decrypted bytes in segment'); | ||
811 | }); | ||
812 | |||
813 | QUnit.test('calling load with an encrypted segment waits for both key and segment ' + | ||
814 | 'before processing', function() { | ||
815 | let keyRequest; | ||
816 | let segmentRequest; | ||
817 | |||
818 | loader.playlist(playlistWithDuration(10, {isEncrypted: true})); | ||
819 | loader.mimeType(this.mimeType); | ||
820 | loader.load(); | ||
821 | |||
822 | QUnit.equal(loader.state, 'WAITING', 'moves to waiting state'); | ||
823 | QUnit.equal(this.requests.length, 2, 'requested a segment and key'); | ||
824 | QUnit.equal(this.requests[0].url, '0-key.php', 'requested the first segment\'s key'); | ||
825 | QUnit.equal(this.requests[1].url, '0.ts', 'requested the first segment'); | ||
826 | // respond to the segment first | ||
827 | segmentRequest = this.requests.pop(); | ||
828 | segmentRequest.response = new Uint8Array(10).buffer; | ||
829 | segmentRequest.respond(200, null, ''); | ||
830 | QUnit.equal(loader.state, 'WAITING', 'still in waiting state'); | ||
831 | // then respond to the key | ||
832 | keyRequest = this.requests.shift(); | ||
833 | keyRequest.response = new Uint32Array([0, 0, 0, 0]).buffer; | ||
834 | keyRequest.respond(200, null, ''); | ||
835 | QUnit.equal(loader.state, 'DECRYPTING', 'moves to decrypting state'); | ||
836 | }); | ||
837 | |||
838 | QUnit.test('key request timeouts reset bandwidth', function() { | ||
839 | loader.playlist(playlistWithDuration(10, {isEncrypted: true})); | ||
840 | loader.mimeType(this.mimeType); | ||
841 | loader.load(); | ||
842 | |||
843 | QUnit.equal(this.requests[0].url, '0-key.php', 'requested the first segment\'s key'); | ||
844 | QUnit.equal(this.requests[1].url, '0.ts', 'requested the first segment'); | ||
845 | // a lot of time passes so the request times out | ||
846 | this.requests[0].timedout = true; | ||
847 | this.clock.tick(100 * 1000); | ||
848 | |||
849 | QUnit.equal(loader.bandwidth, 1, 'reset bandwidth'); | ||
850 | QUnit.ok(isNaN(loader.roundTrip), 'reset round trip time'); | ||
851 | }); | ||
852 | |||
853 | QUnit.module('Segment Loading Calculation', { | ||
854 | beforeEach() { | ||
855 | this.env = useFakeEnvironment(); | ||
856 | this.mse = useFakeMediaSource(); | ||
857 | this.hasPlayed = true; | ||
858 | |||
859 | currentTime = 0; | ||
860 | loader = new SegmentLoader({ | ||
861 | currentTime() { | ||
862 | return currentTime; | ||
863 | }, | ||
864 | mediaSource: new videojs.MediaSource(), | ||
865 | hasPlayed: () => this.hasPlayed | ||
866 | }); | ||
867 | }, | ||
868 | afterEach() { | ||
869 | this.env.restore(); | ||
870 | this.mse.restore(); | ||
871 | } | ||
872 | }); | ||
873 | |||
874 | QUnit.test('requests the first segment with an empty buffer', function() { | ||
875 | loader.mimeType(this.mimeType); | ||
876 | |||
877 | let segmentInfo = loader.checkBuffer_(videojs.createTimeRanges(), | ||
878 | playlistWithDuration(20), | ||
879 | 0); | ||
880 | |||
881 | QUnit.ok(segmentInfo, 'generated a request'); | ||
882 | QUnit.equal(segmentInfo.uri, '0.ts', 'requested the first segment'); | ||
883 | }); | ||
884 | |||
885 | QUnit.test('no request if video not played and 1 segment is buffered', function() { | ||
886 | this.hasPlayed = false; | ||
887 | loader.mimeType(this.mimeType); | ||
888 | |||
889 | let segmentInfo = loader.checkBuffer_(videojs.createTimeRanges([[0, 1]]), | ||
890 | playlistWithDuration(20), | ||
891 | 0); | ||
892 | |||
893 | QUnit.ok(!segmentInfo, 'no request generated'); | ||
894 | |||
895 | }); | ||
896 | |||
897 | QUnit.test('does not download the next segment if the buffer is full', function() { | ||
898 | let buffered; | ||
899 | let segmentInfo; | ||
900 | |||
901 | loader.mimeType(this.mimeType); | ||
902 | |||
903 | buffered = videojs.createTimeRanges([ | ||
904 | [0, 15 + GOAL_BUFFER_LENGTH] | ||
905 | ]); | ||
906 | segmentInfo = loader.checkBuffer_(buffered, playlistWithDuration(30), 15); | ||
907 | |||
908 | QUnit.ok(!segmentInfo, 'no segment request generated'); | ||
909 | }); | ||
910 | |||
911 | QUnit.test('downloads the next segment if the buffer is getting low', function() { | ||
912 | let buffered; | ||
913 | let segmentInfo; | ||
914 | |||
915 | loader.mimeType(this.mimeType); | ||
916 | |||
917 | buffered = videojs.createTimeRanges([[0, 19.999]]); | ||
918 | segmentInfo = loader.checkBuffer_(buffered, playlistWithDuration(30), 15); | ||
919 | |||
920 | QUnit.ok(segmentInfo, 'made a request'); | ||
921 | QUnit.equal(segmentInfo.uri, '2.ts', 'requested the third segment'); | ||
922 | }); | ||
923 | |||
924 | QUnit.test('buffers based on the correct TimeRange if multiple ranges exist', function() { | ||
925 | let buffered; | ||
926 | let segmentInfo; | ||
927 | |||
928 | loader.mimeType(this.mimeType); | ||
929 | |||
930 | buffered = videojs.createTimeRanges([[0, 10], [20, 30]]); | ||
931 | segmentInfo = loader.checkBuffer_(buffered, playlistWithDuration(40), 8); | ||
932 | |||
933 | QUnit.ok(segmentInfo, 'made a request'); | ||
934 | QUnit.equal(segmentInfo.uri, '1.ts', 'requested the second segment'); | ||
935 | |||
936 | segmentInfo = loader.checkBuffer_(buffered, playlistWithDuration(40), 20); | ||
937 | QUnit.ok(segmentInfo, 'made a request'); | ||
938 | QUnit.equal(segmentInfo.uri, '3.ts', 'requested the fourth segment'); | ||
939 | }); | ||
940 | |||
941 | QUnit.test('stops downloading segments at the end of the playlist', function() { | ||
942 | let buffered; | ||
943 | let segmentInfo; | ||
944 | |||
945 | loader.mimeType(this.mimeType); | ||
946 | |||
947 | buffered = videojs.createTimeRanges([[0, 60]]); | ||
948 | segmentInfo = loader.checkBuffer_(buffered, playlistWithDuration(60), 0); | ||
949 | |||
950 | QUnit.ok(!segmentInfo, 'no request was made'); | ||
951 | }); | ||
952 | |||
953 | QUnit.test('stops downloading segments if buffered past reported end of the playlist', | ||
954 | function() { | ||
955 | let buffered; | ||
956 | let segmentInfo; | ||
957 | let playlist; | ||
958 | |||
959 | loader.mimeType(this.mimeType); | ||
960 | |||
961 | buffered = videojs.createTimeRanges([[0, 59.9]]); | ||
962 | playlist = playlistWithDuration(60); | ||
963 | playlist.segments[playlist.segments.length - 1].end = 59.9; | ||
964 | segmentInfo = loader.checkBuffer_(buffered, playlist, 50); | ||
965 | |||
966 | QUnit.ok(!segmentInfo, 'no request was made'); | ||
967 | }); | ||
968 | |||
969 | QUnit.test('calculates timestampOffset for discontinuities', function() { | ||
970 | let segmentInfo; | ||
971 | let playlist; | ||
972 | |||
973 | loader.mimeType(this.mimeType); | ||
974 | |||
975 | playlist = playlistWithDuration(60); | ||
976 | playlist.segments[3].end = 37.9; | ||
977 | playlist.discontinuityStarts = [4]; | ||
978 | playlist.segments[4].discontinuity = true; | ||
979 | playlist.segments[4].timeline = 1; | ||
980 | |||
981 | segmentInfo = loader.checkBuffer_(videojs.createTimeRanges([[0, 37.9]]), playlist, 36); | ||
982 | QUnit.equal(segmentInfo.timestampOffset, 37.9, 'placed the discontinuous segment'); | ||
983 | }); | ||
984 | |||
985 | QUnit.test('adjusts calculations based on expired time', function() { | ||
986 | let buffered; | ||
987 | let playlist; | ||
988 | let segmentInfo; | ||
989 | |||
990 | loader.mimeType(this.mimeType); | ||
991 | |||
992 | buffered = videojs.createTimeRanges([[0, 30]]); | ||
993 | playlist = playlistWithDuration(50); | ||
994 | |||
995 | loader.expired(10); | ||
996 | |||
997 | segmentInfo = loader.checkBuffer_(buffered, | ||
998 | playlist, | ||
999 | 40 - GOAL_BUFFER_LENGTH); | ||
1000 | |||
1001 | QUnit.ok(segmentInfo, 'fetched a segment'); | ||
1002 | QUnit.equal(segmentInfo.uri, '2.ts', 'accounted for expired time'); | ||
1003 | }); |
test/source-updater.test.js
0 → 100644
1 | import SourceUpdater from '../src/source-updater'; | ||
2 | import QUnit from 'qunit'; | ||
3 | import videojs from 'video.js'; | ||
4 | import { useFakeMediaSource } from './test-helpers'; | ||
5 | |||
6 | QUnit.module('Source Updater', { | ||
7 | beforeEach() { | ||
8 | this.mse = useFakeMediaSource(); | ||
9 | this.mediaSource = new videojs.MediaSource(); | ||
10 | }, | ||
11 | afterEach() { | ||
12 | this.mse.restore(); | ||
13 | } | ||
14 | }); | ||
15 | |||
16 | QUnit.test('waits for sourceopen to create a source buffer', function() { | ||
17 | new SourceUpdater(this.mediaSource, 'video/mp2t'); // eslint-disable-line no-new | ||
18 | |||
19 | QUnit.equal(this.mediaSource.sourceBuffers.length, 0, | ||
20 | 'waited to create the source buffer'); | ||
21 | |||
22 | this.mediaSource.trigger('sourceopen'); | ||
23 | |||
24 | QUnit.equal(this.mediaSource.sourceBuffers.length, 1, 'created one source buffer'); | ||
25 | QUnit.equal(this.mediaSource.sourceBuffers[0].mimeType_, 'video/mp2t', | ||
26 | 'assigned the correct MIME type'); | ||
27 | }); | ||
28 | |||
29 | QUnit.test('runs a callback when the source buffer is created', function() { | ||
30 | let updater = new SourceUpdater(this.mediaSource, 'video/mp2t'); | ||
31 | let sourceBuffer; | ||
32 | |||
33 | updater.appendBuffer(new Uint8Array([0, 1, 2])); | ||
34 | |||
35 | this.mediaSource.trigger('sourceopen'); | ||
36 | sourceBuffer = this.mediaSource.sourceBuffers[0]; | ||
37 | QUnit.equal(sourceBuffer.updates_.length, 1, 'called the source buffer once'); | ||
38 | QUnit.deepEqual(sourceBuffer.updates_[0].append, new Uint8Array([0, 1, 2]), | ||
39 | 'appended the bytes'); | ||
40 | }); | ||
41 | |||
42 | QUnit.test('runs the completion callback when updateend fires', function() { | ||
43 | let updater = new SourceUpdater(this.mediaSource, 'video/mp2t'); | ||
44 | let updateends = 0; | ||
45 | let sourceBuffer; | ||
46 | |||
47 | this.mediaSource.trigger('sourceopen'); | ||
48 | sourceBuffer = this.mediaSource.sourceBuffers[0]; | ||
49 | updater.appendBuffer(new Uint8Array([0, 1, 2]), function() { | ||
50 | updateends++; | ||
51 | }); | ||
52 | updater.appendBuffer(new Uint8Array([2, 3, 4]), function() { | ||
53 | throw new Error('Wrong completion callback invoked!'); | ||
54 | }); | ||
55 | |||
56 | QUnit.equal(updateends, 0, 'no completions yet'); | ||
57 | sourceBuffer.trigger('updateend'); | ||
58 | QUnit.equal(updateends, 1, 'ran the completion callback'); | ||
59 | }); | ||
60 | |||
61 | QUnit.test('runs the next callback after updateend fires', function() { | ||
62 | let updater = new SourceUpdater(this.mediaSource, 'video/mp2t'); | ||
63 | let sourceBuffer; | ||
64 | |||
65 | updater.appendBuffer(new Uint8Array([0, 1, 2])); | ||
66 | this.mediaSource.trigger('sourceopen'); | ||
67 | sourceBuffer = this.mediaSource.sourceBuffers[0]; | ||
68 | |||
69 | updater.appendBuffer(new Uint8Array([2, 3, 4])); | ||
70 | QUnit.equal(sourceBuffer.updates_.length, 1, 'delayed the update'); | ||
71 | |||
72 | sourceBuffer.trigger('updateend'); | ||
73 | QUnit.equal(sourceBuffer.updates_.length, 2, 'updated twice'); | ||
74 | QUnit.deepEqual(sourceBuffer.updates_[1].append, new Uint8Array([2, 3, 4]), | ||
75 | 'appended the bytes'); | ||
76 | }); | ||
77 | |||
78 | QUnit.test('runs only one callback at a time', function() { | ||
79 | let updater = new SourceUpdater(this.mediaSource, 'video/mp2t'); | ||
80 | let sourceBuffer; | ||
81 | |||
82 | updater.appendBuffer(new Uint8Array([0])); | ||
83 | updater.appendBuffer(new Uint8Array([1])); | ||
84 | this.mediaSource.trigger('sourceopen'); | ||
85 | sourceBuffer = this.mediaSource.sourceBuffers[0]; | ||
86 | |||
87 | updater.appendBuffer(new Uint8Array([2])); | ||
88 | QUnit.equal(sourceBuffer.updates_.length, 1, 'queued some updates'); | ||
89 | QUnit.deepEqual(sourceBuffer.updates_[0].append, new Uint8Array([0]), | ||
90 | 'ran the first update'); | ||
91 | |||
92 | sourceBuffer.trigger('updateend'); | ||
93 | QUnit.equal(sourceBuffer.updates_.length, 2, 'queued some updates'); | ||
94 | QUnit.deepEqual(sourceBuffer.updates_[1].append, new Uint8Array([1]), | ||
95 | 'ran the second update'); | ||
96 | |||
97 | updater.appendBuffer(new Uint8Array([3])); | ||
98 | sourceBuffer.trigger('updateend'); | ||
99 | QUnit.equal(sourceBuffer.updates_.length, 3, 'queued the updates'); | ||
100 | QUnit.deepEqual(sourceBuffer.updates_[2].append, new Uint8Array([2]), | ||
101 | 'ran the third update'); | ||
102 | |||
103 | sourceBuffer.trigger('updateend'); | ||
104 | QUnit.equal(sourceBuffer.updates_.length, 4, 'finished the updates'); | ||
105 | QUnit.deepEqual(sourceBuffer.updates_[3].append, new Uint8Array([3]), | ||
106 | 'ran the fourth update'); | ||
107 | }); | ||
108 | |||
109 | QUnit.test('runs updates immediately if possible', function() { | ||
110 | let updater = new SourceUpdater(this.mediaSource, 'video/mp2t'); | ||
111 | let sourceBuffer; | ||
112 | |||
113 | this.mediaSource.trigger('sourceopen'); | ||
114 | sourceBuffer = this.mediaSource.sourceBuffers[0]; | ||
115 | updater.appendBuffer(new Uint8Array([0])); | ||
116 | QUnit.equal(sourceBuffer.updates_.length, 1, 'ran an update'); | ||
117 | QUnit.deepEqual(sourceBuffer.updates_[0].append, new Uint8Array([0]), | ||
118 | 'appended the bytes'); | ||
119 | }); | ||
120 | |||
121 | QUnit.test('supports abort', function() { | ||
122 | let updater = new SourceUpdater(this.mediaSource, 'video/mp2t'); | ||
123 | let sourceBuffer; | ||
124 | |||
125 | updater.abort(); | ||
126 | this.mediaSource.trigger('sourceopen'); | ||
127 | |||
128 | sourceBuffer = this.mediaSource.sourceBuffers[0]; | ||
129 | QUnit.ok(sourceBuffer.updates_[0].abort, 'aborted the source buffer'); | ||
130 | }); | ||
131 | |||
132 | QUnit.test('supports buffered', function() { | ||
133 | let updater = new SourceUpdater(this.mediaSource, 'video/mp2t'); | ||
134 | |||
135 | QUnit.equal(updater.buffered().length, 0, 'buffered is empty'); | ||
136 | |||
137 | this.mediaSource.trigger('sourceopen'); | ||
138 | QUnit.ok(updater.buffered(), 'buffered is defined'); | ||
139 | }); | ||
140 | |||
141 | QUnit.test('supports removeBuffer', function() { | ||
142 | let updater = new SourceUpdater(this.mediaSource, 'video/mp2t'); | ||
143 | let sourceBuffer; | ||
144 | |||
145 | this.mediaSource.trigger('sourceopen'); | ||
146 | sourceBuffer = this.mediaSource.sourceBuffers[0]; | ||
147 | updater.remove(1, 14); | ||
148 | |||
149 | QUnit.equal(sourceBuffer.updates_.length, 1, 'ran an update'); | ||
150 | QUnit.deepEqual(sourceBuffer.updates_[0].remove, [1, 14], 'removed the time range'); | ||
151 | }); | ||
152 | |||
153 | QUnit.test('supports setting duration', function() { | ||
154 | let updater = new SourceUpdater(this.mediaSource, 'video/mp2t'); | ||
155 | let sourceBuffer; | ||
156 | |||
157 | this.mediaSource.trigger('sourceopen'); | ||
158 | sourceBuffer = this.mediaSource.sourceBuffers[0]; | ||
159 | updater.duration(21); | ||
160 | |||
161 | QUnit.equal(sourceBuffer.updates_.length, 1, 'ran an update'); | ||
162 | QUnit.deepEqual(sourceBuffer.updates_[0].duration, 21, 'changed duration'); | ||
163 | }); | ||
164 | |||
165 | QUnit.test('supports timestampOffset', function() { | ||
166 | let updater = new SourceUpdater(this.mediaSource, 'video/mp2t'); | ||
167 | let sourceBuffer; | ||
168 | |||
169 | this.mediaSource.trigger('sourceopen'); | ||
170 | sourceBuffer = this.mediaSource.sourceBuffers[0]; | ||
171 | |||
172 | QUnit.equal(updater.timestampOffset(), 0, 'intialized to zero'); | ||
173 | updater.timestampOffset(21); | ||
174 | QUnit.equal(updater.timestampOffset(), 21, 'reflects changes immediately'); | ||
175 | QUnit.equal(sourceBuffer.timestampOffset, 21, 'applied the update'); | ||
176 | |||
177 | updater.appendBuffer(new Uint8Array(2)); | ||
178 | updater.timestampOffset(14); | ||
179 | QUnit.equal(updater.timestampOffset(), 14, 'reflects changes immediately'); | ||
180 | QUnit.equal(sourceBuffer.timestampOffset, 21, 'queues application after updates'); | ||
181 | |||
182 | sourceBuffer.trigger('updateend'); | ||
183 | QUnit.equal(sourceBuffer.timestampOffset, 14, 'applied the update'); | ||
184 | }); |
test/test-helpers.js
0 → 100644
1 | import document from 'global/document'; | ||
2 | import sinon from 'sinon'; | ||
3 | import videojs from 'video.js'; | ||
4 | import QUnit from 'qunit'; | ||
5 | /* eslint-disable no-unused-vars */ | ||
6 | // needed so MediaSource can be registered with videojs | ||
7 | import MediaSource from 'videojs-contrib-media-sources'; | ||
8 | /* eslint-enable */ | ||
9 | import testDataManifests from './test-manifests.js'; | ||
10 | import xhrFactory from '../src/xhr'; | ||
11 | |||
12 | // a SourceBuffer that tracks updates but otherwise is a noop | ||
13 | class MockSourceBuffer extends videojs.EventTarget { | ||
14 | constructor() { | ||
15 | super(); | ||
16 | this.updates_ = []; | ||
17 | |||
18 | this.updating = false; | ||
19 | this.on('updateend', function() { | ||
20 | this.updating = false; | ||
21 | }); | ||
22 | |||
23 | this.buffered = videojs.createTimeRanges(); | ||
24 | this.duration_ = NaN; | ||
25 | |||
26 | Object.defineProperty(this, 'duration', { | ||
27 | get() { | ||
28 | return this.duration_; | ||
29 | }, | ||
30 | set(duration) { | ||
31 | this.updates_.push({ | ||
32 | duration | ||
33 | }); | ||
34 | this.duration_ = duration; | ||
35 | } | ||
36 | }); | ||
37 | } | ||
38 | |||
39 | abort() { | ||
40 | this.updates_.push({ | ||
41 | abort: true | ||
42 | }); | ||
43 | } | ||
44 | |||
45 | appendBuffer(bytes) { | ||
46 | this.updates_.push({ | ||
47 | append: bytes | ||
48 | }); | ||
49 | this.updating = true; | ||
50 | } | ||
51 | |||
52 | remove(start, end) { | ||
53 | this.updates_.push({ | ||
54 | remove: [start, end] | ||
55 | }); | ||
56 | } | ||
57 | } | ||
58 | |||
59 | class MockMediaSource extends videojs.EventTarget { | ||
60 | constructor() { | ||
61 | super(); | ||
62 | this.readyState = 'closed'; | ||
63 | this.on('sourceopen', function() { | ||
64 | this.readyState = 'open'; | ||
65 | }); | ||
66 | |||
67 | this.sourceBuffers = []; | ||
68 | this.duration = NaN; | ||
69 | this.seekable = videojs.createTimeRange(); | ||
70 | } | ||
71 | |||
72 | addSeekableRange_(start, end) { | ||
73 | this.seekable = videojs.createTimeRange(start, end); | ||
74 | } | ||
75 | |||
76 | addSourceBuffer(mime) { | ||
77 | let sourceBuffer = new MockSourceBuffer(); | ||
78 | |||
79 | sourceBuffer.mimeType_ = mime; | ||
80 | this.sourceBuffers.push(sourceBuffer); | ||
81 | return sourceBuffer; | ||
82 | } | ||
83 | |||
84 | endOfStream(error) { | ||
85 | this.readyState = 'closed'; | ||
86 | this.error_ = error; | ||
87 | } | ||
88 | } | ||
89 | |||
90 | export const useFakeMediaSource = function() { | ||
91 | let RealMediaSource = videojs.MediaSource; | ||
92 | let realCreateObjectURL = window.URL.createObjectURL; | ||
93 | let id = 0; | ||
94 | |||
95 | videojs.MediaSource = MockMediaSource; | ||
96 | videojs.MediaSource.supportsNativeMediaSources = | ||
97 | RealMediaSource.supportsNativeMediaSources; | ||
98 | videojs.URL.createObjectURL = function() { | ||
99 | id++; | ||
100 | return 'blob:videojs-contrib-hls-mock-url' + id; | ||
101 | }; | ||
102 | |||
103 | return { | ||
104 | restore() { | ||
105 | videojs.MediaSource = RealMediaSource; | ||
106 | videojs.URL.createObjectURL = realCreateObjectURL; | ||
107 | } | ||
108 | }; | ||
109 | }; | ||
110 | |||
111 | let fakeEnvironment = { | ||
112 | requests: [], | ||
113 | restore() { | ||
114 | this.clock.restore(); | ||
115 | videojs.xhr.XMLHttpRequest = window.XMLHttpRequest; | ||
116 | this.xhr.restore(); | ||
117 | ['warn', 'error'].forEach((level) => { | ||
118 | if (this.log && this.log[level] && this.log[level].restore) { | ||
119 | QUnit.equal(this.log[level].callCount, 0, `no unexpected logs on ${level}`); | ||
120 | this.log[level].restore(); | ||
121 | } | ||
122 | }); | ||
123 | } | ||
124 | }; | ||
125 | |||
126 | export const useFakeEnvironment = function() { | ||
127 | fakeEnvironment.log = {}; | ||
128 | ['warn', 'error'].forEach((level) => { | ||
129 | // you can use .log[level].args to get args | ||
130 | sinon.stub(videojs.log, level); | ||
131 | fakeEnvironment.log[level] = videojs.log[level]; | ||
132 | Object.defineProperty(videojs.log[level], 'calls', { | ||
133 | get() { | ||
134 | // reset callCount to 0 so they don't have to | ||
135 | let callCount = this.callCount; | ||
136 | |||
137 | this.callCount = 0; | ||
138 | return callCount; | ||
139 | } | ||
140 | }); | ||
141 | }); | ||
142 | fakeEnvironment.clock = sinon.useFakeTimers(); | ||
143 | fakeEnvironment.xhr = sinon.useFakeXMLHttpRequest(); | ||
144 | fakeEnvironment.requests.length = 0; | ||
145 | fakeEnvironment.xhr.onCreate = function(xhr) { | ||
146 | fakeEnvironment.requests.push(xhr); | ||
147 | }; | ||
148 | videojs.xhr.XMLHttpRequest = fakeEnvironment.xhr; | ||
149 | |||
150 | return fakeEnvironment; | ||
151 | }; | ||
152 | |||
153 | // patch over some methods of the provided tech so it can be tested | ||
154 | // synchronously with sinon's fake timers | ||
155 | export const mockTech = function(tech) { | ||
156 | if (tech.isMocked_) { | ||
157 | // make this function idempotent because HTML and Flash based | ||
158 | // playback have very different lifecycles. For HTML, the tech | ||
159 | // is available on player creation. For Flash, the tech isn't | ||
160 | // ready until the source has been loaded and one tick has | ||
161 | // expired. | ||
162 | return; | ||
163 | } | ||
164 | |||
165 | tech.isMocked_ = true; | ||
166 | tech.src_ = null; | ||
167 | tech.time_ = null; | ||
168 | |||
169 | tech.paused_ = !tech.autoplay(); | ||
170 | tech.paused = function() { | ||
171 | return tech.paused_; | ||
172 | }; | ||
173 | |||
174 | if (!tech.currentTime_) { | ||
175 | tech.currentTime_ = tech.currentTime; | ||
176 | } | ||
177 | tech.currentTime = function() { | ||
178 | return tech.time_ === null ? tech.currentTime_() : tech.time_; | ||
179 | }; | ||
180 | |||
181 | tech.setSrc = function(src) { | ||
182 | tech.src_ = src; | ||
183 | }; | ||
184 | tech.src = function(src) { | ||
185 | if (src !== null) { | ||
186 | return tech.setSrc(src); | ||
187 | } | ||
188 | return tech.src_ === null ? tech.src : tech.src_; | ||
189 | }; | ||
190 | tech.currentSrc_ = tech.currentSrc; | ||
191 | tech.currentSrc = function() { | ||
192 | return tech.src_ === null ? tech.currentSrc_() : tech.src_; | ||
193 | }; | ||
194 | |||
195 | tech.play_ = tech.play; | ||
196 | tech.play = function() { | ||
197 | tech.play_(); | ||
198 | tech.paused_ = false; | ||
199 | tech.trigger('play'); | ||
200 | }; | ||
201 | tech.pause_ = tech.pause_; | ||
202 | tech.pause = function() { | ||
203 | tech.pause_(); | ||
204 | tech.paused_ = true; | ||
205 | tech.trigger('pause'); | ||
206 | }; | ||
207 | |||
208 | tech.setCurrentTime = function(time) { | ||
209 | tech.time_ = time; | ||
210 | |||
211 | setTimeout(function() { | ||
212 | tech.trigger('seeking'); | ||
213 | setTimeout(function() { | ||
214 | tech.trigger('seeked'); | ||
215 | }, 1); | ||
216 | }, 1); | ||
217 | }; | ||
218 | }; | ||
219 | |||
220 | export const createPlayer = function(options) { | ||
221 | let video; | ||
222 | let player; | ||
223 | |||
224 | video = document.createElement('video'); | ||
225 | video.className = 'video-js'; | ||
226 | document.querySelector('#qunit-fixture').appendChild(video); | ||
227 | player = videojs(video, options || { | ||
228 | flash: { | ||
229 | swf: '' | ||
230 | } | ||
231 | }); | ||
232 | |||
233 | player.buffered = function() { | ||
234 | return videojs.createTimeRange(0, 0); | ||
235 | }; | ||
236 | mockTech(player.tech_); | ||
237 | |||
238 | return player; | ||
239 | }; | ||
240 | |||
241 | export const openMediaSource = function(player, clock) { | ||
242 | // ensure the Flash tech is ready | ||
243 | player.tech_.triggerReady(); | ||
244 | clock.tick(1); | ||
245 | // mock the tech *after* it has finished loading so that we don't | ||
246 | // mock a tech that will be unloaded on the next tick | ||
247 | mockTech(player.tech_); | ||
248 | player.tech_.hls.xhr = xhrFactory(); | ||
249 | |||
250 | // simulate the sourceopen event | ||
251 | player.tech_.hls.mediaSource.readyState = 'open'; | ||
252 | player.tech_.hls.mediaSource.dispatchEvent({ | ||
253 | type: 'sourceopen', | ||
254 | swfId: player.tech_.el().id | ||
255 | }); | ||
256 | }; | ||
257 | |||
258 | export const standardXHRResponse = function(request) { | ||
259 | if (!request.url) { | ||
260 | return; | ||
261 | } | ||
262 | |||
263 | let contentType = 'application/json'; | ||
264 | // contents off the global object | ||
265 | let manifestName = (/(?:.*\/)?(.*)\.m3u8/).exec(request.url); | ||
266 | |||
267 | if (manifestName) { | ||
268 | manifestName = manifestName[1]; | ||
269 | } else { | ||
270 | manifestName = request.url; | ||
271 | } | ||
272 | |||
273 | if (/\.m3u8?/.test(request.url)) { | ||
274 | contentType = 'application/vnd.apple.mpegurl'; | ||
275 | } else if (/\.ts/.test(request.url)) { | ||
276 | contentType = 'video/MP2T'; | ||
277 | } | ||
278 | |||
279 | request.response = new Uint8Array(16).buffer; | ||
280 | request.respond(200, { 'Content-Type': contentType }, | ||
281 | testDataManifests[manifestName]); | ||
282 | }; | ||
283 | |||
284 | // return an absolute version of a page-relative URL | ||
285 | export const absoluteUrl = function(relativeUrl) { | ||
286 | return window.location.protocol + '//' + | ||
287 | window.location.host + | ||
288 | (window.location.pathname | ||
289 | .split('/') | ||
290 | .slice(0, -1) | ||
291 | .concat(relativeUrl) | ||
292 | .join('/') | ||
293 | ); | ||
294 | }; |
This diff could not be displayed because it is too large.
... | @@ -5,18 +5,22 @@ | ... | @@ -5,18 +5,22 @@ |
5 | "segments": [ | 5 | "segments": [ |
6 | { | 6 | { |
7 | "duration": 10, | 7 | "duration": 10, |
8 | "timeline": 0, | ||
8 | "uri": "http://example.com/00001.ts" | 9 | "uri": "http://example.com/00001.ts" |
9 | }, | 10 | }, |
10 | { | 11 | { |
11 | "duration": 10, | 12 | "duration": 10, |
13 | "timeline": 0, | ||
12 | "uri": "https://example.com/00002.ts" | 14 | "uri": "https://example.com/00002.ts" |
13 | }, | 15 | }, |
14 | { | 16 | { |
15 | "duration": 10, | 17 | "duration": 10, |
18 | "timeline": 0, | ||
16 | "uri": "//example.com/00003.ts" | 19 | "uri": "//example.com/00003.ts" |
17 | }, | 20 | }, |
18 | { | 21 | { |
19 | "duration": 10, | 22 | "duration": 10, |
23 | "timeline": 0, | ||
20 | "uri": "http://example.com/00004.ts" | 24 | "uri": "http://example.com/00004.ts" |
21 | } | 25 | } |
22 | ], | 26 | ], |
... | @@ -24,4 +28,4 @@ | ... | @@ -24,4 +28,4 @@ |
24 | "endList": true, | 28 | "endList": true, |
25 | "discontinuitySequence": 0, | 29 | "discontinuitySequence": 0, |
26 | "discontinuityStarts": [] | 30 | "discontinuityStarts": [] |
27 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
31 | } | ... | ... |
... | @@ -9,6 +9,7 @@ | ... | @@ -9,6 +9,7 @@ |
9 | "offset": 0 | 9 | "offset": 0 |
10 | }, | 10 | }, |
11 | "duration": 10, | 11 | "duration": 10, |
12 | "timeline": 0, | ||
12 | "uri": "hls_450k_video.ts" | 13 | "uri": "hls_450k_video.ts" |
13 | }, | 14 | }, |
14 | { | 15 | { |
... | @@ -17,6 +18,7 @@ | ... | @@ -17,6 +18,7 @@ |
17 | "offset": 522828 | 18 | "offset": 522828 |
18 | }, | 19 | }, |
19 | "duration": 10, | 20 | "duration": 10, |
21 | "timeline": 0, | ||
20 | "uri": "hls_450k_video.ts" | 22 | "uri": "hls_450k_video.ts" |
21 | }, | 23 | }, |
22 | { | 24 | { |
... | @@ -25,6 +27,7 @@ | ... | @@ -25,6 +27,7 @@ |
25 | "offset": 1110328 | 27 | "offset": 1110328 |
26 | }, | 28 | }, |
27 | "duration": 10, | 29 | "duration": 10, |
30 | "timeline": 0, | ||
28 | "uri": "hls_450k_video.ts" | 31 | "uri": "hls_450k_video.ts" |
29 | }, | 32 | }, |
30 | { | 33 | { |
... | @@ -33,6 +36,7 @@ | ... | @@ -33,6 +36,7 @@ |
33 | "offset": 1823412 | 36 | "offset": 1823412 |
34 | }, | 37 | }, |
35 | "duration": 10, | 38 | "duration": 10, |
39 | "timeline": 0, | ||
36 | "uri": "hls_450k_video.ts" | 40 | "uri": "hls_450k_video.ts" |
37 | }, | 41 | }, |
38 | { | 42 | { |
... | @@ -41,6 +45,7 @@ | ... | @@ -41,6 +45,7 @@ |
41 | "offset": 2299992 | 45 | "offset": 2299992 |
42 | }, | 46 | }, |
43 | "duration": 10, | 47 | "duration": 10, |
48 | "timeline": 0, | ||
44 | "uri": "hls_450k_video.ts" | 49 | "uri": "hls_450k_video.ts" |
45 | }, | 50 | }, |
46 | { | 51 | { |
... | @@ -49,6 +54,7 @@ | ... | @@ -49,6 +54,7 @@ |
49 | "offset": 2835604 | 54 | "offset": 2835604 |
50 | }, | 55 | }, |
51 | "duration": 10, | 56 | "duration": 10, |
57 | "timeline": 0, | ||
52 | "uri": "hls_450k_video.ts" | 58 | "uri": "hls_450k_video.ts" |
53 | }, | 59 | }, |
54 | { | 60 | { |
... | @@ -57,6 +63,7 @@ | ... | @@ -57,6 +63,7 @@ |
57 | "offset": 3042780 | 63 | "offset": 3042780 |
58 | }, | 64 | }, |
59 | "duration": 10, | 65 | "duration": 10, |
66 | "timeline": 0, | ||
60 | "uri": "hls_450k_video.ts" | 67 | "uri": "hls_450k_video.ts" |
61 | }, | 68 | }, |
62 | { | 69 | { |
... | @@ -65,6 +72,7 @@ | ... | @@ -65,6 +72,7 @@ |
65 | "offset": 3498680 | 72 | "offset": 3498680 |
66 | }, | 73 | }, |
67 | "duration": 10, | 74 | "duration": 10, |
75 | "timeline": 0, | ||
68 | "uri": "hls_450k_video.ts" | 76 | "uri": "hls_450k_video.ts" |
69 | }, | 77 | }, |
70 | { | 78 | { |
... | @@ -73,6 +81,7 @@ | ... | @@ -73,6 +81,7 @@ |
73 | "offset": 4155928 | 81 | "offset": 4155928 |
74 | }, | 82 | }, |
75 | "duration": 10, | 83 | "duration": 10, |
84 | "timeline": 0, | ||
76 | "uri": "hls_450k_video.ts" | 85 | "uri": "hls_450k_video.ts" |
77 | }, | 86 | }, |
78 | { | 87 | { |
... | @@ -81,6 +90,7 @@ | ... | @@ -81,6 +90,7 @@ |
81 | "offset": 4727636 | 90 | "offset": 4727636 |
82 | }, | 91 | }, |
83 | "duration": 10, | 92 | "duration": 10, |
93 | "timeline": 0, | ||
84 | "uri": "hls_450k_video.ts" | 94 | "uri": "hls_450k_video.ts" |
85 | }, | 95 | }, |
86 | { | 96 | { |
... | @@ -89,6 +99,7 @@ | ... | @@ -89,6 +99,7 @@ |
89 | "offset": 5212676 | 99 | "offset": 5212676 |
90 | }, | 100 | }, |
91 | "duration": 10, | 101 | "duration": 10, |
102 | "timeline": 0, | ||
92 | "uri": "hls_450k_video.ts" | 103 | "uri": "hls_450k_video.ts" |
93 | }, | 104 | }, |
94 | { | 105 | { |
... | @@ -97,6 +108,7 @@ | ... | @@ -97,6 +108,7 @@ |
97 | "offset": 5921812 | 108 | "offset": 5921812 |
98 | }, | 109 | }, |
99 | "duration": 10, | 110 | "duration": 10, |
111 | "timeline": 0, | ||
100 | "uri": "hls_450k_video.ts" | 112 | "uri": "hls_450k_video.ts" |
101 | }, | 113 | }, |
102 | { | 114 | { |
... | @@ -105,6 +117,7 @@ | ... | @@ -105,6 +117,7 @@ |
105 | "offset": 6651816 | 117 | "offset": 6651816 |
106 | }, | 118 | }, |
107 | "duration": 10, | 119 | "duration": 10, |
120 | "timeline": 0, | ||
108 | "uri": "hls_450k_video.ts" | 121 | "uri": "hls_450k_video.ts" |
109 | }, | 122 | }, |
110 | { | 123 | { |
... | @@ -113,6 +126,7 @@ | ... | @@ -113,6 +126,7 @@ |
113 | "offset": 7108092 | 126 | "offset": 7108092 |
114 | }, | 127 | }, |
115 | "duration": 10, | 128 | "duration": 10, |
129 | "timeline": 0, | ||
116 | "uri": "hls_450k_video.ts" | 130 | "uri": "hls_450k_video.ts" |
117 | }, | 131 | }, |
118 | { | 132 | { |
... | @@ -121,6 +135,7 @@ | ... | @@ -121,6 +135,7 @@ |
121 | "offset": 7576776 | 135 | "offset": 7576776 |
122 | }, | 136 | }, |
123 | "duration": 10, | 137 | "duration": 10, |
138 | "timeline": 0, | ||
124 | "uri": "hls_450k_video.ts" | 139 | "uri": "hls_450k_video.ts" |
125 | }, | 140 | }, |
126 | { | 141 | { |
... | @@ -129,6 +144,7 @@ | ... | @@ -129,6 +144,7 @@ |
129 | "offset": 8021772 | 144 | "offset": 8021772 |
130 | }, | 145 | }, |
131 | "duration": 10, | 146 | "duration": 10, |
147 | "timeline": 0, | ||
132 | "uri": "hls_450k_video.ts" | 148 | "uri": "hls_450k_video.ts" |
133 | }, | 149 | }, |
134 | { | 150 | { |
... | @@ -137,6 +153,7 @@ | ... | @@ -137,6 +153,7 @@ |
137 | "offset": 8353216 | 153 | "offset": 8353216 |
138 | }, | 154 | }, |
139 | "duration": 1.4167, | 155 | "duration": 1.4167, |
156 | "timeline": 0, | ||
140 | "uri": "hls_450k_video.ts" | 157 | "uri": "hls_450k_video.ts" |
141 | } | 158 | } |
142 | ], | 159 | ], |
... | @@ -144,4 +161,4 @@ | ... | @@ -144,4 +161,4 @@ |
144 | "endList": true, | 161 | "endList": true, |
145 | "discontinuitySequence": 0, | 162 | "discontinuitySequence": 0, |
146 | "discontinuityStarts": [] | 163 | "discontinuityStarts": [] |
147 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
164 | } | ... | ... |
... | @@ -9,6 +9,7 @@ | ... | @@ -9,6 +9,7 @@ |
9 | "offset": 0 | 9 | "offset": 0 |
10 | }, | 10 | }, |
11 | "duration": 10, | 11 | "duration": 10, |
12 | "timeline": 0, | ||
12 | "uri": "hls_450k_video.ts" | 13 | "uri": "hls_450k_video.ts" |
13 | } | 14 | } |
14 | ], | 15 | ], |
... | @@ -16,4 +17,4 @@ | ... | @@ -16,4 +17,4 @@ |
16 | "endList": true, | 17 | "endList": true, |
17 | "discontinuitySequence": 0, | 18 | "discontinuitySequence": 0, |
18 | "discontinuityStarts": [] | 19 | "discontinuityStarts": [] |
19 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
20 | } | ... | ... |
utils/manifest/alternateAudio.js
0 → 100644
1 | { | ||
2 | allowCache: true, | ||
3 | discontinuityStarts: [], | ||
4 | mediaGroups: { | ||
5 | // TYPE | ||
6 | AUDIO: { | ||
7 | // GROUP-ID | ||
8 | "audio": { | ||
9 | // NAME | ||
10 | "English": { | ||
11 | language: 'eng', | ||
12 | autoselect: true, | ||
13 | default: true, | ||
14 | uri: "eng/prog_index.m3u8" | ||
15 | }, | ||
16 | // NAME | ||
17 | "Français": { | ||
18 | language: "fre", | ||
19 | autoselect: true, | ||
20 | default: false, | ||
21 | uri: "fre/prog_index.m3u8" | ||
22 | }, | ||
23 | // NAME | ||
24 | "Espanol": { | ||
25 | language: "sp", | ||
26 | autoselect: true, | ||
27 | default: false, | ||
28 | uri: "sp/prog_index.m3u8" | ||
29 | } | ||
30 | } | ||
31 | }, | ||
32 | VIDEO: {}, | ||
33 | "CLOSED-CAPTIONS": {}, | ||
34 | SUBTITLES: {} | ||
35 | }, | ||
36 | playlists: [{ | ||
37 | attributes: { | ||
38 | "PROGRAM-ID": 1, | ||
39 | BANDWIDTH: 195023, | ||
40 | CODECS: "avc1.42e00a,mp4a.40.2", | ||
41 | AUDIO: 'audio' | ||
42 | }, | ||
43 | timeline: 0, | ||
44 | uri: "lo/prog_index.m3u8" | ||
45 | }, { | ||
46 | attributes: { | ||
47 | "PROGRAM-ID": 1, | ||
48 | BANDWIDTH: 591680, | ||
49 | CODECS: "avc1.42e01e,mp4a.40.2", | ||
50 | AUDIO: 'audio' | ||
51 | }, | ||
52 | timeline: 0, | ||
53 | uri: "hi/prog_index.m3u8" | ||
54 | }] | ||
55 | } |
utils/manifest/alternateAudio.m3u8
0 → 100644
1 | #EXTM3U | ||
2 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",LANGUAGE="eng",NAME="English",AUTOSELECT=YES, DEFAULT=YES,URI="eng/prog_index.m3u8" | ||
3 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",LANGUAGE="fre",NAME="Français",AUTOSELECT=YES, DEFAULT=NO,URI="fre/prog_index.m3u8" | ||
4 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",LANGUAGE="sp",NAME="Espanol",AUTOSELECT=YES, DEFAULT=NO,URI="sp/prog_index.m3u8" | ||
5 | |||
6 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=195023,CODECS="avc1.42e00a,mp4a.40.2",AUDIO="audio" | ||
7 | lo/prog_index.m3u8 | ||
8 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=591680,CODECS="avc1.42e01e,mp4a.40.2",AUDIO="audio" | ||
9 | hi/prog_index.m3u8 | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
utils/manifest/alternateVideo.js
0 → 100644
1 | { | ||
2 | allowCache: true, | ||
3 | discontinuityStarts: [], | ||
4 | mediaGroups: { | ||
5 | AUDIO: { | ||
6 | aac: { | ||
7 | English: { | ||
8 | autoselect: true, | ||
9 | default: true, | ||
10 | language: "eng", | ||
11 | uri: "eng/prog_index.m3u8" | ||
12 | } | ||
13 | } | ||
14 | }, | ||
15 | VIDEO: { | ||
16 | "500kbs": { | ||
17 | Angle1: { | ||
18 | autoselect: true, | ||
19 | default: true | ||
20 | }, | ||
21 | Angle2: { | ||
22 | autoselect: true, | ||
23 | default: false, | ||
24 | uri: "Angle2/500kbs/prog_index.m3u8" | ||
25 | }, | ||
26 | Angle3: { | ||
27 | autoselect: true, | ||
28 | default: false, | ||
29 | uri: "Angle3/500kbs/prog_index.m3u8" | ||
30 | } | ||
31 | } | ||
32 | }, | ||
33 | "CLOSED-CAPTIONS": {}, | ||
34 | SUBTITLES: {} | ||
35 | }, | ||
36 | playlists: [{ | ||
37 | attributes: { | ||
38 | "PROGRAM-ID": 1, | ||
39 | BANDWIDTH: 754857, | ||
40 | CODECS: "mp4a.40.2,avc1.4d401e", | ||
41 | AUDIO: "aac", | ||
42 | VIDEO: "500kbs" | ||
43 | }, | ||
44 | timeline: 0, | ||
45 | uri: "Angle1/500kbs/prog_index.m3u8" | ||
46 | }] | ||
47 | } |
utils/manifest/alternateVideo.m3u8
0 → 100644
1 | #EXTM3U | ||
2 | #EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="500kbs",NAME="Angle1",AUTOSELECT=YES,DEFAULT=YES | ||
3 | #EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="500kbs",NAME="Angle2",AUTOSELECT=YES,DEFAULT=NO,URI="Angle2/500kbs/prog_index.m3u8" | ||
4 | #EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="500kbs",NAME="Angle3",AUTOSELECT=YES,DEFAULT=NO,URI="Angle3/500kbs/prog_index.m3u8" | ||
5 | |||
6 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac",LANGUAGE="eng",NAME="English",AUTOSELECT=YES,DEFAULT=YES,URI="eng/prog_index.m3u8" | ||
7 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=754857,CODECS="mp4a.40.2,avc1.4d401e",VIDEO="500kbs",AUDIO="aac" | ||
8 | Angle1/500kbs/prog_index.m3u8 | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
... | @@ -10,6 +10,7 @@ | ... | @@ -10,6 +10,7 @@ |
10 | "height": 224 | 10 | "height": 224 |
11 | } | 11 | } |
12 | }, | 12 | }, |
13 | "timeline": 0, | ||
13 | "uri": "http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824686811001&videoId=1824650741001" | 14 | "uri": "http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824686811001&videoId=1824650741001" |
14 | }, | 15 | }, |
15 | { | 16 | { |
... | @@ -17,6 +18,7 @@ | ... | @@ -17,6 +18,7 @@ |
17 | "PROGRAM-ID": 1, | 18 | "PROGRAM-ID": 1, |
18 | "BANDWIDTH": 40000 | 19 | "BANDWIDTH": 40000 |
19 | }, | 20 | }, |
21 | "timeline": 0, | ||
20 | "uri": "http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824683759001&videoId=1824650741001" | 22 | "uri": "http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824683759001&videoId=1824650741001" |
21 | }, | 23 | }, |
22 | { | 24 | { |
... | @@ -28,6 +30,7 @@ | ... | @@ -28,6 +30,7 @@ |
28 | "height": 224 | 30 | "height": 224 |
29 | } | 31 | } |
30 | }, | 32 | }, |
33 | "timeline": 0, | ||
31 | "uri": "http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824686593001&videoId=1824650741001" | 34 | "uri": "http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824686593001&videoId=1824650741001" |
32 | }, | 35 | }, |
33 | { | 36 | { |
... | @@ -39,8 +42,15 @@ | ... | @@ -39,8 +42,15 @@ |
39 | "height": 540 | 42 | "height": 540 |
40 | } | 43 | } |
41 | }, | 44 | }, |
45 | "timeline": 0, | ||
42 | "uri": "http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824687660001&videoId=1824650741001" | 46 | "uri": "http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824687660001&videoId=1824650741001" |
43 | } | 47 | } |
44 | ], | 48 | ], |
45 | "discontinuityStarts": [] | ||
46 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
49 | "discontinuityStarts": [], | ||
50 | "mediaGroups": { | ||
51 | "VIDEO": {}, | ||
52 | "AUDIO": {}, | ||
53 | "CLOSED-CAPTIONS": {}, | ||
54 | "SUBTITLES": {} | ||
55 | } | ||
56 | } | ... | ... |
... | @@ -5,6 +5,7 @@ | ... | @@ -5,6 +5,7 @@ |
5 | "segments": [ | 5 | "segments": [ |
6 | { | 6 | { |
7 | "duration": 10, | 7 | "duration": 10, |
8 | "timeline": 0, | ||
8 | "uri": "hls_450k_video.ts" | 9 | "uri": "hls_450k_video.ts" |
9 | }, | 10 | }, |
10 | { | 11 | { |
... | @@ -13,6 +14,7 @@ | ... | @@ -13,6 +14,7 @@ |
13 | "offset": 522828 | 14 | "offset": 522828 |
14 | }, | 15 | }, |
15 | "duration": 10, | 16 | "duration": 10, |
17 | "timeline": 0, | ||
16 | "uri": "hls_450k_video.ts" | 18 | "uri": "hls_450k_video.ts" |
17 | }, | 19 | }, |
18 | { | 20 | { |
... | @@ -21,6 +23,7 @@ | ... | @@ -21,6 +23,7 @@ |
21 | "offset": 0 | 23 | "offset": 0 |
22 | }, | 24 | }, |
23 | "duration": 10, | 25 | "duration": 10, |
26 | "timeline": 0, | ||
24 | "uri": "hls_450k_video2.ts" | 27 | "uri": "hls_450k_video2.ts" |
25 | }, | 28 | }, |
26 | { | 29 | { |
... | @@ -29,6 +32,7 @@ | ... | @@ -29,6 +32,7 @@ |
29 | "offset": 1823412 | 32 | "offset": 1823412 |
30 | }, | 33 | }, |
31 | "duration": 10, | 34 | "duration": 10, |
35 | "timeline": 0, | ||
32 | "uri": "hls_450k_video.ts" | 36 | "uri": "hls_450k_video.ts" |
33 | }, | 37 | }, |
34 | { | 38 | { |
... | @@ -37,6 +41,7 @@ | ... | @@ -37,6 +41,7 @@ |
37 | "offset": 2299992 | 41 | "offset": 2299992 |
38 | }, | 42 | }, |
39 | "duration": 10, | 43 | "duration": 10, |
44 | "timeline": 0, | ||
40 | "uri": "hls_450k_video.ts" | 45 | "uri": "hls_450k_video.ts" |
41 | }, | 46 | }, |
42 | { | 47 | { |
... | @@ -45,6 +50,7 @@ | ... | @@ -45,6 +50,7 @@ |
45 | "offset": 2835604 | 50 | "offset": 2835604 |
46 | }, | 51 | }, |
47 | "duration": 10, | 52 | "duration": 10, |
53 | "timeline": 0, | ||
48 | "uri": "hls_450k_video.ts" | 54 | "uri": "hls_450k_video.ts" |
49 | }, | 55 | }, |
50 | { | 56 | { |
... | @@ -53,6 +59,7 @@ | ... | @@ -53,6 +59,7 @@ |
53 | "offset": 3042780 | 59 | "offset": 3042780 |
54 | }, | 60 | }, |
55 | "duration": 10, | 61 | "duration": 10, |
62 | "timeline": 0, | ||
56 | "uri": "hls_450k_video.ts" | 63 | "uri": "hls_450k_video.ts" |
57 | }, | 64 | }, |
58 | { | 65 | { |
... | @@ -61,6 +68,7 @@ | ... | @@ -61,6 +68,7 @@ |
61 | "offset": 3498680 | 68 | "offset": 3498680 |
62 | }, | 69 | }, |
63 | "duration": 10, | 70 | "duration": 10, |
71 | "timeline": 0, | ||
64 | "uri": "hls_450k_video.ts" | 72 | "uri": "hls_450k_video.ts" |
65 | }, | 73 | }, |
66 | { | 74 | { |
... | @@ -69,6 +77,7 @@ | ... | @@ -69,6 +77,7 @@ |
69 | "offset": 4155928 | 77 | "offset": 4155928 |
70 | }, | 78 | }, |
71 | "duration": 10, | 79 | "duration": 10, |
80 | "timeline": 0, | ||
72 | "uri": "hls_450k_video.ts" | 81 | "uri": "hls_450k_video.ts" |
73 | }, | 82 | }, |
74 | { | 83 | { |
... | @@ -77,6 +86,7 @@ | ... | @@ -77,6 +86,7 @@ |
77 | "offset": 4727636 | 86 | "offset": 4727636 |
78 | }, | 87 | }, |
79 | "duration": 10, | 88 | "duration": 10, |
89 | "timeline": 0, | ||
80 | "uri": "hls_450k_video.ts" | 90 | "uri": "hls_450k_video.ts" |
81 | }, | 91 | }, |
82 | { | 92 | { |
... | @@ -85,6 +95,7 @@ | ... | @@ -85,6 +95,7 @@ |
85 | "offset": 5212676 | 95 | "offset": 5212676 |
86 | }, | 96 | }, |
87 | "duration": 10, | 97 | "duration": 10, |
98 | "timeline": 0, | ||
88 | "uri": "hls_450k_video.ts" | 99 | "uri": "hls_450k_video.ts" |
89 | }, | 100 | }, |
90 | { | 101 | { |
... | @@ -93,6 +104,7 @@ | ... | @@ -93,6 +104,7 @@ |
93 | "offset": 5921812 | 104 | "offset": 5921812 |
94 | }, | 105 | }, |
95 | "duration": 10, | 106 | "duration": 10, |
107 | "timeline": 0, | ||
96 | "uri": "hls_450k_video.ts" | 108 | "uri": "hls_450k_video.ts" |
97 | }, | 109 | }, |
98 | { | 110 | { |
... | @@ -101,6 +113,7 @@ | ... | @@ -101,6 +113,7 @@ |
101 | "offset": 6651816 | 113 | "offset": 6651816 |
102 | }, | 114 | }, |
103 | "duration": 10, | 115 | "duration": 10, |
116 | "timeline": 0, | ||
104 | "uri": "hls_450k_video.ts" | 117 | "uri": "hls_450k_video.ts" |
105 | }, | 118 | }, |
106 | { | 119 | { |
... | @@ -109,6 +122,7 @@ | ... | @@ -109,6 +122,7 @@ |
109 | "offset": 7108092 | 122 | "offset": 7108092 |
110 | }, | 123 | }, |
111 | "duration": 10, | 124 | "duration": 10, |
125 | "timeline": 0, | ||
112 | "uri": "hls_450k_video.ts" | 126 | "uri": "hls_450k_video.ts" |
113 | }, | 127 | }, |
114 | { | 128 | { |
... | @@ -117,6 +131,7 @@ | ... | @@ -117,6 +131,7 @@ |
117 | "offset": 7576776 | 131 | "offset": 7576776 |
118 | }, | 132 | }, |
119 | "duration": 10, | 133 | "duration": 10, |
134 | "timeline": 0, | ||
120 | "uri": "hls_450k_video.ts" | 135 | "uri": "hls_450k_video.ts" |
121 | }, | 136 | }, |
122 | { | 137 | { |
... | @@ -125,6 +140,7 @@ | ... | @@ -125,6 +140,7 @@ |
125 | "offset": 8021772 | 140 | "offset": 8021772 |
126 | }, | 141 | }, |
127 | "duration": 10, | 142 | "duration": 10, |
143 | "timeline": 0, | ||
128 | "uri": "hls_450k_video.ts" | 144 | "uri": "hls_450k_video.ts" |
129 | }, | 145 | }, |
130 | { | 146 | { |
... | @@ -133,6 +149,7 @@ | ... | @@ -133,6 +149,7 @@ |
133 | "offset": 8353216 | 149 | "offset": 8353216 |
134 | }, | 150 | }, |
135 | "duration": 1.4167, | 151 | "duration": 1.4167, |
152 | "timeline": 0, | ||
136 | "uri": "hls_450k_video.ts" | 153 | "uri": "hls_450k_video.ts" |
137 | } | 154 | } |
138 | ], | 155 | ], |
... | @@ -140,4 +157,4 @@ | ... | @@ -140,4 +157,4 @@ |
140 | "endList": true, | 157 | "endList": true, |
141 | "discontinuitySequence": 0, | 158 | "discontinuitySequence": 0, |
142 | "discontinuityStarts": [] | 159 | "discontinuityStarts": [] |
143 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
160 | } | ... | ... |
... | @@ -9,6 +9,7 @@ | ... | @@ -9,6 +9,7 @@ |
9 | "offset": 0 | 9 | "offset": 0 |
10 | }, | 10 | }, |
11 | "duration": 10, | 11 | "duration": 10, |
12 | "timeline": 0, | ||
12 | "uri": "hls_450k_video.ts" | 13 | "uri": "hls_450k_video.ts" |
13 | } | 14 | } |
14 | ], | 15 | ], |
... | @@ -16,4 +17,4 @@ | ... | @@ -16,4 +17,4 @@ |
16 | "endList": true, | 17 | "endList": true, |
17 | "discontinuitySequence": 0, | 18 | "discontinuitySequence": 0, |
18 | "discontinuityStarts": [] | 19 | "discontinuityStarts": [] |
19 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
20 | } | ... | ... |
... | @@ -5,19 +5,23 @@ | ... | @@ -5,19 +5,23 @@ |
5 | "segments": [ | 5 | "segments": [ |
6 | { | 6 | { |
7 | "duration": 10, | 7 | "duration": 10, |
8 | "timeline": 3, | ||
8 | "uri": "001.ts" | 9 | "uri": "001.ts" |
9 | }, | 10 | }, |
10 | { | 11 | { |
11 | "duration": 19, | 12 | "duration": 19, |
13 | "timeline": 3, | ||
12 | "uri": "002.ts" | 14 | "uri": "002.ts" |
13 | }, | 15 | }, |
14 | { | 16 | { |
15 | "discontinuity": true, | 17 | "discontinuity": true, |
16 | "duration": 10, | 18 | "duration": 10, |
19 | "timeline": 4, | ||
17 | "uri": "003.ts" | 20 | "uri": "003.ts" |
18 | }, | 21 | }, |
19 | { | 22 | { |
20 | "duration": 11, | 23 | "duration": 11, |
24 | "timeline": 4, | ||
21 | "uri": "004.ts" | 25 | "uri": "004.ts" |
22 | } | 26 | } |
23 | ], | 27 | ], | ... | ... |
... | @@ -5,41 +5,50 @@ | ... | @@ -5,41 +5,50 @@ |
5 | "segments": [ | 5 | "segments": [ |
6 | { | 6 | { |
7 | "duration": 10, | 7 | "duration": 10, |
8 | "timeline": 0, | ||
8 | "uri": "001.ts" | 9 | "uri": "001.ts" |
9 | }, | 10 | }, |
10 | { | 11 | { |
11 | "duration": 19, | 12 | "duration": 19, |
13 | "timeline": 0, | ||
12 | "uri": "002.ts" | 14 | "uri": "002.ts" |
13 | }, | 15 | }, |
14 | { | 16 | { |
15 | "discontinuity": true, | 17 | "discontinuity": true, |
16 | "duration": 10, | 18 | "duration": 10, |
19 | "timeline": 1, | ||
17 | "uri": "003.ts" | 20 | "uri": "003.ts" |
18 | }, | 21 | }, |
19 | { | 22 | { |
20 | "duration": 11, | 23 | "duration": 11, |
24 | "timeline": 1, | ||
21 | "uri": "004.ts" | 25 | "uri": "004.ts" |
22 | }, | 26 | }, |
23 | { | 27 | { |
24 | "discontinuity": true, | 28 | "discontinuity": true, |
25 | "duration": 10, | 29 | "duration": 10, |
30 | "timeline": 2, | ||
26 | "uri": "005.ts" | 31 | "uri": "005.ts" |
27 | }, | 32 | }, |
28 | { | 33 | { |
29 | "duration": 10, | 34 | "duration": 10, |
35 | "timeline": 2, | ||
30 | "uri": "006.ts" | 36 | "uri": "006.ts" |
31 | }, | 37 | }, |
32 | { | 38 | { |
33 | "duration": 10, | 39 | "duration": 10, |
40 | "timeline": 2, | ||
34 | "uri": "007.ts" | 41 | "uri": "007.ts" |
35 | }, | 42 | }, |
36 | { | 43 | { |
37 | "discontinuity": true, | 44 | "discontinuity": true, |
38 | "duration": 10, | 45 | "duration": 10, |
46 | "timeline": 3, | ||
39 | "uri": "008.ts" | 47 | "uri": "008.ts" |
40 | }, | 48 | }, |
41 | { | 49 | { |
42 | "duration": 16, | 50 | "duration": 16, |
51 | "timeline": 3, | ||
43 | "uri": "009.ts" | 52 | "uri": "009.ts" |
44 | } | 53 | } |
45 | ], | 54 | ], | ... | ... |
... | @@ -5,18 +5,22 @@ | ... | @@ -5,18 +5,22 @@ |
5 | "segments": [ | 5 | "segments": [ |
6 | { | 6 | { |
7 | "duration": 10, | 7 | "duration": 10, |
8 | "timeline": 0, | ||
8 | "uri": "/00001.ts" | 9 | "uri": "/00001.ts" |
9 | }, | 10 | }, |
10 | { | 11 | { |
11 | "duration": 10, | 12 | "duration": 10, |
13 | "timeline": 0, | ||
12 | "uri": "/subdir/00002.ts" | 14 | "uri": "/subdir/00002.ts" |
13 | }, | 15 | }, |
14 | { | 16 | { |
15 | "duration": 10, | 17 | "duration": 10, |
18 | "timeline": 0, | ||
16 | "uri": "/00003.ts" | 19 | "uri": "/00003.ts" |
17 | }, | 20 | }, |
18 | { | 21 | { |
19 | "duration": 10, | 22 | "duration": 10, |
23 | "timeline": 0, | ||
20 | "uri": "/00004.ts" | 24 | "uri": "/00004.ts" |
21 | } | 25 | } |
22 | ], | 26 | ], |
... | @@ -24,4 +28,4 @@ | ... | @@ -24,4 +28,4 @@ |
24 | "endList": true, | 28 | "endList": true, |
25 | "discontinuitySequence": 0, | 29 | "discontinuitySequence": 0, |
26 | "discontinuityStarts": [] | 30 | "discontinuityStarts": [] |
27 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
31 | } | ... | ... |
... | @@ -9,6 +9,7 @@ | ... | @@ -9,6 +9,7 @@ |
9 | "offset": 0 | 9 | "offset": 0 |
10 | }, | 10 | }, |
11 | "duration": 10, | 11 | "duration": 10, |
12 | "timeline": 0, | ||
12 | "uri": "hls_450k_video.ts" | 13 | "uri": "hls_450k_video.ts" |
13 | } | 14 | } |
14 | ], | 15 | ], |
... | @@ -16,4 +17,4 @@ | ... | @@ -16,4 +17,4 @@ |
16 | "endList": true, | 17 | "endList": true, |
17 | "discontinuitySequence": 0, | 18 | "discontinuitySequence": 0, |
18 | "discontinuityStarts": [] | 19 | "discontinuityStarts": [] |
19 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
20 | } | ... | ... |
... | @@ -5,18 +5,22 @@ | ... | @@ -5,18 +5,22 @@ |
5 | "segments": [ | 5 | "segments": [ |
6 | { | 6 | { |
7 | "duration": 6.64, | 7 | "duration": 6.64, |
8 | "timeline": 0, | ||
8 | "uri": "/test/ts-files/tvy7/8a5e2822668b5370f4eb1438b2564fb7ab12ffe1-hi720.ts" | 9 | "uri": "/test/ts-files/tvy7/8a5e2822668b5370f4eb1438b2564fb7ab12ffe1-hi720.ts" |
9 | }, | 10 | }, |
10 | { | 11 | { |
11 | "duration": 6.08, | 12 | "duration": 6.08, |
13 | "timeline": 0, | ||
12 | "uri": "/test/ts-files/tvy7/56be1cef869a1c0cc8e38864ad1add17d187f051-hi720.ts" | 14 | "uri": "/test/ts-files/tvy7/56be1cef869a1c0cc8e38864ad1add17d187f051-hi720.ts" |
13 | }, | 15 | }, |
14 | { | 16 | { |
15 | "duration": 6.6, | 17 | "duration": 6.6, |
18 | "timeline": 0, | ||
16 | "uri": "/test/ts-files/tvy7/549c8c77f55f049741a06596e5c1e01dacaa46d0-hi720.ts" | 19 | "uri": "/test/ts-files/tvy7/549c8c77f55f049741a06596e5c1e01dacaa46d0-hi720.ts" |
17 | }, | 20 | }, |
18 | { | 21 | { |
19 | "duration": 5, | 22 | "duration": 5, |
23 | "timeline": 0, | ||
20 | "uri": "/test/ts-files/tvy7/6cfa378684ffeb1c455a64dae6c103290a1f53d4-hi720.ts" | 24 | "uri": "/test/ts-files/tvy7/6cfa378684ffeb1c455a64dae6c103290a1f53d4-hi720.ts" |
21 | } | 25 | } |
22 | ], | 26 | ], |
... | @@ -24,4 +28,4 @@ | ... | @@ -24,4 +28,4 @@ |
24 | "endList": true, | 28 | "endList": true, |
25 | "discontinuitySequence": 0, | 29 | "discontinuitySequence": 0, |
26 | "discontinuityStarts": [] | 30 | "discontinuityStarts": [] |
27 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
31 | } | ... | ... |
... | @@ -4,26 +4,32 @@ | ... | @@ -4,26 +4,32 @@ |
4 | "segments": [ | 4 | "segments": [ |
5 | { | 5 | { |
6 | "duration": 10, | 6 | "duration": 10, |
7 | "timeline": 0, | ||
7 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00001.ts" | 8 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00001.ts" |
8 | }, | 9 | }, |
9 | { | 10 | { |
10 | "duration": 10, | 11 | "duration": 10, |
12 | "timeline": 0, | ||
11 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00002.ts" | 13 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00002.ts" |
12 | }, | 14 | }, |
13 | { | 15 | { |
14 | "duration": 10, | 16 | "duration": 10, |
17 | "timeline": 0, | ||
15 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00003.ts" | 18 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00003.ts" |
16 | }, | 19 | }, |
17 | { | 20 | { |
18 | "duration": 10, | 21 | "duration": 10, |
22 | "timeline": 0, | ||
19 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00004.ts" | 23 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00004.ts" |
20 | }, | 24 | }, |
21 | { | 25 | { |
22 | "duration": 10, | 26 | "duration": 10, |
27 | "timeline": 0, | ||
23 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00005.ts" | 28 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00005.ts" |
24 | }, | 29 | }, |
25 | { | 30 | { |
26 | "duration": 8, | 31 | "duration": 8, |
32 | "timeline": 0, | ||
27 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00006.ts" | 33 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00006.ts" |
28 | } | 34 | } |
29 | ], | 35 | ], |
... | @@ -31,4 +37,4 @@ | ... | @@ -31,4 +37,4 @@ |
31 | "endList": true, | 37 | "endList": true, |
32 | "discontinuitySequence": 0, | 38 | "discontinuitySequence": 0, |
33 | "discontinuityStarts": [] | 39 | "discontinuityStarts": [] |
34 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
40 | } | ... | ... |
... | @@ -10,6 +10,7 @@ | ... | @@ -10,6 +10,7 @@ |
10 | "height": 224 | 10 | "height": 224 |
11 | } | 11 | } |
12 | }, | 12 | }, |
13 | "timeline": 0, | ||
13 | "uri": "http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824686811001&videoId=1824650741001" | 14 | "uri": "http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824686811001&videoId=1824650741001" |
14 | }, | 15 | }, |
15 | { | 16 | { |
... | @@ -17,6 +18,7 @@ | ... | @@ -17,6 +18,7 @@ |
17 | "PROGRAM-ID": 1, | 18 | "PROGRAM-ID": 1, |
18 | "BANDWIDTH": 40000 | 19 | "BANDWIDTH": 40000 |
19 | }, | 20 | }, |
21 | "timeline": 0, | ||
20 | "uri": "http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824683759001&videoId=1824650741001" | 22 | "uri": "http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824683759001&videoId=1824650741001" |
21 | }, | 23 | }, |
22 | { | 24 | { |
... | @@ -28,6 +30,7 @@ | ... | @@ -28,6 +30,7 @@ |
28 | "height": 224 | 30 | "height": 224 |
29 | } | 31 | } |
30 | }, | 32 | }, |
33 | "timeline": 0, | ||
31 | "uri": "http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824686593001&videoId=1824650741001" | 34 | "uri": "http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824686593001&videoId=1824650741001" |
32 | }, | 35 | }, |
33 | { | 36 | { |
... | @@ -39,8 +42,15 @@ | ... | @@ -39,8 +42,15 @@ |
39 | "height": 540 | 42 | "height": 540 |
40 | } | 43 | } |
41 | }, | 44 | }, |
45 | "timeline": 0, | ||
42 | "uri": "http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824687660001&videoId=1824650741001" | 46 | "uri": "http://c.brightcove.com/services/mobile/streaming/index/rendition.m3u8?assetId=1824687660001&videoId=1824650741001" |
43 | } | 47 | } |
44 | ], | 48 | ], |
45 | "discontinuityStarts": [] | ||
46 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
49 | "discontinuityStarts": [], | ||
50 | "mediaGroups": { | ||
51 | "VIDEO": {}, | ||
52 | "AUDIO": {}, | ||
53 | "CLOSED-CAPTIONS": {}, | ||
54 | "SUBTITLES": {} | ||
55 | } | ||
56 | } | ... | ... |
... | @@ -6,6 +6,7 @@ | ... | @@ -6,6 +6,7 @@ |
6 | "segments": [ | 6 | "segments": [ |
7 | { | 7 | { |
8 | "duration": 2.833, | 8 | "duration": 2.833, |
9 | "timeline": 0, | ||
9 | "key": { | 10 | "key": { |
10 | "method": "AES-128", | 11 | "method": "AES-128", |
11 | "uri": "https://priv.example.com/key.php?r=52" | 12 | "uri": "https://priv.example.com/key.php?r=52" |
... | @@ -14,6 +15,7 @@ | ... | @@ -14,6 +15,7 @@ |
14 | }, | 15 | }, |
15 | { | 16 | { |
16 | "duration": 15, | 17 | "duration": 15, |
18 | "timeline": 0, | ||
17 | "key": { | 19 | "key": { |
18 | "method": "AES-128", | 20 | "method": "AES-128", |
19 | "uri": "https://priv.example.com/key.php?r=52" | 21 | "uri": "https://priv.example.com/key.php?r=52" |
... | @@ -22,6 +24,7 @@ | ... | @@ -22,6 +24,7 @@ |
22 | }, | 24 | }, |
23 | { | 25 | { |
24 | "duration": 13.333, | 26 | "duration": 13.333, |
27 | "timeline": 0, | ||
25 | "key": { | 28 | "key": { |
26 | "method": "AES-128", | 29 | "method": "AES-128", |
27 | "uri": "https://priv.example.com/key.php?r=52" | 30 | "uri": "https://priv.example.com/key.php?r=52" |
... | @@ -30,6 +33,7 @@ | ... | @@ -30,6 +33,7 @@ |
30 | }, | 33 | }, |
31 | { | 34 | { |
32 | "duration": 15, | 35 | "duration": 15, |
36 | "timeline": 0, | ||
33 | "key": { | 37 | "key": { |
34 | "method": "AES-128", | 38 | "method": "AES-128", |
35 | "uri": "https://priv.example.com/key.php?r=53" | 39 | "uri": "https://priv.example.com/key.php?r=53" |
... | @@ -38,6 +42,7 @@ | ... | @@ -38,6 +42,7 @@ |
38 | }, | 42 | }, |
39 | { | 43 | { |
40 | "duration": 14, | 44 | "duration": 14, |
45 | "timeline": 0, | ||
41 | "key": { | 46 | "key": { |
42 | "method": "AES-128", | 47 | "method": "AES-128", |
43 | "uri": "https://priv.example.com/key.php?r=54", | 48 | "uri": "https://priv.example.com/key.php?r=54", |
... | @@ -47,6 +52,7 @@ | ... | @@ -47,6 +52,7 @@ |
47 | }, | 52 | }, |
48 | { | 53 | { |
49 | "duration": 15, | 54 | "duration": 15, |
55 | "timeline": 0, | ||
50 | "uri": "http://media.example.com/fileSequence53-B.ts" | 56 | "uri": "http://media.example.com/fileSequence53-B.ts" |
51 | } | 57 | } |
52 | ], | 58 | ], | ... | ... |
... | @@ -5,26 +5,32 @@ | ... | @@ -5,26 +5,32 @@ |
5 | "segments": [ | 5 | "segments": [ |
6 | { | 6 | { |
7 | "duration": 10, | 7 | "duration": 10, |
8 | "timeline": 0, | ||
8 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00001.ts" | 9 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00001.ts" |
9 | }, | 10 | }, |
10 | { | 11 | { |
11 | "duration": 10, | 12 | "duration": 10, |
13 | "timeline": 0, | ||
12 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00002.ts" | 14 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00002.ts" |
13 | }, | 15 | }, |
14 | { | 16 | { |
15 | "duration": 10, | 17 | "duration": 10, |
18 | "timeline": 0, | ||
16 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00003.ts" | 19 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00003.ts" |
17 | }, | 20 | }, |
18 | { | 21 | { |
19 | "duration": 10, | 22 | "duration": 10, |
23 | "timeline": 0, | ||
20 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00004.ts" | 24 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00004.ts" |
21 | }, | 25 | }, |
22 | { | 26 | { |
23 | "duration": 10, | 27 | "duration": 10, |
28 | "timeline": 0, | ||
24 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00005.ts" | 29 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00005.ts" |
25 | }, | 30 | }, |
26 | { | 31 | { |
27 | "duration": 8, | 32 | "duration": 8, |
33 | "timeline": 0, | ||
28 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00006.ts" | 34 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00006.ts" |
29 | } | 35 | } |
30 | ], | 36 | ], |
... | @@ -32,4 +38,4 @@ | ... | @@ -32,4 +38,4 @@ |
32 | "endList": true, | 38 | "endList": true, |
33 | "discontinuitySequence": 0, | 39 | "discontinuitySequence": 0, |
34 | "discontinuityStarts": [] | 40 | "discontinuityStarts": [] |
35 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
41 | } | ... | ... |
... | @@ -4,6 +4,7 @@ | ... | @@ -4,6 +4,7 @@ |
4 | "segments": [ | 4 | "segments": [ |
5 | { | 5 | { |
6 | "duration": 6.64, | 6 | "duration": 6.64, |
7 | "timeline": 0, | ||
7 | "uri": "/test/ts-files/tvy7/8a5e2822668b5370f4eb1438b2564fb7ab12ffe1-hi720.ts" | 8 | "uri": "/test/ts-files/tvy7/8a5e2822668b5370f4eb1438b2564fb7ab12ffe1-hi720.ts" |
8 | } | 9 | } |
9 | ], | 10 | ], |
... | @@ -11,4 +12,4 @@ | ... | @@ -11,4 +12,4 @@ |
11 | "endList": true, | 12 | "endList": true, |
12 | "discontinuitySequence": 0, | 13 | "discontinuitySequence": 0, |
13 | "discontinuityStarts": [] | 14 | "discontinuityStarts": [] |
14 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
15 | } | ... | ... |
... | @@ -9,6 +9,7 @@ | ... | @@ -9,6 +9,7 @@ |
9 | "offset": 0 | 9 | "offset": 0 |
10 | }, | 10 | }, |
11 | "duration": 10, | 11 | "duration": 10, |
12 | "timeline": 0, | ||
12 | "uri": "hls_450k_video.ts" | 13 | "uri": "hls_450k_video.ts" |
13 | }, | 14 | }, |
14 | { | 15 | { |
... | @@ -17,6 +18,7 @@ | ... | @@ -17,6 +18,7 @@ |
17 | "offset": 522828 | 18 | "offset": 522828 |
18 | }, | 19 | }, |
19 | "duration": 10, | 20 | "duration": 10, |
21 | "timeline": 0, | ||
20 | "uri": "hls_450k_video.ts" | 22 | "uri": "hls_450k_video.ts" |
21 | }, | 23 | }, |
22 | { | 24 | { |
... | @@ -25,6 +27,7 @@ | ... | @@ -25,6 +27,7 @@ |
25 | "offset": 1110328 | 27 | "offset": 1110328 |
26 | }, | 28 | }, |
27 | "duration": 5, | 29 | "duration": 5, |
30 | "timeline": 0, | ||
28 | "uri": "hls_450k_video.ts" | 31 | "uri": "hls_450k_video.ts" |
29 | }, | 32 | }, |
30 | { | 33 | { |
... | @@ -33,6 +36,7 @@ | ... | @@ -33,6 +36,7 @@ |
33 | "offset": 1823412 | 36 | "offset": 1823412 |
34 | }, | 37 | }, |
35 | "duration": 9.7, | 38 | "duration": 9.7, |
39 | "timeline": 0, | ||
36 | "uri": "hls_450k_video.ts" | 40 | "uri": "hls_450k_video.ts" |
37 | }, | 41 | }, |
38 | { | 42 | { |
... | @@ -41,6 +45,7 @@ | ... | @@ -41,6 +45,7 @@ |
41 | "offset": 2299992 | 45 | "offset": 2299992 |
42 | }, | 46 | }, |
43 | "duration": 10, | 47 | "duration": 10, |
48 | "timeline": 0, | ||
44 | "uri": "hls_450k_video.ts" | 49 | "uri": "hls_450k_video.ts" |
45 | }, | 50 | }, |
46 | { | 51 | { |
... | @@ -49,6 +54,7 @@ | ... | @@ -49,6 +54,7 @@ |
49 | "offset": 2835604 | 54 | "offset": 2835604 |
50 | }, | 55 | }, |
51 | "duration": 10, | 56 | "duration": 10, |
57 | "timeline": 0, | ||
52 | "uri": "hls_450k_video.ts" | 58 | "uri": "hls_450k_video.ts" |
53 | }, | 59 | }, |
54 | { | 60 | { |
... | @@ -57,6 +63,7 @@ | ... | @@ -57,6 +63,7 @@ |
57 | "offset": 3042780 | 63 | "offset": 3042780 |
58 | }, | 64 | }, |
59 | "duration": 10, | 65 | "duration": 10, |
66 | "timeline": 0, | ||
60 | "uri": "hls_450k_video.ts" | 67 | "uri": "hls_450k_video.ts" |
61 | }, | 68 | }, |
62 | { | 69 | { |
... | @@ -65,6 +72,7 @@ | ... | @@ -65,6 +72,7 @@ |
65 | "offset": 3498680 | 72 | "offset": 3498680 |
66 | }, | 73 | }, |
67 | "duration": 10, | 74 | "duration": 10, |
75 | "timeline": 0, | ||
68 | "uri": "hls_450k_video.ts" | 76 | "uri": "hls_450k_video.ts" |
69 | }, | 77 | }, |
70 | { | 78 | { |
... | @@ -73,6 +81,7 @@ | ... | @@ -73,6 +81,7 @@ |
73 | "offset": 4155928 | 81 | "offset": 4155928 |
74 | }, | 82 | }, |
75 | "duration": 10, | 83 | "duration": 10, |
84 | "timeline": 0, | ||
76 | "uri": "hls_450k_video.ts" | 85 | "uri": "hls_450k_video.ts" |
77 | }, | 86 | }, |
78 | { | 87 | { |
... | @@ -81,6 +90,7 @@ | ... | @@ -81,6 +90,7 @@ |
81 | "offset": 4727636 | 90 | "offset": 4727636 |
82 | }, | 91 | }, |
83 | "duration": 10, | 92 | "duration": 10, |
93 | "timeline": 0, | ||
84 | "uri": "hls_450k_video.ts" | 94 | "uri": "hls_450k_video.ts" |
85 | }, | 95 | }, |
86 | { | 96 | { |
... | @@ -89,6 +99,7 @@ | ... | @@ -89,6 +99,7 @@ |
89 | "offset": 5212676 | 99 | "offset": 5212676 |
90 | }, | 100 | }, |
91 | "duration": 10, | 101 | "duration": 10, |
102 | "timeline": 0, | ||
92 | "uri": "hls_450k_video.ts" | 103 | "uri": "hls_450k_video.ts" |
93 | }, | 104 | }, |
94 | { | 105 | { |
... | @@ -97,6 +108,7 @@ | ... | @@ -97,6 +108,7 @@ |
97 | "offset": 5921812 | 108 | "offset": 5921812 |
98 | }, | 109 | }, |
99 | "duration": 10, | 110 | "duration": 10, |
111 | "timeline": 0, | ||
100 | "uri": "hls_450k_video.ts" | 112 | "uri": "hls_450k_video.ts" |
101 | }, | 113 | }, |
102 | { | 114 | { |
... | @@ -105,6 +117,7 @@ | ... | @@ -105,6 +117,7 @@ |
105 | "offset": 6651816 | 117 | "offset": 6651816 |
106 | }, | 118 | }, |
107 | "duration": 10, | 119 | "duration": 10, |
120 | "timeline": 0, | ||
108 | "uri": "hls_450k_video.ts" | 121 | "uri": "hls_450k_video.ts" |
109 | }, | 122 | }, |
110 | { | 123 | { |
... | @@ -113,6 +126,7 @@ | ... | @@ -113,6 +126,7 @@ |
113 | "offset": 7108092 | 126 | "offset": 7108092 |
114 | }, | 127 | }, |
115 | "duration": 10, | 128 | "duration": 10, |
129 | "timeline": 0, | ||
116 | "uri": "hls_450k_video.ts" | 130 | "uri": "hls_450k_video.ts" |
117 | }, | 131 | }, |
118 | { | 132 | { |
... | @@ -121,6 +135,7 @@ | ... | @@ -121,6 +135,7 @@ |
121 | "offset": 7576776 | 135 | "offset": 7576776 |
122 | }, | 136 | }, |
123 | "duration": 10, | 137 | "duration": 10, |
138 | "timeline": 0, | ||
124 | "uri": "hls_450k_video.ts" | 139 | "uri": "hls_450k_video.ts" |
125 | }, | 140 | }, |
126 | { | 141 | { |
... | @@ -129,6 +144,7 @@ | ... | @@ -129,6 +144,7 @@ |
129 | "offset": 8021772 | 144 | "offset": 8021772 |
130 | }, | 145 | }, |
131 | "duration": 10, | 146 | "duration": 10, |
147 | "timeline": 0, | ||
132 | "uri": "hls_450k_video.ts" | 148 | "uri": "hls_450k_video.ts" |
133 | }, | 149 | }, |
134 | { | 150 | { |
... | @@ -137,6 +153,7 @@ | ... | @@ -137,6 +153,7 @@ |
137 | "offset": 8353216 | 153 | "offset": 8353216 |
138 | }, | 154 | }, |
139 | "duration": 10, | 155 | "duration": 10, |
156 | "timeline": 0, | ||
140 | "uri": "hls_450k_video.ts" | 157 | "uri": "hls_450k_video.ts" |
141 | } | 158 | } |
142 | ], | 159 | ], |
... | @@ -144,4 +161,4 @@ | ... | @@ -144,4 +161,4 @@ |
144 | "endList": true, | 161 | "endList": true, |
145 | "discontinuitySequence": 0, | 162 | "discontinuitySequence": 0, |
146 | "discontinuityStarts": [] | 163 | "discontinuityStarts": [] |
147 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
164 | } | ... | ... |
... | @@ -9,6 +9,7 @@ | ... | @@ -9,6 +9,7 @@ |
9 | "offset": 0 | 9 | "offset": 0 |
10 | }, | 10 | }, |
11 | "duration": 10, | 11 | "duration": 10, |
12 | "timeline": 0, | ||
12 | "uri": "hls_450k_video.ts" | 13 | "uri": "hls_450k_video.ts" |
13 | } | 14 | } |
14 | ], | 15 | ], |
... | @@ -16,4 +17,4 @@ | ... | @@ -16,4 +17,4 @@ |
16 | "endList": true, | 17 | "endList": true, |
17 | "discontinuitySequence": 0, | 18 | "discontinuitySequence": 0, |
18 | "discontinuityStarts": [] | 19 | "discontinuityStarts": [] |
19 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
20 | } | ... | ... |
... | @@ -5,18 +5,22 @@ | ... | @@ -5,18 +5,22 @@ |
5 | "segments": [ | 5 | "segments": [ |
6 | { | 6 | { |
7 | "duration": 6.64, | 7 | "duration": 6.64, |
8 | "timeline": 0, | ||
8 | "uri": "/test/ts-files/tvy7/8a5e2822668b5370f4eb1438b2564fb7ab12ffe1-hi720.ts" | 9 | "uri": "/test/ts-files/tvy7/8a5e2822668b5370f4eb1438b2564fb7ab12ffe1-hi720.ts" |
9 | }, | 10 | }, |
10 | { | 11 | { |
11 | "duration": 6.08, | 12 | "duration": 6.08, |
13 | "timeline": 0, | ||
12 | "uri": "/test/ts-files/tvy7/56be1cef869a1c0cc8e38864ad1add17d187f051-hi720.ts" | 14 | "uri": "/test/ts-files/tvy7/56be1cef869a1c0cc8e38864ad1add17d187f051-hi720.ts" |
13 | }, | 15 | }, |
14 | { | 16 | { |
15 | "duration": 6.6, | 17 | "duration": 6.6, |
18 | "timeline": 0, | ||
16 | "uri": "/test/ts-files/tvy7/549c8c77f55f049741a06596e5c1e01dacaa46d0-hi720.ts" | 19 | "uri": "/test/ts-files/tvy7/549c8c77f55f049741a06596e5c1e01dacaa46d0-hi720.ts" |
17 | }, | 20 | }, |
18 | { | 21 | { |
19 | "duration": 5, | 22 | "duration": 5, |
23 | "timeline": 0, | ||
20 | "uri": "/test/ts-files/tvy7/6cfa378684ffeb1c455a64dae6c103290a1f53d4-hi720.ts" | 24 | "uri": "/test/ts-files/tvy7/6cfa378684ffeb1c455a64dae6c103290a1f53d4-hi720.ts" |
21 | } | 25 | } |
22 | ], | 26 | ], |
... | @@ -24,4 +28,4 @@ | ... | @@ -24,4 +28,4 @@ |
24 | "endList": true, | 28 | "endList": true, |
25 | "discontinuitySequence": 0, | 29 | "discontinuitySequence": 0, |
26 | "discontinuityStarts": [] | 30 | "discontinuityStarts": [] |
27 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
31 | } | ... | ... |
... | @@ -4,26 +4,32 @@ | ... | @@ -4,26 +4,32 @@ |
4 | "segments": [ | 4 | "segments": [ |
5 | { | 5 | { |
6 | "duration": 10, | 6 | "duration": 10, |
7 | "timeline": 0, | ||
7 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00001.ts" | 8 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00001.ts" |
8 | }, | 9 | }, |
9 | { | 10 | { |
10 | "duration": 10, | 11 | "duration": 10, |
12 | "timeline": 0, | ||
11 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00002.ts" | 13 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00002.ts" |
12 | }, | 14 | }, |
13 | { | 15 | { |
14 | "duration": 10, | 16 | "duration": 10, |
17 | "timeline": 0, | ||
15 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00003.ts" | 18 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00003.ts" |
16 | }, | 19 | }, |
17 | { | 20 | { |
18 | "duration": 10, | 21 | "duration": 10, |
22 | "timeline": 0, | ||
19 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00004.ts" | 23 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00004.ts" |
20 | }, | 24 | }, |
21 | { | 25 | { |
22 | "duration": 10, | 26 | "duration": 10, |
27 | "timeline": 0, | ||
23 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00005.ts" | 28 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00005.ts" |
24 | }, | 29 | }, |
25 | { | 30 | { |
26 | "duration": 8, | 31 | "duration": 8, |
32 | "timeline": 0, | ||
27 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00006.ts" | 33 | "uri": "/test/ts-files/zencoder/haze/Haze_Mantel_President_encoded_1200-00006.ts" |
28 | } | 34 | } |
29 | ], | 35 | ], |
... | @@ -31,4 +37,4 @@ | ... | @@ -31,4 +37,4 @@ |
31 | "endList": true, | 37 | "endList": true, |
32 | "discontinuitySequence": 0, | 38 | "discontinuitySequence": 0, |
33 | "discontinuityStarts": [] | 39 | "discontinuityStarts": [] |
34 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
40 | } | ... | ... |
... | @@ -9,6 +9,7 @@ | ... | @@ -9,6 +9,7 @@ |
9 | "offset": 0 | 9 | "offset": 0 |
10 | }, | 10 | }, |
11 | "duration": 10, | 11 | "duration": 10, |
12 | "timeline": 0, | ||
12 | "uri": "hls_450k_video.ts" | 13 | "uri": "hls_450k_video.ts" |
13 | }, | 14 | }, |
14 | { | 15 | { |
... | @@ -17,6 +18,7 @@ | ... | @@ -17,6 +18,7 @@ |
17 | "offset": 522828 | 18 | "offset": 522828 |
18 | }, | 19 | }, |
19 | "duration": 10, | 20 | "duration": 10, |
21 | "timeline": 0, | ||
20 | "uri": "hls_450k_video.ts" | 22 | "uri": "hls_450k_video.ts" |
21 | }, | 23 | }, |
22 | { | 24 | { |
... | @@ -25,6 +27,7 @@ | ... | @@ -25,6 +27,7 @@ |
25 | "offset": 1110328 | 27 | "offset": 1110328 |
26 | }, | 28 | }, |
27 | "duration": 10, | 29 | "duration": 10, |
30 | "timeline": 0, | ||
28 | "uri": "hls_450k_video.ts" | 31 | "uri": "hls_450k_video.ts" |
29 | }, | 32 | }, |
30 | { | 33 | { |
... | @@ -33,6 +36,7 @@ | ... | @@ -33,6 +36,7 @@ |
33 | "offset": 1823412 | 36 | "offset": 1823412 |
34 | }, | 37 | }, |
35 | "duration": 10, | 38 | "duration": 10, |
39 | "timeline": 0, | ||
36 | "uri": "hls_450k_video.ts" | 40 | "uri": "hls_450k_video.ts" |
37 | }, | 41 | }, |
38 | { | 42 | { |
... | @@ -41,6 +45,7 @@ | ... | @@ -41,6 +45,7 @@ |
41 | "offset": 2299992 | 45 | "offset": 2299992 |
42 | }, | 46 | }, |
43 | "duration": 10, | 47 | "duration": 10, |
48 | "timeline": 0, | ||
44 | "uri": "hls_450k_video.ts" | 49 | "uri": "hls_450k_video.ts" |
45 | }, | 50 | }, |
46 | { | 51 | { |
... | @@ -49,6 +54,7 @@ | ... | @@ -49,6 +54,7 @@ |
49 | "offset": 2835604 | 54 | "offset": 2835604 |
50 | }, | 55 | }, |
51 | "duration": 10, | 56 | "duration": 10, |
57 | "timeline": 0, | ||
52 | "uri": "hls_450k_video.ts" | 58 | "uri": "hls_450k_video.ts" |
53 | }, | 59 | }, |
54 | { | 60 | { |
... | @@ -57,6 +63,7 @@ | ... | @@ -57,6 +63,7 @@ |
57 | "offset": 3042780 | 63 | "offset": 3042780 |
58 | }, | 64 | }, |
59 | "duration": 10, | 65 | "duration": 10, |
66 | "timeline": 0, | ||
60 | "uri": "hls_450k_video.ts" | 67 | "uri": "hls_450k_video.ts" |
61 | }, | 68 | }, |
62 | { | 69 | { |
... | @@ -65,6 +72,7 @@ | ... | @@ -65,6 +72,7 @@ |
65 | "offset": 3498680 | 72 | "offset": 3498680 |
66 | }, | 73 | }, |
67 | "duration": 10, | 74 | "duration": 10, |
75 | "timeline": 0, | ||
68 | "uri": "hls_450k_video.ts" | 76 | "uri": "hls_450k_video.ts" |
69 | }, | 77 | }, |
70 | { | 78 | { |
... | @@ -73,6 +81,7 @@ | ... | @@ -73,6 +81,7 @@ |
73 | "offset": 4155928 | 81 | "offset": 4155928 |
74 | }, | 82 | }, |
75 | "duration": 10, | 83 | "duration": 10, |
84 | "timeline": 0, | ||
76 | "uri": "hls_450k_video.ts" | 85 | "uri": "hls_450k_video.ts" |
77 | }, | 86 | }, |
78 | { | 87 | { |
... | @@ -81,6 +90,7 @@ | ... | @@ -81,6 +90,7 @@ |
81 | "offset": 4727636 | 90 | "offset": 4727636 |
82 | }, | 91 | }, |
83 | "duration": 10, | 92 | "duration": 10, |
93 | "timeline": 0, | ||
84 | "uri": "hls_450k_video.ts" | 94 | "uri": "hls_450k_video.ts" |
85 | }, | 95 | }, |
86 | { | 96 | { |
... | @@ -89,6 +99,7 @@ | ... | @@ -89,6 +99,7 @@ |
89 | "offset": 5212676 | 99 | "offset": 5212676 |
90 | }, | 100 | }, |
91 | "duration": 10, | 101 | "duration": 10, |
102 | "timeline": 0, | ||
92 | "uri": "hls_450k_video.ts" | 103 | "uri": "hls_450k_video.ts" |
93 | }, | 104 | }, |
94 | { | 105 | { |
... | @@ -97,6 +108,7 @@ | ... | @@ -97,6 +108,7 @@ |
97 | "offset": 5921812 | 108 | "offset": 5921812 |
98 | }, | 109 | }, |
99 | "duration": 10, | 110 | "duration": 10, |
111 | "timeline": 0, | ||
100 | "uri": "hls_450k_video.ts" | 112 | "uri": "hls_450k_video.ts" |
101 | }, | 113 | }, |
102 | { | 114 | { |
... | @@ -105,6 +117,7 @@ | ... | @@ -105,6 +117,7 @@ |
105 | "offset": 6651816 | 117 | "offset": 6651816 |
106 | }, | 118 | }, |
107 | "duration": 10, | 119 | "duration": 10, |
120 | "timeline": 0, | ||
108 | "uri": "hls_450k_video.ts" | 121 | "uri": "hls_450k_video.ts" |
109 | }, | 122 | }, |
110 | { | 123 | { |
... | @@ -113,6 +126,7 @@ | ... | @@ -113,6 +126,7 @@ |
113 | "offset": 7108092 | 126 | "offset": 7108092 |
114 | }, | 127 | }, |
115 | "duration": 10, | 128 | "duration": 10, |
129 | "timeline": 0, | ||
116 | "uri": "hls_450k_video.ts" | 130 | "uri": "hls_450k_video.ts" |
117 | }, | 131 | }, |
118 | { | 132 | { |
... | @@ -121,6 +135,7 @@ | ... | @@ -121,6 +135,7 @@ |
121 | "offset": 7576776 | 135 | "offset": 7576776 |
122 | }, | 136 | }, |
123 | "duration": 10, | 137 | "duration": 10, |
138 | "timeline": 0, | ||
124 | "uri": "hls_450k_video.ts" | 139 | "uri": "hls_450k_video.ts" |
125 | }, | 140 | }, |
126 | { | 141 | { |
... | @@ -129,6 +144,7 @@ | ... | @@ -129,6 +144,7 @@ |
129 | "offset": 8021772 | 144 | "offset": 8021772 |
130 | }, | 145 | }, |
131 | "duration": 10, | 146 | "duration": 10, |
147 | "timeline": 0, | ||
132 | "uri": "hls_450k_video.ts" | 148 | "uri": "hls_450k_video.ts" |
133 | }, | 149 | }, |
134 | { | 150 | { |
... | @@ -137,10 +153,11 @@ | ... | @@ -137,10 +153,11 @@ |
137 | "offset": 8353216 | 153 | "offset": 8353216 |
138 | }, | 154 | }, |
139 | "duration": 1.4167, | 155 | "duration": 1.4167, |
156 | "timeline": 0, | ||
140 | "uri": "hls_450k_video.ts" | 157 | "uri": "hls_450k_video.ts" |
141 | } | 158 | } |
142 | ], | 159 | ], |
143 | "endList": true, | 160 | "endList": true, |
144 | "discontinuitySequence": 0, | 161 | "discontinuitySequence": 0, |
145 | "discontinuityStarts": [] | 162 | "discontinuityStarts": [] |
146 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
163 | } | ... | ... |
... | @@ -5,18 +5,21 @@ | ... | @@ -5,18 +5,21 @@ |
5 | "segments": [ | 5 | "segments": [ |
6 | { | 6 | { |
7 | "duration": 6.64, | 7 | "duration": 6.64, |
8 | "timeline": 0, | ||
8 | "uri": "/test/ts-files/tvy7/8a5e2822668b5370f4eb1438b2564fb7ab12ffe1-hi720.ts" | 9 | "uri": "/test/ts-files/tvy7/8a5e2822668b5370f4eb1438b2564fb7ab12ffe1-hi720.ts" |
9 | }, | 10 | }, |
10 | { | 11 | { |
11 | "duration": 8, | 12 | "duration": 8, |
13 | "timeline": 0, | ||
12 | "uri": "/test/ts-files/tvy7/56be1cef869a1c0cc8e38864ad1add17d187f051-hi720.ts" | 14 | "uri": "/test/ts-files/tvy7/56be1cef869a1c0cc8e38864ad1add17d187f051-hi720.ts" |
13 | }, | 15 | }, |
14 | { | 16 | { |
15 | "duration": 8, | 17 | "duration": 8, |
18 | "timeline": 0, | ||
16 | "uri": "/test/ts-files/tvy7/549c8c77f55f049741a06596e5c1e01dacaa46d0-hi720.ts" | 19 | "uri": "/test/ts-files/tvy7/549c8c77f55f049741a06596e5c1e01dacaa46d0-hi720.ts" |
17 | } | 20 | } |
18 | ], | 21 | ], |
19 | "targetDuration": 8, | 22 | "targetDuration": 8, |
20 | "discontinuitySequence": 0, | 23 | "discontinuitySequence": 0, |
21 | "discontinuityStarts": [] | 24 | "discontinuityStarts": [] |
22 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
25 | } | ... | ... |
... | @@ -4,42 +4,51 @@ | ... | @@ -4,42 +4,51 @@ |
4 | "segments": [ | 4 | "segments": [ |
5 | { | 5 | { |
6 | "duration": 10, | 6 | "duration": 10, |
7 | "timeline": 0, | ||
7 | "uri": "001.ts" | 8 | "uri": "001.ts" |
8 | }, | 9 | }, |
9 | { | 10 | { |
10 | "duration": 19, | 11 | "duration": 19, |
12 | "timeline": 0, | ||
11 | "uri": "002.ts" | 13 | "uri": "002.ts" |
12 | }, | 14 | }, |
13 | { | 15 | { |
14 | "duration": 10, | 16 | "duration": 10, |
17 | "timeline": 0, | ||
15 | "uri": "003.ts" | 18 | "uri": "003.ts" |
16 | }, | 19 | }, |
17 | { | 20 | { |
18 | "duration": 11, | 21 | "duration": 11, |
22 | "timeline": 0, | ||
19 | "uri": "004.ts" | 23 | "uri": "004.ts" |
20 | }, | 24 | }, |
21 | { | 25 | { |
22 | "duration": 10, | 26 | "duration": 10, |
27 | "timeline": 0, | ||
23 | "uri": "005.ts" | 28 | "uri": "005.ts" |
24 | }, | 29 | }, |
25 | { | 30 | { |
26 | "duration": 10, | 31 | "duration": 10, |
32 | "timeline": 0, | ||
27 | "uri": "006.ts" | 33 | "uri": "006.ts" |
28 | }, | 34 | }, |
29 | { | 35 | { |
30 | "duration": 10, | 36 | "duration": 10, |
37 | "timeline": 0, | ||
31 | "uri": "007.ts" | 38 | "uri": "007.ts" |
32 | }, | 39 | }, |
33 | { | 40 | { |
34 | "duration": 10, | 41 | "duration": 10, |
42 | "timeline": 0, | ||
35 | "uri": "008.ts" | 43 | "uri": "008.ts" |
36 | }, | 44 | }, |
37 | { | 45 | { |
38 | "duration": 16, | 46 | "duration": 16, |
47 | "timeline": 0, | ||
39 | "uri": "009.ts" | 48 | "uri": "009.ts" |
40 | } | 49 | } |
41 | ], | 50 | ], |
42 | "targetDuration": 10, | 51 | "targetDuration": 10, |
43 | "discontinuitySequence": 0, | 52 | "discontinuitySequence": 0, |
44 | "discontinuityStarts": [] | 53 | "discontinuityStarts": [] |
45 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
54 | } | ... | ... |
... | @@ -4,10 +4,11 @@ | ... | @@ -4,10 +4,11 @@ |
4 | "segments": [ | 4 | "segments": [ |
5 | { | 5 | { |
6 | "duration": 10, | 6 | "duration": 10, |
7 | "timeline": 0, | ||
7 | "uri": "/test/ts-files/zencoder/gogo/00001.ts" | 8 | "uri": "/test/ts-files/zencoder/gogo/00001.ts" |
8 | } | 9 | } |
9 | ], | 10 | ], |
10 | "endList": true, | 11 | "endList": true, |
11 | "discontinuitySequence": 0, | 12 | "discontinuitySequence": 0, |
12 | "discontinuityStarts": [] | 13 | "discontinuityStarts": [] |
13 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
14 | } | ... | ... |
... | @@ -4,22 +4,27 @@ | ... | @@ -4,22 +4,27 @@ |
4 | "segments": [ | 4 | "segments": [ |
5 | { | 5 | { |
6 | "duration": 10, | 6 | "duration": 10, |
7 | "timeline": 0, | ||
7 | "uri": "/test/ts-files/zencoder/gogo/00001.ts" | 8 | "uri": "/test/ts-files/zencoder/gogo/00001.ts" |
8 | }, | 9 | }, |
9 | { | 10 | { |
10 | "duration": 10, | 11 | "duration": 10, |
12 | "timeline": 0, | ||
11 | "uri": "/test/ts-files/zencoder/gogo/00002.ts" | 13 | "uri": "/test/ts-files/zencoder/gogo/00002.ts" |
12 | }, | 14 | }, |
13 | { | 15 | { |
14 | "duration": 10, | 16 | "duration": 10, |
17 | "timeline": 0, | ||
15 | "uri": "/test/ts-files/zencoder/gogo/00003.ts" | 18 | "uri": "/test/ts-files/zencoder/gogo/00003.ts" |
16 | }, | 19 | }, |
17 | { | 20 | { |
18 | "duration": 10, | 21 | "duration": 10, |
22 | "timeline": 0, | ||
19 | "uri": "/test/ts-files/zencoder/gogo/00004.ts" | 23 | "uri": "/test/ts-files/zencoder/gogo/00004.ts" |
20 | }, | 24 | }, |
21 | { | 25 | { |
22 | "duration": 10, | 26 | "duration": 10, |
27 | "timeline": 0, | ||
23 | "uri": "/test/ts-files/zencoder/gogo/00005.ts" | 28 | "uri": "/test/ts-files/zencoder/gogo/00005.ts" |
24 | } | 29 | } |
25 | ], | 30 | ], |
... | @@ -27,4 +32,4 @@ | ... | @@ -27,4 +32,4 @@ |
27 | "endList": true, | 32 | "endList": true, |
28 | "discontinuitySequence": 0, | 33 | "discontinuitySequence": 0, |
29 | "discontinuityStarts": [] | 34 | "discontinuityStarts": [] |
30 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
35 | } | ... | ... |
... | @@ -4,6 +4,7 @@ | ... | @@ -4,6 +4,7 @@ |
4 | "segments": [ | 4 | "segments": [ |
5 | { | 5 | { |
6 | "duration": 10, | 6 | "duration": 10, |
7 | "timeline": 0, | ||
7 | "uri": "/test/ts-files/zencoder/gogo/00001.ts" | 8 | "uri": "/test/ts-files/zencoder/gogo/00001.ts" |
8 | } | 9 | } |
9 | ], | 10 | ], |
... | @@ -11,4 +12,4 @@ | ... | @@ -11,4 +12,4 @@ |
11 | "endList": true, | 12 | "endList": true, |
12 | "discontinuitySequence": 0, | 13 | "discontinuitySequence": 0, |
13 | "discontinuityStarts": [] | 14 | "discontinuityStarts": [] |
14 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
15 | } | ... | ... |
... | @@ -10,6 +10,7 @@ | ... | @@ -10,6 +10,7 @@ |
10 | "height": 224 | 10 | "height": 224 |
11 | } | 11 | } |
12 | }, | 12 | }, |
13 | "timeline": 0, | ||
13 | "uri": "media.m3u8" | 14 | "uri": "media.m3u8" |
14 | }, | 15 | }, |
15 | { | 16 | { |
... | @@ -17,6 +18,7 @@ | ... | @@ -17,6 +18,7 @@ |
17 | "PROGRAM-ID": 1, | 18 | "PROGRAM-ID": 1, |
18 | "BANDWIDTH": 40000 | 19 | "BANDWIDTH": 40000 |
19 | }, | 20 | }, |
21 | "timeline": 0, | ||
20 | "uri": "media1.m3u8" | 22 | "uri": "media1.m3u8" |
21 | }, | 23 | }, |
22 | { | 24 | { |
... | @@ -28,6 +30,7 @@ | ... | @@ -28,6 +30,7 @@ |
28 | "height": 224 | 30 | "height": 224 |
29 | } | 31 | } |
30 | }, | 32 | }, |
33 | "timeline": 0, | ||
31 | "uri": "media2.m3u8" | 34 | "uri": "media2.m3u8" |
32 | }, | 35 | }, |
33 | { | 36 | { |
... | @@ -39,8 +42,15 @@ | ... | @@ -39,8 +42,15 @@ |
39 | "height": 540 | 42 | "height": 540 |
40 | } | 43 | } |
41 | }, | 44 | }, |
45 | "timeline": 0, | ||
42 | "uri": "media3.m3u8" | 46 | "uri": "media3.m3u8" |
43 | } | 47 | } |
44 | ], | 48 | ], |
45 | "discontinuityStarts": [] | ||
46 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
49 | "discontinuityStarts": [], | ||
50 | "mediaGroups": { | ||
51 | "VIDEO": {}, | ||
52 | "AUDIO": {}, | ||
53 | "CLOSED-CAPTIONS": {}, | ||
54 | "SUBTITLES": {} | ||
55 | } | ||
56 | } | ... | ... |
... | @@ -5,18 +5,22 @@ | ... | @@ -5,18 +5,22 @@ |
5 | "segments": [ | 5 | "segments": [ |
6 | { | 6 | { |
7 | "duration": 10, | 7 | "duration": 10, |
8 | "timeline": 0, | ||
8 | "uri": "media-00001.ts" | 9 | "uri": "media-00001.ts" |
9 | }, | 10 | }, |
10 | { | 11 | { |
11 | "duration": 10, | 12 | "duration": 10, |
13 | "timeline": 0, | ||
12 | "uri": "media-00002.ts" | 14 | "uri": "media-00002.ts" |
13 | }, | 15 | }, |
14 | { | 16 | { |
15 | "duration": 10, | 17 | "duration": 10, |
18 | "timeline": 0, | ||
16 | "uri": "media-00003.ts" | 19 | "uri": "media-00003.ts" |
17 | }, | 20 | }, |
18 | { | 21 | { |
19 | "duration": 10, | 22 | "duration": 10, |
23 | "timeline": 0, | ||
20 | "uri": "media-00004.ts" | 24 | "uri": "media-00004.ts" |
21 | } | 25 | } |
22 | ], | 26 | ], |
... | @@ -24,4 +28,4 @@ | ... | @@ -24,4 +28,4 @@ |
24 | "endList": true, | 28 | "endList": true, |
25 | "discontinuitySequence": 0, | 29 | "discontinuitySequence": 0, |
26 | "discontinuityStarts": [] | 30 | "discontinuityStarts": [] |
27 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
31 | } | ... | ... |
... | @@ -5,18 +5,22 @@ | ... | @@ -5,18 +5,22 @@ |
5 | "segments": [ | 5 | "segments": [ |
6 | { | 6 | { |
7 | "duration": 6.64, | 7 | "duration": 6.64, |
8 | "timeline": 0, | ||
8 | "uri": "/test/ts-files/tvy7/8a5e2822668b5370f4eb1438b2564fb7ab12ffe1-hi720.ts" | 9 | "uri": "/test/ts-files/tvy7/8a5e2822668b5370f4eb1438b2564fb7ab12ffe1-hi720.ts" |
9 | }, | 10 | }, |
10 | { | 11 | { |
11 | "duration": 6.08, | 12 | "duration": 6.08, |
13 | "timeline": 0, | ||
12 | "uri": "/test/ts-files/tvy7/56be1cef869a1c0cc8e38864ad1add17d187f051-hi720.ts" | 14 | "uri": "/test/ts-files/tvy7/56be1cef869a1c0cc8e38864ad1add17d187f051-hi720.ts" |
13 | }, | 15 | }, |
14 | { | 16 | { |
15 | "duration": 6.6, | 17 | "duration": 6.6, |
18 | "timeline": 0, | ||
16 | "uri": "/test/ts-files/tvy7/549c8c77f55f049741a06596e5c1e01dacaa46d0-hi720.ts" | 19 | "uri": "/test/ts-files/tvy7/549c8c77f55f049741a06596e5c1e01dacaa46d0-hi720.ts" |
17 | }, | 20 | }, |
18 | { | 21 | { |
19 | "duration": 5, | 22 | "duration": 5, |
23 | "timeline": 0, | ||
20 | "uri": "/test/ts-files/tvy7/6cfa378684ffeb1c455a64dae6c103290a1f53d4-hi720.ts" | 24 | "uri": "/test/ts-files/tvy7/6cfa378684ffeb1c455a64dae6c103290a1f53d4-hi720.ts" |
21 | } | 25 | } |
22 | ], | 26 | ], |
... | @@ -24,4 +28,4 @@ | ... | @@ -24,4 +28,4 @@ |
24 | "endList": true, | 28 | "endList": true, |
25 | "discontinuitySequence": 0, | 29 | "discontinuitySequence": 0, |
26 | "discontinuityStarts": [] | 30 | "discontinuityStarts": [] |
27 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
31 | } | ... | ... |
... | @@ -4,14 +4,16 @@ | ... | @@ -4,14 +4,16 @@ |
4 | "segments": [ | 4 | "segments": [ |
5 | { | 5 | { |
6 | "duration": 10, | 6 | "duration": 10, |
7 | "timeline": 0, | ||
7 | "uri": "00001.ts" | 8 | "uri": "00001.ts" |
8 | }, | 9 | }, |
9 | { | 10 | { |
10 | "duration": 10, | 11 | "duration": 10, |
12 | "timeline": 0, | ||
11 | "uri": "00002.ts" | 13 | "uri": "00002.ts" |
12 | } | 14 | } |
13 | ], | 15 | ], |
14 | "targetDuration": 10, | 16 | "targetDuration": 10, |
15 | "discontinuitySequence": 0, | 17 | "discontinuitySequence": 0, |
16 | "discontinuityStarts": [] | 18 | "discontinuityStarts": [] |
17 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
19 | } | ... | ... |
... | @@ -5,14 +5,17 @@ | ... | @@ -5,14 +5,17 @@ |
5 | "segments": [ | 5 | "segments": [ |
6 | { | 6 | { |
7 | "duration": 10, | 7 | "duration": 10, |
8 | "timeline": 0, | ||
8 | "uri": "hls_450k_video.ts" | 9 | "uri": "hls_450k_video.ts" |
9 | }, | 10 | }, |
10 | { | 11 | { |
11 | "duration": 10, | 12 | "duration": 10, |
13 | "timeline": 0, | ||
12 | "uri": "hls_450k_video.ts" | 14 | "uri": "hls_450k_video.ts" |
13 | }, | 15 | }, |
14 | { | 16 | { |
15 | "duration": 10, | 17 | "duration": 10, |
18 | "timeline": 0, | ||
16 | "uri": "hls_450k_video.ts" | 19 | "uri": "hls_450k_video.ts" |
17 | } | 20 | } |
18 | ], | 21 | ], |
... | @@ -20,4 +23,4 @@ | ... | @@ -20,4 +23,4 @@ |
20 | "endList": true, | 23 | "endList": true, |
21 | "discontinuitySequence": 0, | 24 | "discontinuitySequence": 0, |
22 | "discontinuityStarts": [] | 25 | "discontinuityStarts": [] |
23 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
26 | } | ... | ... |
... | @@ -5,18 +5,22 @@ | ... | @@ -5,18 +5,22 @@ |
5 | "segments": [ | 5 | "segments": [ |
6 | { | 6 | { |
7 | "duration": 6.64, | 7 | "duration": 6.64, |
8 | "timeline": 0, | ||
8 | "uri": "/test/ts-files/tvy7/8a5e2822668b5370f4eb1438b2564fb7ab12ffe1-hi720.ts" | 9 | "uri": "/test/ts-files/tvy7/8a5e2822668b5370f4eb1438b2564fb7ab12ffe1-hi720.ts" |
9 | }, | 10 | }, |
10 | { | 11 | { |
11 | "duration": 6.08, | 12 | "duration": 6.08, |
13 | "timeline": 0, | ||
12 | "uri": "/test/ts-files/tvy7/56be1cef869a1c0cc8e38864ad1add17d187f051-hi720.ts" | 14 | "uri": "/test/ts-files/tvy7/56be1cef869a1c0cc8e38864ad1add17d187f051-hi720.ts" |
13 | }, | 15 | }, |
14 | { | 16 | { |
15 | "duration": 6.6, | 17 | "duration": 6.6, |
18 | "timeline": 0, | ||
16 | "uri": "/test/ts-files/tvy7/549c8c77f55f049741a06596e5c1e01dacaa46d0-hi720.ts" | 19 | "uri": "/test/ts-files/tvy7/549c8c77f55f049741a06596e5c1e01dacaa46d0-hi720.ts" |
17 | }, | 20 | }, |
18 | { | 21 | { |
19 | "duration": 5, | 22 | "duration": 5, |
23 | "timeline": 0, | ||
20 | "uri": "/test/ts-files/tvy7/6cfa378684ffeb1c455a64dae6c103290a1f53d4-hi720.ts" | 24 | "uri": "/test/ts-files/tvy7/6cfa378684ffeb1c455a64dae6c103290a1f53d4-hi720.ts" |
21 | } | 25 | } |
22 | ], | 26 | ], |
... | @@ -24,4 +28,4 @@ | ... | @@ -24,4 +28,4 @@ |
24 | "endList": true, | 28 | "endList": true, |
25 | "discontinuitySequence": 0, | 29 | "discontinuitySequence": 0, |
26 | "discontinuityStarts": [] | 30 | "discontinuityStarts": [] |
27 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
31 | } | ... | ... |
... | @@ -5,18 +5,22 @@ | ... | @@ -5,18 +5,22 @@ |
5 | "segments": [ | 5 | "segments": [ |
6 | { | 6 | { |
7 | "duration": 6.64, | 7 | "duration": 6.64, |
8 | "timeline": 0, | ||
8 | "uri": "/test/ts-files/tvy7/8a5e2822668b5370f4eb1438b2564fb7ab12ffe1-hi720.ts" | 9 | "uri": "/test/ts-files/tvy7/8a5e2822668b5370f4eb1438b2564fb7ab12ffe1-hi720.ts" |
9 | }, | 10 | }, |
10 | { | 11 | { |
11 | "duration": 8, | 12 | "duration": 8, |
13 | "timeline": 0, | ||
12 | "uri": "/test/ts-files/tvy7/56be1cef869a1c0cc8e38864ad1add17d187f051-hi720.ts" | 14 | "uri": "/test/ts-files/tvy7/56be1cef869a1c0cc8e38864ad1add17d187f051-hi720.ts" |
13 | }, | 15 | }, |
14 | { | 16 | { |
15 | "duration": 8, | 17 | "duration": 8, |
18 | "timeline": 0, | ||
16 | "uri": "/test/ts-files/tvy7/549c8c77f55f049741a06596e5c1e01dacaa46d0-hi720.ts" | 19 | "uri": "/test/ts-files/tvy7/549c8c77f55f049741a06596e5c1e01dacaa46d0-hi720.ts" |
17 | }, | 20 | }, |
18 | { | 21 | { |
19 | "duration": 8, | 22 | "duration": 8, |
23 | "timeline": 0, | ||
20 | "uri": "/test/ts-files/tvy7/6cfa378684ffeb1c455a64dae6c103290a1f53d4-hi720.ts" | 24 | "uri": "/test/ts-files/tvy7/6cfa378684ffeb1c455a64dae6c103290a1f53d4-hi720.ts" |
21 | } | 25 | } |
22 | ], | 26 | ], |
... | @@ -24,4 +28,4 @@ | ... | @@ -24,4 +28,4 @@ |
24 | "endList": true, | 28 | "endList": true, |
25 | "discontinuitySequence": 0, | 29 | "discontinuitySequence": 0, |
26 | "discontinuityStarts": [] | 30 | "discontinuityStarts": [] |
27 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
31 | } | ... | ... |
utils/manifest/multipleAudioGroups.js
0 → 100644
1 | { | ||
2 | allowCache: true, | ||
3 | discontinuityStarts: [], | ||
4 | mediaGroups: { | ||
5 | AUDIO: { | ||
6 | "audio-lo": { | ||
7 | "English": { | ||
8 | autoselect: true, | ||
9 | default: true, | ||
10 | language: "eng", | ||
11 | uri: "englo/prog_index.m3u8" | ||
12 | }, | ||
13 | "Français": { | ||
14 | autoselect: true, | ||
15 | default: false, | ||
16 | language: "fre", | ||
17 | uri: "frelo/prog_index.m3u8" | ||
18 | }, | ||
19 | "Espanol": { | ||
20 | autoselect: true, | ||
21 | default: false, | ||
22 | language: "sp", | ||
23 | uri: "splo/prog_index.m3u8" | ||
24 | } | ||
25 | }, | ||
26 | "audio-hi": { | ||
27 | "English": { | ||
28 | autoselect: true, | ||
29 | default: true, | ||
30 | language: "eng", | ||
31 | uri: "eng/prog_index.m3u8" | ||
32 | }, | ||
33 | "Français": { | ||
34 | autoselect: true, | ||
35 | default: false, | ||
36 | language: "fre", | ||
37 | uri: "fre/prog_index.m3u8" | ||
38 | }, | ||
39 | "Espanol": { | ||
40 | autoselect: true, | ||
41 | default: false, | ||
42 | language: "sp", | ||
43 | uri: "sp/prog_index.m3u8" | ||
44 | } | ||
45 | } | ||
46 | }, | ||
47 | VIDEO: {}, | ||
48 | "CLOSED-CAPTIONS": {}, | ||
49 | SUBTITLES: {} | ||
50 | }, | ||
51 | playlists: [{ | ||
52 | attributes: { | ||
53 | "PROGRAM-ID": 1, | ||
54 | BANDWIDTH: 195023, | ||
55 | CODECS: "mp4a.40.5", | ||
56 | AUDIO: "audio-lo", | ||
57 | }, | ||
58 | timeline: 0, | ||
59 | uri: "lo/prog_index.m3u8" | ||
60 | }, { | ||
61 | attributes: { | ||
62 | "PROGRAM-ID": 1, | ||
63 | BANDWIDTH: 260000, | ||
64 | CODECS: "avc1.42e01e,mp4a.40.2", | ||
65 | AUDIO: "audio-lo" | ||
66 | }, | ||
67 | timeline: 0, | ||
68 | uri: "lo2/prog_index.m3u8" | ||
69 | }, { | ||
70 | attributes: { | ||
71 | "PROGRAM-ID": 1, | ||
72 | BANDWIDTH: 591680, | ||
73 | CODECS: "mp4a.40.2, avc1.64001e", | ||
74 | AUDIO: "audio-hi" | ||
75 | }, | ||
76 | timeline: 0, | ||
77 | uri: "hi/prog_index.m3u8" | ||
78 | }, { | ||
79 | attributes: { | ||
80 | "PROGRAM-ID": 1, | ||
81 | BANDWIDTH: 650000, | ||
82 | CODECS: "avc1.42e01e,mp4a.40.2", | ||
83 | AUDIO: "audio-hi" | ||
84 | }, | ||
85 | timeline: 0, | ||
86 | uri: "hi2/prog_index.m3u8" | ||
87 | }] | ||
88 | } |
utils/manifest/multipleAudioGroups.m3u8
0 → 100644
1 | #EXTM3U | ||
2 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-lo",LANGUAGE="eng",NAME="English",AUTOSELECT=YES, DEFAULT=YES,URI="englo/prog_index.m3u8" | ||
3 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-lo",LANGUAGE="fre",NAME="Français",AUTOSELECT=YES, DEFAULT=NO,URI="frelo/prog_index.m3u8" | ||
4 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-lo",LANGUAGE="sp",NAME="Espanol",AUTOSELECT=YES, DEFAULT=NO,URI="splo/prog_index.m3u8" | ||
5 | |||
6 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-hi",LANGUAGE="eng",NAME="English",AUTOSELECT=YES, DEFAULT=YES,URI="eng/prog_index.m3u8" | ||
7 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-hi",LANGUAGE="fre",NAME="Français",AUTOSELECT=YES, DEFAULT=NO,URI="fre/prog_index.m3u8" | ||
8 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-hi",LANGUAGE="sp",NAME="Espanol",AUTOSELECT=YES, DEFAULT=NO,URI="sp/prog_index.m3u8" | ||
9 | |||
10 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=195023,CODECS="mp4a.40.5", AUDIO="audio-lo" | ||
11 | lo/prog_index.m3u8 | ||
12 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=260000,CODECS="avc1.42e01e,mp4a.40.2", AUDIO="audio-lo" | ||
13 | lo2/prog_index.m3u8 | ||
14 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=591680,CODECS="mp4a.40.2, avc1.64001e", AUDIO="audio-hi" | ||
15 | hi/prog_index.m3u8 | ||
16 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=650000,CODECS="avc1.42e01e,mp4a.40.2", AUDIO="audio-hi" | ||
17 | hi2/prog_index.m3u8 |
1 | { | ||
2 | allowCache: true, | ||
3 | discontinuityStarts: [], | ||
4 | mediaGroups: { | ||
5 | AUDIO: { | ||
6 | "audio-lo": { | ||
7 | "English": { | ||
8 | autoselect: true, | ||
9 | default: true, | ||
10 | language: "eng", | ||
11 | }, | ||
12 | "Français": { | ||
13 | autoselect: true, | ||
14 | default: false, | ||
15 | language: "fre", | ||
16 | uri: "frelo/prog_index.m3u8" | ||
17 | }, | ||
18 | "Espanol": { | ||
19 | autoselect: true, | ||
20 | default: false, | ||
21 | language: "sp", | ||
22 | uri: "splo/prog_index.m3u8" | ||
23 | } | ||
24 | }, | ||
25 | "audio-hi": { | ||
26 | "English": { | ||
27 | autoselect: true, | ||
28 | default: true, | ||
29 | language: "eng", | ||
30 | uri: "eng/prog_index.m3u8" | ||
31 | }, | ||
32 | "Français": { | ||
33 | autoselect: true, | ||
34 | default: false, | ||
35 | language: "fre", | ||
36 | uri: "fre/prog_index.m3u8" | ||
37 | }, | ||
38 | "Espanol": { | ||
39 | autoselect: true, | ||
40 | default: false, | ||
41 | language: "sp", | ||
42 | uri: "sp/prog_index.m3u8" | ||
43 | } | ||
44 | } | ||
45 | }, | ||
46 | VIDEO: {}, | ||
47 | "CLOSED-CAPTIONS": {}, | ||
48 | SUBTITLES: {} | ||
49 | }, | ||
50 | playlists: [{ | ||
51 | attributes: { | ||
52 | "PROGRAM-ID": 1, | ||
53 | BANDWIDTH: 195023, | ||
54 | CODECS: "mp4a.40.5", | ||
55 | AUDIO: "audio-lo", | ||
56 | }, | ||
57 | timeline: 0, | ||
58 | uri: "lo/prog_index.m3u8" | ||
59 | }, { | ||
60 | attributes: { | ||
61 | "PROGRAM-ID": 1, | ||
62 | BANDWIDTH: 260000, | ||
63 | CODECS: "avc1.42e01e,mp4a.40.2", | ||
64 | AUDIO: "audio-lo" | ||
65 | }, | ||
66 | timeline: 0, | ||
67 | uri: "lo2/prog_index.m3u8" | ||
68 | }, { | ||
69 | attributes: { | ||
70 | "PROGRAM-ID": 1, | ||
71 | BANDWIDTH: 591680, | ||
72 | CODECS: "mp4a.40.2, avc1.64001e", | ||
73 | AUDIO: "audio-hi" | ||
74 | }, | ||
75 | timeline: 0, | ||
76 | uri: "hi/prog_index.m3u8" | ||
77 | }, { | ||
78 | attributes: { | ||
79 | "PROGRAM-ID": 1, | ||
80 | BANDWIDTH: 650000, | ||
81 | CODECS: "avc1.42e01e,mp4a.40.2", | ||
82 | AUDIO: "audio-hi" | ||
83 | }, | ||
84 | timeline: 0, | ||
85 | uri: "hi2/prog_index.m3u8" | ||
86 | }] | ||
87 | } |
1 | #EXTM3U | ||
2 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-lo",LANGUAGE="eng",NAME="English",AUTOSELECT=YES, DEFAULT=YES | ||
3 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-lo",LANGUAGE="fre",NAME="Français",AUTOSELECT=YES, DEFAULT=NO,URI="frelo/prog_index.m3u8" | ||
4 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-lo",LANGUAGE="sp",NAME="Espanol",AUTOSELECT=YES, DEFAULT=NO,URI="splo/prog_index.m3u8" | ||
5 | |||
6 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-hi",LANGUAGE="eng",NAME="English",AUTOSELECT=YES, DEFAULT=YES,URI="eng/prog_index.m3u8" | ||
7 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-hi",LANGUAGE="fre",NAME="Français",AUTOSELECT=YES, DEFAULT=NO,URI="fre/prog_index.m3u8" | ||
8 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-hi",LANGUAGE="sp",NAME="Espanol",AUTOSELECT=YES, DEFAULT=NO,URI="sp/prog_index.m3u8" | ||
9 | |||
10 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=195023,CODECS="mp4a.40.5", AUDIO="audio-lo" | ||
11 | lo/prog_index.m3u8 | ||
12 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=260000,CODECS="avc1.42e01e,mp4a.40.2", AUDIO="audio-lo" | ||
13 | lo2/prog_index.m3u8 | ||
14 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=591680,CODECS="mp4a.40.2, avc1.64001e", AUDIO="audio-hi" | ||
15 | hi/prog_index.m3u8 | ||
16 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=650000,CODECS="avc1.42e01e,mp4a.40.2", AUDIO="audio-hi" | ||
17 | hi2/prog_index.m3u8 |
... | @@ -4,21 +4,25 @@ | ... | @@ -4,21 +4,25 @@ |
4 | "targetDuration": 10, | 4 | "targetDuration": 10, |
5 | "segments": [ | 5 | "segments": [ |
6 | { | 6 | { |
7 | "uri": "001.ts" | 7 | "uri": "001.ts", |
8 | "timeline": 0 | ||
8 | }, | 9 | }, |
9 | { | 10 | { |
10 | "uri": "002.ts", | 11 | "uri": "002.ts", |
11 | "duration": 9 | 12 | "duration": 9, |
13 | "timeline": 0 | ||
12 | }, | 14 | }, |
13 | { | 15 | { |
14 | "uri": "003.ts", | 16 | "uri": "003.ts", |
15 | "duration": 7 | 17 | "duration": 7, |
18 | "timeline": 0 | ||
16 | }, | 19 | }, |
17 | { | 20 | { |
18 | "uri": "004.ts", | 21 | "uri": "004.ts", |
19 | "duration": 10 | 22 | "duration": 10, |
23 | "timeline": 0 | ||
20 | } | 24 | } |
21 | ], | 25 | ], |
22 | "discontinuitySequence": 0, | 26 | "discontinuitySequence": 0, |
23 | "discontinuityStarts": [] | 27 | "discontinuityStarts": [] |
24 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
28 | } | ... | ... |
utils/manifest/multipleVideo.js
0 → 100644
1 | { | ||
2 | allowCache: true, | ||
3 | discontinuityStarts: [], | ||
4 | mediaGroups: { | ||
5 | AUDIO: { | ||
6 | aac: { | ||
7 | English: { | ||
8 | autoselect: true, | ||
9 | default: true, | ||
10 | language: "eng", | ||
11 | uri: "eng/prog_index.m3u8" | ||
12 | } | ||
13 | } | ||
14 | }, | ||
15 | VIDEO: { | ||
16 | "200kbs": { | ||
17 | Angle1: { | ||
18 | autoselect: true, | ||
19 | default: true | ||
20 | }, | ||
21 | Angle2: { | ||
22 | autoselect: true, | ||
23 | default: false, | ||
24 | uri: "Angle2/200kbs/prog_index.m3u8" | ||
25 | }, | ||
26 | Angle3: { | ||
27 | autoselect: true, | ||
28 | default: false, | ||
29 | uri: "Angle3/200kbs/prog_index.m3u8" | ||
30 | } | ||
31 | }, | ||
32 | "500kbs": { | ||
33 | Angle1: { | ||
34 | autoselect: true, | ||
35 | default: true | ||
36 | }, | ||
37 | Angle2: { | ||
38 | autoselect: true, | ||
39 | default: false, | ||
40 | uri: "Angle2/500kbs/prog_index.m3u8" | ||
41 | }, | ||
42 | Angle3: { | ||
43 | autoselect: true, | ||
44 | default: false, | ||
45 | uri: "Angle3/500kbs/prog_index.m3u8" | ||
46 | } | ||
47 | } | ||
48 | }, | ||
49 | "CLOSED-CAPTIONS": {}, | ||
50 | SUBTITLES: {} | ||
51 | }, | ||
52 | playlists: [{ | ||
53 | attributes: { | ||
54 | "PROGRAM-ID": 1, | ||
55 | BANDWIDTH: 300000, | ||
56 | CODECS: "mp4a.40.2,avc1.4d401e", | ||
57 | AUDIO: "aac", | ||
58 | VIDEO: "200kbs" | ||
59 | }, | ||
60 | timeline: 0, | ||
61 | uri: "Angle1/200kbs/prog_index.m3u" | ||
62 | }, { | ||
63 | attributes: { | ||
64 | "PROGRAM-ID": 1, | ||
65 | BANDWIDTH: 754857, | ||
66 | CODECS: "mp4a.40.2,avc1.4d401e", | ||
67 | AUDIO: "aac", | ||
68 | VIDEO: "500kbs" | ||
69 | }, | ||
70 | timeline: 0, | ||
71 | uri: "Angle1/500kbs/prog_index.m3u8" | ||
72 | }] | ||
73 | } |
utils/manifest/multipleVideo.m3u8
0 → 100644
1 | #EXTM3U | ||
2 | #EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="200kbs",NAME="Angle1",AUTOSELECT=YES,DEFAULT=YES | ||
3 | #EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="200kbs",NAME="Angle2",AUTOSELECT=YES,DEFAULT=NO,URI="Angle2/200kbs/prog_index.m3u8" | ||
4 | #EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="200kbs",NAME="Angle3",AUTOSELECT=YES,DEFAULT=NO,URI="Angle3/200kbs/prog_index.m3u8" | ||
5 | |||
6 | #EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="500kbs",NAME="Angle1",AUTOSELECT=YES,DEFAULT=YES | ||
7 | #EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="500kbs",NAME="Angle2",AUTOSELECT=YES,DEFAULT=NO,URI="Angle2/500kbs/prog_index.m3u8" | ||
8 | #EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="500kbs",NAME="Angle3",AUTOSELECT=YES,DEFAULT=NO,URI="Angle3/500kbs/prog_index.m3u8" | ||
9 | |||
10 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aac",LANGUAGE="eng",NAME="English",AUTOSELECT=YES,DEFAULT=YES,URI="eng/prog_index.m3u8" | ||
11 | |||
12 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=300000,CODECS="mp4a.40.2,avc1.4d401e",VIDEO="200kbs",AUDIO="aac" | ||
13 | Angle1/200kbs/prog_index.m3u | ||
14 | |||
15 | #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=754857,CODECS="mp4a.40.2,avc1.4d401e",VIDEO="500kbs",AUDIO="aac" | ||
16 | Angle1/500kbs/prog_index.m3u8 | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
... | @@ -5,18 +5,22 @@ | ... | @@ -5,18 +5,22 @@ |
5 | "segments": [ | 5 | "segments": [ |
6 | { | 6 | { |
7 | "duration": 6.64, | 7 | "duration": 6.64, |
8 | "timeline": 0, | ||
8 | "uri": "/test/ts-files/tvy7/8a5e2822668b5370f4eb1438b2564fb7ab12ffe1-hi720.ts" | 9 | "uri": "/test/ts-files/tvy7/8a5e2822668b5370f4eb1438b2564fb7ab12ffe1-hi720.ts" |
9 | }, | 10 | }, |
10 | { | 11 | { |
11 | "duration": 6.08, | 12 | "duration": 6.08, |
13 | "timeline": 0, | ||
12 | "uri": "/test/ts-files/tvy7/56be1cef869a1c0cc8e38864ad1add17d187f051-hi720.ts" | 14 | "uri": "/test/ts-files/tvy7/56be1cef869a1c0cc8e38864ad1add17d187f051-hi720.ts" |
13 | }, | 15 | }, |
14 | { | 16 | { |
15 | "duration": 6.6, | 17 | "duration": 6.6, |
18 | "timeline": 0, | ||
16 | "uri": "/test/ts-files/tvy7/549c8c77f55f049741a06596e5c1e01dacaa46d0-hi720.ts" | 19 | "uri": "/test/ts-files/tvy7/549c8c77f55f049741a06596e5c1e01dacaa46d0-hi720.ts" |
17 | }, | 20 | }, |
18 | { | 21 | { |
19 | "duration": 5, | 22 | "duration": 5, |
23 | "timeline": 0, | ||
20 | "uri": "/test/ts-files/tvy7/6cfa378684ffeb1c455a64dae6c103290a1f53d4-hi720.ts" | 24 | "uri": "/test/ts-files/tvy7/6cfa378684ffeb1c455a64dae6c103290a1f53d4-hi720.ts" |
21 | } | 25 | } |
22 | ], | 26 | ], |
... | @@ -24,4 +28,4 @@ | ... | @@ -24,4 +28,4 @@ |
24 | "endList": true, | 28 | "endList": true, |
25 | "discontinuitySequence": 0, | 29 | "discontinuitySequence": 0, |
26 | "discontinuityStarts": [] | 30 | "discontinuityStarts": [] |
27 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
31 | } | ... | ... |
... | @@ -9,6 +9,7 @@ | ... | @@ -9,6 +9,7 @@ |
9 | "offset": 0 | 9 | "offset": 0 |
10 | }, | 10 | }, |
11 | "duration": 10, | 11 | "duration": 10, |
12 | "timeline": 0, | ||
12 | "uri": "hls_450k_video.ts" | 13 | "uri": "hls_450k_video.ts" |
13 | }, | 14 | }, |
14 | { | 15 | { |
... | @@ -17,6 +18,7 @@ | ... | @@ -17,6 +18,7 @@ |
17 | "offset": 522828 | 18 | "offset": 522828 |
18 | }, | 19 | }, |
19 | "duration": 10, | 20 | "duration": 10, |
21 | "timeline": 0, | ||
20 | "uri": "hls_450k_video.ts" | 22 | "uri": "hls_450k_video.ts" |
21 | }, | 23 | }, |
22 | { | 24 | { |
... | @@ -25,6 +27,7 @@ | ... | @@ -25,6 +27,7 @@ |
25 | "offset": 1110328 | 27 | "offset": 1110328 |
26 | }, | 28 | }, |
27 | "duration": 10, | 29 | "duration": 10, |
30 | "timeline": 0, | ||
28 | "uri": "hls_450k_video.ts" | 31 | "uri": "hls_450k_video.ts" |
29 | }, | 32 | }, |
30 | { | 33 | { |
... | @@ -33,6 +36,7 @@ | ... | @@ -33,6 +36,7 @@ |
33 | "offset": 1823412 | 36 | "offset": 1823412 |
34 | }, | 37 | }, |
35 | "duration": 10, | 38 | "duration": 10, |
39 | "timeline": 0, | ||
36 | "uri": "hls_450k_video.ts" | 40 | "uri": "hls_450k_video.ts" |
37 | }, | 41 | }, |
38 | { | 42 | { |
... | @@ -41,6 +45,7 @@ | ... | @@ -41,6 +45,7 @@ |
41 | "offset": 2299992 | 45 | "offset": 2299992 |
42 | }, | 46 | }, |
43 | "duration": 10, | 47 | "duration": 10, |
48 | "timeline": 0, | ||
44 | "uri": "hls_450k_video.ts" | 49 | "uri": "hls_450k_video.ts" |
45 | }, | 50 | }, |
46 | { | 51 | { |
... | @@ -49,6 +54,7 @@ | ... | @@ -49,6 +54,7 @@ |
49 | "offset": 2835604 | 54 | "offset": 2835604 |
50 | }, | 55 | }, |
51 | "duration": 10, | 56 | "duration": 10, |
57 | "timeline": 0, | ||
52 | "uri": "hls_450k_video.ts" | 58 | "uri": "hls_450k_video.ts" |
53 | }, | 59 | }, |
54 | { | 60 | { |
... | @@ -57,6 +63,7 @@ | ... | @@ -57,6 +63,7 @@ |
57 | "offset": 3042780 | 63 | "offset": 3042780 |
58 | }, | 64 | }, |
59 | "duration": 10, | 65 | "duration": 10, |
66 | "timeline": 0, | ||
60 | "uri": "hls_450k_video.ts" | 67 | "uri": "hls_450k_video.ts" |
61 | }, | 68 | }, |
62 | { | 69 | { |
... | @@ -65,6 +72,7 @@ | ... | @@ -65,6 +72,7 @@ |
65 | "offset": 3498680 | 72 | "offset": 3498680 |
66 | }, | 73 | }, |
67 | "duration": 10, | 74 | "duration": 10, |
75 | "timeline": 0, | ||
68 | "uri": "hls_450k_video.ts" | 76 | "uri": "hls_450k_video.ts" |
69 | }, | 77 | }, |
70 | { | 78 | { |
... | @@ -73,6 +81,7 @@ | ... | @@ -73,6 +81,7 @@ |
73 | "offset": 4155928 | 81 | "offset": 4155928 |
74 | }, | 82 | }, |
75 | "duration": 10, | 83 | "duration": 10, |
84 | "timeline": 0, | ||
76 | "uri": "hls_450k_video.ts" | 85 | "uri": "hls_450k_video.ts" |
77 | }, | 86 | }, |
78 | { | 87 | { |
... | @@ -81,6 +90,7 @@ | ... | @@ -81,6 +90,7 @@ |
81 | "offset": 4727636 | 90 | "offset": 4727636 |
82 | }, | 91 | }, |
83 | "duration": 10, | 92 | "duration": 10, |
93 | "timeline": 0, | ||
84 | "uri": "hls_450k_video.ts" | 94 | "uri": "hls_450k_video.ts" |
85 | }, | 95 | }, |
86 | { | 96 | { |
... | @@ -89,6 +99,7 @@ | ... | @@ -89,6 +99,7 @@ |
89 | "offset": 5212676 | 99 | "offset": 5212676 |
90 | }, | 100 | }, |
91 | "duration": 10, | 101 | "duration": 10, |
102 | "timeline": 0, | ||
92 | "uri": "hls_450k_video.ts" | 103 | "uri": "hls_450k_video.ts" |
93 | }, | 104 | }, |
94 | { | 105 | { |
... | @@ -97,6 +108,7 @@ | ... | @@ -97,6 +108,7 @@ |
97 | "offset": 5921812 | 108 | "offset": 5921812 |
98 | }, | 109 | }, |
99 | "duration": 10, | 110 | "duration": 10, |
111 | "timeline": 0, | ||
100 | "uri": "hls_450k_video.ts" | 112 | "uri": "hls_450k_video.ts" |
101 | }, | 113 | }, |
102 | { | 114 | { |
... | @@ -105,6 +117,7 @@ | ... | @@ -105,6 +117,7 @@ |
105 | "offset": 6651816 | 117 | "offset": 6651816 |
106 | }, | 118 | }, |
107 | "duration": 10, | 119 | "duration": 10, |
120 | "timeline": 0, | ||
108 | "uri": "hls_450k_video.ts" | 121 | "uri": "hls_450k_video.ts" |
109 | }, | 122 | }, |
110 | { | 123 | { |
... | @@ -113,6 +126,7 @@ | ... | @@ -113,6 +126,7 @@ |
113 | "offset": 7108092 | 126 | "offset": 7108092 |
114 | }, | 127 | }, |
115 | "duration": 10, | 128 | "duration": 10, |
129 | "timeline": 0, | ||
116 | "uri": "hls_450k_video.ts" | 130 | "uri": "hls_450k_video.ts" |
117 | }, | 131 | }, |
118 | { | 132 | { |
... | @@ -121,6 +135,7 @@ | ... | @@ -121,6 +135,7 @@ |
121 | "offset": 7576776 | 135 | "offset": 7576776 |
122 | }, | 136 | }, |
123 | "duration": 10, | 137 | "duration": 10, |
138 | "timeline": 0, | ||
124 | "uri": "hls_450k_video.ts" | 139 | "uri": "hls_450k_video.ts" |
125 | }, | 140 | }, |
126 | { | 141 | { |
... | @@ -129,6 +144,7 @@ | ... | @@ -129,6 +144,7 @@ |
129 | "offset": 8021772 | 144 | "offset": 8021772 |
130 | }, | 145 | }, |
131 | "duration": 10, | 146 | "duration": 10, |
147 | "timeline": 0, | ||
132 | "uri": "hls_450k_video.ts" | 148 | "uri": "hls_450k_video.ts" |
133 | }, | 149 | }, |
134 | { | 150 | { |
... | @@ -137,6 +153,7 @@ | ... | @@ -137,6 +153,7 @@ |
137 | "offset": 8353216 | 153 | "offset": 8353216 |
138 | }, | 154 | }, |
139 | "duration": 1.4167, | 155 | "duration": 1.4167, |
156 | "timeline": 0, | ||
140 | "uri": "hls_450k_video.ts" | 157 | "uri": "hls_450k_video.ts" |
141 | } | 158 | } |
142 | ], | 159 | ], |
... | @@ -144,4 +161,4 @@ | ... | @@ -144,4 +161,4 @@ |
144 | "endList": true, | 161 | "endList": true, |
145 | "discontinuitySequence": 0, | 162 | "discontinuitySequence": 0, |
146 | "discontinuityStarts": [] | 163 | "discontinuityStarts": [] |
147 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
164 | } | ... | ... |
... | @@ -5,6 +5,7 @@ | ... | @@ -5,6 +5,7 @@ |
5 | "segments": [ | 5 | "segments": [ |
6 | { | 6 | { |
7 | "duration": 6.64, | 7 | "duration": 6.64, |
8 | "timeline": 0, | ||
8 | "uri": "/test/ts-files/tvy7/8a5e2822668b5370f4eb1438b2564fb7ab12ffe1-hi720.ts" | 9 | "uri": "/test/ts-files/tvy7/8a5e2822668b5370f4eb1438b2564fb7ab12ffe1-hi720.ts" |
9 | } | 10 | } |
10 | ], | 11 | ], |
... | @@ -12,4 +13,4 @@ | ... | @@ -12,4 +13,4 @@ |
12 | "endList": true, | 13 | "endList": true, |
13 | "discontinuitySequence": 0, | 14 | "discontinuitySequence": 0, |
14 | "discontinuityStarts": [] | 15 | "discontinuityStarts": [] |
15 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
16 | } | ... | ... |
... | @@ -5,11 +5,19 @@ | ... | @@ -5,11 +5,19 @@ |
5 | "attributes": { | 5 | "attributes": { |
6 | "PROGRAM-ID": 1 | 6 | "PROGRAM-ID": 1 |
7 | }, | 7 | }, |
8 | "timeline": 0, | ||
8 | "uri": "media.m3u8" | 9 | "uri": "media.m3u8" |
9 | }, | 10 | }, |
10 | { | 11 | { |
12 | "timeline": 0, | ||
11 | "uri": "media1.m3u8" | 13 | "uri": "media1.m3u8" |
12 | } | 14 | } |
13 | ], | 15 | ], |
14 | "discontinuityStarts": [] | ||
15 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
16 | "discontinuityStarts": [], | ||
17 | "mediaGroups": { | ||
18 | "VIDEO": {}, | ||
19 | "AUDIO": {}, | ||
20 | "CLOSED-CAPTIONS": {}, | ||
21 | "SUBTITLES": {} | ||
22 | } | ||
23 | } | ... | ... |
... | @@ -5,18 +5,22 @@ | ... | @@ -5,18 +5,22 @@ |
5 | "segments": [ | 5 | "segments": [ |
6 | { | 6 | { |
7 | "duration": 6.64, | 7 | "duration": 6.64, |
8 | "timeline": 0, | ||
8 | "uri": "/test/ts-files/tvy7/8a5e2822668b5370f4eb1438b2564fb7ab12ffe1-hi720.ts" | 9 | "uri": "/test/ts-files/tvy7/8a5e2822668b5370f4eb1438b2564fb7ab12ffe1-hi720.ts" |
9 | }, | 10 | }, |
10 | { | 11 | { |
11 | "duration": 6.08, | 12 | "duration": 6.08, |
13 | "timeline": 0, | ||
12 | "uri": "/test/ts-files/tvy7/56be1cef869a1c0cc8e38864ad1add17d187f051-hi720.ts" | 14 | "uri": "/test/ts-files/tvy7/56be1cef869a1c0cc8e38864ad1add17d187f051-hi720.ts" |
13 | }, | 15 | }, |
14 | { | 16 | { |
15 | "duration": 6.6, | 17 | "duration": 6.6, |
18 | "timeline": 0, | ||
16 | "uri": "/test/ts-files/tvy7/549c8c77f55f049741a06596e5c1e01dacaa46d0-hi720.ts" | 19 | "uri": "/test/ts-files/tvy7/549c8c77f55f049741a06596e5c1e01dacaa46d0-hi720.ts" |
17 | }, | 20 | }, |
18 | { | 21 | { |
19 | "duration": 5, | 22 | "duration": 5, |
23 | "timeline": 0, | ||
20 | "uri": "/test/ts-files/tvy7/6cfa378684ffeb1c455a64dae6c103290a1f53d4-hi720.ts" | 24 | "uri": "/test/ts-files/tvy7/6cfa378684ffeb1c455a64dae6c103290a1f53d4-hi720.ts" |
21 | } | 25 | } |
22 | ], | 26 | ], |
... | @@ -24,4 +28,4 @@ | ... | @@ -24,4 +28,4 @@ |
24 | "endList": true, | 28 | "endList": true, |
25 | "discontinuitySequence": 0, | 29 | "discontinuitySequence": 0, |
26 | "discontinuityStarts": [] | 30 | "discontinuityStarts": [] |
27 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
31 | } | ... | ... |
... | @@ -5,6 +5,7 @@ | ... | @@ -5,6 +5,7 @@ |
5 | "segments": [ | 5 | "segments": [ |
6 | { | 6 | { |
7 | "duration": 10, | 7 | "duration": 10, |
8 | "timeline": 0, | ||
8 | "uri": "hls_450k_video.ts" | 9 | "uri": "hls_450k_video.ts" |
9 | } | 10 | } |
10 | ], | 11 | ], |
... | @@ -12,4 +13,4 @@ | ... | @@ -12,4 +13,4 @@ |
12 | "endList": true, | 13 | "endList": true, |
13 | "discontinuitySequence": 0, | 14 | "discontinuitySequence": 0, |
14 | "discontinuityStarts": [] | 15 | "discontinuityStarts": [] |
15 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
16 | } | ... | ... |
... | @@ -5,18 +5,22 @@ | ... | @@ -5,18 +5,22 @@ |
5 | "segments": [ | 5 | "segments": [ |
6 | { | 6 | { |
7 | "duration": 10, | 7 | "duration": 10, |
8 | "timeline": 0, | ||
8 | "uri": "http://example.com/00001.ts" | 9 | "uri": "http://example.com/00001.ts" |
9 | }, | 10 | }, |
10 | { | 11 | { |
11 | "duration": 10, | 12 | "duration": 10, |
13 | "timeline": 0, | ||
12 | "uri": "https://example.com/00002.ts" | 14 | "uri": "https://example.com/00002.ts" |
13 | }, | 15 | }, |
14 | { | 16 | { |
15 | "duration": 10, | 17 | "duration": 10, |
18 | "timeline": 0, | ||
16 | "uri": "//example.com/00003.ts" | 19 | "uri": "//example.com/00003.ts" |
17 | }, | 20 | }, |
18 | { | 21 | { |
19 | "duration": 10, | 22 | "duration": 10, |
23 | "timeline": 0, | ||
20 | "uri": "http://example.com/00004.ts" | 24 | "uri": "http://example.com/00004.ts" |
21 | } | 25 | } |
22 | ], | 26 | ], |
... | @@ -24,4 +28,4 @@ | ... | @@ -24,4 +28,4 @@ |
24 | "endList": true, | 28 | "endList": true, |
25 | "discontinuitySequence": 0, | 29 | "discontinuitySequence": 0, |
26 | "discontinuityStarts": [] | 30 | "discontinuityStarts": [] |
27 | } | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
31 | } | ... | ... |
utils/stats/audio-track-selector.js
0 → 100644
1 | (function(videojs) { | ||
2 | var Component = videojs.getComponent('Component'); | ||
3 | |||
4 | // ----------------- | ||
5 | // AudioTrackMenuItem | ||
6 | // ----------------- | ||
7 | // | ||
8 | var MenuItem = videojs.getComponent('MenuItem'); | ||
9 | |||
10 | var AudioTrackMenuItem = videojs.extend(MenuItem, { | ||
11 | constructor: function(player, options) { | ||
12 | var track = options.track; | ||
13 | var tracks = player.audioTracks(); | ||
14 | |||
15 | options.label = track.label || track.language || 'Unknown'; | ||
16 | options.selected = track.enabled; | ||
17 | |||
18 | MenuItem.call(this, player, options); | ||
19 | |||
20 | this.track = track; | ||
21 | |||
22 | if (tracks) { | ||
23 | var changeHandler = videojs.bind(this, this.handleTracksChange); | ||
24 | |||
25 | tracks.addEventListener('change', changeHandler); | ||
26 | this.on('dispose', function() { | ||
27 | tracks.removeEventListener('change', changeHandler); | ||
28 | }); | ||
29 | } | ||
30 | }, | ||
31 | |||
32 | handleClick: function(event) { | ||
33 | var kind = this.track.kind; | ||
34 | var tracks = this.player_.audioTracks(); | ||
35 | |||
36 | MenuItem.prototype.handleClick.call(this, event); | ||
37 | |||
38 | if (!tracks) return; | ||
39 | |||
40 | for (var i = 0; i < tracks.length; i++) { | ||
41 | var track = tracks[i]; | ||
42 | |||
43 | if (track === this.track) { | ||
44 | track.enabled = true; | ||
45 | } | ||
46 | } | ||
47 | }, | ||
48 | |||
49 | handleTracksChange: function(event) { | ||
50 | this.selected(this.track.enabled); | ||
51 | } | ||
52 | }); | ||
53 | |||
54 | Component.registerComponent('AudioTrackMenuItem', AudioTrackMenuItem); | ||
55 | |||
56 | // ----------------- | ||
57 | // AudioTrackButton | ||
58 | // ----------------- | ||
59 | // | ||
60 | var MenuButton = videojs.getComponent('MenuButton'); | ||
61 | |||
62 | var AudioTrackButton = videojs.extend(MenuButton, { | ||
63 | constructor: function(player, options) { | ||
64 | MenuButton.call(this, player, options); | ||
65 | this.el_.setAttribute('aria-label','Audio Menu'); | ||
66 | |||
67 | var tracks = this.player_.audioTracks(); | ||
68 | |||
69 | if (this.items.length <= 1) { | ||
70 | this.hide(); | ||
71 | } | ||
72 | |||
73 | if (!tracks) { | ||
74 | return; | ||
75 | } | ||
76 | |||
77 | var updateHandler = videojs.bind(this, this.update); | ||
78 | tracks.addEventListener('removetrack', updateHandler); | ||
79 | tracks.addEventListener('addtrack', updateHandler); | ||
80 | |||
81 | this.player_.on('dispose', function() { | ||
82 | tracks.removeEventListener('removetrack', updateHandler); | ||
83 | tracks.removeEventListener('addtrack', updateHandler); | ||
84 | }); | ||
85 | }, | ||
86 | |||
87 | buildCSSClass() { | ||
88 | return 'vjs-subtitles-button ' + MenuButton.prototype.buildCSSClass.call(this); | ||
89 | }, | ||
90 | |||
91 | createItems: function(items) { | ||
92 | items = items || []; | ||
93 | |||
94 | var tracks = this.player_.audioTracks(); | ||
95 | |||
96 | if (!tracks) { | ||
97 | return items; | ||
98 | } | ||
99 | |||
100 | for (var i = 0; i < tracks.length; i++) { | ||
101 | var track = tracks[i]; | ||
102 | |||
103 | items.push(new AudioTrackMenuItem(this.player_, { | ||
104 | 'selectable': true, | ||
105 | 'track': track | ||
106 | })); | ||
107 | } | ||
108 | |||
109 | return items; | ||
110 | } | ||
111 | }); | ||
112 | |||
113 | Component.registerComponent('AudioTrackButton', AudioTrackButton); | ||
114 | })(window.videojs); |
... | @@ -9,28 +9,16 @@ | ... | @@ -9,28 +9,16 @@ |
9 | <!-- video.js --> | 9 | <!-- video.js --> |
10 | <script src="../../node_modules/video.js/dist/video.js"></script> | 10 | <script src="../../node_modules/video.js/dist/video.js"></script> |
11 | 11 | ||
12 | <!-- Media Sources plugin --> | ||
13 | <script src="../../node_modules/videojs-contrib-media-sources/dist/videojs-media-sources.js"></script> | ||
14 | |||
15 | <!-- HLS plugin --> | 12 | <!-- HLS plugin --> |
16 | <script src="../../src/videojs-hls.js"></script> | 13 | <script src="../../dist/videojs-contrib-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 | 14 | ||
25 | <script src="../../node_modules/pkcs7/dist/pkcs7.unpad.js"></script> | 15 | <!-- Track Selector plugin --> |
26 | <script src="../../src/decrypter.js"></script> | 16 | <script src="audio-track-selector.js"></script> |
27 | 17 | ||
28 | <!-- player stats visualization --> | 18 | <!-- player stats visualization --> |
29 | <link href="stats.css" rel="stylesheet"> | 19 | <link href="stats.css" rel="stylesheet"> |
30 | <script src="../switcher/js/vendor/d3.min.js"></script> | 20 | <script src="../switcher/js/vendor/d3.min.js"></script> |
31 | 21 | ||
32 | <!-- debugging --> | ||
33 | <script src="../../src/bin-utils.js"></script> | ||
34 | <style> | 22 | <style> |
35 | body { | 23 | body { |
36 | font-family: Arial, sans-serif; | 24 | font-family: Arial, sans-serif; |
... | @@ -43,13 +31,12 @@ | ... | @@ -43,13 +31,12 @@ |
43 | padding: 0 5px; | 31 | padding: 0 5px; |
44 | margin: 20px 0; | 32 | margin: 20px 0; |
45 | } | 33 | } |
46 | </style> | 34 | input { |
47 | 35 | margin-top: 15px; | |
48 | <script> | 36 | min-width: 450px; |
49 | if (window.location.search === '?flash') { | 37 | padding: 5px; |
50 | videojs.options.techOrder = ['flash']; | ||
51 | } | 38 | } |
52 | </script> | 39 | </style> |
53 | 40 | ||
54 | </head> | 41 | </head> |
55 | <body> | 42 | <body> |
... | @@ -57,18 +44,50 @@ | ... | @@ -57,18 +44,50 @@ |
57 | <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> | 44 | <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> |
58 | <p>Due to security restrictions in Flash, you will have to load this page over HTTP(S) to see the example in action.</p> | 45 | <p>Due to security restrictions in Flash, you will have to load this page over HTTP(S) to see the example in action.</p> |
59 | </div> | 46 | </div> |
60 | <video id="video" | 47 | |
61 | class="video-js vjs-default-skin" | 48 | <div id="fixture"> |
62 | height="300" | 49 | </div> |
63 | width="600" | 50 | |
64 | controls> | 51 | <form id="load-url"> |
65 | <source | 52 | <label> |
66 | src="http://solutions.brightcove.com/jwhisenant/hls/apple/bipbop/bipbopall.m3u8" | 53 | URL to Load: |
67 | type="application/x-mpegURL"> | 54 | <input id="url-to-load" type="url" value="https://s3.amazonaws.com/_bc_dml/example-content/bipbop-advanced/bipbop_16x9_variant.m3u8"> |
68 | <source | 55 | </label> |
69 | src="http://s3.amazonaws.com/_bc_dml/example-content/bipbop-id3/index.m3u8" | 56 | <br> |
70 | type="application/x-mpegURL"> | 57 | <label> |
71 | </video> | 58 | Player Options: |
59 | <input id="player-options" type="text" value='{}'> | ||
60 | </label> | ||
61 | <br> | ||
62 | <label> | ||
63 | URL Content Type: | ||
64 | <input id="url-content-type" type="text" value="application/x-mpegURL"> | ||
65 | </label> | ||
66 | <br> | ||
67 | <label> | ||
68 | Default Caption Track Label (blank for none): | ||
69 | <input id="caption-track" type="text" value=""> | ||
70 | </label> | ||
71 | <br> | ||
72 | <label> | ||
73 | Technology Mode: | ||
74 | <input type="radio" name="technology-mode" value="auto" checked> Auto | ||
75 | <input type="radio" name="technology-mode" value="html"> HTML | ||
76 | <input type="radio" name="technology-mode" value="flash"> Flash | ||
77 | </label> | ||
78 | <br> | ||
79 | <label> | ||
80 | Autoplay: | ||
81 | <input type="radio" name="autoplay" value="on"> On | ||
82 | <input type="radio" name="autoplay" value="off" checked> Off | ||
83 | </label> | ||
84 | |||
85 | <br> | ||
86 | <br> | ||
87 | <button type="submit">Load</button> | ||
88 | </form> | ||
89 | <br> | ||
90 | |||
72 | <section class="stats"> | 91 | <section class="stats"> |
73 | <div class="player-stats"> | 92 | <div class="player-stats"> |
74 | <h2>Player Stats</h2> | 93 | <h2>Player Stats</h2> |
... | @@ -109,98 +128,181 @@ | ... | @@ -109,98 +128,181 @@ |
109 | 128 | ||
110 | <script src="stats.js"></script> | 129 | <script src="stats.js"></script> |
111 | <script> | 130 | <script> |
112 | videojs.options.flash.swf = '../../node_modules/videojs-swf/dist/video-js.swf'; | 131 | function getCheckedValue(name) { |
113 | // initialize the player | 132 | var radios = document.getElementsByName(name); |
114 | var player = videojs('video').ready(function() { | 133 | var value; |
115 | 134 | for (var i = 0, length = radios.length; i < length; i++) { | |
116 | // ------------ | 135 | if (radios[i].checked) { |
117 | // Player Stats | 136 | value = radios[i].value; |
118 | // ------------ | 137 | break; |
119 | 138 | } | |
120 | var currentTimeStat = document.querySelector('.current-time-stat'); | 139 | } |
121 | var bufferedStat = document.querySelector('.buffered-stat'); | 140 | return value; |
122 | var seekableStartStat = document.querySelector('.seekable-start-stat'); | 141 | } |
123 | var seekableEndStat = document.querySelector('.seekable-end-stat'); | ||
124 | var videoBitrateState = document.querySelector('.video-bitrate-stat'); | ||
125 | var measuredBitrateStat = document.querySelector('.measured-bitrate-stat'); | ||
126 | |||
127 | player.on('timeupdate', function() { | ||
128 | currentTimeStat.textContent = player.currentTime().toFixed(1); | ||
129 | }); | ||
130 | 142 | ||
131 | window.setInterval(function() { | 143 | function createPlayer(cb) { |
132 | var bufferedText = '', oldStart, oldEnd, i; | 144 | if (window.stats_timer) { |
145 | clearInterval(window.stats_timer); | ||
146 | } | ||
147 | // dispose of existing player | ||
148 | if(window.player) { | ||
149 | window.player.dispose(); | ||
150 | } | ||
133 | 151 | ||
134 | // buffered | 152 | // create video element in the dom |
135 | var buffered = player.buffered(); | 153 | var video = document.createElement('video'); |
136 | if (buffered.length) { | 154 | video.id = 'videojs-contrib-hls-player'; |
137 | bufferedText += buffered.start(0) + ' - ' + buffered.end(0); | 155 | video.className = 'video-js vjs-default-skin'; |
156 | video.setAttribute('controls', true); | ||
157 | video.setAttribute('height', 300); | ||
158 | video.setAttribute('width', 600); | ||
159 | document.querySelector('#fixture').appendChild(video); | ||
160 | var techRadios = document.getElementsByName('technology-mode'); | ||
161 | var techMode = getCheckedValue('technology-mode') || 'auto'; | ||
162 | var autoplay = getCheckedValue('autoplay') || 'off'; | ||
163 | var captionTrack = document.getElementById('caption-track').value || ""; | ||
164 | var url = document.getElementById('url-to-load').value || ""; | ||
165 | var options = {}; | ||
166 | // try to parse options from the form | ||
167 | try { | ||
168 | options = JSON.parse(document.getElementById('player-options').value); | ||
169 | } catch(err) { | ||
170 | console.log("Reseting options to {}, JSON Parse Error:", err); | ||
171 | } | ||
172 | if(typeof options.techOrder === 'undefined') { | ||
173 | if (techMode === 'html') { | ||
174 | options.techOrder = ['html5']; | ||
175 | } else if (techMode === 'flash') { | ||
176 | options.techOrder = ['flash']; | ||
138 | } | 177 | } |
139 | for (i = 1; i < buffered.length; i++) { | 178 | } |
140 | bufferedText += ', ' + buffered.start(i) + ' - ' + buffered.end(i); | 179 | if(typeof options.autoplay === 'undefined') { |
180 | if (autoplay === 'on') { | ||
181 | options.autoplay = true; | ||
182 | } else if (techMode === 'off') { | ||
183 | options.autoplay = false; | ||
141 | } | 184 | } |
142 | bufferedStat.textContent = bufferedText; | 185 | } |
186 | var type = document.getElementById('url-content-type').value; | ||
187 | // use the form data to add a src to the player | ||
188 | try { | ||
189 | window.player = videojs(video.id, options); | ||
190 | if(captionTrack) { | ||
191 | // hackey way to show captions | ||
192 | window.player.on('loadeddata', function(event) { | ||
193 | textTracks = this.textTracks().tracks_; | ||
194 | for(var i = 0; i < textTracks.length; i++) { | ||
195 | if(textTracks[i].label === captionTrack) { | ||
196 | console.log("Found matching track, going to show " + captionTrack); | ||
197 | textTracks[i].mode = "showing"; | ||
198 | break; | ||
199 | } | ||
200 | } | ||
201 | }); | ||
202 | } | ||
203 | window.player.src({ | ||
204 | src: url, | ||
205 | type: type | ||
206 | }); | ||
207 | |||
208 | cb(player); | ||
209 | } catch(err) { | ||
210 | console.log("caught an error trying to create and add src to player:", err); | ||
211 | } | ||
212 | } | ||
213 | |||
214 | function setup(player) { | ||
215 | player.ready(function() { | ||
143 | 216 | ||
144 | // seekable | 217 | // ------------ |
145 | var seekable = player.seekable(); | 218 | // Audio Track Switcher |
146 | if (seekable && seekable.length) { | 219 | // ------------ |
147 | 220 | ||
148 | oldStart = seekableStartStat.textContent; | 221 | player.controlBar.addChild('AudioTrackButton', {}, 13); |
149 | if (seekable.start(0).toFixed(1) !== oldStart) { | 222 | |
150 | seekableStartStat.textContent = seekable.start(0).toFixed(1); | 223 | // ------------ |
224 | // Player Stats | ||
225 | // ------------ | ||
226 | |||
227 | var currentTimeStat = document.querySelector('.current-time-stat'); | ||
228 | var bufferedStat = document.querySelector('.buffered-stat'); | ||
229 | var seekableStartStat = document.querySelector('.seekable-start-stat'); | ||
230 | var seekableEndStat = document.querySelector('.seekable-end-stat'); | ||
231 | var videoBitrateState = document.querySelector('.video-bitrate-stat'); | ||
232 | var measuredBitrateStat = document.querySelector('.measured-bitrate-stat'); | ||
233 | |||
234 | player.on('timeupdate', function() { | ||
235 | currentTimeStat.textContent = player.currentTime().toFixed(1); | ||
236 | }); | ||
237 | |||
238 | window.stats_timer = window.setInterval(function() { | ||
239 | var bufferedText = '', oldStart, oldEnd, i; | ||
240 | |||
241 | // buffered | ||
242 | var buffered = player.buffered(); | ||
243 | if (buffered.length) { | ||
244 | bufferedText += buffered.start(0) + ' - ' + buffered.end(0); | ||
151 | } | 245 | } |
152 | oldEnd = seekableEndStat.textContent; | 246 | for (i = 1; i < buffered.length; i++) { |
153 | if (seekable.end(0).toFixed(1) !== oldEnd) { | 247 | bufferedText += ', ' + buffered.start(i) + ' - ' + buffered.end(i); |
154 | seekableEndStat.textContent = seekable.end(0).toFixed(1); | ||
155 | } | 248 | } |
156 | } | 249 | bufferedStat.textContent = bufferedText; |
157 | 250 | ||
158 | // bitrates | 251 | // seekable |
159 | var playlist = player.tech_.hls.playlists.media(); | 252 | var seekable = player.seekable(); |
160 | if (playlist && playlist.attributes && playlist.attributes.BANDWIDTH) { | 253 | if (seekable && seekable.length) { |
161 | videoBitrateState.textContent = (playlist.attributes.BANDWIDTH / 1024).toLocaleString(undefined, { | ||
162 | maximumFractionDigits: 1 | ||
163 | }) + ' kbps'; | ||
164 | } | ||
165 | if (player.tech_.hls.bandwidth) { | ||
166 | measuredBitrateStat.textContent = (player.tech_.hls.bandwidth / 1024).toLocaleString(undefined, { | ||
167 | maximumFractionDigits: 1 | ||
168 | }) + ' kbps'; | ||
169 | } | ||
170 | }, 1000); | ||
171 | 254 | ||
172 | var trackEventCount = function(eventName, selector) { | 255 | oldStart = seekableStartStat.textContent; |
173 | var count = 0, element = document.querySelector(selector); | 256 | if (seekable.start(0).toFixed(1) !== oldStart) { |
174 | player.on(eventName, function() { | 257 | seekableStartStat.textContent = seekable.start(0).toFixed(1); |
175 | count++; | 258 | } |
176 | element.innerHTML = count; | 259 | oldEnd = seekableEndStat.textContent; |
177 | }); | 260 | if (seekable.end(0).toFixed(1) !== oldEnd) { |
178 | }; | 261 | seekableEndStat.textContent = seekable.end(0).toFixed(1); |
179 | trackEventCount('play', '.play-count'); | 262 | } |
180 | trackEventCount('playing', '.playing-count'); | 263 | } |
181 | trackEventCount('seeking', '.seeking-count'); | 264 | |
182 | trackEventCount('seeked', '.seeked-count'); | 265 | // bitrates |
183 | 266 | var playlist = player.tech_.hls.playlists.media(); | |
184 | videojs.Hls.displayStats(document.querySelector('.switching-stats'), player); | 267 | if (playlist && playlist.attributes && playlist.attributes.BANDWIDTH) { |
185 | videojs.Hls.displayCues(document.querySelector('.segment-timeline'), player); | 268 | videoBitrateState.textContent = (playlist.attributes.BANDWIDTH / 1024).toLocaleString(undefined, { |
186 | }); | 269 | maximumFractionDigits: 1 |
187 | 270 | }) + ' kbps'; | |
188 | // ----------- | 271 | } |
189 | // Tech Switch | 272 | if (player.tech_.hls.bandwidth) { |
190 | // ----------- | 273 | measuredBitrateStat.textContent = (player.tech_.hls.bandwidth / 1024).toLocaleString(undefined, { |
191 | 274 | maximumFractionDigits: 1 | |
192 | var techSwitch = document.createElement('a'); | 275 | }) + ' kbps'; |
193 | techSwitch.className = 'tech-switch'; | 276 | } |
194 | if (player.el().querySelector('video')) { | 277 | }, 1000); |
195 | techSwitch.href = window.location.origin + window.location.pathname + '?flash'; | ||
196 | techSwitch.appendChild(document.createTextNode('Switch to the Flash tech')); | ||
197 | } else { | ||
198 | techSwitch.href = window.location.origin + window.location.pathname; | ||
199 | techSwitch.appendChild(document.createTextNode('Stop forcing Flash')); | ||
200 | } | ||
201 | 278 | ||
202 | document.body.insertBefore(techSwitch, document.querySelector('.stats')); | 279 | var trackEventCount = function(eventName, selector) { |
280 | var count = 0, element = document.querySelector(selector); | ||
281 | player.on(eventName, function() { | ||
282 | count++; | ||
283 | element.innerHTML = count; | ||
284 | }); | ||
285 | }; | ||
286 | trackEventCount('play', '.play-count'); | ||
287 | trackEventCount('playing', '.playing-count'); | ||
288 | trackEventCount('seeking', '.seeking-count'); | ||
289 | trackEventCount('seeked', '.seeked-count'); | ||
203 | 290 | ||
291 | videojs.Hls.displayStats(document.querySelector('.switching-stats'), player); | ||
292 | videojs.Hls.displayCues(document.querySelector('.segment-timeline'), player); | ||
293 | }); | ||
294 | } | ||
295 | |||
296 | (function(window, videojs) { | ||
297 | videojs.options.flash.swf = '../../node_modules/videojs-swf/dist/video-js.swf'; | ||
298 | createPlayer(setup); | ||
299 | // hook up the video switcher | ||
300 | document.getElementById('load-url').addEventListener('submit', function(event) { | ||
301 | event.preventDefault(); | ||
302 | createPlayer(setup); | ||
303 | return false; | ||
304 | }); | ||
305 | }(window, window.videojs)); | ||
204 | </script> | 306 | </script> |
205 | </body> | 307 | </body> |
206 | </html> | 308 | </html> | ... | ... |
-
Please register or sign in to post a comment