Merge pull request #50 from videojs/feature/playlist-loader
Refactor M3U8 loading
Showing
12 changed files
with
259 additions
and
7 deletions
... | @@ -30,7 +30,8 @@ module.exports = function(grunt) { | ... | @@ -30,7 +30,8 @@ module.exports = function(grunt) { |
30 | 'src/aac-stream.js', | 30 | 'src/aac-stream.js', |
31 | 'src/segment-parser.js', | 31 | 'src/segment-parser.js', |
32 | 'src/stream.js', | 32 | 'src/stream.js', |
33 | 'src/m3u8/m3u8-parser.js' | 33 | 'src/m3u8/m3u8-parser.js', |
34 | 'src/playlist-loader.js' | ||
34 | ], | 35 | ], |
35 | dest: 'dist/videojs.hls.js' | 36 | dest: 'dist/videojs.hls.js' |
36 | } | 37 | } | ... | ... |
... | @@ -68,19 +68,25 @@ See html5rocks's [article](http://www.html5rocks.com/en/tutorials/cors/) | ... | @@ -68,19 +68,25 @@ See html5rocks's [article](http://www.html5rocks.com/en/tutorials/cors/) |
68 | for more info. | 68 | for more info. |
69 | 69 | ||
70 | ### Runtime Properties | 70 | ### Runtime Properties |
71 | #### player.hls.master | 71 | #### player.hls.playlists.master |
72 | Type: `object` | 72 | Type: `object` |
73 | 73 | ||
74 | An object representing the parsed master playlist. If a media playlist | 74 | An object representing the parsed master playlist. If a media playlist |
75 | is loaded directly, a master playlist with only one entry will be | 75 | is loaded directly, a master playlist with only one entry will be |
76 | created. | 76 | created. |
77 | 77 | ||
78 | #### player.hls.media | 78 | #### player.hls.playlists.media |
79 | Type: `object` | 79 | Type: `function` |
80 | 80 | ||
81 | An object representing the currently selected media playlist. This is | 81 | A function that can be used to retrieve or modify the currently active |
82 | the playlist that is being referred to when a additional video data | 82 | media playlist. The active media playlist is referred to when |
83 | needs to be downloaded. | 83 | additional video data needs to be downloaded. Calling this function |
84 | with no arguments returns the parsed playlist object for the active | ||
85 | media playlist. Calling this function with a playlist object from the | ||
86 | master playlist or a URI string as specified in the master playlist | ||
87 | will kick off an asynchronous load of the specified media | ||
88 | playlist. Once it has been retreived, it will become the active media | ||
89 | playlist. | ||
84 | 90 | ||
85 | #### player.hls.mediaIndex | 91 | #### player.hls.mediaIndex |
86 | Type: `number` | 92 | Type: `number` | ... | ... |
docs/playlist-loader-states.graffle
0 → 100644
No preview for this file type
docs/playlist-loader-states.png
0 → 100644
43.2 KB
... | @@ -25,6 +25,7 @@ | ... | @@ -25,6 +25,7 @@ |
25 | <!-- m3u8 handling --> | 25 | <!-- m3u8 handling --> |
26 | <script src="src/stream.js"></script> | 26 | <script src="src/stream.js"></script> |
27 | <script src="src/m3u8/m3u8-parser.js"></script> | 27 | <script src="src/m3u8/m3u8-parser.js"></script> |
28 | <script src="src/playlist-loader.js"></script> | ||
28 | 29 | ||
29 | <!-- example MPEG2-TS segments --> | 30 | <!-- example MPEG2-TS segments --> |
30 | <!-- bipbop --> | 31 | <!-- bipbop --> | ... | ... |
src/playlist-loader.js
0 → 100644
1 | /** | ||
2 | * A state machine that manages the loading, caching, and updating of | ||
3 | * M3U8 playlists. | ||
4 | */ | ||
5 | (function(window, videojs) { | ||
6 | 'use strict'; | ||
7 | var | ||
8 | resolveUrl = videojs.hls.resolveUrl, | ||
9 | xhr = videojs.hls.xhr, | ||
10 | |||
11 | /** | ||
12 | * Returns a new master playlist that is the result of merging an | ||
13 | * updated media playlist into the original version. If the | ||
14 | * updated media playlist does not match any of the playlist | ||
15 | * entries in the original master playlist, null is returned. | ||
16 | * @param master {object} a parsed master M3U8 object | ||
17 | * @param media {object} a parsed media M3U8 object | ||
18 | * @return {object} a new object that represents the original | ||
19 | * master playlist with the updated media playlist merged in, or | ||
20 | * null if the merge produced no change. | ||
21 | */ | ||
22 | updateMaster = function(master, media) { | ||
23 | var | ||
24 | changed = false, | ||
25 | result = videojs.util.mergeOptions(master, {}), | ||
26 | i, | ||
27 | playlist; | ||
28 | |||
29 | i = master.playlists.length; | ||
30 | while (i--) { | ||
31 | playlist = result.playlists[i]; | ||
32 | if (playlist.uri === media.uri) { | ||
33 | // consider the playlist unchanged if the number of segments | ||
34 | // are equal and the media sequence number is unchanged | ||
35 | if (playlist.segments && | ||
36 | media.segments && | ||
37 | playlist.segments.length === media.segments.length && | ||
38 | playlist.mediaSequence === media.mediaSequence) { | ||
39 | continue; | ||
40 | } | ||
41 | |||
42 | result.playlists[i] = videojs.util.mergeOptions(playlist, media); | ||
43 | result.playlists[media.uri] = result.playlists[i]; | ||
44 | changed = true; | ||
45 | } | ||
46 | } | ||
47 | return changed ? result : null; | ||
48 | }, | ||
49 | |||
50 | PlaylistLoader = function(srcUrl, withCredentials) { | ||
51 | var | ||
52 | loader = this, | ||
53 | media, | ||
54 | request, | ||
55 | |||
56 | haveMetadata = function(error, xhr, url) { | ||
57 | var parser, refreshDelay, update; | ||
58 | |||
59 | // any in-flight request is now finished | ||
60 | request = null; | ||
61 | |||
62 | if (error) { | ||
63 | loader.error = { | ||
64 | status: xhr.status, | ||
65 | message: 'HLS playlist request error at URL: ' + url, | ||
66 | code: (xhr.status >= 500) ? 4 : 2 | ||
67 | }; | ||
68 | return loader.trigger('error'); | ||
69 | } | ||
70 | |||
71 | loader.state = 'HAVE_METADATA'; | ||
72 | |||
73 | parser = new videojs.m3u8.Parser(); | ||
74 | parser.push(xhr.responseText); | ||
75 | parser.manifest.uri = url; | ||
76 | |||
77 | // merge this playlist into the master | ||
78 | update = updateMaster(loader.master, parser.manifest); | ||
79 | refreshDelay = (parser.manifest.targetDuration || 10) * 1000; | ||
80 | if (update) { | ||
81 | loader.master = update; | ||
82 | media = loader.master.playlists[url]; | ||
83 | } else { | ||
84 | // if the playlist is unchanged since the last reload, | ||
85 | // try again after half the target duration | ||
86 | refreshDelay /= 2; | ||
87 | } | ||
88 | |||
89 | // refresh live playlists after a target duration passes | ||
90 | if (!loader.media().endList) { | ||
91 | window.setTimeout(function() { | ||
92 | loader.trigger('mediaupdatetimeout'); | ||
93 | }, refreshDelay); | ||
94 | } | ||
95 | |||
96 | loader.trigger('loadedplaylist'); | ||
97 | }; | ||
98 | |||
99 | PlaylistLoader.prototype.init.call(this); | ||
100 | |||
101 | if (!srcUrl) { | ||
102 | throw new Error('A non-empty playlist URL is required'); | ||
103 | } | ||
104 | |||
105 | loader.state = 'HAVE_NOTHING'; | ||
106 | |||
107 | /** | ||
108 | * When called without any arguments, returns the currently | ||
109 | * active media playlist. When called with a single argument, | ||
110 | * triggers the playlist loader to asynchronously switch to the | ||
111 | * specified media playlist. Calling this method while the | ||
112 | * loader is in the HAVE_NOTHING or HAVE_MASTER states causes an | ||
113 | * error to be emitted but otherwise has no effect. | ||
114 | * @param playlist (optional) {object} the parsed media playlist | ||
115 | * object to switch to | ||
116 | */ | ||
117 | loader.media = function(playlist) { | ||
118 | // getter | ||
119 | if (!playlist) { | ||
120 | return media; | ||
121 | } | ||
122 | |||
123 | // setter | ||
124 | if (loader.state === 'HAVE_NOTHING' || loader.state === 'HAVE_MASTER') { | ||
125 | throw new Error('Cannot switch media playlist from ' + loader.state); | ||
126 | } | ||
127 | |||
128 | // find the playlist object if the target playlist has been | ||
129 | // specified by URI | ||
130 | if (typeof playlist === 'string') { | ||
131 | if (!loader.master.playlists[playlist]) { | ||
132 | throw new Error('Unknown playlist URI: ' + playlist); | ||
133 | } | ||
134 | playlist = loader.master.playlists[playlist]; | ||
135 | } | ||
136 | |||
137 | if (playlist.uri === media.uri) { | ||
138 | // switching to the currently active playlist is a no-op | ||
139 | return; | ||
140 | } | ||
141 | |||
142 | loader.state = 'SWITCHING_MEDIA'; | ||
143 | |||
144 | // abort any outstanding playlist refreshes | ||
145 | if (request) { | ||
146 | request.abort(); | ||
147 | request = null; | ||
148 | } | ||
149 | |||
150 | // request the new playlist | ||
151 | request = xhr({ | ||
152 | url: resolveUrl(loader.master.uri, playlist.uri), | ||
153 | withCredentials: withCredentials | ||
154 | }, function(error) { | ||
155 | haveMetadata(error, this, playlist.uri); | ||
156 | }); | ||
157 | }; | ||
158 | |||
159 | // live playlist staleness timeout | ||
160 | loader.on('mediaupdatetimeout', function() { | ||
161 | if (loader.state !== 'HAVE_METADATA') { | ||
162 | // only refresh the media playlist if no other activity is going on | ||
163 | return; | ||
164 | } | ||
165 | |||
166 | loader.state = 'HAVE_CURRENT_METADATA'; | ||
167 | request = xhr({ | ||
168 | url: resolveUrl(loader.master.uri, loader.media().uri), | ||
169 | withCredentials: withCredentials | ||
170 | }, function(error) { | ||
171 | haveMetadata(error, this, loader.media().uri); | ||
172 | }); | ||
173 | }); | ||
174 | |||
175 | // request the specified URL | ||
176 | xhr({ | ||
177 | url: srcUrl, | ||
178 | withCredentials: withCredentials | ||
179 | }, function(error) { | ||
180 | var parser, i; | ||
181 | |||
182 | if (error) { | ||
183 | loader.error = { | ||
184 | status: this.status, | ||
185 | message: 'HLS playlist request error at URL: ' + srcUrl, | ||
186 | code: 2 // MEDIA_ERR_NETWORK | ||
187 | }; | ||
188 | return loader.trigger('error'); | ||
189 | } | ||
190 | |||
191 | parser = new videojs.m3u8.Parser(); | ||
192 | parser.push(this.responseText); | ||
193 | |||
194 | loader.state = 'HAVE_MASTER'; | ||
195 | |||
196 | parser.manifest.uri = srcUrl; | ||
197 | |||
198 | // loaded a master playlist | ||
199 | if (parser.manifest.playlists) { | ||
200 | loader.master = parser.manifest; | ||
201 | |||
202 | // setup by-URI lookups | ||
203 | i = loader.master.playlists.length; | ||
204 | while (i--) { | ||
205 | loader.master.playlists[loader.master.playlists[i].uri] = loader.master.playlists[i]; | ||
206 | } | ||
207 | |||
208 | request = xhr({ | ||
209 | url: resolveUrl(srcUrl, parser.manifest.playlists[0].uri), | ||
210 | withCredentials: withCredentials | ||
211 | }, function(error) { | ||
212 | // pass along the URL specified in the master playlist | ||
213 | haveMetadata(error, | ||
214 | this, | ||
215 | parser.manifest.playlists[0].uri); | ||
216 | loader.trigger('loadedmetadata'); | ||
217 | }); | ||
218 | return loader.trigger('loadedplaylist'); | ||
219 | } | ||
220 | |||
221 | // loaded a media playlist | ||
222 | // infer a master playlist if none was previously requested | ||
223 | loader.master = { | ||
224 | uri: window.location.href, | ||
225 | playlists: [{ | ||
226 | uri: srcUrl | ||
227 | }] | ||
228 | }; | ||
229 | loader.master.playlists[srcUrl] = loader.master.playlists[0]; | ||
230 | haveMetadata(null, this, srcUrl); | ||
231 | return loader.trigger('loadedmetadata'); | ||
232 | }); | ||
233 | }; | ||
234 | PlaylistLoader.prototype = new videojs.hls.Stream(); | ||
235 | |||
236 | videojs.hls.PlaylistLoader = PlaylistLoader; | ||
237 | })(window, window.videojs); |
This diff is collapsed.
Click to expand it.
... | @@ -74,6 +74,7 @@ module.exports = function(config) { | ... | @@ -74,6 +74,7 @@ module.exports = function(config) { |
74 | '../node_modules/sinon/lib/sinon/util/event.js', | 74 | '../node_modules/sinon/lib/sinon/util/event.js', |
75 | '../node_modules/sinon/lib/sinon/util/fake_xml_http_request.js', | 75 | '../node_modules/sinon/lib/sinon/util/fake_xml_http_request.js', |
76 | '../node_modules/sinon/lib/sinon/util/xhr_ie.js', | 76 | '../node_modules/sinon/lib/sinon/util/xhr_ie.js', |
77 | '../node_modules/sinon/lib/sinon/util/fake_timers.js', | ||
77 | '../node_modules/video.js/dist/video-js/video.js', | 78 | '../node_modules/video.js/dist/video-js/video.js', |
78 | '../node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js', | 79 | '../node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js', |
79 | '../test/karma-qunit-shim.js', | 80 | '../test/karma-qunit-shim.js', |
... | @@ -85,6 +86,7 @@ module.exports = function(config) { | ... | @@ -85,6 +86,7 @@ module.exports = function(config) { |
85 | '../src/segment-parser.js', | 86 | '../src/segment-parser.js', |
86 | '../src/stream.js', | 87 | '../src/stream.js', |
87 | '../src/m3u8/m3u8-parser.js', | 88 | '../src/m3u8/m3u8-parser.js', |
89 | '../src/playlist-loader.js', | ||
88 | '../tmp/manifests.js', | 90 | '../tmp/manifests.js', |
89 | '../tmp/expected.js', | 91 | '../tmp/expected.js', |
90 | 'tsSegment-bc.js', | 92 | 'tsSegment-bc.js', | ... | ... |
... | @@ -38,6 +38,7 @@ module.exports = function(config) { | ... | @@ -38,6 +38,7 @@ module.exports = function(config) { |
38 | '../node_modules/sinon/lib/sinon/util/event.js', | 38 | '../node_modules/sinon/lib/sinon/util/event.js', |
39 | '../node_modules/sinon/lib/sinon/util/fake_xml_http_request.js', | 39 | '../node_modules/sinon/lib/sinon/util/fake_xml_http_request.js', |
40 | '../node_modules/sinon/lib/sinon/util/xhr_ie.js', | 40 | '../node_modules/sinon/lib/sinon/util/xhr_ie.js', |
41 | '../node_modules/sinon/lib/sinon/util/fake_timers.js', | ||
41 | '../node_modules/video.js/dist/video-js/video.js', | 42 | '../node_modules/video.js/dist/video-js/video.js', |
42 | '../node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js', | 43 | '../node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js', |
43 | '../test/karma-qunit-shim.js', | 44 | '../test/karma-qunit-shim.js', |
... | @@ -49,6 +50,7 @@ module.exports = function(config) { | ... | @@ -49,6 +50,7 @@ module.exports = function(config) { |
49 | '../src/segment-parser.js', | 50 | '../src/segment-parser.js', |
50 | '../src/stream.js', | 51 | '../src/stream.js', |
51 | '../src/m3u8/m3u8-parser.js', | 52 | '../src/m3u8/m3u8-parser.js', |
53 | '../src/playlist-loader.js', | ||
52 | '../tmp/manifests.js', | 54 | '../tmp/manifests.js', |
53 | '../tmp/expected.js', | 55 | '../tmp/expected.js', |
54 | 'tsSegment-bc.js', | 56 | 'tsSegment-bc.js', | ... | ... |
test/playlist-loader_test.js
0 → 100644
This diff is collapsed.
Click to expand it.
... | @@ -8,6 +8,7 @@ | ... | @@ -8,6 +8,7 @@ |
8 | <script src="../node_modules/sinon/lib/sinon/util/event.js"></script> | 8 | <script src="../node_modules/sinon/lib/sinon/util/event.js"></script> |
9 | <script src="../node_modules/sinon/lib/sinon/util/fake_xml_http_request.js"></script> | 9 | <script src="../node_modules/sinon/lib/sinon/util/fake_xml_http_request.js"></script> |
10 | <script src="../node_modules/sinon/lib/sinon/util/xhr_ie.js"></script> | 10 | <script src="../node_modules/sinon/lib/sinon/util/xhr_ie.js"></script> |
11 | <script src="../node_modules/sinon/lib/sinon/util/fake_timers.js"></script> | ||
11 | 12 | ||
12 | <!-- Load local QUnit. --> | 13 | <!-- Load local QUnit. --> |
13 | <link rel="stylesheet" href="../libs/qunit/qunit.css" media="screen"> | 14 | <link rel="stylesheet" href="../libs/qunit/qunit.css" media="screen"> |
... | @@ -28,6 +29,7 @@ | ... | @@ -28,6 +29,7 @@ |
28 | <!-- M3U8 --> | 29 | <!-- M3U8 --> |
29 | <script src="../src/stream.js"></script> | 30 | <script src="../src/stream.js"></script> |
30 | <script src="../src/m3u8/m3u8-parser.js"></script> | 31 | <script src="../src/m3u8/m3u8-parser.js"></script> |
32 | <script src="../src/playlist-loader.js"></script> | ||
31 | <!-- M3U8 TEST DATA --> | 33 | <!-- M3U8 TEST DATA --> |
32 | <script src="../tmp/manifests.js"></script> | 34 | <script src="../tmp/manifests.js"></script> |
33 | <script src="../tmp/expected.js"></script> | 35 | <script src="../tmp/expected.js"></script> |
... | @@ -51,6 +53,7 @@ | ... | @@ -51,6 +53,7 @@ |
51 | <script src="exp-golomb_test.js"></script> | 53 | <script src="exp-golomb_test.js"></script> |
52 | <script src="flv-tag_test.js"></script> | 54 | <script src="flv-tag_test.js"></script> |
53 | <script src="m3u8_test.js"></script> | 55 | <script src="m3u8_test.js"></script> |
56 | <script src="playlist-loader_test.js"></script> | ||
54 | </head> | 57 | </head> |
55 | <body> | 58 | <body> |
56 | <div id="qunit"></div> | 59 | <div id="qunit"></div> | ... | ... |
This diff is collapsed.
Click to expand it.
-
Please register or sign in to post a comment