72adc094 by Jon-Carlos Rivera

Merge pull request #633 from videojs/xhr-beforerequest

Expose the ability to set an `beforeRequest` function on xhr
2 parents 6fe9fa2f 9dde5c62
......@@ -19,6 +19,7 @@ Play back HLS with video.js, even where it's not natively supported.
- [hls.bandwidth](#hlsbandwidth)
- [hls.bytesReceived](#hlsbytesreceived)
- [hls.selectPlaylist](#hlsselectplaylist)
- [hls.xhr](#hlsxhr)
- [Events](#events)
- [loadedmetadata](#loadedmetadata)
- [loadedplaylist](#loadedplaylist)
......@@ -194,6 +195,44 @@ segment is downloaded. You can override this function to provide your
adaptive streaming logic. You must, however, be sure to return a valid
media playlist object that is present in `player.hls.master`.
#### hls.xhr
Type: `function`
The xhr function that is used by HLS internally is exposed on the per-
player `hls` object. While it is possible, we do not recommend replacing
the function with your own implementation. Instead, the `xhr` provides
the ability to specify a `beforeRequest` function that will be called
with an object containing the options that will be used to create the
xhr request.
Example:
```javascript
player.hls.xhr.beforeRequest = function(options) {
options.uri = options.uri.replace('example.com', 'foo.com');
return options;
};
```
The global `videojs.Hls` also exposes an `xhr` property. Specifying a
`beforeRequest` function on that will allow you to intercept the options
for *all* requests in every player on a page.
Example
```javascript
videojs.Hls.xhr.beforeRequest = function(options) {
/*
* Modifications to requests that will affect every player.
*/
return options;
};
```
For information on the type of options that you can modify see the
documentation at [https://github.com/Raynos/xhr](https://github.com/Raynos/xhr).
### Events
Standard HTML video events are handled by video.js automatically and
are triggered on the player object. In addition, there are a couple
......
......@@ -6,7 +6,6 @@
*
*/
import resolveUrl from './resolve-url';
import XhrModule from './xhr';
import {mergeOptions} from 'video.js';
import Stream from './stream';
import m3u8 from './m3u8';
......@@ -86,7 +85,7 @@ const updateSegments = function(original, update, offset) {
};
export default class PlaylistLoader extends Stream {
constructor(srcUrl, withCredentials) {
constructor(srcUrl, hls, withCredentials) {
super();
let loader = this;
let dispose;
......@@ -95,6 +94,8 @@ export default class PlaylistLoader extends Stream {
let playlistRequestError;
let haveMetadata;
this.hls_ = hls;
// a flag that disables "expired time"-tracking this setting has
// no effect when not playing a live stream
this.trackExpiredTime_ = false;
......@@ -261,7 +262,7 @@ export default class PlaylistLoader extends Stream {
}
// request the new playlist
request = XhrModule({
request = this.hls_.xhr({
uri: resolveUrl(loader.master.uri, playlist.uri),
withCredentials
}, function(error, request) {
......@@ -298,7 +299,7 @@ export default class PlaylistLoader extends Stream {
}
loader.state = 'HAVE_CURRENT_METADATA';
request = XhrModule({
request = this.hls_.xhr({
uri: resolveUrl(loader.master.uri, loader.media().uri),
withCredentials
}, function(error, request) {
......@@ -310,7 +311,7 @@ export default class PlaylistLoader extends Stream {
});
// request the specified URL
request = XhrModule({
request = this.hls_.xhr({
uri: srcUrl,
withCredentials
}, function(error, req) {
......
......@@ -5,7 +5,7 @@
*/
import PlaylistLoader from './playlist-loader';
import Playlist from './playlist';
import xhr from './xhr';
import xhrFactory from './xhr';
import {Decrypter, AsyncStream, decrypt} from './decrypter';
import utils from './bin-utils';
import {MediaSource, URL} from 'videojs-contrib-media-sources';
......@@ -20,7 +20,7 @@ const Hls = {
AsyncStream,
decrypt,
utils,
xhr
xhr: xhrFactory()
};
// the desired length of video to maintain in the buffer, in seconds
......@@ -416,6 +416,7 @@ export default class HlsHandler extends Component {
this.options_.withCredentials = videojs.options.hls.withCredentials;
}
this.playlists = new Hls.PlaylistLoader(this.source_.src,
this.tech_.hls,
this.options_.withCredentials);
this.tech_.one('canplay', this.setupFirstPlay.bind(this));
......@@ -1224,7 +1225,7 @@ export default class HlsHandler extends Component {
}
// request the next segment
this.segmentXhr_ = Hls.xhr({
this.segmentXhr_ = this.tech_.hls.xhr({
uri: segmentInfo.uri,
responseType: 'arraybuffer',
withCredentials: this.source_.withCredentials,
......@@ -1504,7 +1505,7 @@ export default class HlsHandler extends Component {
// request the key if the retry limit hasn't been reached
if (!key.bytes && !keyFailed(key)) {
this.keyXhr_ = Hls.xhr({
this.keyXhr_ = this.tech_.hls.xhr({
uri: this.playlistUriToUrl(key.uri),
responseType: 'arraybuffer',
withCredentials: settings.withCredentials
......@@ -1563,6 +1564,14 @@ const HlsSourceHandler = function(mode) {
source,
mode
});
tech.hls.xhr = xhrFactory();
// Use a global `before` function if specified on videojs.Hls.xhr
// but still allow for a per-player override
if (videojs.Hls.xhr.beforeRequest) {
tech.hls.xhr.beforeRequest = videojs.Hls.xhr.beforeRequest;
}
tech.hls.src(source.src);
return tech.hls;
},
......
......@@ -2,12 +2,25 @@
* A wrapper for videojs.xhr that tracks bandwidth.
*/
import {xhr as videojsXHR, mergeOptions} from 'video.js';
const xhr = function(options, callback) {
const xhrFactory = function() {
const xhr = function XhrFunction(options, callback) {
// Add a default timeout for all hls requests
options = mergeOptions({
timeout: 45e3
}, options);
// Allow an optional user-specified function to modify the option
// object before we construct the xhr request
if (XhrFunction.beforeRequest &&
typeof XhrFunction.beforeRequest === 'function') {
let newOptions = XhrFunction.beforeRequest(options);
if (newOptions) {
options = newOptions;
}
}
let request = videojsXHR(options, function(error, response) {
if (!error && request.response) {
request.responseTime = (new Date()).getTime();
......@@ -44,6 +57,9 @@ const xhr = function(options, callback) {
request.requestTime = (new Date()).getTime();
return request;
};
return xhr;
};
export default xhr;
export default xhrFactory;
......
......@@ -2,6 +2,7 @@ import sinon from 'sinon';
import QUnit from 'qunit';
import PlaylistLoader from '../src/playlist-loader';
import videojs from 'video.js';
import xhrFactory from '../src/xhr';
// Attempts to produce an absolute URL to a given relative path
// based on window.location.href
const urlTo = function(path) {
......@@ -27,6 +28,10 @@ QUnit.module('Playlist Loader', {
// fake timers
this.clock = sinon.useFakeTimers();
videojs.xhr.XMLHttpRequest = this.sinonXhr;
this.fakeHls = {
xhr: xhrFactory()
};
},
afterEach() {
this.sinonXhr.restore();
......@@ -45,13 +50,13 @@ QUnit.test('throws if the playlist url is empty or undefined', function() {
});
QUnit.test('starts without any metadata', function() {
let loader = new PlaylistLoader('master.m3u8');
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
QUnit.strictEqual(loader.state, 'HAVE_NOTHING', 'no metadata has loaded yet');
});
QUnit.test('starts with no expired time', function() {
let loader = new PlaylistLoader('media.m3u8');
let loader = new PlaylistLoader('media.m3u8', this.fakeHls);
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
......@@ -64,7 +69,7 @@ QUnit.test('starts with no expired time', function() {
QUnit.test('requests the initial playlist immediately', function() {
/* eslint-disable no-unused-vars */
let loader = new PlaylistLoader('master.m3u8');
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
/* eslint-enable no-unused-vars */
QUnit.strictEqual(this.requests.length, 1, 'made a request');
......@@ -74,7 +79,7 @@ QUnit.test('requests the initial playlist immediately', function() {
});
QUnit.test('moves to HAVE_MASTER after loading a master playlist', function() {
let loader = new PlaylistLoader('master.m3u8');
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
let state;
loader.on('loadedplaylist', function() {
......@@ -90,7 +95,7 @@ QUnit.test('moves to HAVE_MASTER after loading a master playlist', function() {
QUnit.test('jumps to HAVE_METADATA when initialized with a media playlist', function() {
let loadedmetadatas = 0;
let loader = new PlaylistLoader('media.m3u8');
let loader = new PlaylistLoader('media.m3u8', this.fakeHls);
loader.on('loadedmetadata', function() {
loadedmetadatas++;
......@@ -110,7 +115,7 @@ QUnit.test('jumps to HAVE_METADATA when initialized with a media playlist', func
QUnit.test('jumps to HAVE_METADATA when initialized with a live media playlist',
function() {
let loader = new PlaylistLoader('media.m3u8');
let loader = new PlaylistLoader('media.m3u8', this.fakeHls);
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
......@@ -124,7 +129,7 @@ function() {
QUnit.test('moves to HAVE_METADATA after loading a media playlist', function() {
let loadedPlaylist = 0;
let loadedMetadata = 0;
let loader = new PlaylistLoader('master.m3u8');
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
loader.on('loadedplaylist', function() {
loadedPlaylist++;
......@@ -157,7 +162,7 @@ QUnit.test('moves to HAVE_METADATA after loading a media playlist', function() {
});
QUnit.test('moves to HAVE_CURRENT_METADATA when refreshing the playlist', function() {
let loader = new PlaylistLoader('live.m3u8');
let loader = new PlaylistLoader('live.m3u8', this.fakeHls);
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
......@@ -173,7 +178,7 @@ QUnit.test('moves to HAVE_CURRENT_METADATA when refreshing the playlist', functi
});
QUnit.test('returns to HAVE_METADATA after refreshing the playlist', function() {
let loader = new PlaylistLoader('live.m3u8');
let loader = new PlaylistLoader('live.m3u8', this.fakeHls);
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
......@@ -190,7 +195,7 @@ QUnit.test('returns to HAVE_METADATA after refreshing the playlist', function()
QUnit.test('does not increment expired seconds before firstplay is triggered',
function() {
let loader = new PlaylistLoader('live.m3u8');
let loader = new PlaylistLoader('live.m3u8', this.fakeHls);
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
......@@ -220,7 +225,7 @@ function() {
});
QUnit.test('increments expired seconds after a segment is removed', function() {
let loader = new PlaylistLoader('live.m3u8');
let loader = new PlaylistLoader('live.m3u8', this.fakeHls);
loader.trigger('firstplay');
this.requests.pop().respond(200, null,
......@@ -251,7 +256,7 @@ QUnit.test('increments expired seconds after a segment is removed', function() {
});
QUnit.test('increments expired seconds after a discontinuity', function() {
let loader = new PlaylistLoader('live.m3u8');
let loader = new PlaylistLoader('live.m3u8', this.fakeHls);
loader.trigger('firstplay');
this.requests.pop().respond(200, null,
......@@ -299,7 +304,7 @@ QUnit.test('increments expired seconds after a discontinuity', function() {
QUnit.test('tracks expired seconds properly when two discontinuities expire at once',
function() {
let loader = new PlaylistLoader('live.m3u8');
let loader = new PlaylistLoader('live.m3u8', this.fakeHls);
loader.trigger('firstplay');
this.requests.pop().respond(200, null,
......@@ -327,7 +332,7 @@ function() {
QUnit.test('estimates expired if an entire window elapses between live playlist updates',
function() {
let loader = new PlaylistLoader('live.m3u8');
let loader = new PlaylistLoader('live.m3u8', this.fakeHls);
loader.trigger('firstplay');
this.requests.pop().respond(200, null,
......@@ -354,7 +359,7 @@ function() {
QUnit.test('emits an error when an initial playlist request fails', function() {
let errors = [];
let loader = new PlaylistLoader('master.m3u8');
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
loader.on('error', function() {
errors.push(loader.error);
......@@ -367,7 +372,7 @@ QUnit.test('emits an error when an initial playlist request fails', function() {
QUnit.test('errors when an initial media playlist request fails', function() {
let errors = [];
let loader = new PlaylistLoader('master.m3u8');
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
loader.on('error', function() {
errors.push(loader.error);
......@@ -389,7 +394,7 @@ QUnit.test('errors when an initial media playlist request fails', function() {
QUnit.test('halves the refresh timeout if a playlist is unchanged since the last reload',
function() {
/* eslint-disable no-unused-vars */
let loader = new PlaylistLoader('live.m3u8');
let loader = new PlaylistLoader('live.m3u8', this.fakeHls);
/* eslint-enable no-unused-vars */
this.requests.pop().respond(200, null,
......@@ -414,7 +419,7 @@ function() {
});
QUnit.test('preserves segment metadata across playlist refreshes', function() {
let loader = new PlaylistLoader('live.m3u8');
let loader = new PlaylistLoader('live.m3u8', this.fakeHls);
let segment;
this.requests.pop().respond(200, null,
......@@ -446,7 +451,7 @@ QUnit.test('preserves segment metadata across playlist refreshes', function() {
});
QUnit.test('clears the update timeout when switching quality', function() {
let loader = new PlaylistLoader('live-master.m3u8');
let loader = new PlaylistLoader('live-master.m3u8', this.fakeHls);
let refreshes = 0;
// track the number of playlist refreshes triggered
......@@ -481,7 +486,7 @@ QUnit.test('clears the update timeout when switching quality', function() {
QUnit.test('media-sequence updates are considered a playlist change', function() {
/* eslint-disable no-unused-vars */
let loader = new PlaylistLoader('live.m3u8');
let loader = new PlaylistLoader('live.m3u8', this.fakeHls);
/* eslint-enable no-unused-vars */
this.requests.pop().respond(200, null,
......@@ -505,7 +510,7 @@ QUnit.test('media-sequence updates are considered a playlist change', function()
QUnit.test('emits an error if a media refresh fails', function() {
let errors = 0;
let errorResponseText = 'custom error message';
let loader = new PlaylistLoader('live.m3u8');
let loader = new PlaylistLoader('live.m3u8', this.fakeHls);
loader.on('error', function() {
errors++;
......@@ -527,7 +532,7 @@ QUnit.test('emits an error if a media refresh fails', function() {
});
QUnit.test('switches media playlists when requested', function() {
let loader = new PlaylistLoader('master.m3u8');
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
......@@ -556,7 +561,7 @@ QUnit.test('switches media playlists when requested', function() {
});
QUnit.test('can switch playlists immediately after the master is downloaded', function() {
let loader = new PlaylistLoader('master.m3u8');
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
loader.on('loadedplaylist', function() {
loader.media('high.m3u8');
......@@ -571,7 +576,7 @@ QUnit.test('can switch playlists immediately after the master is downloaded', fu
});
QUnit.test('can switch media playlists based on URI', function() {
let loader = new PlaylistLoader('master.m3u8');
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
......@@ -600,7 +605,7 @@ QUnit.test('can switch media playlists based on URI', function() {
});
QUnit.test('aborts in-flight playlist refreshes when switching', function() {
let loader = new PlaylistLoader('master.m3u8');
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
......@@ -622,7 +627,7 @@ QUnit.test('aborts in-flight playlist refreshes when switching', function() {
});
QUnit.test('switching to the active playlist is a no-op', function() {
let loader = new PlaylistLoader('master.m3u8');
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
......@@ -642,7 +647,7 @@ QUnit.test('switching to the active playlist is a no-op', function() {
});
QUnit.test('switching to the active live playlist is a no-op', function() {
let loader = new PlaylistLoader('master.m3u8');
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
......@@ -661,7 +666,7 @@ QUnit.test('switching to the active live playlist is a no-op', function() {
});
QUnit.test('switches back to loaded playlists without re-requesting them', function() {
let loader = new PlaylistLoader('master.m3u8');
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
......@@ -690,7 +695,7 @@ QUnit.test('switches back to loaded playlists without re-requesting them', funct
QUnit.test('aborts outstanding requests if switching back to an already loaded playlist',
function() {
let loader = new PlaylistLoader('master.m3u8');
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
......@@ -724,7 +729,7 @@ function() {
QUnit.test('does not abort requests when the same playlist is re-requested',
function() {
let loader = new PlaylistLoader('master.m3u8');
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
......@@ -746,7 +751,7 @@ function() {
});
QUnit.test('throws an error if a media switch is initiated too early', function() {
let loader = new PlaylistLoader('master.m3u8');
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
QUnit.throws(function() {
loader.media('high.m3u8');
......@@ -762,7 +767,7 @@ QUnit.test('throws an error if a media switch is initiated too early', function(
QUnit.test('throws an error if a switch to an unrecognized playlist is requested',
function() {
let loader = new PlaylistLoader('master.m3u8');
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
......@@ -775,7 +780,7 @@ function() {
});
QUnit.test('dispose cancels the refresh timeout', function() {
let loader = new PlaylistLoader('live.m3u8');
let loader = new PlaylistLoader('live.m3u8', this.fakeHls);
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
......@@ -790,7 +795,7 @@ QUnit.test('dispose cancels the refresh timeout', function() {
});
QUnit.test('dispose aborts pending refresh requests', function() {
let loader = new PlaylistLoader('live.m3u8');
let loader = new PlaylistLoader('live.m3u8', this.fakeHls);
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
......@@ -807,7 +812,7 @@ QUnit.test('dispose aborts pending refresh requests', function() {
});
QUnit.test('errors if requests take longer than 45s', function() {
let loader = new PlaylistLoader('media.m3u8');
let loader = new PlaylistLoader('media.m3u8', this.fakeHls);
let errors = 0;
loader.on('error', function() {
......@@ -820,7 +825,7 @@ QUnit.test('errors if requests take longer than 45s', function() {
});
QUnit.test('triggers an event when the active media changes', function() {
let loader = new PlaylistLoader('master.m3u8');
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
let mediaChanges = 0;
loader.on('mediachange', function() {
......@@ -861,7 +866,7 @@ QUnit.test('triggers an event when the active media changes', function() {
});
QUnit.test('can get media index by playback position for non-live videos', function() {
let loader = new PlaylistLoader('media.m3u8');
let loader = new PlaylistLoader('media.m3u8', this.fakeHls);
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
......@@ -886,7 +891,7 @@ QUnit.test('can get media index by playback position for non-live videos', funct
});
QUnit.test('returns the lower index when calculating for a segment boundary', function() {
let loader = new PlaylistLoader('media.m3u8');
let loader = new PlaylistLoader('media.m3u8', this.fakeHls);
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
......@@ -903,7 +908,7 @@ QUnit.test('returns the lower index when calculating for a segment boundary', fu
QUnit.test('accounts for non-zero starting segment time when calculating media index',
function() {
let loader = new PlaylistLoader('media.m3u8');
let loader = new PlaylistLoader('media.m3u8', this.fakeHls);
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
......@@ -941,7 +946,7 @@ function() {
});
QUnit.test('prefers precise segment timing when tracking expired time', function() {
let loader = new PlaylistLoader('media.m3u8');
let loader = new PlaylistLoader('media.m3u8', this.fakeHls);
loader.trigger('firstplay');
this.requests.shift().respond(200, null,
......@@ -975,7 +980,7 @@ QUnit.test('prefers precise segment timing when tracking expired time', function
});
QUnit.test('accounts for expired time when calculating media index', function() {
let loader = new PlaylistLoader('media.m3u8');
let loader = new PlaylistLoader('media.m3u8', this.fakeHls);
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
......@@ -1007,7 +1012,7 @@ QUnit.test('accounts for expired time when calculating media index', function()
});
QUnit.test('does not misintrepret playlists missing newlines at the end', function() {
let loader = new PlaylistLoader('media.m3u8');
let loader = new PlaylistLoader('media.m3u8', this.fakeHls);
// no newline
this.requests.shift().respond(200, null,
......
......@@ -3363,6 +3363,78 @@ QUnit.test('selectPlaylist does not fail if getComputedStyle returns null', func
window.getComputedStyle = oldGetComputedStyle;
});
QUnit.test('Allows specifying the beforeRequest functionon the player', function() {
let beforeRequestCalled = false;
this.player.src({
src: 'master.m3u8',
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(this.player, this.clock);
this.player.hls.xhr.beforeRequest = function() {
beforeRequestCalled = true;
};
// master
standardXHRResponse(this.requests.shift());
// media
standardXHRResponse(this.requests.shift());
QUnit.ok(beforeRequestCalled, 'beforeRequest was called');
});
QUnit.test('Allows specifying the beforeRequest function globally', function() {
let beforeRequestCalled = false;
videojs.Hls.xhr.beforeRequest = function() {
beforeRequestCalled = true;
};
this.player.src({
src: 'master.m3u8',
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(this.player, this.clock);
// master
standardXHRResponse(this.requests.shift());
QUnit.ok(beforeRequestCalled, 'beforeRequest was called');
delete videojs.Hls.xhr.beforeRequest;
});
QUnit.test('Allows overriding the global beforeRequest function', function() {
let beforeGlobalRequestCalled = 0;
let beforeLocalRequestCalled = 0;
videojs.Hls.xhr.beforeRequest = function() {
beforeGlobalRequestCalled++;
};
this.player.src({
src: 'master.m3u8',
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(this.player, this.clock);
this.player.hls.xhr.beforeRequest = function() {
beforeLocalRequestCalled++;
};
// master
standardXHRResponse(this.requests.shift());
// media
standardXHRResponse(this.requests.shift());
// ts
standardXHRResponse(this.requests.shift());
QUnit.equal(beforeLocalRequestCalled, 2, 'local beforeRequest was called twice ' +
'for the media playlist and media');
QUnit.equal(beforeGlobalRequestCalled, 1, 'global beforeRequest was called once ' +
'for the master playlist');
delete videojs.Hls.xhr.beforeRequest;
});
QUnit.module('Buffer Inspection');
QUnit.test('detects time range end-point changed by updates', function() {
let edge;
......