browserify-p4: playlist*, xhr, and resolve-url
updated stub.js to keep unit tests working updated build/watch scripts ripped resolve-url out of videojs-contrib-hls for now
Showing
12 changed files
with
2041 additions
and
1857 deletions
... | @@ -48,10 +48,7 @@ | ... | @@ -48,10 +48,7 @@ |
48 | <script src="/node_modules/video.js/dist/video.js"></script> | 48 | <script src="/node_modules/video.js/dist/video.js"></script> |
49 | <script src="/node_modules/videojs-contrib-media-sources/dist/videojs-media-sources.js"></script> | 49 | <script src="/node_modules/videojs-contrib-media-sources/dist/videojs-media-sources.js"></script> |
50 | <script src="/src/videojs-contrib-hls.js"></script> | 50 | <script src="/src/videojs-contrib-hls.js"></script> |
51 | <script src="/src/xhr.js"></script> | ||
52 | <script src="/dist/videojs-contrib-hls.js"></script> | 51 | <script src="/dist/videojs-contrib-hls.js"></script> |
53 | <script src="/src/playlist.js"></script> | ||
54 | <script src="/src/playlist-loader.js"></script> | ||
55 | <script src="/src/bin-utils.js"></script> | 52 | <script src="/src/bin-utils.js"></script> |
56 | <script> | 53 | <script> |
57 | (function(window, videojs) { | 54 | (function(window, videojs) { | ... | ... |
... | @@ -2,7 +2,7 @@ var browserify = require('browserify'); | ... | @@ -2,7 +2,7 @@ var browserify = require('browserify'); |
2 | var fs = require('fs'); | 2 | var fs = require('fs'); |
3 | var glob = require('glob'); | 3 | var glob = require('glob'); |
4 | 4 | ||
5 | glob('test/{decryper,m3u8,stub}.test.js', function(err, files) { | 5 | glob('test/{playlist*,decryper,m3u8,stub}.test.js', function(err, files) { |
6 | browserify(files) | 6 | browserify(files) |
7 | .transform('babelify') | 7 | .transform('babelify') |
8 | .bundle() | 8 | .bundle() | ... | ... |
... | @@ -3,7 +3,7 @@ var fs = require('fs'); | ... | @@ -3,7 +3,7 @@ var fs = require('fs'); |
3 | var glob = require('glob'); | 3 | var glob = require('glob'); |
4 | var watchify = require('watchify'); | 4 | var watchify = require('watchify'); |
5 | 5 | ||
6 | glob('test/{decrypter,m3u8,stub}.test.js', function(err, files) { | 6 | glob('test/{playlist*,decrypter,m3u8,stub}.test.js', function(err, files) { |
7 | var b = browserify(files, { | 7 | var b = browserify(files, { |
8 | cache: {}, | 8 | cache: {}, |
9 | packageCache: {}, | 9 | packageCache: {}, | ... | ... |
... | @@ -5,376 +5,385 @@ | ... | @@ -5,376 +5,385 @@ |
5 | * M3U8 playlists. | 5 | * M3U8 playlists. |
6 | * | 6 | * |
7 | */ | 7 | */ |
8 | (function(window, videojs) { | 8 | import resolveUrl from './resolve-url'; |
9 | 'use strict'; | 9 | import XhrModule from './xhr'; |
10 | var | 10 | import {mergeOptions} from 'video.js'; |
11 | resolveUrl = videojs.Hls.resolveUrl, | 11 | import Stream from './stream'; |
12 | xhr = videojs.Hls.xhr, | 12 | import m3u8 from './m3u8'; |
13 | mergeOptions = videojs.mergeOptions, | 13 | |
14 | 14 | /** | |
15 | /** | 15 | * Returns a new array of segments that is the result of merging |
16 | * Returns a new master playlist that is the result of merging an | 16 | * properties from an older list of segments onto an updated |
17 | * updated media playlist into the original version. If the | 17 | * list. No properties on the updated playlist will be overridden. |
18 | * updated media playlist does not match any of the playlist | 18 | * @param original {array} the outdated list of segments |
19 | * entries in the original master playlist, null is returned. | 19 | * @param update {array} the updated list of segments |
20 | * @param master {object} a parsed master M3U8 object | 20 | * @param offset {number} (optional) the index of the first update |
21 | * @param media {object} a parsed media M3U8 object | 21 | * segment in the original segment list. For non-live playlists, |
22 | * @return {object} a new object that represents the original | 22 | * this should always be zero and does not need to be |
23 | * master playlist with the updated media playlist merged in, or | 23 | * specified. For live playlists, it should be the difference |
24 | * null if the merge produced no change. | 24 | * between the media sequence numbers in the original and updated |
25 | */ | 25 | * playlists. |
26 | updateMaster = function(master, media) { | 26 | * @return a list of merged segment objects |
27 | var | 27 | */ |
28 | changed = false, | 28 | const updateSegments = function(original, update, offset) { |
29 | result = mergeOptions(master, {}), | 29 | let result = update.slice(); |
30 | i, | 30 | let length; |
31 | playlist; | 31 | let i; |
32 | 32 | ||
33 | i = master.playlists.length; | 33 | offset = offset || 0; |
34 | while (i--) { | 34 | length = Math.min(original.length, update.length + offset); |
35 | playlist = result.playlists[i]; | 35 | |
36 | if (playlist.uri === media.uri) { | 36 | for (i = offset; i < length; i++) { |
37 | // consider the playlist unchanged if the number of segments | 37 | result[i - offset] = mergeOptions(original[i], result[i - offset]); |
38 | // are equal and the media sequence number is unchanged | 38 | } |
39 | if (playlist.segments && | 39 | return result; |
40 | media.segments && | 40 | }; |
41 | playlist.segments.length === media.segments.length && | 41 | |
42 | playlist.mediaSequence === media.mediaSequence) { | 42 | /** |
43 | continue; | 43 | * Returns a new master playlist that is the result of merging an |
44 | } | 44 | * updated media playlist into the original version. If the |
45 | 45 | * updated media playlist does not match any of the playlist | |
46 | result.playlists[i] = mergeOptions(playlist, media); | 46 | * entries in the original master playlist, null is returned. |
47 | result.playlists[media.uri] = result.playlists[i]; | 47 | * @param master {object} a parsed master M3U8 object |
48 | 48 | * @param media {object} a parsed media M3U8 object | |
49 | // if the update could overlap existing segment information, | 49 | * @return {object} a new object that represents the original |
50 | // merge the two lists | 50 | * master playlist with the updated media playlist merged in, or |
51 | if (playlist.segments) { | 51 | * null if the merge produced no change. |
52 | result.playlists[i].segments = updateSegments(playlist.segments, | 52 | */ |
53 | media.segments, | 53 | const updateMaster = function(master, media) { |
54 | media.mediaSequence - playlist.mediaSequence); | 54 | let changed = false; |
55 | } | 55 | let result = mergeOptions(master, {}); |
56 | changed = true; | 56 | let i = master.playlists.length; |
57 | } | 57 | let playlist; |
58 | } | 58 | |
59 | return changed ? result : null; | 59 | while (i--) { |
60 | }, | 60 | playlist = result.playlists[i]; |
61 | 61 | if (playlist.uri === media.uri) { | |
62 | /** | 62 | // consider the playlist unchanged if the number of segments |
63 | * Returns a new array of segments that is the result of merging | 63 | // are equal and the media sequence number is unchanged |
64 | * properties from an older list of segments onto an updated | 64 | if (playlist.segments && |
65 | * list. No properties on the updated playlist will be overridden. | 65 | media.segments && |
66 | * @param original {array} the outdated list of segments | 66 | playlist.segments.length === media.segments.length && |
67 | * @param update {array} the updated list of segments | 67 | playlist.mediaSequence === media.mediaSequence) { |
68 | * @param offset {number} (optional) the index of the first update | 68 | continue; |
69 | * segment in the original segment list. For non-live playlists, | ||
70 | * this should always be zero and does not need to be | ||
71 | * specified. For live playlists, it should be the difference | ||
72 | * between the media sequence numbers in the original and updated | ||
73 | * playlists. | ||
74 | * @return a list of merged segment objects | ||
75 | */ | ||
76 | updateSegments = function(original, update, offset) { | ||
77 | var result = update.slice(), length, i; | ||
78 | offset = offset || 0; | ||
79 | length = Math.min(original.length, update.length + offset); | ||
80 | |||
81 | for (i = offset; i < length; i++) { | ||
82 | result[i - offset] = mergeOptions(original[i], result[i - offset]); | ||
83 | } | 69 | } |
84 | return result; | 70 | |
85 | }, | 71 | result.playlists[i] = mergeOptions(playlist, media); |
86 | 72 | result.playlists[media.uri] = result.playlists[i]; | |
87 | PlaylistLoader = function(srcUrl, withCredentials) { | 73 | |
88 | var | 74 | // if the update could overlap existing segment information, |
89 | loader = this, | 75 | // merge the two lists |
90 | dispose, | 76 | if (playlist.segments) { |
91 | mediaUpdateTimeout, | 77 | result.playlists[i].segments = updateSegments( |
92 | request, | 78 | playlist.segments, |
93 | playlistRequestError, | 79 | media.segments, |
94 | haveMetadata; | 80 | media.mediaSequence - playlist.mediaSequence |
95 | 81 | ); | |
96 | PlaylistLoader.prototype.init.call(this); | ||
97 | |||
98 | // a flag that disables "expired time"-tracking this setting has | ||
99 | // no effect when not playing a live stream | ||
100 | this.trackExpiredTime_ = false; | ||
101 | |||
102 | if (!srcUrl) { | ||
103 | throw new Error('A non-empty playlist URL is required'); | ||
104 | } | 82 | } |
83 | changed = true; | ||
84 | } | ||
85 | } | ||
86 | return changed ? result : null; | ||
87 | }; | ||
105 | 88 | ||
106 | playlistRequestError = function(xhr, url, startingState) { | 89 | export default class PlaylistLoader extends Stream { |
107 | loader.setBandwidth(request || xhr); | 90 | constructor(srcUrl, withCredentials) { |
91 | super(); | ||
92 | this.srcUrl = srcUrl; | ||
93 | this.withCredentials = withCredentials; | ||
108 | 94 | ||
109 | // any in-flight request is now finished | 95 | this.mediaUpdateTimeout = null; |
110 | request = null; | ||
111 | |||
112 | if (startingState) { | ||
113 | loader.state = startingState; | ||
114 | } | ||
115 | 96 | ||
116 | loader.error = { | 97 | // initialize the loader state |
117 | playlist: loader.master.playlists[url], | 98 | this.state = 'HAVE_NOTHING'; |
118 | status: xhr.status, | ||
119 | message: 'HLS playlist request error at URL: ' + url, | ||
120 | responseText: xhr.responseText, | ||
121 | code: (xhr.status >= 500) ? 4 : 2 | ||
122 | }; | ||
123 | loader.trigger('error'); | ||
124 | }; | ||
125 | 99 | ||
126 | // update the playlist loader's state in response to a new or | 100 | // track the time that has expired from the live window |
127 | // updated playlist. | 101 | // this allows the seekable start range to be calculated even if |
102 | // all segments with timing information have expired | ||
103 | this.expired_ = 0; | ||
128 | 104 | ||
129 | haveMetadata = function(xhr, url) { | 105 | // a flag that disables "expired time"-tracking this setting has |
130 | var parser, refreshDelay, update; | 106 | // no effect when not playing a live stream |
107 | this.trackExpiredTime_ = false; | ||
131 | 108 | ||
132 | loader.setBandwidth(request || xhr); | 109 | if (!this.srcUrl) { |
110 | throw new Error('A non-empty playlist URL is required'); | ||
111 | } | ||
133 | 112 | ||
134 | // any in-flight request is now finished | 113 | // In a live list, don't keep track of the expired time until |
135 | request = null; | 114 | // HLS tells us that "first play" has commenced |
136 | loader.state = 'HAVE_METADATA'; | 115 | this.on('firstplay', function() { |
116 | this.trackExpiredTime_ = true; | ||
117 | }); | ||
137 | 118 | ||
138 | parser = new videojs.m3u8.Parser(); | 119 | // live playlist staleness timeout |
139 | parser.push(xhr.responseText); | 120 | this.on('mediaupdatetimeout', () => { |
140 | parser.end(); | 121 | if (this.state !== 'HAVE_METADATA') { |
141 | parser.manifest.uri = url; | 122 | // only refresh the media playlist if no other activity is going on |
123 | return; | ||
124 | } | ||
142 | 125 | ||
143 | // merge this playlist into the master | 126 | this.state = 'HAVE_CURRENT_METADATA'; |
144 | update = updateMaster(loader.master, parser.manifest); | 127 | this.request = XhrModule({ |
145 | refreshDelay = (parser.manifest.targetDuration || 10) * 1000; | 128 | uri: resolveUrl(this.master.uri, this.media().uri), |
146 | if (update) { | 129 | withCredentials: this.withCredentials |
147 | loader.master = update; | 130 | }, (error, request) => { |
148 | loader.updateMediaPlaylist_(parser.manifest); | 131 | if (error) { |
149 | } else { | 132 | return this.playlistRequestError(request, this.media().uri); |
150 | // if the playlist is unchanged since the last reload, | ||
151 | // try again after half the target duration | ||
152 | refreshDelay /= 2; | ||
153 | } | 133 | } |
134 | this.haveMetadata(request, this.media().uri); | ||
135 | }); | ||
136 | }); | ||
137 | |||
138 | // request the specified URL | ||
139 | this.request = XhrModule({ | ||
140 | uri: this.srcUrl, | ||
141 | withCredentials: this.withCredentials | ||
142 | }, (error, request) => { | ||
143 | let parser = new m3u8.Parser(); | ||
144 | let i; | ||
145 | |||
146 | // clear the loader's request reference | ||
147 | this.request = null; | ||
148 | |||
149 | if (error) { | ||
150 | this.error = { | ||
151 | status: request.status, | ||
152 | message: 'HLS playlist request error at URL: ' + this.srcUrl, | ||
153 | responseText: request.responseText, | ||
154 | // MEDIA_ERR_NETWORK | ||
155 | code: 2 | ||
156 | }; | ||
157 | return this.trigger('error'); | ||
158 | } | ||
154 | 159 | ||
155 | // refresh live playlists after a target duration passes | 160 | parser.push(request.responseText); |
156 | if (!loader.media().endList) { | 161 | parser.end(); |
157 | window.clearTimeout(mediaUpdateTimeout); | ||
158 | mediaUpdateTimeout = window.setTimeout(function() { | ||
159 | loader.trigger('mediaupdatetimeout'); | ||
160 | }, refreshDelay); | ||
161 | } | ||
162 | 162 | ||
163 | loader.trigger('loadedplaylist'); | 163 | this.state = 'HAVE_MASTER'; |
164 | }; | ||
165 | 164 | ||
166 | // initialize the loader state | 165 | parser.manifest.uri = this.srcUrl; |
167 | loader.state = 'HAVE_NOTHING'; | ||
168 | |||
169 | // track the time that has expired from the live window | ||
170 | // this allows the seekable start range to be calculated even if | ||
171 | // all segments with timing information have expired | ||
172 | this.expired_ = 0; | ||
173 | |||
174 | // capture the prototype dispose function | ||
175 | dispose = this.dispose; | ||
176 | |||
177 | /** | ||
178 | * Abort any outstanding work and clean up. | ||
179 | */ | ||
180 | loader.dispose = function() { | ||
181 | if (request) { | ||
182 | request.onreadystatechange = null; | ||
183 | request.abort(); | ||
184 | request = null; | ||
185 | } | ||
186 | window.clearTimeout(mediaUpdateTimeout); | ||
187 | dispose.call(this); | ||
188 | }; | ||
189 | 166 | ||
190 | /** | 167 | // loaded a master playlist |
191 | * When called without any arguments, returns the currently | 168 | if (parser.manifest.playlists) { |
192 | * active media playlist. When called with a single argument, | 169 | this.master = parser.manifest; |
193 | * triggers the playlist loader to asynchronously switch to the | ||
194 | * specified media playlist. Calling this method while the | ||
195 | * loader is in the HAVE_NOTHING causes an error to be emitted | ||
196 | * but otherwise has no effect. | ||
197 | * @param playlist (optional) {object} the parsed media playlist | ||
198 | * object to switch to | ||
199 | */ | ||
200 | loader.media = function(playlist) { | ||
201 | var startingState = loader.state, mediaChange; | ||
202 | // getter | ||
203 | if (!playlist) { | ||
204 | return loader.media_; | ||
205 | } | ||
206 | 170 | ||
207 | // setter | 171 | // setup by-URI lookups |
208 | if (loader.state === 'HAVE_NOTHING') { | 172 | i = this.master.playlists.length; |
209 | throw new Error('Cannot switch media playlist from ' + loader.state); | 173 | while (i--) { |
174 | this.master.playlists[this.master.playlists[i].uri] = | ||
175 | this.master.playlists[i]; | ||
210 | } | 176 | } |
211 | 177 | ||
212 | // find the playlist object if the target playlist has been | 178 | this.trigger('loadedplaylist'); |
213 | // specified by URI | 179 | if (!this.request) { |
214 | if (typeof playlist === 'string') { | 180 | // no media playlist was specifically selected so start |
215 | if (!loader.master.playlists[playlist]) { | 181 | // from the first listed one |
216 | throw new Error('Unknown playlist URI: ' + playlist); | 182 | this.media(parser.manifest.playlists[0]); |
217 | } | ||
218 | playlist = loader.master.playlists[playlist]; | ||
219 | } | 183 | } |
184 | return; | ||
185 | } | ||
220 | 186 | ||
221 | mediaChange = !loader.media_ || playlist.uri !== loader.media_.uri; | 187 | // loaded a media playlist |
222 | 188 | // infer a master playlist if none was previously requested | |
223 | // switch to fully loaded playlists immediately | 189 | this.master = { |
224 | if (loader.master.playlists[playlist.uri].endList) { | 190 | uri: window.location.href, |
225 | // abort outstanding playlist requests | 191 | playlists: [{ |
226 | if (request) { | 192 | uri: this.srcUrl |
227 | request.onreadystatechange = null; | 193 | }] |
228 | request.abort(); | 194 | }; |
229 | request = null; | 195 | this.master.playlists[this.srcUrl] = this.master.playlists[0]; |
230 | } | 196 | this.haveMetadata(request, this.srcUrl); |
231 | loader.state = 'HAVE_METADATA'; | 197 | return this.trigger('loadedmetadata'); |
232 | loader.media_ = playlist; | 198 | }); |
233 | 199 | } | |
234 | // trigger media change if the active media has been updated | ||
235 | if (mediaChange) { | ||
236 | loader.trigger('mediachange'); | ||
237 | } | ||
238 | return; | ||
239 | } | ||
240 | 200 | ||
241 | // switching to the active playlist is a no-op | 201 | playlistRequestError(xhr, url, startingState) { |
242 | if (!mediaChange) { | 202 | this.setBandwidth(this.request || xhr); |
243 | return; | ||
244 | } | ||
245 | 203 | ||
246 | loader.state = 'SWITCHING_MEDIA'; | 204 | // any in-flight request is now finished |
247 | 205 | this.request = null; | |
248 | // there is already an outstanding playlist request | ||
249 | if (request) { | ||
250 | if (resolveUrl(loader.master.uri, playlist.uri) === request.url) { | ||
251 | // requesting to switch to the same playlist multiple times | ||
252 | // has no effect after the first | ||
253 | return; | ||
254 | } | ||
255 | request.onreadystatechange = null; | ||
256 | request.abort(); | ||
257 | request = null; | ||
258 | } | ||
259 | 206 | ||
260 | // request the new playlist | 207 | if (startingState) { |
261 | request = xhr({ | 208 | this.state = startingState; |
262 | uri: resolveUrl(loader.master.uri, playlist.uri), | 209 | } |
263 | withCredentials: withCredentials | ||
264 | }, function(error, request) { | ||
265 | if (error) { | ||
266 | return playlistRequestError(request, playlist.uri, startingState); | ||
267 | } | ||
268 | |||
269 | haveMetadata(request, playlist.uri); | ||
270 | |||
271 | // fire loadedmetadata the first time a media playlist is loaded | ||
272 | if (startingState === 'HAVE_MASTER') { | ||
273 | loader.trigger('loadedmetadata'); | ||
274 | } else { | ||
275 | loader.trigger('mediachange'); | ||
276 | } | ||
277 | }); | ||
278 | }; | ||
279 | 210 | ||
280 | loader.setBandwidth = function(xhr) { | 211 | this.error = { |
281 | loader.bandwidth = xhr.bandwidth; | 212 | playlist: this.master.playlists[url], |
282 | }; | 213 | status: xhr.status, |
214 | message: 'HLS playlist request error at URL: ' + url, | ||
215 | responseText: xhr.responseText, | ||
216 | code: (xhr.status >= 500) ? 4 : 2 | ||
217 | }; | ||
218 | this.trigger('error'); | ||
219 | } | ||
220 | |||
221 | // update the playlist loader's state in response to a new or | ||
222 | // updated playlist. | ||
223 | haveMetadata(xhr, url) { | ||
224 | let parser; | ||
225 | let refreshDelay; | ||
226 | let update; | ||
227 | |||
228 | this.setBandwidth(this.request || xhr); | ||
229 | |||
230 | // any in-flight request is now finished | ||
231 | this.request = null; | ||
232 | |||
233 | this.state = 'HAVE_METADATA'; | ||
234 | |||
235 | parser = new m3u8.Parser(); | ||
236 | parser.push(xhr.responseText); | ||
237 | parser.end(); | ||
238 | parser.manifest.uri = url; | ||
239 | |||
240 | // merge this playlist into the master | ||
241 | update = updateMaster(this.master, parser.manifest); | ||
242 | refreshDelay = (parser.manifest.targetDuration || 10) * 1000; | ||
243 | if (update) { | ||
244 | this.master = update; | ||
245 | this.updateMediaPlaylist_(parser.manifest); | ||
246 | } else { | ||
247 | // if the playlist is unchanged since the last reload, | ||
248 | // try again after half the target duration | ||
249 | refreshDelay /= 2; | ||
250 | } | ||
283 | 251 | ||
284 | // In a live list, don't keep track of the expired time until | 252 | // refresh live playlists after a target duration passes |
285 | // HLS tells us that "first play" has commenced | 253 | if (!this.media().endList) { |
286 | loader.on('firstplay', function() { | 254 | this.clearMediaUpdateTimeout_(); |
287 | this.trackExpiredTime_ = true; | 255 | this.mediaUpdateTimeout = window.setTimeout(() => { |
288 | }); | 256 | this.trigger('mediaupdatetimeout'); |
257 | }, refreshDelay); | ||
258 | } | ||
289 | 259 | ||
290 | // live playlist staleness timeout | 260 | this.trigger('loadedplaylist'); |
291 | loader.on('mediaupdatetimeout', function() { | 261 | } |
292 | if (loader.state !== 'HAVE_METADATA') { | ||
293 | // only refresh the media playlist if no other activity is going on | ||
294 | return; | ||
295 | } | ||
296 | 262 | ||
297 | loader.state = 'HAVE_CURRENT_METADATA'; | 263 | clearMediaUpdateTimeout_() { |
298 | request = xhr({ | 264 | if (this.mediaUpdateTimeout) { |
299 | uri: resolveUrl(loader.master.uri, loader.media().uri), | 265 | window.clearTimeout(this.mediaUpdateTimeout); |
300 | withCredentials: withCredentials | 266 | } |
301 | }, function(error, request) { | 267 | } |
302 | if (error) { | ||
303 | return playlistRequestError(request, loader.media().uri); | ||
304 | } | ||
305 | haveMetadata(request, loader.media().uri); | ||
306 | }); | ||
307 | }); | ||
308 | 268 | ||
309 | // request the specified URL | 269 | requestDispose_() { |
310 | request = xhr({ | 270 | if (this.request) { |
311 | uri: srcUrl, | 271 | this.request.onreadystatechange = null; |
312 | withCredentials: withCredentials | 272 | this.request.abort(); |
313 | }, function(error, req) { | 273 | this.request = null; |
314 | var parser, i; | 274 | } |
275 | } | ||
315 | 276 | ||
316 | // clear the loader's request reference | 277 | /** |
317 | request = null; | 278 | * Abort any outstanding work and clean up. |
279 | */ | ||
280 | dispose() { | ||
281 | this.requestDispose_(); | ||
282 | this.clearMediaUpdateTimeout_(); | ||
283 | super.dispose(); | ||
284 | } | ||
318 | 285 | ||
319 | if (error) { | 286 | /** |
320 | loader.error = { | 287 | * When called without any arguments, returns the currently |
321 | status: req.status, | 288 | * active media playlist. When called with a single argument, |
322 | message: 'HLS playlist request error at URL: ' + srcUrl, | 289 | * triggers the playlist loader to asynchronously switch to the |
323 | responseText: req.responseText, | 290 | * specified media playlist. Calling this method while the |
324 | code: 2 // MEDIA_ERR_NETWORK | 291 | * loader is in the HAVE_NOTHING causes an error to be emitted |
325 | }; | 292 | * but otherwise has no effect. |
326 | return loader.trigger('error'); | 293 | * @param playlist (optional) {object} the parsed media playlist |
327 | } | 294 | * object to switch to |
295 | */ | ||
296 | media(playlist) { | ||
297 | let startingState = this.state; | ||
298 | let mediaChange; | ||
328 | 299 | ||
329 | parser = new videojs.m3u8.Parser(); | 300 | // getter |
330 | parser.push(req.responseText); | 301 | if (!playlist) { |
331 | parser.end(); | 302 | return this.media_; |
303 | } | ||
332 | 304 | ||
333 | loader.state = 'HAVE_MASTER'; | 305 | // setter |
306 | if (this.state === 'HAVE_NOTHING') { | ||
307 | throw new Error('Cannot switch media playlist from ' + this.state); | ||
308 | } | ||
334 | 309 | ||
335 | parser.manifest.uri = srcUrl; | 310 | // find the playlist object if the target playlist has been |
311 | // specified by URI | ||
312 | if (typeof playlist === 'string') { | ||
313 | if (!this.master.playlists[playlist]) { | ||
314 | throw new Error('Unknown playlist URI: ' + playlist); | ||
315 | } | ||
316 | playlist = this.master.playlists[playlist]; | ||
317 | } | ||
336 | 318 | ||
337 | // loaded a master playlist | 319 | mediaChange = !this.media_ || playlist.uri !== this.media_.uri; |
338 | if (parser.manifest.playlists) { | ||
339 | loader.master = parser.manifest; | ||
340 | 320 | ||
341 | // setup by-URI lookups | 321 | // switch to fully loaded playlists immediately |
342 | i = loader.master.playlists.length; | 322 | if (this.master.playlists[playlist.uri].endList) { |
343 | while (i--) { | 323 | // abort outstanding playlist requests |
344 | loader.master.playlists[loader.master.playlists[i].uri] = loader.master.playlists[i]; | 324 | this.requestDispose_(); |
345 | } | 325 | this.state = 'HAVE_METADATA'; |
326 | this.media_ = playlist; | ||
346 | 327 | ||
347 | loader.trigger('loadedplaylist'); | 328 | // trigger media change if the active media has been updated |
348 | if (!request) { | 329 | if (mediaChange) { |
349 | // no media playlist was specifically selected so start | 330 | this.trigger('mediachange'); |
350 | // from the first listed one | 331 | } |
351 | loader.media(parser.manifest.playlists[0]); | 332 | return; |
352 | } | 333 | } |
353 | return; | ||
354 | } | ||
355 | 334 | ||
356 | // loaded a media playlist | 335 | // switching to the active playlist is a no-op |
357 | // infer a master playlist if none was previously requested | 336 | if (!mediaChange) { |
358 | loader.master = { | 337 | return; |
359 | uri: window.location.href, | 338 | } |
360 | playlists: [{ | 339 | |
361 | uri: srcUrl | 340 | this.state = 'SWITCHING_MEDIA'; |
362 | }] | 341 | |
363 | }; | 342 | // there is already an outstanding playlist request |
364 | loader.master.playlists[srcUrl] = loader.master.playlists[0]; | 343 | if (this.request) { |
365 | haveMetadata(req, srcUrl); | 344 | if (resolveUrl(this.master.uri, playlist.uri) === this.request.url) { |
366 | return loader.trigger('loadedmetadata'); | 345 | // requesting to switch to the same playlist multiple times |
367 | }); | 346 | // has no effect after the first |
368 | }; | 347 | return; |
369 | PlaylistLoader.prototype = new videojs.Hls.Stream(); | 348 | } |
349 | this.requestDispose_(); | ||
350 | } | ||
351 | |||
352 | // request the new playlist | ||
353 | this.request = XhrModule({ | ||
354 | uri: resolveUrl(this.master.uri, playlist.uri), | ||
355 | withCredentials: this.withCredentials | ||
356 | }, (error, request) => { | ||
357 | if (error) { | ||
358 | return this.playlistRequestError(request, playlist.uri, startingState); | ||
359 | } | ||
360 | this.haveMetadata(request, playlist.uri); | ||
370 | 361 | ||
362 | if (error) { | ||
363 | return; | ||
364 | } | ||
365 | |||
366 | // fire loadedmetadata the first time a media playlist is loaded | ||
367 | if (startingState === 'HAVE_MASTER') { | ||
368 | this.trigger('loadedmetadata'); | ||
369 | } else { | ||
370 | this.trigger('mediachange'); | ||
371 | } | ||
372 | }); | ||
373 | } | ||
374 | |||
375 | setBandwidth(xhr) { | ||
376 | this.bandwidth = xhr.bandwidth; | ||
377 | } | ||
371 | /** | 378 | /** |
372 | * Update the PlaylistLoader state to reflect the changes in an | 379 | * Update the PlaylistLoader state to reflect the changes in an |
373 | * update to the current media playlist. | 380 | * update to the current media playlist. |
374 | * @param update {object} the updated media playlist object | 381 | * @param update {object} the updated media playlist object |
375 | */ | 382 | */ |
376 | PlaylistLoader.prototype.updateMediaPlaylist_ = function(update) { | 383 | updateMediaPlaylist_(update) { |
377 | var outdated, i, segment; | 384 | let outdated; |
385 | let i; | ||
386 | let segment; | ||
378 | 387 | ||
379 | outdated = this.media_; | 388 | outdated = this.media_; |
380 | this.media_ = this.master.playlists[update.uri]; | 389 | this.media_ = this.master.playlists[update.uri]; |
... | @@ -398,10 +407,10 @@ | ... | @@ -398,10 +407,10 @@ |
398 | // try using precise timing from first segment of the updated | 407 | // try using precise timing from first segment of the updated |
399 | // playlist | 408 | // playlist |
400 | if (update.segments.length) { | 409 | if (update.segments.length) { |
401 | if (update.segments[0].start !== undefined) { | 410 | if (typeof update.segments[0].start !== 'undefined') { |
402 | this.expired_ = update.segments[0].start; | 411 | this.expired_ = update.segments[0].start; |
403 | return; | 412 | return; |
404 | } else if (update.segments[0].end !== undefined) { | 413 | } else if (typeof update.segments[0].end !== 'undefined') { |
405 | this.expired_ = update.segments[0].end - update.segments[0].duration; | 414 | this.expired_ = update.segments[0].end - update.segments[0].duration; |
406 | return; | 415 | return; |
407 | } | 416 | } |
... | @@ -422,17 +431,17 @@ | ... | @@ -422,17 +431,17 @@ |
422 | continue; | 431 | continue; |
423 | } | 432 | } |
424 | 433 | ||
425 | if (segment.end !== undefined) { | 434 | if (typeof segment.end !== 'undefined') { |
426 | this.expired_ = segment.end; | 435 | this.expired_ = segment.end; |
427 | return; | 436 | return; |
428 | } | 437 | } |
429 | if (segment.start !== undefined) { | 438 | if (typeof segment.start !== 'undefined') { |
430 | this.expired_ = segment.start + segment.duration; | 439 | this.expired_ = segment.start + segment.duration; |
431 | return; | 440 | return; |
432 | } | 441 | } |
433 | this.expired_ += segment.duration; | 442 | this.expired_ += segment.duration; |
434 | } | 443 | } |
435 | }; | 444 | } |
436 | 445 | ||
437 | /** | 446 | /** |
438 | * Determine the index of the segment that contains a specified | 447 | * Determine the index of the segment that contains a specified |
... | @@ -452,17 +461,16 @@ | ... | @@ -452,17 +461,16 @@ |
452 | * value will be clamped to the index of the segment containing the | 461 | * value will be clamped to the index of the segment containing the |
453 | * closest playback position that is currently available. | 462 | * closest playback position that is currently available. |
454 | */ | 463 | */ |
455 | PlaylistLoader.prototype.getMediaIndexForTime_ = function(time) { | 464 | getMediaIndexForTime_(time) { |
456 | var | 465 | let i; |
457 | i, | 466 | let segment; |
458 | segment, | 467 | let originalTime = time; |
459 | originalTime = time, | 468 | let numSegments = this.media_.segments.length; |
460 | numSegments = this.media_.segments.length, | 469 | let lastSegment = numSegments - 1; |
461 | lastSegment = numSegments - 1, | 470 | let startIndex; |
462 | startIndex, | 471 | let endIndex; |
463 | endIndex, | 472 | let knownStart; |
464 | knownStart, | 473 | let knownEnd; |
465 | knownEnd; | ||
466 | 474 | ||
467 | if (!this.media_) { | 475 | if (!this.media_) { |
468 | return 0; | 476 | return 0; |
... | @@ -492,7 +500,7 @@ | ... | @@ -492,7 +500,7 @@ |
492 | 500 | ||
493 | // use the bounds we just found and playlist information to | 501 | // use the bounds we just found and playlist information to |
494 | // estimate the segment that contains the time we are looking for | 502 | // estimate the segment that contains the time we are looking for |
495 | if (startIndex !== undefined) { | 503 | if (typeof startIndex !== 'undefined') { |
496 | // We have a known-start point that is before our desired time so | 504 | // We have a known-start point that is before our desired time so |
497 | // walk from that point forwards | 505 | // walk from that point forwards |
498 | time = time - knownStart; | 506 | time = time - knownStart; |
... | @@ -517,7 +525,7 @@ | ... | @@ -517,7 +525,7 @@ |
517 | 525 | ||
518 | // We _still_ haven't found a segment so load the last one | 526 | // We _still_ haven't found a segment so load the last one |
519 | return lastSegment; | 527 | return lastSegment; |
520 | } else if (endIndex !== undefined) { | 528 | } else if (typeof endIndex !== 'undefined') { |
521 | // We _only_ have a known-end point that is after our desired time so | 529 | // We _only_ have a known-end point that is after our desired time so |
522 | // walk from that point backwards | 530 | // walk from that point backwards |
523 | time = knownEnd - time; | 531 | time = knownEnd - time; |
... | @@ -533,32 +541,28 @@ | ... | @@ -533,32 +541,28 @@ |
533 | // We haven't found a segment so load the first one if time is zero | 541 | // We haven't found a segment so load the first one if time is zero |
534 | if (time === 0) { | 542 | if (time === 0) { |
535 | return 0; | 543 | return 0; |
536 | } else { | ||
537 | return -1; | ||
538 | } | 544 | } |
539 | } else { | 545 | return -1; |
540 | // We known nothing so walk from the front of the playlist, | 546 | } |
541 | // subtracting durations until we find a segment that contains | 547 | // We known nothing so walk from the front of the playlist, |
542 | // time and return it | 548 | // subtracting durations until we find a segment that contains |
543 | time = time - this.expired_; | 549 | // time and return it |
550 | time = time - this.expired_; | ||
544 | 551 | ||
545 | if (time < 0) { | 552 | if (time < 0) { |
546 | return -1; | 553 | return -1; |
547 | } | 554 | } |
548 | 555 | ||
549 | for (i = 0; i < numSegments; i++) { | 556 | for (i = 0; i < numSegments; i++) { |
550 | segment = this.media_.segments[i]; | 557 | segment = this.media_.segments[i]; |
551 | time -= segment.duration; | 558 | time -= segment.duration; |
552 | if (time < 0) { | 559 | if (time < 0) { |
553 | return i; | 560 | return i; |
554 | } | ||
555 | } | 561 | } |
556 | // We are out of possible candidates so load the last one... | ||
557 | // The last one is the least likely to overlap a buffer and therefore | ||
558 | // the one most likely to tell us something about the timeline | ||
559 | return lastSegment; | ||
560 | } | 562 | } |
561 | }; | 563 | // We are out of possible candidates so load the last one... |
562 | 564 | // The last one is the least likely to overlap a buffer and therefore | |
563 | videojs.Hls.PlaylistLoader = PlaylistLoader; | 565 | // the one most likely to tell us something about the timeline |
564 | })(window, window.videojs); | 566 | return lastSegment; |
567 | } | ||
568 | } | ... | ... |
1 | /** | 1 | /** |
2 | * Playlist related utilities. | 2 | * Playlist related utilities. |
3 | */ | 3 | */ |
4 | (function(window, videojs) { | 4 | import {createTimeRange} from 'video.js'; |
5 | 'use strict'; | 5 | |
6 | 6 | const backwardDuration = function(playlist, endSequence) { | |
7 | var duration, intervalDuration, backwardDuration, forwardDuration, seekable; | 7 | let result = 0; |
8 | 8 | let i = endSequence - playlist.mediaSequence; | |
9 | backwardDuration = function(playlist, endSequence) { | 9 | // if a start time is available for segment immediately following |
10 | var result = 0, segment, i; | 10 | // the interval, use it |
11 | 11 | let segment = playlist.segments[i]; | |
12 | i = endSequence - playlist.mediaSequence; | 12 | |
13 | // if a start time is available for segment immediately following | 13 | // Walk backward until we find the latest segment with timeline |
14 | // the interval, use it | 14 | // information that is earlier than endSequence |
15 | if (segment) { | ||
16 | if (typeof segment.start !== 'undefined') { | ||
17 | return { result: segment.start, precise: true }; | ||
18 | } | ||
19 | if (typeof segment.end !== 'undefined') { | ||
20 | return { | ||
21 | result: segment.end - segment.duration, | ||
22 | precise: true | ||
23 | }; | ||
24 | } | ||
25 | } | ||
26 | while (i--) { | ||
15 | segment = playlist.segments[i]; | 27 | segment = playlist.segments[i]; |
16 | // Walk backward until we find the latest segment with timeline | 28 | if (typeof segment.end !== 'undefined') { |
17 | // information that is earlier than endSequence | 29 | return { result: result + segment.end, precise: true }; |
18 | if (segment) { | ||
19 | if (segment.start !== undefined) { | ||
20 | return { result: segment.start, precise: true }; | ||
21 | } | ||
22 | if (segment.end !== undefined) { | ||
23 | return { | ||
24 | result: segment.end - segment.duration, | ||
25 | precise: true | ||
26 | }; | ||
27 | } | ||
28 | } | 30 | } |
29 | while (i--) { | ||
30 | segment = playlist.segments[i]; | ||
31 | if (segment.end !== undefined) { | ||
32 | return { result: result + segment.end, precise: true }; | ||
33 | } | ||
34 | 31 | ||
35 | result += segment.duration; | 32 | result += segment.duration; |
36 | 33 | ||
37 | if (segment.start !== undefined) { | 34 | if (typeof segment.start !== 'undefined') { |
38 | return { result: result + segment.start, precise: true }; | 35 | return { result: result + segment.start, precise: true }; |
39 | } | ||
40 | } | 36 | } |
41 | return { result: result, precise: false }; | 37 | } |
42 | }; | 38 | return { result, precise: false }; |
43 | 39 | }; | |
44 | forwardDuration = function(playlist, endSequence) { | 40 | |
45 | var result = 0, segment, i; | 41 | const forwardDuration = function(playlist, endSequence) { |
46 | 42 | let result = 0; | |
47 | i = endSequence - playlist.mediaSequence; | 43 | let segment; |
48 | // Walk forward until we find the earliest segment with timeline | 44 | let i = endSequence - playlist.mediaSequence; |
49 | // information | 45 | // Walk forward until we find the earliest segment with timeline |
50 | for (; i < playlist.segments.length; i++) { | 46 | // information |
51 | segment = playlist.segments[i]; | 47 | |
52 | if (segment.start !== undefined) { | 48 | for (; i < playlist.segments.length; i++) { |
53 | return { | 49 | segment = playlist.segments[i]; |
54 | result: segment.start - result, | 50 | if (typeof segment.start !== 'undefined') { |
55 | precise: true | 51 | return { |
56 | }; | 52 | result: segment.start - result, |
57 | } | 53 | precise: true |
58 | 54 | }; | |
59 | result += segment.duration; | ||
60 | |||
61 | if (segment.end !== undefined) { | ||
62 | return { | ||
63 | result: segment.end - result, | ||
64 | precise: true | ||
65 | }; | ||
66 | } | ||
67 | |||
68 | } | ||
69 | // indicate we didn't find a useful duration estimate | ||
70 | return { result: -1, precise: false }; | ||
71 | }; | ||
72 | |||
73 | /** | ||
74 | * Calculate the media duration from the segments associated with a | ||
75 | * playlist. The duration of a subinterval of the available segments | ||
76 | * may be calculated by specifying an end index. | ||
77 | * | ||
78 | * @param playlist {object} a media playlist object | ||
79 | * @param endSequence {number} (optional) an exclusive upper boundary | ||
80 | * for the playlist. Defaults to playlist length. | ||
81 | * @return {number} the duration between the first available segment | ||
82 | * and end index. | ||
83 | */ | ||
84 | intervalDuration = function(playlist, endSequence) { | ||
85 | var backward, forward; | ||
86 | |||
87 | if (endSequence === undefined) { | ||
88 | endSequence = playlist.mediaSequence + playlist.segments.length; | ||
89 | } | 55 | } |
90 | 56 | ||
91 | if (endSequence < playlist.mediaSequence) { | 57 | result += segment.duration; |
92 | return 0; | ||
93 | } | ||
94 | 58 | ||
95 | // do a backward walk to estimate the duration | 59 | if (typeof segment.end !== 'undefined') { |
96 | backward = backwardDuration(playlist, endSequence); | 60 | return { |
97 | if (backward.precise) { | 61 | result: segment.end - result, |
98 | // if we were able to base our duration estimate on timing | 62 | precise: true |
99 | // information provided directly from the Media Source, return | 63 | }; |
100 | // it | ||
101 | return backward.result; | ||
102 | } | 64 | } |
103 | 65 | ||
104 | // walk forward to see if a precise duration estimate can be made | 66 | } |
105 | // that way | 67 | // indicate we didn't find a useful duration estimate |
106 | forward = forwardDuration(playlist, endSequence); | 68 | return { result: -1, precise: false }; |
107 | if (forward.precise) { | 69 | }; |
108 | // we found a segment that has been buffered and so it's | ||
109 | // position is known precisely | ||
110 | return forward.result; | ||
111 | } | ||
112 | 70 | ||
113 | // return the less-precise, playlist-based duration estimate | 71 | /** |
72 | * Calculate the media duration from the segments associated with a | ||
73 | * playlist. The duration of a subinterval of the available segments | ||
74 | * may be calculated by specifying an end index. | ||
75 | * | ||
76 | * @param playlist {object} a media playlist object | ||
77 | * @param endSequence {number} (optional) an exclusive upper boundary | ||
78 | * for the playlist. Defaults to playlist length. | ||
79 | * @return {number} the duration between the first available segment | ||
80 | * and end index. | ||
81 | */ | ||
82 | const intervalDuration = function(playlist, endSequence) { | ||
83 | let backward; | ||
84 | let forward; | ||
85 | |||
86 | if (typeof endSequence === 'undefined') { | ||
87 | endSequence = playlist.mediaSequence + playlist.segments.length; | ||
88 | } | ||
89 | |||
90 | if (endSequence < playlist.mediaSequence) { | ||
91 | return 0; | ||
92 | } | ||
93 | |||
94 | // do a backward walk to estimate the duration | ||
95 | backward = backwardDuration(playlist, endSequence); | ||
96 | if (backward.precise) { | ||
97 | // if we were able to base our duration estimate on timing | ||
98 | // information provided directly from the Media Source, return | ||
99 | // it | ||
114 | return backward.result; | 100 | return backward.result; |
115 | }; | 101 | } |
116 | |||
117 | /** | ||
118 | * Calculates the duration of a playlist. If a start and end index | ||
119 | * are specified, the duration will be for the subset of the media | ||
120 | * timeline between those two indices. The total duration for live | ||
121 | * playlists is always Infinity. | ||
122 | * @param playlist {object} a media playlist object | ||
123 | * @param endSequence {number} (optional) an exclusive upper | ||
124 | * boundary for the playlist. Defaults to the playlist media | ||
125 | * sequence number plus its length. | ||
126 | * @param includeTrailingTime {boolean} (optional) if false, the | ||
127 | * interval between the final segment and the subsequent segment | ||
128 | * will not be included in the result | ||
129 | * @return {number} the duration between the start index and end | ||
130 | * index. | ||
131 | */ | ||
132 | duration = function(playlist, endSequence, includeTrailingTime) { | ||
133 | if (!playlist) { | ||
134 | return 0; | ||
135 | } | ||
136 | 102 | ||
137 | if (includeTrailingTime === undefined) { | 103 | // walk forward to see if a precise duration estimate can be made |
138 | includeTrailingTime = true; | 104 | // that way |
139 | } | 105 | forward = forwardDuration(playlist, endSequence); |
106 | if (forward.precise) { | ||
107 | // we found a segment that has been buffered and so it's | ||
108 | // position is known precisely | ||
109 | return forward.result; | ||
110 | } | ||
140 | 111 | ||
141 | // if a slice of the total duration is not requested, use | 112 | // return the less-precise, playlist-based duration estimate |
142 | // playlist-level duration indicators when they're present | 113 | return backward.result; |
143 | if (endSequence === undefined) { | 114 | }; |
144 | // if present, use the duration specified in the playlist | ||
145 | if (playlist.totalDuration) { | ||
146 | return playlist.totalDuration; | ||
147 | } | ||
148 | |||
149 | // duration should be Infinity for live playlists | ||
150 | if (!playlist.endList) { | ||
151 | return window.Infinity; | ||
152 | } | ||
153 | } | ||
154 | 115 | ||
155 | // calculate the total duration based on the segment durations | 116 | /** |
156 | return intervalDuration(playlist, | 117 | * Calculates the duration of a playlist. If a start and end index |
157 | endSequence, | 118 | * are specified, the duration will be for the subset of the media |
158 | includeTrailingTime); | 119 | * timeline between those two indices. The total duration for live |
159 | }; | 120 | * playlists is always Infinity. |
160 | 121 | * @param playlist {object} a media playlist object | |
161 | /** | 122 | * @param endSequence {number} (optional) an exclusive upper |
162 | * Calculates the interval of time that is currently seekable in a | 123 | * boundary for the playlist. Defaults to the playlist media |
163 | * playlist. The returned time ranges are relative to the earliest | 124 | * sequence number plus its length. |
164 | * moment in the specified playlist that is still available. A full | 125 | * @param includeTrailingTime {boolean} (optional) if false, the |
165 | * seekable implementation for live streams would need to offset | 126 | * interval between the final segment and the subsequent segment |
166 | * these values by the duration of content that has expired from the | 127 | * will not be included in the result |
167 | * stream. | 128 | * @return {number} the duration between the start index and end |
168 | * @param playlist {object} a media playlist object | 129 | * index. |
169 | * @return {TimeRanges} the periods of time that are valid targets | 130 | */ |
170 | * for seeking | 131 | export const duration = function(playlist, endSequence, includeTrailingTime) { |
171 | */ | 132 | if (!playlist) { |
172 | seekable = function(playlist) { | 133 | return 0; |
173 | var start, end; | 134 | } |
174 | 135 | ||
175 | // without segments, there are no seekable ranges | 136 | if (typeof includeTrailingTime === 'undefined') { |
176 | if (!playlist.segments) { | 137 | includeTrailingTime = true; |
177 | return videojs.createTimeRange(); | 138 | } |
139 | |||
140 | // if a slice of the total duration is not requested, use | ||
141 | // playlist-level duration indicators when they're present | ||
142 | if (typeof endSequence === 'undefined') { | ||
143 | // if present, use the duration specified in the playlist | ||
144 | if (playlist.totalDuration) { | ||
145 | return playlist.totalDuration; | ||
178 | } | 146 | } |
179 | // when the playlist is complete, the entire duration is seekable | 147 | |
180 | if (playlist.endList) { | 148 | // duration should be Infinity for live playlists |
181 | return videojs.createTimeRange(0, duration(playlist)); | 149 | if (!playlist.endList) { |
150 | return window.Infinity; | ||
182 | } | 151 | } |
152 | } | ||
183 | 153 | ||
184 | // live playlists should not expose three segment durations worth | 154 | // calculate the total duration based on the segment durations |
185 | // of content from the end of the playlist | 155 | return intervalDuration( |
186 | // https://tools.ietf.org/html/draft-pantos-http-live-streaming-16#section-6.3.3 | 156 | playlist, |
187 | start = intervalDuration(playlist, playlist.mediaSequence); | 157 | endSequence, |
188 | end = intervalDuration(playlist, | 158 | includeTrailingTime |
189 | playlist.mediaSequence + Math.max(0, playlist.segments.length - 3)); | 159 | ); |
190 | return videojs.createTimeRange(start, end); | 160 | }; |
191 | }; | 161 | |
192 | 162 | /** | |
193 | // exports | 163 | * Calculates the interval of time that is currently seekable in a |
194 | videojs.Hls.Playlist = { | 164 | * playlist. The returned time ranges are relative to the earliest |
195 | duration: duration, | 165 | * moment in the specified playlist that is still available. A full |
196 | seekable: seekable | 166 | * seekable implementation for live streams would need to offset |
197 | }; | 167 | * these values by the duration of content that has expired from the |
198 | })(window, window.videojs); | 168 | * stream. |
169 | * @param playlist {object} a media playlist object | ||
170 | * @return {TimeRanges} the periods of time that are valid targets | ||
171 | * for seeking | ||
172 | */ | ||
173 | export const seekable = function(playlist) { | ||
174 | let start; | ||
175 | let end; | ||
176 | |||
177 | // without segments, there are no seekable ranges | ||
178 | if (!playlist.segments) { | ||
179 | return createTimeRange(); | ||
180 | } | ||
181 | // when the playlist is complete, the entire duration is seekable | ||
182 | if (playlist.endList) { | ||
183 | return createTimeRange(0, duration(playlist)); | ||
184 | } | ||
185 | |||
186 | // live playlists should not expose three segment durations worth | ||
187 | // of content from the end of the playlist | ||
188 | // https://tools.ietf.org/html/draft-pantos-http-live-streaming-16#section-6.3.3 | ||
189 | start = intervalDuration(playlist, playlist.mediaSequence); | ||
190 | end = intervalDuration( | ||
191 | playlist, | ||
192 | playlist.mediaSequence + Math.max(0, playlist.segments.length - 3) | ||
193 | ); | ||
194 | return createTimeRange(start, end); | ||
195 | }; | ||
196 | |||
197 | // exports | ||
198 | export default { | ||
199 | duration, | ||
200 | seekable | ||
201 | }; | ... | ... |
src/resolve-url.js
0 → 100644
1 | import document from 'global/document'; | ||
2 | /* eslint-disable max-len */ | ||
3 | /** | ||
4 | * Constructs a new URI by interpreting a path relative to another | ||
5 | * URI. | ||
6 | * @param basePath {string} a relative or absolute URI | ||
7 | * @param path {string} a path part to combine with the base | ||
8 | * @return {string} a URI that is equivalent to composing `base` | ||
9 | * with `path` | ||
10 | * @see http://stackoverflow.com/questions/470832/getting-an-absolute-url-from-a-relative-one-ie6-issue | ||
11 | */ | ||
12 | /* eslint-enable max-len */ | ||
13 | const resolveUrl = function(basePath, path) { | ||
14 | // use the base element to get the browser to handle URI resolution | ||
15 | let oldBase = document.querySelector('base'); | ||
16 | let docHead = document.querySelector('head'); | ||
17 | let a = document.createElement('a'); | ||
18 | let base = oldBase; | ||
19 | let oldHref; | ||
20 | let result; | ||
21 | |||
22 | // prep the document | ||
23 | if (oldBase) { | ||
24 | oldHref = oldBase.href; | ||
25 | } else { | ||
26 | base = docHead.appendChild(document.createElement('base')); | ||
27 | } | ||
28 | |||
29 | base.href = basePath; | ||
30 | a.href = path; | ||
31 | result = a.href; | ||
32 | |||
33 | // clean up | ||
34 | if (oldBase) { | ||
35 | oldBase.href = oldHref; | ||
36 | } else { | ||
37 | docHead.removeChild(base); | ||
38 | } | ||
39 | return result; | ||
40 | }; | ||
41 | |||
42 | export default resolveUrl; |
... | @@ -2,6 +2,10 @@ import m3u8 from './m3u8'; | ... | @@ -2,6 +2,10 @@ import m3u8 from './m3u8'; |
2 | import Stream from './stream'; | 2 | import Stream from './stream'; |
3 | import videojs from 'video.js'; | 3 | import videojs from 'video.js'; |
4 | import {Decrypter, decrypt, AsyncStream} from './decrypter'; | 4 | import {Decrypter, decrypt, AsyncStream} from './decrypter'; |
5 | import Playlist from './playlist'; | ||
6 | import PlaylistLoader from './playlist-loader'; | ||
7 | import xhr from './xhr'; | ||
8 | |||
5 | 9 | ||
6 | if(typeof window.videojs.Hls === 'undefined') { | 10 | if(typeof window.videojs.Hls === 'undefined') { |
7 | videojs.Hls = {}; | 11 | videojs.Hls = {}; |
... | @@ -11,3 +15,6 @@ videojs.m3u8 = m3u8; | ... | @@ -11,3 +15,6 @@ videojs.m3u8 = m3u8; |
11 | videojs.Hls.decrypt = decrypt; | 15 | videojs.Hls.decrypt = decrypt; |
12 | videojs.Hls.Decrypter = Decrypter; | 16 | videojs.Hls.Decrypter = Decrypter; |
13 | videojs.Hls.AsyncStream = AsyncStream; | 17 | videojs.Hls.AsyncStream = AsyncStream; |
18 | videojs.Hls.xhr = xhr; | ||
19 | videojs.Hls.Playlist = Playlist; | ||
20 | videojs.Hls.PlaylistLoader = PlaylistLoader; | ... | ... |
1 | (function(videojs) { | 1 | /** |
2 | 'use strict'; | 2 | * A wrapper for videojs.xhr that tracks bandwidth. |
3 | */ | ||
4 | import {xhr as videojsXHR, mergeOptions} from 'video.js'; | ||
5 | const xhr = function(options, callback) { | ||
6 | // Add a default timeout for all hls requests | ||
7 | options = mergeOptions({ | ||
8 | timeout: 45e3 | ||
9 | }, options); | ||
3 | 10 | ||
4 | /** | 11 | let request = videojsXHR(options, function(error, response) { |
5 | * A wrapper for videojs.xhr that tracks bandwidth. | 12 | if (!error && request.response) { |
6 | */ | 13 | request.responseTime = (new Date()).getTime(); |
7 | videojs.Hls.xhr = function(options, callback) { | 14 | request.roundTripTime = request.responseTime - request.requestTime; |
8 | // Add a default timeout for all hls requests | 15 | request.bytesReceived = request.response.byteLength || request.response.length; |
9 | options = videojs.mergeOptions({ | 16 | if (!request.bandwidth) { |
10 | timeout: 45e3 | 17 | request.bandwidth = |
11 | }, options); | 18 | Math.floor((request.bytesReceived / request.roundTripTime) * 8 * 1000); |
12 | |||
13 | var request = videojs.xhr(options, function(error, response) { | ||
14 | if (!error && request.response) { | ||
15 | request.responseTime = (new Date()).getTime(); | ||
16 | request.roundTripTime = request.responseTime - request.requestTime; | ||
17 | request.bytesReceived = request.response.byteLength || request.response.length; | ||
18 | if (!request.bandwidth) { | ||
19 | request.bandwidth = Math.floor((request.bytesReceived / request.roundTripTime) * 8 * 1000); | ||
20 | } | ||
21 | } | 19 | } |
20 | } | ||
22 | 21 | ||
23 | // videojs.xhr now uses a specific code on the error object to signal that a request has | 22 | // videojs.xhr now uses a specific code |
24 | // timed out errors of setting a boolean on the request object | 23 | // on the error object to signal that a request has |
25 | if (error || request.timedout) { | 24 | // timed out errors of setting a boolean on the request object |
26 | request.timedout = request.timedout || (error.code === 'ETIMEDOUT'); | 25 | if (error || request.timedout) { |
27 | } else { | 26 | request.timedout = request.timedout || (error.code === 'ETIMEDOUT'); |
28 | request.timedout = false; | 27 | } else { |
29 | } | 28 | request.timedout = false; |
29 | } | ||
30 | 30 | ||
31 | // videojs.xhr no longer considers status codes outside of 200 and 0 | 31 | // videojs.xhr no longer considers status codes outside of 200 and 0 |
32 | // (for file uris) to be errors, but the old XHR did, so emulate that | 32 | // (for file uris) to be errors, but the old XHR did, so emulate that |
33 | // behavior. Status 206 may be used in response to byterange requests. | 33 | // behavior. Status 206 may be used in response to byterange requests. |
34 | if (!error && | 34 | if (!error && |
35 | response.statusCode !== 200 && | 35 | response.statusCode !== 200 && |
36 | response.statusCode !== 206 && | 36 | response.statusCode !== 206 && |
37 | response.statusCode !== 0) { | 37 | response.statusCode !== 0) { |
38 | error = new Error('XHR Failed with a response of: ' + | 38 | error = new Error( |
39 | (request && (request.response || request.responseText))); | 39 | 'XHR Failed with a response of: ' + |
40 | } | 40 | (request && (request.response || request.responseText)) |
41 | ); | ||
42 | } | ||
43 | |||
44 | callback(error, request); | ||
45 | }); | ||
41 | 46 | ||
42 | callback(error, request); | 47 | request.requestTime = (new Date()).getTime(); |
43 | }); | 48 | return request; |
49 | }; | ||
44 | 50 | ||
45 | request.requestTime = (new Date()).getTime(); | 51 | export default xhr; |
46 | return request; | ||
47 | }; | ||
48 | })(window.videojs); | ... | ... |
... | @@ -16,16 +16,11 @@ | ... | @@ -16,16 +16,11 @@ |
16 | <script src="/node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js"></script> | 16 | <script src="/node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js"></script> |
17 | 17 | ||
18 | <script src="/src/videojs-contrib-hls.js"></script> | 18 | <script src="/src/videojs-contrib-hls.js"></script> |
19 | <script src="/src/xhr.js"></script> | ||
20 | <script src="/dist/videojs-contrib-hls.js"></script> | 19 | <script src="/dist/videojs-contrib-hls.js"></script> |
21 | <script src="/src/playlist.js"></script> | ||
22 | <script src="/src/playlist-loader.js"></script> | ||
23 | <script src="/src/bin-utils.js"></script> | 20 | <script src="/src/bin-utils.js"></script> |
24 | 21 | ||
25 | <script src="/test/videojs-contrib-hls.test.js"></script> | 22 | <script src="/test/videojs-contrib-hls.test.js"></script> |
26 | <script src="/dist-test/videojs-contrib-hls.js"></script> | 23 | <script src="/dist-test/videojs-contrib-hls.js"></script> |
27 | <script src="/test/playlist.test.js"></script> | ||
28 | <script src="/test/playlist-loader.test.js"></script> | ||
29 | 24 | ||
30 | </body> | 25 | </body> |
31 | </html> | 26 | </html> | ... | ... |
... | @@ -16,11 +16,8 @@ var DEFAULTS = { | ... | @@ -16,11 +16,8 @@ var DEFAULTS = { |
16 | 16 | ||
17 | // these two stub old functionality | 17 | // these two stub old functionality |
18 | 'src/videojs-contrib-hls.js', | 18 | 'src/videojs-contrib-hls.js', |
19 | 'src/xhr.js', | ||
20 | 'dist/videojs-contrib-hls.js', | 19 | 'dist/videojs-contrib-hls.js', |
21 | 20 | ||
22 | 'src/playlist.js', | ||
23 | 'src/playlist-loader.js', | ||
24 | 'src/bin-utils.js', | 21 | 'src/bin-utils.js', |
25 | 22 | ||
26 | 'test/stub.test.js', | 23 | 'test/stub.test.js', |
... | @@ -45,7 +42,7 @@ var DEFAULTS = { | ... | @@ -45,7 +42,7 @@ var DEFAULTS = { |
45 | ], | 42 | ], |
46 | 43 | ||
47 | preprocessors: { | 44 | preprocessors: { |
48 | 'test/{decrypter,stub,m3u8}.test.js': ['browserify'] | 45 | 'test/{playlist*,decrypter,stub,m3u8}.test.js': ['browserify'] |
49 | }, | 46 | }, |
50 | 47 | ||
51 | reporters: ['dots'], | 48 | reporters: ['dots'], | ... | ... |
1 | (function(window) { | 1 | import sinon from 'sinon'; |
2 | 'use strict'; | 2 | import QUnit from 'qunit'; |
3 | var | 3 | import PlaylistLoader from '../src/playlist-loader'; |
4 | sinonXhr, | 4 | import videojs from 'video.js'; |
5 | clock, | 5 | // Attempts to produce an absolute URL to a given relative path |
6 | requests, | 6 | // based on window.location.href |
7 | videojs = window.videojs, | 7 | const urlTo = function(path) { |
8 | 8 | return window.location.href | |
9 | // Attempts to produce an absolute URL to a given relative path | 9 | .split('/') |
10 | // based on window.location.href | 10 | .slice(0, -1) |
11 | urlTo = function(path) { | 11 | .concat([path]) |
12 | return window.location.href | 12 | .join('/'); |
13 | .split('/') | 13 | }; |
14 | .slice(0, -1) | 14 | |
15 | .concat([path]) | 15 | const respond = function(request, string) { |
16 | .join('/'); | 16 | return request.respond(200, null, string); |
17 | }; | ||
18 | |||
19 | QUnit.module('Playlist Loader', { | ||
20 | beforeEach() { | ||
21 | // fake XHRs | ||
22 | this.oldXHR = videojs.xhr.XMLHttpRequest; | ||
23 | this.sinonXhr = sinon.useFakeXMLHttpRequest(); | ||
24 | this.requests = []; | ||
25 | this.sinonXhr.onCreate = (xhr) => { | ||
26 | // force the XHR2 timeout polyfill | ||
27 | xhr.timeout = null; | ||
28 | this.requests.push(xhr); | ||
17 | }; | 29 | }; |
18 | 30 | ||
19 | QUnit.module('Playlist Loader', { | 31 | // fake timers |
20 | setup: function() { | 32 | this.clock = sinon.useFakeTimers(); |
21 | // fake XHRs | 33 | videojs.xhr.XMLHttpRequest = this.sinonXhr; |
22 | sinonXhr = sinon.useFakeXMLHttpRequest(); | 34 | }, |
23 | videojs.xhr.XMLHttpRequest = sinonXhr; | 35 | afterEach() { |
24 | 36 | this.sinonXhr.restore(); | |
25 | requests = []; | 37 | this.clock.restore(); |
26 | sinonXhr.onCreate = function(xhr) { | 38 | videojs.xhr.XMLHttpRequest = this.oldXHR; |
27 | // force the XHR2 timeout polyfill | 39 | } |
28 | xhr.timeout = undefined; | 40 | }); |
29 | requests.push(xhr); | 41 | |
30 | }; | 42 | QUnit.test('throws if the playlist url is empty or undefined', function() { |
31 | 43 | QUnit.throws(function() { | |
32 | // fake timers | 44 | PlaylistLoader(); |
33 | clock = sinon.useFakeTimers(); | 45 | }, 'requires an argument'); |
34 | }, | 46 | QUnit.throws(function() { |
35 | teardown: function() { | 47 | PlaylistLoader(''); |
36 | sinonXhr.restore(); | 48 | }, 'does not accept the empty string'); |
37 | videojs.xhr.XMLHttpRequest = window.XMLHttpRequest; | 49 | }); |
38 | clock.restore(); | 50 | |
39 | } | 51 | QUnit.test('starts without any metadata', function() { |
52 | let loader = new PlaylistLoader('master.m3u8'); | ||
53 | |||
54 | QUnit.strictEqual(loader.state, 'HAVE_NOTHING', 'no metadata has loaded yet'); | ||
55 | }); | ||
56 | |||
57 | QUnit.test('starts with no expired time', function() { | ||
58 | let loader = new PlaylistLoader('media.m3u8'); | ||
59 | |||
60 | respond(this.requests.pop(), | ||
61 | '#EXTM3U\n' + | ||
62 | '#EXTINF:10,\n' + | ||
63 | '0.ts\n'); | ||
64 | QUnit.equal(loader.expired_, | ||
65 | 0, | ||
66 | 'zero seconds expired'); | ||
67 | }); | ||
68 | |||
69 | QUnit.test('this.requests the initial playlist immediately', function() { | ||
70 | /* eslint-disable no-unused-vars */ | ||
71 | let loader = new PlaylistLoader('master.m3u8'); | ||
72 | /* eslint-enable no-unused-vars */ | ||
73 | |||
74 | QUnit.strictEqual(this.requests.length, 1, 'made a request'); | ||
75 | QUnit.strictEqual(this.requests[0].url, | ||
76 | 'master.m3u8', | ||
77 | 'requested the initial playlist'); | ||
78 | }); | ||
79 | |||
80 | QUnit.test('moves to HAVE_MASTER after loading a master playlist', function() { | ||
81 | let loader = new PlaylistLoader('master.m3u8'); | ||
82 | let state; | ||
83 | |||
84 | loader.on('loadedplaylist', function() { | ||
85 | state = loader.state; | ||
40 | }); | 86 | }); |
41 | 87 | respond(this.requests.pop(), | |
42 | test('throws if the playlist url is empty or undefined', function() { | 88 | '#EXTM3U\n' + |
43 | throws(function() { | 89 | '#EXT-X-STREAM-INF:\n' + |
44 | videojs.Hls.PlaylistLoader(); | 90 | 'media.m3u8\n'); |
45 | }, 'requires an argument'); | 91 | QUnit.ok(loader.master, 'the master playlist is available'); |
46 | throws(function() { | 92 | QUnit.strictEqual(state, 'HAVE_MASTER', 'the state at loadedplaylist correct'); |
47 | videojs.Hls.PlaylistLoader(''); | 93 | }); |
48 | }, 'does not accept the empty string'); | 94 | |
49 | }); | 95 | QUnit.test('jumps to HAVE_METADATA when initialized with a media playlist', function() { |
50 | 96 | let loadedmetadatas = 0; | |
51 | test('starts without any metadata', function() { | 97 | let loader = new PlaylistLoader('media.m3u8'); |
52 | var loader = new videojs.Hls.PlaylistLoader('master.m3u8'); | 98 | |
53 | strictEqual(loader.state, 'HAVE_NOTHING', 'no metadata has loaded yet'); | 99 | loader.on('loadedmetadata', function() { |
54 | }); | 100 | loadedmetadatas++; |
55 | |||
56 | test('starts with no expired time', function() { | ||
57 | var loader = new videojs.Hls.PlaylistLoader('media.m3u8'); | ||
58 | requests.pop().respond(200, null, | ||
59 | '#EXTM3U\n' + | ||
60 | '#EXTINF:10,\n' + | ||
61 | '0.ts\n'); | ||
62 | equal(loader.expired_, | ||
63 | 0, | ||
64 | 'zero seconds expired'); | ||
65 | }); | ||
66 | |||
67 | test('requests the initial playlist immediately', function() { | ||
68 | new videojs.Hls.PlaylistLoader('master.m3u8'); | ||
69 | strictEqual(requests.length, 1, 'made a request'); | ||
70 | strictEqual(requests[0].url, 'master.m3u8', 'requested the initial playlist'); | ||
71 | }); | ||
72 | |||
73 | test('moves to HAVE_MASTER after loading a master playlist', function() { | ||
74 | var loader = new videojs.Hls.PlaylistLoader('master.m3u8'), state; | ||
75 | loader.on('loadedplaylist', function() { | ||
76 | state = loader.state; | ||
77 | }); | ||
78 | requests.pop().respond(200, null, | ||
79 | '#EXTM3U\n' + | ||
80 | '#EXT-X-STREAM-INF:\n' + | ||
81 | 'media.m3u8\n'); | ||
82 | ok(loader.master, 'the master playlist is available'); | ||
83 | strictEqual(state, 'HAVE_MASTER', 'the state at loadedplaylist correct'); | ||
84 | }); | ||
85 | |||
86 | test('jumps to HAVE_METADATA when initialized with a media playlist', function() { | ||
87 | var | ||
88 | loadedmetadatas = 0, | ||
89 | loader = new videojs.Hls.PlaylistLoader('media.m3u8'); | ||
90 | loader.on('loadedmetadata', function() { | ||
91 | loadedmetadatas++; | ||
92 | }); | ||
93 | requests.pop().respond(200, null, | ||
94 | '#EXTM3U\n' + | ||
95 | '#EXTINF:10,\n' + | ||
96 | '0.ts\n' + | ||
97 | '#EXT-X-ENDLIST\n'); | ||
98 | ok(loader.master, 'infers a master playlist'); | ||
99 | ok(loader.media(), 'sets the media playlist'); | ||
100 | ok(loader.media().uri, 'sets the media playlist URI'); | ||
101 | strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct'); | ||
102 | strictEqual(requests.length, 0, 'no more requests are made'); | ||
103 | strictEqual(loadedmetadatas, 1, 'fired one loadedmetadata'); | ||
104 | }); | ||
105 | |||
106 | test('jumps to HAVE_METADATA when initialized with a live media playlist', function() { | ||
107 | var loader = new videojs.Hls.PlaylistLoader('media.m3u8'); | ||
108 | requests.pop().respond(200, null, | ||
109 | '#EXTM3U\n' + | ||
110 | '#EXTINF:10,\n' + | ||
111 | '0.ts\n'); | ||
112 | ok(loader.master, 'infers a master playlist'); | ||
113 | ok(loader.media(), 'sets the media playlist'); | ||
114 | strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct'); | ||
115 | }); | ||
116 | |||
117 | test('moves to HAVE_METADATA after loading a media playlist', function() { | ||
118 | var | ||
119 | loadedPlaylist = 0, | ||
120 | loadedMetadata = 0, | ||
121 | loader = new videojs.Hls.PlaylistLoader('master.m3u8'); | ||
122 | loader.on('loadedplaylist', function() { | ||
123 | loadedPlaylist++; | ||
124 | }); | ||
125 | loader.on('loadedmetadata', function() { | ||
126 | loadedMetadata++; | ||
127 | }); | ||
128 | requests.pop().respond(200, null, | ||
129 | '#EXTM3U\n' + | ||
130 | '#EXT-X-STREAM-INF:\n' + | ||
131 | 'media.m3u8\n' + | ||
132 | 'alt.m3u8\n'); | ||
133 | strictEqual(loadedPlaylist, 1, 'fired loadedplaylist once'); | ||
134 | strictEqual(loadedMetadata, 0, 'did not fire loadedmetadata'); | ||
135 | strictEqual(requests.length, 1, 'requests the media playlist'); | ||
136 | strictEqual(requests[0].method, 'GET', 'GETs the media playlist'); | ||
137 | strictEqual(requests[0].url, | ||
138 | urlTo('media.m3u8'), | ||
139 | 'requests the first playlist'); | ||
140 | |||
141 | requests.pop().respond(200, null, | ||
142 | '#EXTM3U\n' + | ||
143 | '#EXTINF:10,\n' + | ||
144 | '0.ts\n'); | ||
145 | ok(loader.master, 'sets the master playlist'); | ||
146 | ok(loader.media(), 'sets the media playlist'); | ||
147 | strictEqual(loadedPlaylist, 2, 'fired loadedplaylist twice'); | ||
148 | strictEqual(loadedMetadata, 1, 'fired loadedmetadata once'); | ||
149 | strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct'); | ||
150 | }); | ||
151 | |||
152 | test('moves to HAVE_CURRENT_METADATA when refreshing the playlist', function() { | ||
153 | var loader = new videojs.Hls.PlaylistLoader('live.m3u8'); | ||
154 | requests.pop().respond(200, null, | ||
155 | '#EXTM3U\n' + | ||
156 | '#EXTINF:10,\n' + | ||
157 | '0.ts\n'); | ||
158 | clock.tick(10 * 1000); // 10s, one target duration | ||
159 | strictEqual(loader.state, 'HAVE_CURRENT_METADATA', 'the state is correct'); | ||
160 | strictEqual(requests.length, 1, 'requested playlist'); | ||
161 | strictEqual(requests[0].url, | ||
162 | urlTo('live.m3u8'), | ||
163 | 'refreshes the media playlist'); | ||
164 | }); | ||
165 | |||
166 | test('returns to HAVE_METADATA after refreshing the playlist', function() { | ||
167 | var loader = new videojs.Hls.PlaylistLoader('live.m3u8'); | ||
168 | requests.pop().respond(200, null, | ||
169 | '#EXTM3U\n' + | ||
170 | '#EXTINF:10,\n' + | ||
171 | '0.ts\n'); | ||
172 | clock.tick(10 * 1000); // 10s, one target duration | ||
173 | requests.pop().respond(200, null, | ||
174 | '#EXTM3U\n' + | ||
175 | '#EXTINF:10,\n' + | ||
176 | '1.ts\n'); | ||
177 | strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct'); | ||
178 | }); | ||
179 | |||
180 | test('does not increment expired seconds before firstplay is triggered', function() { | ||
181 | var loader = new videojs.Hls.PlaylistLoader('live.m3u8'); | ||
182 | requests.pop().respond(200, null, | ||
183 | '#EXTM3U\n' + | ||
184 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
185 | '#EXTINF:10,\n' + | ||
186 | '0.ts\n' + | ||
187 | '#EXTINF:10,\n' + | ||
188 | '1.ts\n' + | ||
189 | '#EXTINF:10,\n' + | ||
190 | '2.ts\n' + | ||
191 | '#EXTINF:10,\n' + | ||
192 | '3.ts\n'); | ||
193 | clock.tick(10 * 1000); // 10s, one target duration | ||
194 | requests.pop().respond(200, null, | ||
195 | '#EXTM3U\n' + | ||
196 | '#EXT-X-MEDIA-SEQUENCE:1\n' + | ||
197 | '#EXTINF:10,\n' + | ||
198 | '1.ts\n' + | ||
199 | '#EXTINF:10,\n' + | ||
200 | '2.ts\n' + | ||
201 | '#EXTINF:10,\n' + | ||
202 | '3.ts\n' + | ||
203 | '#EXTINF:10,\n' + | ||
204 | '4.ts\n'); | ||
205 | equal(loader.expired_, 0, 'expired one segment'); | ||
206 | }); | ||
207 | |||
208 | test('increments expired seconds after a segment is removed', function() { | ||
209 | var loader = new videojs.Hls.PlaylistLoader('live.m3u8'); | ||
210 | loader.trigger('firstplay'); | ||
211 | requests.pop().respond(200, null, | ||
212 | '#EXTM3U\n' + | ||
213 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
214 | '#EXTINF:10,\n' + | ||
215 | '0.ts\n' + | ||
216 | '#EXTINF:10,\n' + | ||
217 | '1.ts\n' + | ||
218 | '#EXTINF:10,\n' + | ||
219 | '2.ts\n' + | ||
220 | '#EXTINF:10,\n' + | ||
221 | '3.ts\n'); | ||
222 | clock.tick(10 * 1000); // 10s, one target duration | ||
223 | requests.pop().respond(200, null, | ||
224 | '#EXTM3U\n' + | ||
225 | '#EXT-X-MEDIA-SEQUENCE:1\n' + | ||
226 | '#EXTINF:10,\n' + | ||
227 | '1.ts\n' + | ||
228 | '#EXTINF:10,\n' + | ||
229 | '2.ts\n' + | ||
230 | '#EXTINF:10,\n' + | ||
231 | '3.ts\n' + | ||
232 | '#EXTINF:10,\n' + | ||
233 | '4.ts\n'); | ||
234 | equal(loader.expired_, 10, 'expired one segment'); | ||
235 | }); | ||
236 | |||
237 | test('increments expired seconds after a discontinuity', function() { | ||
238 | var loader = new videojs.Hls.PlaylistLoader('live.m3u8'); | ||
239 | loader.trigger('firstplay'); | ||
240 | requests.pop().respond(200, null, | ||
241 | '#EXTM3U\n' + | ||
242 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
243 | '#EXTINF:10,\n' + | ||
244 | '0.ts\n' + | ||
245 | '#EXTINF:3,\n' + | ||
246 | '1.ts\n' + | ||
247 | '#EXT-X-DISCONTINUITY\n' + | ||
248 | '#EXTINF:4,\n' + | ||
249 | '2.ts\n'); | ||
250 | clock.tick(10 * 1000); // 10s, one target duration | ||
251 | requests.pop().respond(200, null, | ||
252 | '#EXTM3U\n' + | ||
253 | '#EXT-X-MEDIA-SEQUENCE:1\n' + | ||
254 | '#EXTINF:3,\n' + | ||
255 | '1.ts\n' + | ||
256 | '#EXT-X-DISCONTINUITY\n' + | ||
257 | '#EXTINF:4,\n' + | ||
258 | '2.ts\n'); | ||
259 | equal(loader.expired_, 10, 'expired one segment'); | ||
260 | |||
261 | clock.tick(10 * 1000); // 10s, one target duration | ||
262 | requests.pop().respond(200, null, | ||
263 | '#EXTM3U\n' + | ||
264 | '#EXT-X-MEDIA-SEQUENCE:2\n' + | ||
265 | '#EXT-X-DISCONTINUITY\n' + | ||
266 | '#EXTINF:4,\n' + | ||
267 | '2.ts\n'); | ||
268 | equal(loader.expired_, 13, 'no expirations after the discontinuity yet'); | ||
269 | |||
270 | clock.tick(10 * 1000); // 10s, one target duration | ||
271 | requests.pop().respond(200, null, | ||
272 | '#EXTM3U\n' + | ||
273 | '#EXT-X-MEDIA-SEQUENCE:3\n' + | ||
274 | '#EXT-X-DISCONTINUITY-SEQUENCE:1\n' + | ||
275 | '#EXTINF:10,\n' + | ||
276 | '3.ts\n'); | ||
277 | equal(loader.expired_, 17, 'tracked expiration across the discontinuity'); | ||
278 | }); | ||
279 | |||
280 | test('tracks expired seconds properly when two discontinuities expire at once', function() { | ||
281 | var loader = new videojs.Hls.PlaylistLoader('live.m3u8'); | ||
282 | loader.trigger('firstplay'); | ||
283 | requests.pop().respond(200, null, | ||
284 | '#EXTM3U\n' + | ||
285 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
286 | '#EXTINF:4,\n' + | ||
287 | '0.ts\n' + | ||
288 | '#EXT-X-DISCONTINUITY\n' + | ||
289 | '#EXTINF:5,\n' + | ||
290 | '1.ts\n' + | ||
291 | '#EXT-X-DISCONTINUITY\n' + | ||
292 | '#EXTINF:6,\n' + | ||
293 | '2.ts\n' + | ||
294 | '#EXTINF:7,\n' + | ||
295 | '3.ts\n'); | ||
296 | clock.tick(10 * 1000); | ||
297 | requests.pop().respond(200, null, | ||
298 | '#EXTM3U\n' + | ||
299 | '#EXT-X-MEDIA-SEQUENCE:3\n' + | ||
300 | '#EXT-X-DISCONTINUITY-SEQUENCE:2\n' + | ||
301 | '#EXTINF:7,\n' + | ||
302 | '3.ts\n'); | ||
303 | equal(loader.expired_, 4 + 5 + 6, 'tracked multiple expiring discontinuities'); | ||
304 | }); | ||
305 | |||
306 | test('estimates expired if an entire window elapses between live playlist updates', function() { | ||
307 | var loader = new videojs.Hls.PlaylistLoader('live.m3u8'); | ||
308 | loader.trigger('firstplay'); | ||
309 | requests.pop().respond(200, null, | ||
310 | '#EXTM3U\n' + | ||
311 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
312 | '#EXTINF:4,\n' + | ||
313 | '0.ts\n' + | ||
314 | '#EXTINF:5,\n' + | ||
315 | '1.ts\n'); | ||
316 | |||
317 | clock.tick(10 * 1000); | ||
318 | requests.pop().respond(200, null, | ||
319 | '#EXTM3U\n' + | ||
320 | '#EXT-X-MEDIA-SEQUENCE:4\n' + | ||
321 | '#EXTINF:6,\n' + | ||
322 | '4.ts\n' + | ||
323 | '#EXTINF:7,\n' + | ||
324 | '5.ts\n'); | ||
325 | |||
326 | equal(loader.expired_, | ||
327 | 4 + 5 + (2 * 10), | ||
328 | 'made a very rough estimate of expired time'); | ||
329 | }); | ||
330 | |||
331 | test('emits an error when an initial playlist request fails', function() { | ||
332 | var | ||
333 | errors = [], | ||
334 | loader = new videojs.Hls.PlaylistLoader('master.m3u8'); | ||
335 | |||
336 | loader.on('error', function() { | ||
337 | errors.push(loader.error); | ||
338 | }); | ||
339 | requests.pop().respond(500); | ||
340 | |||
341 | strictEqual(errors.length, 1, 'emitted one error'); | ||
342 | strictEqual(errors[0].status, 500, 'http status is captured'); | ||
343 | }); | ||
344 | |||
345 | test('errors when an initial media playlist request fails', function() { | ||
346 | var | ||
347 | errors = [], | ||
348 | loader = new videojs.Hls.PlaylistLoader('master.m3u8'); | ||
349 | |||
350 | loader.on('error', function() { | ||
351 | errors.push(loader.error); | ||
352 | }); | ||
353 | requests.pop().respond(200, null, | ||
354 | '#EXTM3U\n' + | ||
355 | '#EXT-X-STREAM-INF:\n' + | ||
356 | 'media.m3u8\n'); | ||
357 | |||
358 | strictEqual(errors.length, 0, 'emitted no errors'); | ||
359 | |||
360 | requests.pop().respond(500); | ||
361 | |||
362 | strictEqual(errors.length, 1, 'emitted one error'); | ||
363 | strictEqual(errors[0].status, 500, 'http status is captured'); | ||
364 | }); | ||
365 | |||
366 | // http://tools.ietf.org/html/draft-pantos-http-live-streaming-12#section-6.3.4 | ||
367 | test('halves the refresh timeout if a playlist is unchanged' + | ||
368 | 'since the last reload', function() { | ||
369 | new videojs.Hls.PlaylistLoader('live.m3u8'); | ||
370 | requests.pop().respond(200, null, | ||
371 | '#EXTM3U\n' + | ||
372 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
373 | '#EXTINF:10,\n' + | ||
374 | '0.ts\n'); | ||
375 | clock.tick(10 * 1000); // trigger a refresh | ||
376 | requests.pop().respond(200, null, | ||
377 | '#EXTM3U\n' + | ||
378 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
379 | '#EXTINF:10,\n' + | ||
380 | '0.ts\n'); | ||
381 | clock.tick(5 * 1000); // half the default target-duration | ||
382 | |||
383 | strictEqual(requests.length, 1, 'sent a request'); | ||
384 | strictEqual(requests[0].url, | ||
385 | urlTo('live.m3u8'), | ||
386 | 'requested the media playlist'); | ||
387 | }); | ||
388 | |||
389 | test('preserves segment metadata across playlist refreshes', function() { | ||
390 | var loader = new videojs.Hls.PlaylistLoader('live.m3u8'), segment; | ||
391 | requests.pop().respond(200, null, | ||
392 | '#EXTM3U\n' + | ||
393 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
394 | '#EXTINF:10,\n' + | ||
395 | '0.ts\n' + | ||
396 | '#EXTINF:10,\n' + | ||
397 | '1.ts\n' + | ||
398 | '#EXTINF:10,\n' + | ||
399 | '2.ts\n'); | ||
400 | // add PTS info to 1.ts | ||
401 | segment = loader.media().segments[1]; | ||
402 | segment.minVideoPts = 14; | ||
403 | segment.maxAudioPts = 27; | ||
404 | segment.preciseDuration = 10.045; | ||
405 | |||
406 | clock.tick(10 * 1000); // trigger a refresh | ||
407 | requests.pop().respond(200, null, | ||
408 | '#EXTM3U\n' + | ||
409 | '#EXT-X-MEDIA-SEQUENCE:1\n' + | ||
410 | '#EXTINF:10,\n' + | ||
411 | '1.ts\n' + | ||
412 | '#EXTINF:10,\n' + | ||
413 | '2.ts\n'); | ||
414 | |||
415 | deepEqual(loader.media().segments[0], segment, 'preserved segment attributes'); | ||
416 | }); | ||
417 | |||
418 | test('clears the update timeout when switching quality', function() { | ||
419 | var loader = new videojs.Hls.PlaylistLoader('live-master.m3u8'), refreshes = 0; | ||
420 | // track the number of playlist refreshes triggered | ||
421 | loader.on('mediaupdatetimeout', function() { | ||
422 | refreshes++; | ||
423 | }); | ||
424 | // deliver the master | ||
425 | requests.pop().respond(200, null, | ||
426 | '#EXTM3U\n' + | ||
427 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
428 | 'live-low.m3u8\n' + | ||
429 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
430 | 'live-high.m3u8\n'); | ||
431 | // deliver the low quality playlist | ||
432 | requests.pop().respond(200, null, | ||
433 | '#EXTM3U\n' + | ||
434 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
435 | '#EXTINF:10,\n' + | ||
436 | 'low-0.ts\n'); | ||
437 | // change to a higher quality playlist | ||
438 | loader.media('live-high.m3u8'); | ||
439 | requests.pop().respond(200, null, | ||
440 | '#EXTM3U\n' + | ||
441 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
442 | '#EXTINF:10,\n' + | ||
443 | 'high-0.ts\n'); | ||
444 | clock.tick(10 * 1000); // trigger a refresh | ||
445 | |||
446 | equal(1, refreshes, 'only one refresh was triggered'); | ||
447 | }); | 101 | }); |
448 | 102 | respond(this.requests.pop(), | |
449 | test('media-sequence updates are considered a playlist change', function() { | 103 | '#EXTM3U\n' + |
450 | new videojs.Hls.PlaylistLoader('live.m3u8'); | 104 | '#EXTINF:10,\n' + |
451 | requests.pop().respond(200, null, | 105 | '0.ts\n' + |
452 | '#EXTM3U\n' + | 106 | '#EXT-X-ENDLIST\n'); |
453 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | 107 | QUnit.ok(loader.master, 'infers a master playlist'); |
454 | '#EXTINF:10,\n' + | 108 | QUnit.ok(loader.media(), 'sets the media playlist'); |
455 | '0.ts\n'); | 109 | QUnit.ok(loader.media().uri, 'sets the media playlist URI'); |
456 | clock.tick(10 * 1000); // trigger a refresh | 110 | QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct'); |
457 | requests.pop().respond(200, null, | 111 | QUnit.strictEqual(this.requests.length, 0, 'no more this.requests are made'); |
458 | '#EXTM3U\n' + | 112 | QUnit.strictEqual(loadedmetadatas, 1, 'fired one loadedmetadata'); |
459 | '#EXT-X-MEDIA-SEQUENCE:1\n' + | 113 | }); |
460 | '#EXTINF:10,\n' + | 114 | |
461 | '0.ts\n'); | 115 | QUnit.test( |
462 | clock.tick(5 * 1000); // half the default target-duration | 116 | 'jumps to HAVE_METADATA when initialized with a live media playlist', |
463 | 117 | function() { | |
464 | strictEqual(requests.length, 0, 'no request is sent'); | 118 | let loader = new PlaylistLoader('media.m3u8'); |
465 | }); | 119 | |
466 | 120 | respond(this.requests.pop(), | |
467 | test('emits an error if a media refresh fails', function() { | 121 | '#EXTM3U\n' + |
468 | var | 122 | '#EXTINF:10,\n' + |
469 | errors = 0, | 123 | '0.ts\n'); |
470 | errorResponseText = 'custom error message', | 124 | QUnit.ok(loader.master, 'infers a master playlist'); |
471 | loader = new videojs.Hls.PlaylistLoader('live.m3u8'); | 125 | QUnit.ok(loader.media(), 'sets the media playlist'); |
472 | 126 | QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct'); | |
473 | loader.on('error', function() { | 127 | }); |
474 | errors++; | 128 | |
475 | }); | 129 | QUnit.test('moves to HAVE_METADATA after loading a media playlist', function() { |
476 | requests.pop().respond(200, null, | 130 | let loadedPlaylist = 0; |
477 | '#EXTM3U\n' + | 131 | let loadedMetadata = 0; |
478 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | 132 | let loader = new PlaylistLoader('master.m3u8'); |
479 | '#EXTINF:10,\n' + | 133 | |
480 | '0.ts\n'); | 134 | loader.on('loadedplaylist', function() { |
481 | clock.tick(10 * 1000); // trigger a refresh | 135 | loadedPlaylist++; |
482 | requests.pop().respond(500, null, errorResponseText); | ||
483 | |||
484 | strictEqual(errors, 1, 'emitted an error'); | ||
485 | strictEqual(loader.error.status, 500, 'captured the status code'); | ||
486 | strictEqual(loader.error.responseText, errorResponseText, 'captured the responseText'); | ||
487 | }); | 136 | }); |
488 | 137 | loader.on('loadedmetadata', function() { | |
489 | test('switches media playlists when requested', function() { | 138 | loadedMetadata++; |
490 | var loader = new videojs.Hls.PlaylistLoader('master.m3u8'); | ||
491 | requests.pop().respond(200, null, | ||
492 | '#EXTM3U\n' + | ||
493 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
494 | 'low.m3u8\n' + | ||
495 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
496 | 'high.m3u8\n'); | ||
497 | requests.pop().respond(200, null, | ||
498 | '#EXTM3U\n' + | ||
499 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
500 | '#EXTINF:10,\n' + | ||
501 | 'low-0.ts\n'); | ||
502 | |||
503 | loader.media(loader.master.playlists[1]); | ||
504 | strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state'); | ||
505 | |||
506 | requests.pop().respond(200, null, | ||
507 | '#EXTM3U\n' + | ||
508 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
509 | '#EXTINF:10,\n' + | ||
510 | 'high-0.ts\n'); | ||
511 | strictEqual(loader.state, 'HAVE_METADATA', 'switched active media'); | ||
512 | strictEqual(loader.media(), | ||
513 | loader.master.playlists[1], | ||
514 | 'updated the active media'); | ||
515 | }); | 139 | }); |
516 | 140 | respond(this.requests.pop(), | |
517 | test('can switch playlists immediately after the master is downloaded', function() { | 141 | '#EXTM3U\n' + |
518 | var loader = new videojs.Hls.PlaylistLoader('master.m3u8'); | 142 | '#EXT-X-STREAM-INF:\n' + |
519 | loader.on('loadedplaylist', function() { | 143 | 'media.m3u8\n' + |
520 | loader.media('high.m3u8'); | 144 | 'alt.m3u8\n'); |
521 | }); | 145 | QUnit.strictEqual(loadedPlaylist, 1, 'fired loadedplaylist once'); |
522 | requests.pop().respond(200, null, | 146 | QUnit.strictEqual(loadedMetadata, 0, 'did not fire loadedmetadata'); |
523 | '#EXTM3U\n' + | 147 | QUnit.strictEqual(this.requests.length, 1, 'this.requests the media playlist'); |
524 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | 148 | QUnit.strictEqual(this.requests[0].method, 'GET', 'GETs the media playlist'); |
525 | 'low.m3u8\n' + | 149 | QUnit.strictEqual(this.requests[0].url, |
526 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | 150 | urlTo('media.m3u8'), |
527 | 'high.m3u8\n'); | 151 | 'this.requests the first playlist'); |
528 | equal(requests[0].url, urlTo('high.m3u8'), 'switched variants immediately'); | 152 | |
153 | respond(this.requests.pop(), | ||
154 | '#EXTM3U\n' + | ||
155 | '#EXTINF:10,\n' + | ||
156 | '0.ts\n'); | ||
157 | QUnit.ok(loader.master, 'sets the master playlist'); | ||
158 | QUnit.ok(loader.media(), 'sets the media playlist'); | ||
159 | QUnit.strictEqual(loadedPlaylist, 2, 'fired loadedplaylist twice'); | ||
160 | QUnit.strictEqual(loadedMetadata, 1, 'fired loadedmetadata once'); | ||
161 | QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct'); | ||
162 | }); | ||
163 | |||
164 | QUnit.test('moves to HAVE_CURRENT_METADATA when refreshing the playlist', function() { | ||
165 | let loader = new PlaylistLoader('live.m3u8'); | ||
166 | |||
167 | respond(this.requests.pop(), | ||
168 | '#EXTM3U\n' + | ||
169 | '#EXTINF:10,\n' + | ||
170 | '0.ts\n'); | ||
171 | // 10s, one target duration | ||
172 | this.clock.tick(10 * 1000); | ||
173 | QUnit.strictEqual(loader.state, 'HAVE_CURRENT_METADATA', 'the state is correct'); | ||
174 | QUnit.strictEqual(this.requests.length, 1, 'requested playlist'); | ||
175 | QUnit.strictEqual(this.requests[0].url, | ||
176 | urlTo('live.m3u8'), | ||
177 | 'refreshes the media playlist'); | ||
178 | }); | ||
179 | |||
180 | QUnit.test('returns to HAVE_METADATA after refreshing the playlist', function() { | ||
181 | let loader = new PlaylistLoader('live.m3u8'); | ||
182 | |||
183 | respond(this.requests.pop(), | ||
184 | '#EXTM3U\n' + | ||
185 | '#EXTINF:10,\n' + | ||
186 | '0.ts\n'); | ||
187 | // 10s, one target duration | ||
188 | this.clock.tick(10 * 1000); | ||
189 | respond(this.requests.pop(), | ||
190 | '#EXTM3U\n' + | ||
191 | '#EXTINF:10,\n' + | ||
192 | '1.ts\n'); | ||
193 | QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct'); | ||
194 | }); | ||
195 | |||
196 | QUnit.test( | ||
197 | 'does not increment expired seconds before firstplay is triggered', | ||
198 | function() { | ||
199 | let loader = new PlaylistLoader('live.m3u8'); | ||
200 | |||
201 | respond(this.requests.pop(), | ||
202 | '#EXTM3U\n' + | ||
203 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
204 | '#EXTINF:10,\n' + | ||
205 | '0.ts\n' + | ||
206 | '#EXTINF:10,\n' + | ||
207 | '1.ts\n' + | ||
208 | '#EXTINF:10,\n' + | ||
209 | '2.ts\n' + | ||
210 | '#EXTINF:10,\n' + | ||
211 | '3.ts\n'); | ||
212 | // 10s, one target duration | ||
213 | this.clock.tick(10 * 1000); | ||
214 | respond(this.requests.pop(), | ||
215 | '#EXTM3U\n' + | ||
216 | '#EXT-X-MEDIA-SEQUENCE:1\n' + | ||
217 | '#EXTINF:10,\n' + | ||
218 | '1.ts\n' + | ||
219 | '#EXTINF:10,\n' + | ||
220 | '2.ts\n' + | ||
221 | '#EXTINF:10,\n' + | ||
222 | '3.ts\n' + | ||
223 | '#EXTINF:10,\n' + | ||
224 | '4.ts\n'); | ||
225 | QUnit.equal(loader.expired_, 0, 'expired one segment'); | ||
226 | }); | ||
227 | |||
228 | QUnit.test('increments expired seconds after a segment is removed', function() { | ||
229 | let loader = new PlaylistLoader('live.m3u8'); | ||
230 | |||
231 | loader.trigger('firstplay'); | ||
232 | respond(this.requests.pop(), | ||
233 | '#EXTM3U\n' + | ||
234 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
235 | '#EXTINF:10,\n' + | ||
236 | '0.ts\n' + | ||
237 | '#EXTINF:10,\n' + | ||
238 | '1.ts\n' + | ||
239 | '#EXTINF:10,\n' + | ||
240 | '2.ts\n' + | ||
241 | '#EXTINF:10,\n' + | ||
242 | '3.ts\n'); | ||
243 | // 10s, one target duration | ||
244 | this.clock.tick(10 * 1000); | ||
245 | respond(this.requests.pop(), | ||
246 | '#EXTM3U\n' + | ||
247 | '#EXT-X-MEDIA-SEQUENCE:1\n' + | ||
248 | '#EXTINF:10,\n' + | ||
249 | '1.ts\n' + | ||
250 | '#EXTINF:10,\n' + | ||
251 | '2.ts\n' + | ||
252 | '#EXTINF:10,\n' + | ||
253 | '3.ts\n' + | ||
254 | '#EXTINF:10,\n' + | ||
255 | '4.ts\n'); | ||
256 | QUnit.equal(loader.expired_, 10, 'expired one segment'); | ||
257 | }); | ||
258 | |||
259 | QUnit.test('increments expired seconds after a discontinuity', function() { | ||
260 | let loader = new PlaylistLoader('live.m3u8'); | ||
261 | |||
262 | loader.trigger('firstplay'); | ||
263 | respond(this.requests.pop(), | ||
264 | '#EXTM3U\n' + | ||
265 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
266 | '#EXTINF:10,\n' + | ||
267 | '0.ts\n' + | ||
268 | '#EXTINF:3,\n' + | ||
269 | '1.ts\n' + | ||
270 | '#EXT-X-DISCONTINUITY\n' + | ||
271 | '#EXTINF:4,\n' + | ||
272 | '2.ts\n'); | ||
273 | // 10s, one target duration | ||
274 | this.clock.tick(10 * 1000); | ||
275 | respond(this.requests.pop(), | ||
276 | '#EXTM3U\n' + | ||
277 | '#EXT-X-MEDIA-SEQUENCE:1\n' + | ||
278 | '#EXTINF:3,\n' + | ||
279 | '1.ts\n' + | ||
280 | '#EXT-X-DISCONTINUITY\n' + | ||
281 | '#EXTINF:4,\n' + | ||
282 | '2.ts\n'); | ||
283 | QUnit.equal(loader.expired_, 10, 'expired one segment'); | ||
284 | |||
285 | // 10s, one target duration | ||
286 | this.clock.tick(10 * 1000); | ||
287 | respond(this.requests.pop(), | ||
288 | '#EXTM3U\n' + | ||
289 | '#EXT-X-MEDIA-SEQUENCE:2\n' + | ||
290 | '#EXT-X-DISCONTINUITY\n' + | ||
291 | '#EXTINF:4,\n' + | ||
292 | '2.ts\n'); | ||
293 | QUnit.equal(loader.expired_, 13, 'no expirations after the discontinuity yet'); | ||
294 | |||
295 | // 10s, one target duration | ||
296 | this.clock.tick(10 * 1000); | ||
297 | respond(this.requests.pop(), | ||
298 | '#EXTM3U\n' + | ||
299 | '#EXT-X-MEDIA-SEQUENCE:3\n' + | ||
300 | '#EXT-X-DISCONTINUITY-SEQUENCE:1\n' + | ||
301 | '#EXTINF:10,\n' + | ||
302 | '3.ts\n'); | ||
303 | QUnit.equal(loader.expired_, 17, 'tracked expiration across the discontinuity'); | ||
304 | }); | ||
305 | |||
306 | QUnit.test( | ||
307 | 'tracks expired seconds properly when two discontinuities expire at once', | ||
308 | function() { | ||
309 | let loader = new PlaylistLoader('live.m3u8'); | ||
310 | |||
311 | loader.trigger('firstplay'); | ||
312 | respond(this.requests.pop(), | ||
313 | '#EXTM3U\n' + | ||
314 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
315 | '#EXTINF:4,\n' + | ||
316 | '0.ts\n' + | ||
317 | '#EXT-X-DISCONTINUITY\n' + | ||
318 | '#EXTINF:5,\n' + | ||
319 | '1.ts\n' + | ||
320 | '#EXT-X-DISCONTINUITY\n' + | ||
321 | '#EXTINF:6,\n' + | ||
322 | '2.ts\n' + | ||
323 | '#EXTINF:7,\n' + | ||
324 | '3.ts\n'); | ||
325 | this.clock.tick(10 * 1000); | ||
326 | respond(this.requests.pop(), | ||
327 | '#EXTM3U\n' + | ||
328 | '#EXT-X-MEDIA-SEQUENCE:3\n' + | ||
329 | '#EXT-X-DISCONTINUITY-SEQUENCE:2\n' + | ||
330 | '#EXTINF:7,\n' + | ||
331 | '3.ts\n'); | ||
332 | QUnit.equal(loader.expired_, 4 + 5 + 6, 'tracked multiple expiring discontinuities'); | ||
333 | }); | ||
334 | |||
335 | QUnit.test( | ||
336 | 'estimates expired if an entire window elapses between live playlist updates', | ||
337 | function() { | ||
338 | let loader = new PlaylistLoader('live.m3u8'); | ||
339 | |||
340 | loader.trigger('firstplay'); | ||
341 | respond(this.requests.pop(), | ||
342 | '#EXTM3U\n' + | ||
343 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
344 | '#EXTINF:4,\n' + | ||
345 | '0.ts\n' + | ||
346 | '#EXTINF:5,\n' + | ||
347 | '1.ts\n'); | ||
348 | |||
349 | this.clock.tick(10 * 1000); | ||
350 | respond(this.requests.pop(), | ||
351 | '#EXTM3U\n' + | ||
352 | '#EXT-X-MEDIA-SEQUENCE:4\n' + | ||
353 | '#EXTINF:6,\n' + | ||
354 | '4.ts\n' + | ||
355 | '#EXTINF:7,\n' + | ||
356 | '5.ts\n'); | ||
357 | |||
358 | QUnit.equal(loader.expired_, | ||
359 | 4 + 5 + (2 * 10), | ||
360 | 'made a very rough estimate of expired time'); | ||
361 | }); | ||
362 | |||
363 | QUnit.test('emits an error when an initial playlist request fails', function() { | ||
364 | let errors = []; | ||
365 | let loader = new PlaylistLoader('master.m3u8'); | ||
366 | |||
367 | loader.on('error', function() { | ||
368 | errors.push(loader.error); | ||
529 | }); | 369 | }); |
370 | this.requests.pop().respond(500); | ||
530 | 371 | ||
531 | test('can switch media playlists based on URI', function() { | 372 | QUnit.strictEqual(errors.length, 1, 'emitted one error'); |
532 | var loader = new videojs.Hls.PlaylistLoader('master.m3u8'); | 373 | QUnit.strictEqual(errors[0].status, 500, 'http status is captured'); |
533 | requests.pop().respond(200, null, | 374 | }); |
534 | '#EXTM3U\n' + | ||
535 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
536 | 'low.m3u8\n' + | ||
537 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
538 | 'high.m3u8\n'); | ||
539 | requests.pop().respond(200, null, | ||
540 | '#EXTM3U\n' + | ||
541 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
542 | '#EXTINF:10,\n' + | ||
543 | 'low-0.ts\n'); | ||
544 | 375 | ||
545 | loader.media('high.m3u8'); | 376 | QUnit.test('errors when an initial media playlist request fails', function() { |
546 | strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state'); | 377 | let errors = []; |
547 | 378 | let loader = new PlaylistLoader('master.m3u8'); | |
548 | requests.pop().respond(200, null, | ||
549 | '#EXTM3U\n' + | ||
550 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
551 | '#EXTINF:10,\n' + | ||
552 | 'high-0.ts\n'); | ||
553 | strictEqual(loader.state, 'HAVE_METADATA', 'switched active media'); | ||
554 | strictEqual(loader.media(), | ||
555 | loader.master.playlists[1], | ||
556 | 'updated the active media'); | ||
557 | }); | ||
558 | 379 | ||
559 | test('aborts in-flight playlist refreshes when switching', function() { | 380 | loader.on('error', function() { |
560 | var loader = new videojs.Hls.PlaylistLoader('master.m3u8'); | 381 | errors.push(loader.error); |
561 | requests.pop().respond(200, null, | ||
562 | '#EXTM3U\n' + | ||
563 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
564 | 'low.m3u8\n' + | ||
565 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
566 | 'high.m3u8\n'); | ||
567 | requests.pop().respond(200, null, | ||
568 | '#EXTM3U\n' + | ||
569 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
570 | '#EXTINF:10,\n' + | ||
571 | 'low-0.ts\n'); | ||
572 | clock.tick(10 * 1000); | ||
573 | loader.media('high.m3u8'); | ||
574 | strictEqual(requests[0].aborted, true, 'aborted refresh request'); | ||
575 | ok(!requests[0].onreadystatechange, 'onreadystatechange handlers should be removed on abort'); | ||
576 | strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state'); | ||
577 | }); | 382 | }); |
578 | 383 | respond(this.requests.pop(), | |
579 | test('switching to the active playlist is a no-op', function() { | 384 | '#EXTM3U\n' + |
580 | var loader = new videojs.Hls.PlaylistLoader('master.m3u8'); | 385 | '#EXT-X-STREAM-INF:\n' + |
581 | requests.pop().respond(200, null, | 386 | 'media.m3u8\n'); |
582 | '#EXTM3U\n' + | 387 | |
583 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | 388 | QUnit.strictEqual(errors.length, 0, 'emitted no errors'); |
584 | 'low.m3u8\n' + | 389 | |
585 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | 390 | this.requests.pop().respond(500); |
586 | 'high.m3u8\n'); | 391 | |
587 | requests.pop().respond(200, null, | 392 | QUnit.strictEqual(errors.length, 1, 'emitted one error'); |
588 | '#EXTM3U\n' + | 393 | QUnit.strictEqual(errors[0].status, 500, 'http status is captured'); |
589 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | 394 | }); |
590 | '#EXTINF:10,\n' + | 395 | |
591 | 'low-0.ts\n' + | 396 | // http://tools.ietf.org/html/draft-pantos-http-live-streaming-12#section-6.3.4 |
592 | '#EXT-X-ENDLIST\n'); | 397 | QUnit.test( |
593 | loader.media('low.m3u8'); | 398 | 'halves the refresh timeout if a playlist is unchanged since the last reload', |
594 | 399 | function() { | |
595 | strictEqual(requests.length, 0, 'no requests are sent'); | 400 | /* eslint-disable no-unused-vars */ |
401 | let loader = new PlaylistLoader('live.m3u8'); | ||
402 | /* eslint-enable no-unused-vars */ | ||
403 | |||
404 | respond(this.requests.pop(), | ||
405 | '#EXTM3U\n' + | ||
406 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
407 | '#EXTINF:10,\n' + | ||
408 | '0.ts\n'); | ||
409 | // trigger a refresh | ||
410 | this.clock.tick(10 * 1000); | ||
411 | respond(this.requests.pop(), | ||
412 | '#EXTM3U\n' + | ||
413 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
414 | '#EXTINF:10,\n' + | ||
415 | '0.ts\n'); | ||
416 | // half the default target-duration | ||
417 | this.clock.tick(5 * 1000); | ||
418 | |||
419 | QUnit.strictEqual(this.requests.length, 1, 'sent a request'); | ||
420 | QUnit.strictEqual(this.requests[0].url, | ||
421 | urlTo('live.m3u8'), | ||
422 | 'requested the media playlist'); | ||
423 | }); | ||
424 | |||
425 | QUnit.test('preserves segment metadata across playlist refreshes', function() { | ||
426 | let loader = new PlaylistLoader('live.m3u8'); | ||
427 | let segment; | ||
428 | |||
429 | respond(this.requests.pop(), | ||
430 | '#EXTM3U\n' + | ||
431 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
432 | '#EXTINF:10,\n' + | ||
433 | '0.ts\n' + | ||
434 | '#EXTINF:10,\n' + | ||
435 | '1.ts\n' + | ||
436 | '#EXTINF:10,\n' + | ||
437 | '2.ts\n'); | ||
438 | // add PTS info to 1.ts | ||
439 | segment = loader.media().segments[1]; | ||
440 | segment.minVideoPts = 14; | ||
441 | segment.maxAudioPts = 27; | ||
442 | segment.preciseDuration = 10.045; | ||
443 | |||
444 | // trigger a refresh | ||
445 | this.clock.tick(10 * 1000); | ||
446 | respond(this.requests.pop(), | ||
447 | '#EXTM3U\n' + | ||
448 | '#EXT-X-MEDIA-SEQUENCE:1\n' + | ||
449 | '#EXTINF:10,\n' + | ||
450 | '1.ts\n' + | ||
451 | '#EXTINF:10,\n' + | ||
452 | '2.ts\n'); | ||
453 | |||
454 | QUnit.deepEqual(loader.media().segments[0], segment, 'preserved segment attributes'); | ||
455 | }); | ||
456 | |||
457 | QUnit.test('clears the update timeout when switching quality', function() { | ||
458 | let loader = new PlaylistLoader('live-master.m3u8'); | ||
459 | let refreshes = 0; | ||
460 | |||
461 | // track the number of playlist refreshes triggered | ||
462 | loader.on('mediaupdatetimeout', function() { | ||
463 | refreshes++; | ||
596 | }); | 464 | }); |
597 | 465 | // deliver the master | |
598 | test('switching to the active live playlist is a no-op', function() { | 466 | respond(this.requests.pop(), |
599 | var loader = new videojs.Hls.PlaylistLoader('master.m3u8'); | 467 | '#EXTM3U\n' + |
600 | requests.pop().respond(200, null, | 468 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + |
601 | '#EXTM3U\n' + | 469 | 'live-low.m3u8\n' + |
602 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | 470 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + |
603 | 'low.m3u8\n' + | 471 | 'live-high.m3u8\n'); |
604 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | 472 | // deliver the low quality playlist |
605 | 'high.m3u8\n'); | 473 | respond(this.requests.pop(), |
606 | requests.pop().respond(200, null, | 474 | '#EXTM3U\n' + |
607 | '#EXTM3U\n' + | 475 | '#EXT-X-MEDIA-SEQUENCE:0\n' + |
608 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | 476 | '#EXTINF:10,\n' + |
609 | '#EXTINF:10,\n' + | 477 | 'low-0.ts\n'); |
610 | 'low-0.ts\n'); | 478 | // change to a higher quality playlist |
611 | loader.media('low.m3u8'); | 479 | loader.media('live-high.m3u8'); |
612 | 480 | respond(this.requests.pop(), | |
613 | strictEqual(requests.length, 0, 'no requests are sent'); | 481 | '#EXTM3U\n' + |
482 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
483 | '#EXTINF:10,\n' + | ||
484 | 'high-0.ts\n'); | ||
485 | // trigger a refresh | ||
486 | this.clock.tick(10 * 1000); | ||
487 | |||
488 | QUnit.equal(1, refreshes, 'only one refresh was triggered'); | ||
489 | }); | ||
490 | |||
491 | QUnit.test('media-sequence updates are considered a playlist change', function() { | ||
492 | /* eslint-disable no-unused-vars */ | ||
493 | let loader = new PlaylistLoader('live.m3u8'); | ||
494 | /* eslint-enable no-unused-vars */ | ||
495 | |||
496 | respond(this.requests.pop(), | ||
497 | '#EXTM3U\n' + | ||
498 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
499 | '#EXTINF:10,\n' + | ||
500 | '0.ts\n'); | ||
501 | // trigger a refresh | ||
502 | this.clock.tick(10 * 1000); | ||
503 | respond(this.requests.pop(), | ||
504 | '#EXTM3U\n' + | ||
505 | '#EXT-X-MEDIA-SEQUENCE:1\n' + | ||
506 | '#EXTINF:10,\n' + | ||
507 | '0.ts\n'); | ||
508 | // half the default target-duration | ||
509 | this.clock.tick(5 * 1000); | ||
510 | |||
511 | QUnit.strictEqual(this.requests.length, 0, 'no request is sent'); | ||
512 | }); | ||
513 | |||
514 | QUnit.test('emits an error if a media refresh fails', function() { | ||
515 | let errors = 0; | ||
516 | let errorResponseText = 'custom error message'; | ||
517 | let loader = new PlaylistLoader('live.m3u8'); | ||
518 | |||
519 | loader.on('error', function() { | ||
520 | errors++; | ||
614 | }); | 521 | }); |
615 | 522 | respond(this.requests.pop(), | |
616 | test('switches back to loaded playlists without re-requesting them', function() { | 523 | '#EXTM3U\n' + |
617 | var loader = new videojs.Hls.PlaylistLoader('master.m3u8'); | 524 | '#EXT-X-MEDIA-SEQUENCE:0\n' + |
618 | requests.pop().respond(200, null, | 525 | '#EXTINF:10,\n' + |
619 | '#EXTM3U\n' + | 526 | '0.ts\n'); |
620 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | 527 | // trigger a refresh |
621 | 'low.m3u8\n' + | 528 | this.clock.tick(10 * 1000); |
622 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | 529 | this.requests.pop().respond(500, null, errorResponseText); |
623 | 'high.m3u8\n'); | 530 | |
624 | requests.pop().respond(200, null, | 531 | QUnit.strictEqual(errors, 1, 'emitted an error'); |
625 | '#EXTM3U\n' + | 532 | QUnit.strictEqual(loader.error.status, 500, 'captured the status code'); |
626 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | 533 | QUnit.strictEqual(loader.error.responseText, |
627 | '#EXTINF:10,\n' + | 534 | errorResponseText, |
628 | 'low-0.ts\n' + | 535 | 'captured the responseText'); |
629 | '#EXT-X-ENDLIST\n'); | 536 | }); |
537 | |||
538 | QUnit.test('switches media playlists when requested', function() { | ||
539 | let loader = new PlaylistLoader('master.m3u8'); | ||
540 | |||
541 | respond(this.requests.pop(), | ||
542 | '#EXTM3U\n' + | ||
543 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
544 | 'low.m3u8\n' + | ||
545 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
546 | 'high.m3u8\n'); | ||
547 | respond(this.requests.pop(), | ||
548 | '#EXTM3U\n' + | ||
549 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
550 | '#EXTINF:10,\n' + | ||
551 | 'low-0.ts\n'); | ||
552 | |||
553 | loader.media(loader.master.playlists[1]); | ||
554 | QUnit.strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state'); | ||
555 | |||
556 | respond(this.requests.pop(), | ||
557 | '#EXTM3U\n' + | ||
558 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
559 | '#EXTINF:10,\n' + | ||
560 | 'high-0.ts\n'); | ||
561 | QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'switched active media'); | ||
562 | QUnit.strictEqual(loader.media(), | ||
563 | loader.master.playlists[1], | ||
564 | 'updated the active media'); | ||
565 | }); | ||
566 | |||
567 | QUnit.test('can switch playlists immediately after the master is downloaded', function() { | ||
568 | let loader = new PlaylistLoader('master.m3u8'); | ||
569 | |||
570 | loader.on('loadedplaylist', function() { | ||
630 | loader.media('high.m3u8'); | 571 | loader.media('high.m3u8'); |
631 | requests.pop().respond(200, null, | ||
632 | '#EXTM3U\n' + | ||
633 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
634 | '#EXTINF:10,\n' + | ||
635 | 'high-0.ts\n' + | ||
636 | '#EXT-X-ENDLIST\n'); | ||
637 | loader.media('low.m3u8'); | ||
638 | |||
639 | strictEqual(requests.length, 0, 'no outstanding requests'); | ||
640 | strictEqual(loader.state, 'HAVE_METADATA', 'returned to loaded playlist'); | ||
641 | }); | 572 | }); |
642 | 573 | respond(this.requests.pop(), | |
643 | test('aborts outstanding requests if switching back to an already loaded playlist', function() { | 574 | '#EXTM3U\n' + |
644 | var loader = new videojs.Hls.PlaylistLoader('master.m3u8'); | 575 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + |
645 | requests.pop().respond(200, null, | 576 | 'low.m3u8\n' + |
646 | '#EXTM3U\n' + | 577 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + |
647 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | 578 | 'high.m3u8\n'); |
648 | 'low.m3u8\n' + | 579 | QUnit.equal(this.requests[0].url, urlTo('high.m3u8'), 'switched variants immediately'); |
649 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | 580 | }); |
650 | 'high.m3u8\n'); | 581 | |
651 | requests.pop().respond(200, null, | 582 | QUnit.test('can switch media playlists based on URI', function() { |
652 | '#EXTM3U\n' + | 583 | let loader = new PlaylistLoader('master.m3u8'); |
653 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | 584 | |
654 | '#EXTINF:10,\n' + | 585 | respond(this.requests.pop(), |
655 | 'low-0.ts\n' + | 586 | '#EXTM3U\n' + |
656 | '#EXT-X-ENDLIST\n'); | 587 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + |
588 | 'low.m3u8\n' + | ||
589 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
590 | 'high.m3u8\n'); | ||
591 | respond(this.requests.pop(), | ||
592 | '#EXTM3U\n' + | ||
593 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
594 | '#EXTINF:10,\n' + | ||
595 | 'low-0.ts\n'); | ||
596 | |||
597 | loader.media('high.m3u8'); | ||
598 | QUnit.strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state'); | ||
599 | |||
600 | respond(this.requests.pop(), | ||
601 | '#EXTM3U\n' + | ||
602 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
603 | '#EXTINF:10,\n' + | ||
604 | 'high-0.ts\n'); | ||
605 | QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'switched active media'); | ||
606 | QUnit.strictEqual(loader.media(), | ||
607 | loader.master.playlists[1], | ||
608 | 'updated the active media'); | ||
609 | }); | ||
610 | |||
611 | QUnit.test('aborts in-flight playlist refreshes when switching', function() { | ||
612 | let loader = new PlaylistLoader('master.m3u8'); | ||
613 | |||
614 | respond(this.requests.pop(), | ||
615 | '#EXTM3U\n' + | ||
616 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
617 | 'low.m3u8\n' + | ||
618 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
619 | 'high.m3u8\n'); | ||
620 | respond(this.requests.pop(), | ||
621 | '#EXTM3U\n' + | ||
622 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
623 | '#EXTINF:10,\n' + | ||
624 | 'low-0.ts\n'); | ||
625 | this.clock.tick(10 * 1000); | ||
626 | loader.media('high.m3u8'); | ||
627 | QUnit.strictEqual(this.requests[0].aborted, true, 'aborted refresh request'); | ||
628 | QUnit.ok(!this.requests[0].onreadystatechange, | ||
629 | 'onreadystatechange handlers should be removed on abort'); | ||
630 | QUnit.strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state'); | ||
631 | }); | ||
632 | |||
633 | QUnit.test('switching to the active playlist is a no-op', function() { | ||
634 | let loader = new PlaylistLoader('master.m3u8'); | ||
635 | |||
636 | respond(this.requests.pop(), | ||
637 | '#EXTM3U\n' + | ||
638 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
639 | 'low.m3u8\n' + | ||
640 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
641 | 'high.m3u8\n'); | ||
642 | respond(this.requests.pop(), | ||
643 | '#EXTM3U\n' + | ||
644 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
645 | '#EXTINF:10,\n' + | ||
646 | 'low-0.ts\n' + | ||
647 | '#EXT-X-ENDLIST\n'); | ||
648 | loader.media('low.m3u8'); | ||
649 | |||
650 | QUnit.strictEqual(this.requests.length, 0, 'no this.requests are sent'); | ||
651 | }); | ||
652 | |||
653 | QUnit.test('switching to the active live playlist is a no-op', function() { | ||
654 | let loader = new PlaylistLoader('master.m3u8'); | ||
655 | |||
656 | respond(this.requests.pop(), | ||
657 | '#EXTM3U\n' + | ||
658 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
659 | 'low.m3u8\n' + | ||
660 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
661 | 'high.m3u8\n'); | ||
662 | respond(this.requests.pop(), | ||
663 | '#EXTM3U\n' + | ||
664 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
665 | '#EXTINF:10,\n' + | ||
666 | 'low-0.ts\n'); | ||
667 | loader.media('low.m3u8'); | ||
668 | |||
669 | QUnit.strictEqual(this.requests.length, 0, 'no this.requests are sent'); | ||
670 | }); | ||
671 | |||
672 | QUnit.test('switches back to loaded playlists without re-requesting them', function() { | ||
673 | let loader = new PlaylistLoader('master.m3u8'); | ||
674 | |||
675 | respond(this.requests.pop(), | ||
676 | '#EXTM3U\n' + | ||
677 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
678 | 'low.m3u8\n' + | ||
679 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
680 | 'high.m3u8\n'); | ||
681 | respond(this.requests.pop(), | ||
682 | '#EXTM3U\n' + | ||
683 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
684 | '#EXTINF:10,\n' + | ||
685 | 'low-0.ts\n' + | ||
686 | '#EXT-X-ENDLIST\n'); | ||
687 | loader.media('high.m3u8'); | ||
688 | respond(this.requests.pop(), | ||
689 | '#EXTM3U\n' + | ||
690 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
691 | '#EXTINF:10,\n' + | ||
692 | 'high-0.ts\n' + | ||
693 | '#EXT-X-ENDLIST\n'); | ||
694 | loader.media('low.m3u8'); | ||
695 | |||
696 | QUnit.strictEqual(this.requests.length, 0, 'no outstanding this.requests'); | ||
697 | QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'returned to loaded playlist'); | ||
698 | }); | ||
699 | |||
700 | QUnit.test( | ||
701 | 'aborts outstanding this.requests if switching back to an already loaded playlist', | ||
702 | function() { | ||
703 | let loader = new PlaylistLoader('master.m3u8'); | ||
704 | |||
705 | respond(this.requests.pop(), | ||
706 | '#EXTM3U\n' + | ||
707 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
708 | 'low.m3u8\n' + | ||
709 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
710 | 'high.m3u8\n'); | ||
711 | respond(this.requests.pop(), | ||
712 | '#EXTM3U\n' + | ||
713 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
714 | '#EXTINF:10,\n' + | ||
715 | 'low-0.ts\n' + | ||
716 | '#EXT-X-ENDLIST\n'); | ||
717 | loader.media('high.m3u8'); | ||
718 | loader.media('low.m3u8'); | ||
719 | |||
720 | QUnit.strictEqual(this.requests.length, | ||
721 | 1, | ||
722 | 'requested high playlist'); | ||
723 | QUnit.ok(this.requests[0].aborted, | ||
724 | 'aborted playlist request'); | ||
725 | QUnit.ok(!this.requests[0].onreadystatechange, | ||
726 | 'onreadystatechange handlers should be removed on abort'); | ||
727 | QUnit.strictEqual(loader.state, | ||
728 | 'HAVE_METADATA', | ||
729 | 'returned to loaded playlist'); | ||
730 | QUnit.strictEqual(loader.media(), | ||
731 | loader.master.playlists[0], | ||
732 | 'switched to loaded playlist'); | ||
733 | }); | ||
734 | |||
735 | QUnit.test( | ||
736 | 'does not abort this.requests when the same playlist is re-requested', | ||
737 | function() { | ||
738 | let loader = new PlaylistLoader('master.m3u8'); | ||
739 | |||
740 | respond(this.requests.pop(), | ||
741 | '#EXTM3U\n' + | ||
742 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
743 | 'low.m3u8\n' + | ||
744 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
745 | 'high.m3u8\n'); | ||
746 | respond(this.requests.pop(), | ||
747 | '#EXTM3U\n' + | ||
748 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
749 | '#EXTINF:10,\n' + | ||
750 | 'low-0.ts\n' + | ||
751 | '#EXT-X-ENDLIST\n'); | ||
752 | loader.media('high.m3u8'); | ||
753 | loader.media('high.m3u8'); | ||
754 | |||
755 | QUnit.strictEqual(this.requests.length, 1, 'made only one request'); | ||
756 | QUnit.ok(!this.requests[0].aborted, 'request not aborted'); | ||
757 | }); | ||
758 | |||
759 | QUnit.test('throws an error if a media switch is initiated too early', function() { | ||
760 | let loader = new PlaylistLoader('master.m3u8'); | ||
761 | |||
762 | QUnit.throws(function() { | ||
657 | loader.media('high.m3u8'); | 763 | loader.media('high.m3u8'); |
658 | loader.media('low.m3u8'); | 764 | }, 'threw an error from HAVE_NOTHING'); |
659 | 765 | ||
660 | strictEqual(requests.length, 1, 'requested high playlist'); | 766 | respond(this.requests.pop(), |
661 | ok(requests[0].aborted, 'aborted playlist request'); | 767 | '#EXTM3U\n' + |
662 | ok(!requests[0].onreadystatechange, 'onreadystatechange handlers should be removed on abort'); | 768 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + |
663 | strictEqual(loader.state, 'HAVE_METADATA', 'returned to loaded playlist'); | 769 | 'low.m3u8\n' + |
664 | strictEqual(loader.media(), loader.master.playlists[0], 'switched to loaded playlist'); | 770 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + |
771 | 'high.m3u8\n'); | ||
772 | }); | ||
773 | |||
774 | QUnit.test( | ||
775 | 'throws an error if a switch to an unrecognized playlist is requested', | ||
776 | function() { | ||
777 | let loader = new PlaylistLoader('master.m3u8'); | ||
778 | |||
779 | respond(this.requests.pop(), | ||
780 | '#EXTM3U\n' + | ||
781 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
782 | 'media.m3u8\n'); | ||
783 | |||
784 | QUnit.throws(function() { | ||
785 | loader.media('unrecognized.m3u8'); | ||
786 | }, 'throws an error'); | ||
787 | }); | ||
788 | |||
789 | QUnit.test('dispose cancels the refresh timeout', function() { | ||
790 | let loader = new PlaylistLoader('live.m3u8'); | ||
791 | |||
792 | respond(this.requests.pop(), | ||
793 | '#EXTM3U\n' + | ||
794 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
795 | '#EXTINF:10,\n' + | ||
796 | '0.ts\n'); | ||
797 | loader.dispose(); | ||
798 | // a lot of time passes... | ||
799 | this.clock.tick(15 * 1000); | ||
800 | |||
801 | QUnit.strictEqual(this.requests.length, 0, 'no refresh request was made'); | ||
802 | }); | ||
803 | |||
804 | QUnit.test('dispose aborts pending refresh this.requests', function() { | ||
805 | let loader = new PlaylistLoader('live.m3u8'); | ||
806 | |||
807 | respond(this.requests.pop(), | ||
808 | '#EXTM3U\n' + | ||
809 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
810 | '#EXTINF:10,\n' + | ||
811 | '0.ts\n'); | ||
812 | this.clock.tick(10 * 1000); | ||
813 | |||
814 | loader.dispose(); | ||
815 | QUnit.ok(this.requests[0].aborted, 'refresh request aborted'); | ||
816 | QUnit.ok( | ||
817 | !this.requests[0].onreadystatechange, | ||
818 | 'onreadystatechange handler should not exist after dispose called' | ||
819 | ); | ||
820 | }); | ||
821 | |||
822 | QUnit.test('errors if this.requests take longer than 45s', function() { | ||
823 | let loader = new PlaylistLoader('media.m3u8'); | ||
824 | let errors = 0; | ||
825 | |||
826 | loader.on('error', function() { | ||
827 | errors++; | ||
665 | }); | 828 | }); |
829 | this.clock.tick(45 * 1000); | ||
666 | 830 | ||
831 | QUnit.strictEqual(errors, 1, 'fired one error'); | ||
832 | QUnit.strictEqual(loader.error.code, 2, 'fired a network error'); | ||
833 | }); | ||
667 | 834 | ||
668 | test('does not abort requests when the same playlist is re-requested', function() { | 835 | QUnit.test('triggers an event when the active media changes', function() { |
669 | var loader = new videojs.Hls.PlaylistLoader('master.m3u8'); | 836 | let loader = new PlaylistLoader('master.m3u8'); |
670 | requests.pop().respond(200, null, | 837 | let mediaChanges = 0; |
671 | '#EXTM3U\n' + | ||
672 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | ||
673 | 'low.m3u8\n' + | ||
674 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | ||
675 | 'high.m3u8\n'); | ||
676 | requests.pop().respond(200, null, | ||
677 | '#EXTM3U\n' + | ||
678 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
679 | '#EXTINF:10,\n' + | ||
680 | 'low-0.ts\n' + | ||
681 | '#EXT-X-ENDLIST\n'); | ||
682 | loader.media('high.m3u8'); | ||
683 | loader.media('high.m3u8'); | ||
684 | 838 | ||
685 | strictEqual(requests.length, 1, 'made only one request'); | 839 | loader.on('mediachange', function() { |
686 | ok(!requests[0].aborted, 'request not aborted'); | 840 | mediaChanges++; |
687 | }); | 841 | }); |
688 | 842 | respond(this.requests.pop(), | |
689 | test('throws an error if a media switch is initiated too early', function() { | 843 | '#EXTM3U\n' + |
690 | var loader = new videojs.Hls.PlaylistLoader('master.m3u8'); | 844 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + |
691 | 845 | 'low.m3u8\n' + | |
692 | throws(function() { | 846 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + |
693 | loader.media('high.m3u8'); | 847 | 'high.m3u8\n'); |
694 | }, 'threw an error from HAVE_NOTHING'); | 848 | respond(this.requests.shift(), |
695 | 849 | '#EXTM3U\n' + | |
696 | requests.pop().respond(200, null, | 850 | '#EXT-X-MEDIA-SEQUENCE:0\n' + |
697 | '#EXTM3U\n' + | 851 | '#EXTINF:10,\n' + |
698 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | 852 | 'low-0.ts\n' + |
699 | 'low.m3u8\n' + | 853 | '#EXT-X-ENDLIST\n'); |
700 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | 854 | QUnit.strictEqual(mediaChanges, 0, 'initial selection is not a media change'); |
701 | 'high.m3u8\n'); | 855 | |
702 | }); | 856 | loader.media('high.m3u8'); |
703 | 857 | QUnit.strictEqual(mediaChanges, 0, 'mediachange does not fire immediately'); | |
704 | test('throws an error if a switch to an unrecognized playlist is requested', function() { | 858 | |
705 | var loader = new videojs.Hls.PlaylistLoader('master.m3u8'); | 859 | respond(this.requests.shift(), |
706 | requests.pop().respond(200, null, | 860 | '#EXTM3U\n' + |
707 | '#EXTM3U\n' + | 861 | '#EXT-X-MEDIA-SEQUENCE:0\n' + |
708 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | 862 | '#EXTINF:10,\n' + |
709 | 'media.m3u8\n'); | 863 | 'high-0.ts\n' + |
710 | 864 | '#EXT-X-ENDLIST\n'); | |
711 | throws(function() { | 865 | QUnit.strictEqual(mediaChanges, 1, 'fired a mediachange'); |
712 | loader.media('unrecognized.m3u8'); | 866 | |
713 | }, 'throws an error'); | 867 | // switch back to an already loaded playlist |
714 | }); | 868 | loader.media('low.m3u8'); |
715 | 869 | QUnit.strictEqual(mediaChanges, 2, 'fired a mediachange'); | |
716 | test('dispose cancels the refresh timeout', function() { | 870 | |
717 | var loader = new videojs.Hls.PlaylistLoader('live.m3u8'); | 871 | // trigger a no-op switch |
718 | requests.pop().respond(200, null, | 872 | loader.media('low.m3u8'); |
719 | '#EXTM3U\n' + | 873 | QUnit.strictEqual(mediaChanges, 2, 'ignored a no-op media change'); |
720 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | 874 | }); |
721 | '#EXTINF:10,\n' + | 875 | |
722 | '0.ts\n'); | 876 | QUnit.test('can get media index by playback position for non-live videos', function() { |
723 | loader.dispose(); | 877 | let loader = new PlaylistLoader('media.m3u8'); |
724 | // a lot of time passes... | 878 | |
725 | clock.tick(15 * 1000); | 879 | respond(this.requests.shift(), |
726 | 880 | '#EXTM3U\n' + | |
727 | strictEqual(requests.length, 0, 'no refresh request was made'); | 881 | '#EXT-X-MEDIA-SEQUENCE:0\n' + |
728 | }); | 882 | '#EXTINF:4,\n' + |
729 | 883 | '0.ts\n' + | |
730 | test('dispose aborts pending refresh requests', function() { | 884 | '#EXTINF:5,\n' + |
731 | var loader = new videojs.Hls.PlaylistLoader('live.m3u8'); | 885 | '1.ts\n' + |
732 | requests.pop().respond(200, null, | 886 | '#EXTINF:6,\n' + |
733 | '#EXTM3U\n' + | 887 | '2.ts\n' + |
734 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | 888 | '#EXT-X-ENDLIST\n'); |
735 | '#EXTINF:10,\n' + | 889 | |
736 | '0.ts\n'); | 890 | QUnit.equal(loader.getMediaIndexForTime_(-1), |
737 | clock.tick(10 * 1000); | 891 | 0, |
738 | 892 | 'the index is never less than zero'); | |
739 | loader.dispose(); | 893 | QUnit.equal(loader.getMediaIndexForTime_(0), 0, 'time zero is index zero'); |
740 | ok(requests[0].aborted, 'refresh request aborted'); | 894 | QUnit.equal(loader.getMediaIndexForTime_(3), 0, 'time three is index zero'); |
741 | ok(!requests[0].onreadystatechange, 'onreadystatechange handler should not exist after dispose called'); | 895 | QUnit.equal(loader.getMediaIndexForTime_(10), 2, 'time 10 is index 2'); |
742 | }); | 896 | QUnit.equal(loader.getMediaIndexForTime_(22), |
743 | 897 | 2, | |
744 | test('errors if requests take longer than 45s', function() { | 898 | 'time greater than the length is index 2'); |
745 | var | 899 | }); |
746 | loader = new videojs.Hls.PlaylistLoader('media.m3u8'), | 900 | |
747 | errors = 0; | 901 | QUnit.test('returns the lower index when calculating for a segment boundary', function() { |
748 | loader.on('error', function() { | 902 | let loader = new PlaylistLoader('media.m3u8'); |
749 | errors++; | 903 | |
750 | }); | 904 | respond(this.requests.shift(), |
751 | clock.tick(45 * 1000); | 905 | '#EXTM3U\n' + |
752 | 906 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | |
753 | strictEqual(errors, 1, 'fired one error'); | 907 | '#EXTINF:4,\n' + |
754 | strictEqual(loader.error.code, 2, 'fired a network error'); | 908 | '0.ts\n' + |
755 | }); | 909 | '#EXTINF:5,\n' + |
756 | 910 | '1.ts\n' + | |
757 | test('triggers an event when the active media changes', function() { | 911 | '#EXT-X-ENDLIST\n'); |
758 | var | 912 | QUnit.equal(loader.getMediaIndexForTime_(4), 1, 'rounds up exact matches'); |
759 | loader = new videojs.Hls.PlaylistLoader('master.m3u8'), | 913 | QUnit.equal(loader.getMediaIndexForTime_(3.7), 0, 'rounds down'); |
760 | mediaChanges = 0; | 914 | QUnit.equal(loader.getMediaIndexForTime_(4.5), 1, 'rounds up at 0.5'); |
761 | loader.on('mediachange', function() { | 915 | }); |
762 | mediaChanges++; | 916 | |
763 | }); | 917 | QUnit.test( |
764 | requests.pop().respond(200, null, | 918 | 'accounts for non-zero starting segment time when calculating media index', |
765 | '#EXTM3U\n' + | 919 | function() { |
766 | '#EXT-X-STREAM-INF:BANDWIDTH=1\n' + | 920 | let loader = new PlaylistLoader('media.m3u8'); |
767 | 'low.m3u8\n' + | 921 | |
768 | '#EXT-X-STREAM-INF:BANDWIDTH=2\n' + | 922 | respond(this.requests.shift(), |
769 | 'high.m3u8\n'); | 923 | '#EXTM3U\n' + |
770 | requests.shift().respond(200, null, | 924 | '#EXT-X-MEDIA-SEQUENCE:1001\n' + |
771 | '#EXTM3U\n' + | 925 | '#EXTINF:4,\n' + |
772 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | 926 | '1001.ts\n' + |
773 | '#EXTINF:10,\n' + | 927 | '#EXTINF:5,\n' + |
774 | 'low-0.ts\n' + | 928 | '1002.ts\n'); |
775 | '#EXT-X-ENDLIST\n'); | 929 | loader.media().segments[0].end = 154; |
776 | strictEqual(mediaChanges, 0, 'initial selection is not a media change'); | 930 | |
777 | 931 | QUnit.equal(loader.getMediaIndexForTime_(0), | |
778 | loader.media('high.m3u8'); | 932 | -1, |
779 | strictEqual(mediaChanges, 0, 'mediachange does not fire immediately'); | 933 | 'the lowest returned value is negative one'); |
780 | 934 | QUnit.equal(loader.getMediaIndexForTime_(45), | |
781 | requests.shift().respond(200, null, | 935 | -1, |
782 | '#EXTM3U\n' + | 936 | 'expired content returns negative one'); |
783 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | 937 | QUnit.equal(loader.getMediaIndexForTime_(75), |
784 | '#EXTINF:10,\n' + | 938 | -1, |
785 | 'high-0.ts\n' + | 939 | 'expired content returns negative one'); |
786 | '#EXT-X-ENDLIST\n'); | 940 | QUnit.equal(loader.getMediaIndexForTime_(50 + 100), |
787 | strictEqual(mediaChanges, 1, 'fired a mediachange'); | 941 | 0, |
788 | 942 | 'calculates the earliest available position'); | |
789 | // switch back to an already loaded playlist | 943 | QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 2), |
790 | loader.media('low.m3u8'); | 944 | 0, |
791 | strictEqual(mediaChanges, 2, 'fired a mediachange'); | 945 | 'calculates within the first segment'); |
792 | 946 | QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 2), | |
793 | // trigger a no-op switch | 947 | 0, |
794 | loader.media('low.m3u8'); | 948 | 'calculates within the first segment'); |
795 | strictEqual(mediaChanges, 2, 'ignored a no-op media change'); | 949 | QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 4), |
796 | }); | 950 | 1, |
797 | 951 | 'calculates within the second segment'); | |
798 | test('can get media index by playback position for non-live videos', function() { | 952 | QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 4.5), |
799 | var loader = new videojs.Hls.PlaylistLoader('media.m3u8'); | 953 | 1, |
800 | requests.shift().respond(200, null, | 954 | 'calculates within the second segment'); |
801 | '#EXTM3U\n' + | 955 | QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 6), |
802 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | 956 | 1, |
803 | '#EXTINF:4,\n' + | 957 | 'calculates within the second segment'); |
804 | '0.ts\n' + | 958 | }); |
805 | '#EXTINF:5,\n' + | 959 | |
806 | '1.ts\n' + | 960 | QUnit.test('prefers precise segment timing when tracking expired time', function() { |
807 | '#EXTINF:6,\n' + | 961 | let loader = new PlaylistLoader('media.m3u8'); |
808 | '2.ts\n' + | 962 | |
809 | '#EXT-X-ENDLIST\n'); | 963 | loader.trigger('firstplay'); |
810 | 964 | respond(this.requests.shift(), | |
811 | equal(loader.getMediaIndexForTime_(-1), | 965 | '#EXTM3U\n' + |
812 | 0, | 966 | '#EXT-X-MEDIA-SEQUENCE:1001\n' + |
813 | 'the index is never less than zero'); | 967 | '#EXTINF:4,\n' + |
814 | equal(loader.getMediaIndexForTime_(0), 0, 'time zero is index zero'); | 968 | '1001.ts\n' + |
815 | equal(loader.getMediaIndexForTime_(3), 0, 'time three is index zero'); | 969 | '#EXTINF:5,\n' + |
816 | equal(loader.getMediaIndexForTime_(10), 2, 'time 10 is index 2'); | 970 | '1002.ts\n'); |
817 | equal(loader.getMediaIndexForTime_(22), | 971 | // setup the loader with an "imprecise" value as if it had been |
818 | 2, | 972 | // accumulating segment durations as they expire |
819 | 'time greater than the length is index 2'); | 973 | loader.expired_ = 160; |
820 | }); | 974 | // annotate the first segment with a start time |
821 | 975 | // this number would be coming from the Source Buffer in practice | |
822 | test('returns the lower index when calculating for a segment boundary', function() { | 976 | loader.media().segments[0].end = 150; |
823 | var loader = new videojs.Hls.PlaylistLoader('media.m3u8'); | 977 | |
824 | requests.shift().respond(200, null, | 978 | QUnit.equal(loader.getMediaIndexForTime_(149), |
825 | '#EXTM3U\n' + | 979 | 0, |
826 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | 980 | 'prefers the value on the first segment'); |
827 | '#EXTINF:4,\n' + | 981 | |
828 | '0.ts\n' + | 982 | // trigger a playlist refresh |
829 | '#EXTINF:5,\n' + | 983 | this.clock.tick(10 * 1000); |
830 | '1.ts\n' + | 984 | respond(this.requests.shift(), |
831 | '#EXT-X-ENDLIST\n'); | 985 | '#EXTM3U\n' + |
832 | equal(loader.getMediaIndexForTime_(4), 1, 'rounds up exact matches'); | 986 | '#EXT-X-MEDIA-SEQUENCE:1002\n' + |
833 | equal(loader.getMediaIndexForTime_(3.7), 0, 'rounds down'); | 987 | '#EXTINF:5,\n' + |
834 | equal(loader.getMediaIndexForTime_(4.5), 1, 'rounds up at 0.5'); | 988 | '1002.ts\n'); |
835 | }); | 989 | QUnit.equal(loader.getMediaIndexForTime_(150 + 4 + 1), |
836 | 990 | 0, | |
837 | test('accounts for non-zero starting segment time when calculating media index', function() { | 991 | 'tracks precise expired times'); |
838 | var loader = new videojs.Hls.PlaylistLoader('media.m3u8'); | 992 | }); |
839 | requests.shift().respond(200, null, | 993 | |
840 | '#EXTM3U\n' + | 994 | QUnit.test('accounts for expired time when calculating media index', function() { |
841 | '#EXT-X-MEDIA-SEQUENCE:1001\n' + | 995 | let loader = new PlaylistLoader('media.m3u8'); |
842 | '#EXTINF:4,\n' + | 996 | |
843 | '1001.ts\n' + | 997 | respond(this.requests.shift(), |
844 | '#EXTINF:5,\n' + | 998 | '#EXTM3U\n' + |
845 | '1002.ts\n'); | 999 | '#EXT-X-MEDIA-SEQUENCE:1001\n' + |
846 | loader.media().segments[0].end = 154; | 1000 | '#EXTINF:4,\n' + |
847 | 1001 | '1001.ts\n' + | |
848 | equal(loader.getMediaIndexForTime_(0), -1, 'the lowest returned value is negative one'); | 1002 | '#EXTINF:5,\n' + |
849 | equal(loader.getMediaIndexForTime_(45), -1, 'expired content returns negative one'); | 1003 | '1002.ts\n'); |
850 | equal(loader.getMediaIndexForTime_(75), -1, 'expired content returns negative one'); | 1004 | loader.expired_ = 150; |
851 | equal(loader.getMediaIndexForTime_(50 + 100), 0, 'calculates the earliest available position'); | 1005 | |
852 | equal(loader.getMediaIndexForTime_(50 + 100 + 2), 0, 'calculates within the first segment'); | 1006 | QUnit.equal(loader.getMediaIndexForTime_(0), |
853 | equal(loader.getMediaIndexForTime_(50 + 100 + 2), 0, 'calculates within the first segment'); | 1007 | -1, |
854 | equal(loader.getMediaIndexForTime_(50 + 100 + 4), 1, 'calculates within the second segment'); | 1008 | 'expired content returns a negative index'); |
855 | equal(loader.getMediaIndexForTime_(50 + 100 + 4.5), 1, 'calculates within the second segment'); | 1009 | QUnit.equal(loader.getMediaIndexForTime_(75), |
856 | equal(loader.getMediaIndexForTime_(50 + 100 + 6), 1, 'calculates within the second segment'); | 1010 | -1, |
857 | }); | 1011 | 'expired content returns a negative index'); |
858 | 1012 | QUnit.equal(loader.getMediaIndexForTime_(50 + 100), | |
859 | test('prefers precise segment timing when tracking expired time', function() { | 1013 | 0, |
860 | var loader = new videojs.Hls.PlaylistLoader('media.m3u8'); | 1014 | 'calculates the earliest available position'); |
861 | loader.trigger('firstplay'); | 1015 | QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 2), |
862 | requests.shift().respond(200, null, | 1016 | 0, |
863 | '#EXTM3U\n' + | 1017 | 'calculates within the first segment'); |
864 | '#EXT-X-MEDIA-SEQUENCE:1001\n' + | 1018 | QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 2), |
865 | '#EXTINF:4,\n' + | 1019 | 0, |
866 | '1001.ts\n' + | 1020 | 'calculates within the first segment'); |
867 | '#EXTINF:5,\n' + | 1021 | QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 4.5), |
868 | '1002.ts\n'); | 1022 | 1, |
869 | // setup the loader with an "imprecise" value as if it had been | 1023 | 'calculates within the second segment'); |
870 | // accumulating segment durations as they expire | 1024 | QUnit.equal(loader.getMediaIndexForTime_(50 + 100 + 6), |
871 | loader.expired_ = 160; | 1025 | 1, |
872 | // annotate the first segment with a start time | 1026 | 'calculates within the second segment'); |
873 | // this number would be coming from the Source Buffer in practice | 1027 | }); |
874 | loader.media().segments[0].end = 150; | 1028 | |
875 | 1029 | QUnit.test('does not misintrepret playlists missing newlines at the end', function() { | |
876 | equal(loader.getMediaIndexForTime_(149), 0, 'prefers the value on the first segment'); | 1030 | let loader = new PlaylistLoader('media.m3u8'); |
877 | 1031 | ||
878 | clock.tick(10 * 1000); // trigger a playlist refresh | 1032 | // no newline |
879 | requests.shift().respond(200, null, | 1033 | respond(this.requests.shift(), |
880 | '#EXTM3U\n' + | 1034 | '#EXTM3U\n' + |
881 | '#EXT-X-MEDIA-SEQUENCE:1002\n' + | 1035 | '#EXT-X-MEDIA-SEQUENCE:0\n' + |
882 | '#EXTINF:5,\n' + | 1036 | '#EXTINF:10,\n' + |
883 | '1002.ts\n'); | 1037 | 'low-0.ts\n' + |
884 | equal(loader.getMediaIndexForTime_(150 + 4 + 1), 0, 'tracks precise expired times'); | 1038 | '#EXT-X-ENDLIST'); |
885 | }); | 1039 | QUnit.ok(loader.media().endList, 'flushed the final line of input'); |
886 | 1040 | }); | |
887 | test('accounts for expired time when calculating media index', function() { | ||
888 | var loader = new videojs.Hls.PlaylistLoader('media.m3u8'); | ||
889 | requests.shift().respond(200, null, | ||
890 | '#EXTM3U\n' + | ||
891 | '#EXT-X-MEDIA-SEQUENCE:1001\n' + | ||
892 | '#EXTINF:4,\n' + | ||
893 | '1001.ts\n' + | ||
894 | '#EXTINF:5,\n' + | ||
895 | '1002.ts\n'); | ||
896 | loader.expired_ = 150; | ||
897 | |||
898 | equal(loader.getMediaIndexForTime_(0), -1, 'expired content returns a negative index'); | ||
899 | equal(loader.getMediaIndexForTime_(75), -1, 'expired content returns a negative index'); | ||
900 | equal(loader.getMediaIndexForTime_(50 + 100), 0, 'calculates the earliest available position'); | ||
901 | equal(loader.getMediaIndexForTime_(50 + 100 + 2), 0, 'calculates within the first segment'); | ||
902 | equal(loader.getMediaIndexForTime_(50 + 100 + 2), 0, 'calculates within the first segment'); | ||
903 | equal(loader.getMediaIndexForTime_(50 + 100 + 4.5), 1, 'calculates within the second segment'); | ||
904 | equal(loader.getMediaIndexForTime_(50 + 100 + 6), 1, 'calculates within the second segment'); | ||
905 | }); | ||
906 | |||
907 | test('does not misintrepret playlists missing newlines at the end', function() { | ||
908 | var loader = new videojs.Hls.PlaylistLoader('media.m3u8'); | ||
909 | requests.shift().respond(200, null, | ||
910 | '#EXTM3U\n' + | ||
911 | '#EXT-X-MEDIA-SEQUENCE:0\n' + | ||
912 | '#EXTINF:10,\n' + | ||
913 | 'low-0.ts\n' + | ||
914 | '#EXT-X-ENDLIST'); // no newline | ||
915 | ok(loader.media().endList, 'flushed the final line of input'); | ||
916 | }); | ||
917 | |||
918 | })(window); | ... | ... |
1 | /* Tests for the playlist utilities */ | 1 | import Playlist from '../src/playlist'; |
2 | (function(window, videojs) { | 2 | import QUnit from 'qunit'; |
3 | 'use strict'; | 3 | QUnit.module('Playlist Duration'); |
4 | var Playlist = videojs.Hls.Playlist; | 4 | |
5 | 5 | QUnit.test('total duration for live playlists is Infinity', function() { | |
6 | QUnit.module('Playlist Duration'); | 6 | let duration = Playlist.duration({ |
7 | 7 | segments: [{ | |
8 | test('total duration for live playlists is Infinity', function() { | 8 | duration: 4, |
9 | var duration = Playlist.duration({ | 9 | uri: '0.ts' |
10 | segments: [{ | 10 | }] |
11 | duration: 4, | ||
12 | uri: '0.ts' | ||
13 | }] | ||
14 | }); | ||
15 | |||
16 | equal(duration, Infinity, 'duration is infinity'); | ||
17 | }); | 11 | }); |
18 | 12 | ||
19 | QUnit.module('Playlist Interval Duration'); | 13 | QUnit.equal(duration, Infinity, 'duration is infinity'); |
20 | 14 | }); | |
21 | test('accounts for non-zero starting VOD media sequences', function() { | 15 | |
22 | var duration = Playlist.duration({ | 16 | QUnit.module('Playlist Interval Duration'); |
23 | mediaSequence: 10, | 17 | |
24 | endList: true, | 18 | QUnit.test('accounts for non-zero starting VOD media sequences', function() { |
25 | segments: [{ | 19 | let duration = Playlist.duration({ |
26 | duration: 10, | 20 | mediaSequence: 10, |
27 | uri: '0.ts' | 21 | endList: true, |
28 | }, { | 22 | segments: [{ |
29 | duration: 10, | 23 | duration: 10, |
30 | uri: '1.ts' | 24 | uri: '0.ts' |
31 | }, { | 25 | }, { |
32 | duration: 10, | 26 | duration: 10, |
33 | uri: '2.ts' | 27 | uri: '1.ts' |
34 | }, { | 28 | }, { |
35 | duration: 10, | 29 | duration: 10, |
36 | uri: '3.ts' | 30 | uri: '2.ts' |
37 | }] | 31 | }, { |
38 | }); | 32 | duration: 10, |
39 | 33 | uri: '3.ts' | |
40 | equal(duration, 4 * 10, 'includes only listed segments'); | 34 | }] |
41 | }); | 35 | }); |
42 | 36 | ||
43 | test('uses timeline values when available', function() { | 37 | QUnit.equal(duration, 4 * 10, 'includes only listed segments'); |
44 | var duration = Playlist.duration({ | 38 | }); |
45 | mediaSequence: 0, | 39 | |
46 | endList: true, | 40 | QUnit.test('uses timeline values when available', function() { |
47 | segments: [{ | 41 | let duration = Playlist.duration({ |
48 | start: 0, | 42 | mediaSequence: 0, |
49 | uri: '0.ts' | 43 | endList: true, |
50 | }, { | 44 | segments: [{ |
51 | duration: 10, | 45 | start: 0, |
52 | end: 2 * 10 + 2, | 46 | uri: '0.ts' |
53 | uri: '1.ts' | 47 | }, { |
54 | }, { | 48 | duration: 10, |
55 | duration: 10, | 49 | end: 2 * 10 + 2, |
56 | end: 3 * 10 + 2, | 50 | uri: '1.ts' |
57 | uri: '2.ts' | 51 | }, { |
58 | }, { | 52 | duration: 10, |
59 | duration: 10, | 53 | end: 3 * 10 + 2, |
60 | end: 4 * 10 + 2, | 54 | uri: '2.ts' |
61 | uri: '3.ts' | 55 | }, { |
62 | }] | 56 | duration: 10, |
63 | }, 4); | 57 | end: 4 * 10 + 2, |
64 | 58 | uri: '3.ts' | |
65 | equal(duration, 4 * 10 + 2, 'used timeline values'); | 59 | }] |
60 | }, 4); | ||
61 | |||
62 | QUnit.equal(duration, 4 * 10 + 2, 'used timeline values'); | ||
63 | }); | ||
64 | |||
65 | QUnit.test('works when partial timeline information is available', function() { | ||
66 | let duration = Playlist.duration({ | ||
67 | mediaSequence: 0, | ||
68 | endList: true, | ||
69 | segments: [{ | ||
70 | start: 0, | ||
71 | uri: '0.ts' | ||
72 | }, { | ||
73 | duration: 9, | ||
74 | uri: '1.ts' | ||
75 | }, { | ||
76 | duration: 10, | ||
77 | uri: '2.ts' | ||
78 | }, { | ||
79 | duration: 10, | ||
80 | start: 30.007, | ||
81 | end: 40.002, | ||
82 | uri: '3.ts' | ||
83 | }, { | ||
84 | duration: 10, | ||
85 | end: 50.0002, | ||
86 | uri: '4.ts' | ||
87 | }] | ||
88 | }, 5); | ||
89 | |||
90 | QUnit.equal(duration, 50.0002, 'calculated with mixed intervals'); | ||
91 | }); | ||
92 | |||
93 | QUnit.test('uses timeline values for the expired duration of live playlists', function() { | ||
94 | let playlist = { | ||
95 | mediaSequence: 12, | ||
96 | segments: [{ | ||
97 | duration: 10, | ||
98 | end: 120.5, | ||
99 | uri: '0.ts' | ||
100 | }, { | ||
101 | duration: 9, | ||
102 | uri: '1.ts' | ||
103 | }] | ||
104 | }; | ||
105 | let duration; | ||
106 | |||
107 | duration = Playlist.duration(playlist, playlist.mediaSequence); | ||
108 | QUnit.equal(duration, 110.5, 'used segment end time'); | ||
109 | duration = Playlist.duration(playlist, playlist.mediaSequence + 1); | ||
110 | QUnit.equal(duration, 120.5, 'used segment end time'); | ||
111 | duration = Playlist.duration(playlist, playlist.mediaSequence + 2); | ||
112 | QUnit.equal(duration, 120.5 + 9, 'used segment end time'); | ||
113 | }); | ||
114 | |||
115 | QUnit.test( | ||
116 | 'looks outside the queried interval for live playlist timeline values', | ||
117 | function() { | ||
118 | let playlist = { | ||
119 | mediaSequence: 12, | ||
120 | segments: [{ | ||
121 | duration: 10, | ||
122 | uri: '0.ts' | ||
123 | }, { | ||
124 | duration: 9, | ||
125 | end: 120.5, | ||
126 | uri: '1.ts' | ||
127 | }] | ||
128 | }; | ||
129 | let duration; | ||
130 | |||
131 | duration = Playlist.duration(playlist, playlist.mediaSequence); | ||
132 | QUnit.equal(duration, 120.5 - 9 - 10, 'used segment end time'); | ||
133 | }); | ||
134 | |||
135 | QUnit.test('ignores discontinuity sequences later than the end', function() { | ||
136 | let duration = Playlist.duration({ | ||
137 | mediaSequence: 0, | ||
138 | discontinuityStarts: [1, 3], | ||
139 | segments: [{ | ||
140 | duration: 10, | ||
141 | uri: '0.ts' | ||
142 | }, { | ||
143 | discontinuity: true, | ||
144 | duration: 9, | ||
145 | uri: '1.ts' | ||
146 | }, { | ||
147 | duration: 10, | ||
148 | uri: '2.ts' | ||
149 | }, { | ||
150 | discontinuity: true, | ||
151 | duration: 10, | ||
152 | uri: '3.ts' | ||
153 | }] | ||
154 | }, 2); | ||
155 | |||
156 | QUnit.equal(duration, 19, 'excluded the later segments'); | ||
157 | }); | ||
158 | |||
159 | QUnit.test('handles trailing segments without timeline information', function() { | ||
160 | let duration; | ||
161 | let playlist = { | ||
162 | mediaSequence: 0, | ||
163 | endList: true, | ||
164 | segments: [{ | ||
165 | start: 0, | ||
166 | end: 10.5, | ||
167 | uri: '0.ts' | ||
168 | }, { | ||
169 | duration: 9, | ||
170 | uri: '1.ts' | ||
171 | }, { | ||
172 | duration: 10, | ||
173 | uri: '2.ts' | ||
174 | }, { | ||
175 | start: 29.45, | ||
176 | end: 39.5, | ||
177 | uri: '3.ts' | ||
178 | }] | ||
179 | }; | ||
180 | |||
181 | duration = Playlist.duration(playlist, 3); | ||
182 | QUnit.equal(duration, 29.45, 'calculated duration'); | ||
183 | |||
184 | duration = Playlist.duration(playlist, 2); | ||
185 | QUnit.equal(duration, 19.5, 'calculated duration'); | ||
186 | }); | ||
187 | |||
188 | QUnit.test('uses timeline intervals when segments have them', function() { | ||
189 | let duration; | ||
190 | let playlist = { | ||
191 | mediaSequence: 0, | ||
192 | segments: [{ | ||
193 | start: 0, | ||
194 | end: 10, | ||
195 | uri: '0.ts' | ||
196 | }, { | ||
197 | duration: 9, | ||
198 | uri: '1.ts' | ||
199 | }, { | ||
200 | start: 20.1, | ||
201 | end: 30.1, | ||
202 | duration: 10, | ||
203 | uri: '2.ts' | ||
204 | }] | ||
205 | }; | ||
206 | |||
207 | duration = Playlist.duration(playlist, 2); | ||
208 | QUnit.equal(duration, 20.1, 'used the timeline-based interval'); | ||
209 | |||
210 | duration = Playlist.duration(playlist, 3); | ||
211 | QUnit.equal(duration, 30.1, 'used the timeline-based interval'); | ||
212 | }); | ||
213 | |||
214 | QUnit.test( | ||
215 | 'counts the time between segments as part of the earlier segment\'s duration', | ||
216 | function() { | ||
217 | let duration = Playlist.duration({ | ||
218 | mediaSequence: 0, | ||
219 | endList: true, | ||
220 | segments: [{ | ||
221 | start: 0, | ||
222 | end: 10, | ||
223 | uri: '0.ts' | ||
224 | }, { | ||
225 | start: 10.1, | ||
226 | end: 20.1, | ||
227 | duration: 10, | ||
228 | uri: '1.ts' | ||
229 | }] | ||
230 | }, 1); | ||
231 | |||
232 | QUnit.equal(duration, 10.1, 'included the segment gap'); | ||
233 | }); | ||
234 | |||
235 | QUnit.test('accounts for discontinuities', function() { | ||
236 | let duration = Playlist.duration({ | ||
237 | mediaSequence: 0, | ||
238 | endList: true, | ||
239 | discontinuityStarts: [1], | ||
240 | segments: [{ | ||
241 | duration: 10, | ||
242 | uri: '0.ts' | ||
243 | }, { | ||
244 | discontinuity: true, | ||
245 | duration: 10, | ||
246 | uri: '1.ts' | ||
247 | }] | ||
248 | }, 2); | ||
249 | |||
250 | QUnit.equal(duration, 10 + 10, 'handles discontinuities'); | ||
251 | }); | ||
252 | |||
253 | QUnit.test('a non-positive length interval has zero duration', function() { | ||
254 | let playlist = { | ||
255 | mediaSequence: 0, | ||
256 | discontinuityStarts: [1], | ||
257 | segments: [{ | ||
258 | duration: 10, | ||
259 | uri: '0.ts' | ||
260 | }, { | ||
261 | discontinuity: true, | ||
262 | duration: 10, | ||
263 | uri: '1.ts' | ||
264 | }] | ||
265 | }; | ||
266 | |||
267 | QUnit.equal(Playlist.duration(playlist, 0), 0, 'zero-length duration is zero'); | ||
268 | QUnit.equal(Playlist.duration(playlist, 0, false), 0, 'zero-length duration is zero'); | ||
269 | QUnit.equal(Playlist.duration(playlist, -1), 0, 'negative length duration is zero'); | ||
270 | }); | ||
271 | |||
272 | QUnit.module('Playlist Seekable'); | ||
273 | |||
274 | QUnit.test('calculates seekable time ranges from the available segments', function() { | ||
275 | let playlist = { | ||
276 | mediaSequence: 0, | ||
277 | segments: [{ | ||
278 | duration: 10, | ||
279 | uri: '0.ts' | ||
280 | }, { | ||
281 | duration: 10, | ||
282 | uri: '1.ts' | ||
283 | }], | ||
284 | endList: true | ||
285 | }; | ||
286 | let seekable = Playlist.seekable(playlist); | ||
287 | |||
288 | QUnit.equal(seekable.length, 1, 'there are seekable ranges'); | ||
289 | QUnit.equal(seekable.start(0), 0, 'starts at zero'); | ||
290 | QUnit.equal(seekable.end(0), Playlist.duration(playlist), 'ends at the duration'); | ||
291 | }); | ||
292 | |||
293 | QUnit.test('master playlists have empty seekable ranges', function() { | ||
294 | let seekable = Playlist.seekable({ | ||
295 | playlists: [{ | ||
296 | uri: 'low.m3u8' | ||
297 | }, { | ||
298 | uri: 'high.m3u8' | ||
299 | }] | ||
66 | }); | 300 | }); |
67 | 301 | ||
68 | test('works when partial timeline information is available', function() { | 302 | QUnit.equal(seekable.length, 0, 'no seekable ranges from a master playlist'); |
69 | var duration = Playlist.duration({ | 303 | }); |
70 | mediaSequence: 0, | 304 | |
71 | endList: true, | 305 | QUnit.test( |
72 | segments: [{ | 306 | 'seekable end is three target durations from the actual end of live playlists', |
73 | start: 0, | 307 | function() { |
74 | uri: '0.ts' | 308 | let seekable = Playlist.seekable({ |
75 | }, { | 309 | mediaSequence: 0, |
76 | duration: 9, | 310 | segments: [{ |
77 | uri: '1.ts' | 311 | duration: 7, |
78 | }, { | 312 | uri: '0.ts' |
79 | duration: 10, | 313 | }, { |
80 | uri: '2.ts' | 314 | duration: 10, |
81 | }, { | 315 | uri: '1.ts' |
82 | duration: 10, | 316 | }, { |
83 | start: 30.007, | 317 | duration: 10, |
84 | end: 40.002, | 318 | uri: '2.ts' |
85 | uri: '3.ts' | 319 | }, { |
86 | }, { | 320 | duration: 10, |
87 | duration: 10, | 321 | uri: '3.ts' |
88 | end: 50.0002, | 322 | }] |
89 | uri: '4.ts' | ||
90 | }] | ||
91 | }, 5); | ||
92 | |||
93 | equal(duration, 50.0002, 'calculated with mixed intervals'); | ||
94 | }); | 323 | }); |
95 | 324 | ||
96 | test('uses timeline values for the expired duration of live playlists', function() { | 325 | QUnit.equal(seekable.length, 1, 'there are seekable ranges'); |
97 | var playlist = { | 326 | QUnit.equal(seekable.start(0), 0, 'starts at zero'); |
98 | mediaSequence: 12, | 327 | QUnit.equal(seekable.end(0), 7, 'ends three target durations from the last segment'); |
99 | segments: [{ | 328 | }); |
100 | duration: 10, | 329 | |
101 | end: 120.5, | 330 | QUnit.test('only considers available segments', function() { |
102 | uri: '0.ts' | 331 | let seekable = Playlist.seekable({ |
103 | }, { | 332 | mediaSequence: 7, |
104 | duration: 9, | 333 | segments: [{ |
105 | uri: '1.ts' | 334 | uri: '8.ts', |
106 | }] | 335 | duration: 10 |
107 | }, duration; | 336 | }, { |
108 | 337 | uri: '9.ts', | |
109 | duration = Playlist.duration(playlist, playlist.mediaSequence); | 338 | duration: 10 |
110 | equal(duration, 110.5, 'used segment end time'); | 339 | }, { |
111 | duration = Playlist.duration(playlist, playlist.mediaSequence + 1); | 340 | uri: '10.ts', |
112 | equal(duration, 120.5, 'used segment end time'); | 341 | duration: 10 |
113 | duration = Playlist.duration(playlist, playlist.mediaSequence + 2); | 342 | }, { |
114 | equal(duration, 120.5 + 9, 'used segment end time'); | 343 | uri: '11.ts', |
344 | duration: 10 | ||
345 | }] | ||
115 | }); | 346 | }); |
116 | 347 | ||
117 | test('looks outside the queried interval for live playlist timeline values', function() { | 348 | QUnit.equal(seekable.length, 1, 'there are seekable ranges'); |
118 | var playlist = { | 349 | QUnit.equal(seekable.start(0), 0, 'starts at the earliest available segment'); |
119 | mediaSequence: 12, | 350 | QUnit.equal( |
120 | segments: [{ | 351 | seekable.end(0), |
121 | duration: 10, | 352 | 10, |
122 | uri: '0.ts' | 353 | 'ends three target durations from the last available segment' |
123 | }, { | 354 | ); |
124 | duration: 9, | 355 | }); |
125 | end: 120.5, | 356 | |
126 | uri: '1.ts' | 357 | QUnit.test('seekable end accounts for non-standard target durations', function() { |
127 | }] | 358 | let seekable = Playlist.seekable({ |
128 | }, duration; | 359 | targetDuration: 2, |
129 | 360 | mediaSequence: 0, | |
130 | duration = Playlist.duration(playlist, playlist.mediaSequence); | 361 | segments: [{ |
131 | equal(duration, 120.5 - 9 - 10, 'used segment end time'); | 362 | duration: 2, |
363 | uri: '0.ts' | ||
364 | }, { | ||
365 | duration: 2, | ||
366 | uri: '1.ts' | ||
367 | }, { | ||
368 | duration: 1, | ||
369 | uri: '2.ts' | ||
370 | }, { | ||
371 | duration: 2, | ||
372 | uri: '3.ts' | ||
373 | }, { | ||
374 | duration: 2, | ||
375 | uri: '4.ts' | ||
376 | }] | ||
132 | }); | 377 | }); |
133 | 378 | ||
134 | test('ignores discontinuity sequences later than the end', function() { | 379 | QUnit.equal(seekable.start(0), 0, 'starts at the earliest available segment'); |
135 | var duration = Playlist.duration({ | 380 | QUnit.equal( |
136 | mediaSequence: 0, | 381 | seekable.end(0), |
137 | discontinuityStarts: [1, 3], | 382 | 9 - (2 + 2 + 1), |
138 | segments: [{ | 383 | 'allows seeking no further than three segments from the end' |
139 | duration: 10, | 384 | ); |
140 | uri: '0.ts' | 385 | }); |
141 | }, { | ||
142 | discontinuity: true, | ||
143 | duration: 9, | ||
144 | uri: '1.ts' | ||
145 | }, { | ||
146 | duration: 10, | ||
147 | uri: '2.ts' | ||
148 | }, { | ||
149 | discontinuity: true, | ||
150 | duration: 10, | ||
151 | uri: '3.ts' | ||
152 | }] | ||
153 | }, 2); | ||
154 | |||
155 | equal(duration, 19, 'excluded the later segments'); | ||
156 | }); | ||
157 | |||
158 | test('handles trailing segments without timeline information', function() { | ||
159 | var playlist, duration; | ||
160 | playlist = { | ||
161 | mediaSequence: 0, | ||
162 | endList: true, | ||
163 | segments: [{ | ||
164 | start: 0, | ||
165 | end: 10.5, | ||
166 | uri: '0.ts' | ||
167 | }, { | ||
168 | duration: 9, | ||
169 | uri: '1.ts' | ||
170 | }, { | ||
171 | duration: 10, | ||
172 | uri: '2.ts' | ||
173 | }, { | ||
174 | start: 29.45, | ||
175 | end: 39.5, | ||
176 | uri: '3.ts' | ||
177 | }] | ||
178 | }; | ||
179 | |||
180 | duration = Playlist.duration(playlist, 3); | ||
181 | equal(duration, 29.45, 'calculated duration'); | ||
182 | |||
183 | duration = Playlist.duration(playlist, 2); | ||
184 | equal(duration, 19.5, 'calculated duration'); | ||
185 | }); | ||
186 | |||
187 | test('uses timeline intervals when segments have them', function() { | ||
188 | var playlist, duration; | ||
189 | playlist = { | ||
190 | mediaSequence: 0, | ||
191 | segments: [{ | ||
192 | start: 0, | ||
193 | end: 10, | ||
194 | uri: '0.ts' | ||
195 | }, { | ||
196 | duration: 9, | ||
197 | uri: '1.ts' | ||
198 | },{ | ||
199 | start: 20.1, | ||
200 | end: 30.1, | ||
201 | duration: 10, | ||
202 | uri: '2.ts' | ||
203 | }] | ||
204 | }; | ||
205 | duration = Playlist.duration(playlist, 2); | ||
206 | |||
207 | equal(duration, 20.1, 'used the timeline-based interval'); | ||
208 | |||
209 | duration = Playlist.duration(playlist, 3); | ||
210 | equal(duration, 30.1, 'used the timeline-based interval'); | ||
211 | }); | ||
212 | |||
213 | test('counts the time between segments as part of the earlier segment\'s duration', function() { | ||
214 | var duration = Playlist.duration({ | ||
215 | mediaSequence: 0, | ||
216 | endList: true, | ||
217 | segments: [{ | ||
218 | start: 0, | ||
219 | end: 10, | ||
220 | uri: '0.ts' | ||
221 | }, { | ||
222 | start: 10.1, | ||
223 | end: 20.1, | ||
224 | duration: 10, | ||
225 | uri: '1.ts' | ||
226 | }] | ||
227 | }, 1); | ||
228 | |||
229 | equal(duration, 10.1, 'included the segment gap'); | ||
230 | }); | ||
231 | |||
232 | test('accounts for discontinuities', function() { | ||
233 | var duration = Playlist.duration({ | ||
234 | mediaSequence: 0, | ||
235 | endList: true, | ||
236 | discontinuityStarts: [1], | ||
237 | segments: [{ | ||
238 | duration: 10, | ||
239 | uri: '0.ts' | ||
240 | }, { | ||
241 | discontinuity: true, | ||
242 | duration: 10, | ||
243 | uri: '1.ts' | ||
244 | }] | ||
245 | }, 2); | ||
246 | |||
247 | equal(duration, 10 + 10, 'handles discontinuities'); | ||
248 | }); | ||
249 | |||
250 | test('a non-positive length interval has zero duration', function() { | ||
251 | var playlist = { | ||
252 | mediaSequence: 0, | ||
253 | discontinuityStarts: [1], | ||
254 | segments: [{ | ||
255 | duration: 10, | ||
256 | uri: '0.ts' | ||
257 | }, { | ||
258 | discontinuity: true, | ||
259 | duration: 10, | ||
260 | uri: '1.ts' | ||
261 | }] | ||
262 | }; | ||
263 | |||
264 | equal(Playlist.duration(playlist, 0), 0, 'zero-length duration is zero'); | ||
265 | equal(Playlist.duration(playlist, 0, false), 0, 'zero-length duration is zero'); | ||
266 | equal(Playlist.duration(playlist, -1), 0, 'negative length duration is zero'); | ||
267 | }); | ||
268 | |||
269 | QUnit.module('Playlist Seekable'); | ||
270 | |||
271 | test('calculates seekable time ranges from the available segments', function() { | ||
272 | var playlist = { | ||
273 | mediaSequence: 0, | ||
274 | segments: [{ | ||
275 | duration: 10, | ||
276 | uri: '0.ts' | ||
277 | }, { | ||
278 | duration: 10, | ||
279 | uri: '1.ts' | ||
280 | }], | ||
281 | endList: true | ||
282 | }, seekable = Playlist.seekable(playlist); | ||
283 | |||
284 | equal(seekable.length, 1, 'there are seekable ranges'); | ||
285 | equal(seekable.start(0), 0, 'starts at zero'); | ||
286 | equal(seekable.end(0), Playlist.duration(playlist), 'ends at the duration'); | ||
287 | }); | ||
288 | |||
289 | test('master playlists have empty seekable ranges', function() { | ||
290 | var seekable = Playlist.seekable({ | ||
291 | playlists: [{ | ||
292 | uri: 'low.m3u8' | ||
293 | }, { | ||
294 | uri: 'high.m3u8' | ||
295 | }] | ||
296 | }); | ||
297 | equal(seekable.length, 0, 'no seekable ranges from a master playlist'); | ||
298 | }); | ||
299 | |||
300 | test('seekable end is three target durations from the actual end of live playlists', function() { | ||
301 | var seekable = Playlist.seekable({ | ||
302 | mediaSequence: 0, | ||
303 | segments: [{ | ||
304 | duration: 7, | ||
305 | uri: '0.ts' | ||
306 | }, { | ||
307 | duration: 10, | ||
308 | uri: '1.ts' | ||
309 | }, { | ||
310 | duration: 10, | ||
311 | uri: '2.ts' | ||
312 | }, { | ||
313 | duration: 10, | ||
314 | uri: '3.ts' | ||
315 | }] | ||
316 | }); | ||
317 | equal(seekable.length, 1, 'there are seekable ranges'); | ||
318 | equal(seekable.start(0), 0, 'starts at zero'); | ||
319 | equal(seekable.end(0), 7, 'ends three target durations from the last segment'); | ||
320 | }); | ||
321 | |||
322 | test('only considers available segments', function() { | ||
323 | var seekable = Playlist.seekable({ | ||
324 | mediaSequence: 7, | ||
325 | segments: [{ | ||
326 | uri: '8.ts', | ||
327 | duration: 10 | ||
328 | }, { | ||
329 | uri: '9.ts', | ||
330 | duration: 10 | ||
331 | }, { | ||
332 | uri: '10.ts', | ||
333 | duration: 10 | ||
334 | }, { | ||
335 | uri: '11.ts', | ||
336 | duration: 10 | ||
337 | }] | ||
338 | }); | ||
339 | equal(seekable.length, 1, 'there are seekable ranges'); | ||
340 | equal(seekable.start(0), 0, 'starts at the earliest available segment'); | ||
341 | equal(seekable.end(0), 10, 'ends three target durations from the last available segment'); | ||
342 | }); | ||
343 | |||
344 | test('seekable end accounts for non-standard target durations', function() { | ||
345 | var seekable = Playlist.seekable({ | ||
346 | targetDuration: 2, | ||
347 | mediaSequence: 0, | ||
348 | segments: [{ | ||
349 | duration: 2, | ||
350 | uri: '0.ts' | ||
351 | }, { | ||
352 | duration: 2, | ||
353 | uri: '1.ts' | ||
354 | }, { | ||
355 | duration: 1, | ||
356 | uri: '2.ts' | ||
357 | }, { | ||
358 | duration: 2, | ||
359 | uri: '3.ts' | ||
360 | }, { | ||
361 | duration: 2, | ||
362 | uri: '4.ts' | ||
363 | }] | ||
364 | }); | ||
365 | equal(seekable.start(0), 0, 'starts at the earliest available segment'); | ||
366 | equal(seekable.end(0), | ||
367 | 9 - (2 + 2 + 1), | ||
368 | 'allows seeking no further than three segments from the end'); | ||
369 | }); | ||
370 | |||
371 | })(window, window.videojs); | ... | ... |
-
Please register or sign in to post a comment