bbf47741 by David LaPalomento Committed by GitHub

Fmp4 support (#829)

* Media init segment support
Resolve EXT-X-MAP URI information in the playlist loader. Add support for requesting and appending initialization segments to the segment loader.

* Basic support for fragmented MP4 playback
Re-arrange source updater and track support to fit our design goals more closely. Make adjustments so that the correct source buffer types are created when a fragmented mp4 is encountered. This version will play Apple's fMp4 bipbop stream but you have to seek the player to 10 seconds after starting because the first fragment starts at 10, not 0.

* Finish consolidating audio loaders
Manage a single pair of audio playlist and segment loaders, instead of one per track. Update track logic to work with the new flow.

* Detect and set the correct starting timestamp offset
Probe the init and first MP4 segment to correctly set timestamp offset so that the stream begins at time zero. After this change, Apple's fragmented MP4 HLS example stream plays without additional modification.

* Guard against media playlists without bandwidth information
If a media playlist is loaded directly or bandwidth info is unavailable, make sure the lowest bitrate check doesn't error. Add some unnecessary request shifting to tests to avoid extraneous requests caused by the current behavior of segment loader when abort()-ing THEN pause()-ing.

* Add stub prog_index.m3u8 for tests
Some of the tests point to master playlists that reference prog_index.m3u8. Sinon caught most of the exceptions related to this but the tests weren't really exercising realistic scenarios. Add a stub prog_index to the test fixtures so that requests for prog_index don't unintentionally error.

* Abort init segment XHR alongside other segment XHRs
If the segment loader XHRs are aborted, stop the init segment one as well. Make sure to check the right property for the init segment XHR before continuing the loading process. Make sure falsey values do not cause a playlist to be blacklisted in FF for audio info changes.

* Fix audio track management after reorganization
Delay segment loader initialization steps until all starting configuration is ready. This allowed source updater MIME types to be specified early without triggering the main updater to have its audio disabled on startup. Tweak the mime type identifier function to signal alternate audio earlier. Move `this` references in segment loader's checkBuffer_ out to stateful functions to align with the original design goals. Removed a segment loader test that seemed duplicative after the checkBuffer_ change.

* Fix D3 on stats page
Update URL for D3. Remove audio switcher since it's included by default now.

* Only override codec defaults if an actual value was parsed
When converting codec strings into MIME type configurations for source buffers, make sure to use default values if the codec string didn't supply particular fields. Export the codec to MIME helper function so it can be unit-tested.

* IE fixes
Array.prototype.find() isn't available in IE so use .filter()[0] instead.

* Blacklist unsupported codecs
If MediaSource.isTypeSupported fails in a generic MP4 container, swapping to a variant with those codecs is unlikely to be successful. For instance, the fragmented bip-bop stream includes AC-3 and EC-3 audio which is not supported on Chrome or Firefox today. Exclude variants with codecs that don't pass the isTypeSupported test.
1 parent 99e84e9f
......@@ -87,6 +87,7 @@
"dependencies": {
"m3u8-parser": "^1.0.2",
"aes-decrypter": "^1.0.3",
"mux.js": "^2.4.0",
"video.js": "^5.10.1",
"videojs-contrib-media-sources": "^3.1.0",
"videojs-swf": "^5.0.2",
......
......@@ -97,6 +97,9 @@ const updateMaster = function(master, media) {
if (segment.key && !segment.key.resolvedUri) {
segment.key.resolvedUri = resolveUrl(playlist.resolvedUri, segment.key.uri);
}
if (segment.map && !segment.map.resolvedUri) {
segment.map.resolvedUri = resolveUrl(playlist.resolvedUri, segment.map.uri);
}
}
changed = true;
}
......@@ -246,11 +249,13 @@ const PlaylistLoader = function(srcUrl, hls, withCredentials) {
* @return {Boolean} true if on lowest rendition
*/
loader.isLowestEnabledRendition_ = function() {
if (!loader.media()) {
let media = loader.media();
if (!media || !media.attributes) {
return false;
}
let currentPlaylist = loader.media().attributes.BANDWIDTH;
let currentBandwidth = loader.media().attributes.BANDWIDTH || 0;
return !(loader.master.playlists.filter((element, index, array) => {
let enabled = typeof element.excludeUntil === 'undefined' ||
......@@ -262,7 +267,7 @@ const PlaylistLoader = function(srcUrl, hls, withCredentials) {
let item = element.attributes.BANDWIDTH;
return item <= currentPlaylist;
return item <= currentBandwidth;
}).length > 1);
};
......@@ -508,6 +513,12 @@ const PlaylistLoader = function(srcUrl, hls, withCredentials) {
// loaded a media playlist
// infer a master playlist if none was previously requested
loader.master = {
mediaGroups: {
'AUDIO': {},
'VIDEO': {},
'CLOSED-CAPTIONS': {},
'SUBTITLES': {}
},
uri: window.location.href,
playlists: [{
uri: srcUrl
......
......@@ -13,39 +13,12 @@ import utils from './bin-utils';
import {MediaSource, URL} from 'videojs-contrib-media-sources';
import m3u8 from 'm3u8-parser';
import videojs from 'video.js';
import MasterPlaylistController from './master-playlist-controller';
import { MasterPlaylistController } from './master-playlist-controller';
import Config from './config';
import renditionSelectionMixin from './rendition-mixin';
import GapSkipper from './gap-skipper';
import window from 'global/window';
/**
* determine if an object a is differnt from
* and object b. both only having one dimensional
* properties
*
* @param {Object} a object one
* @param {Object} b object two
* @return {Boolean} if the object has changed or not
*/
const objectChanged = function(a, b) {
if (typeof a !== typeof b) {
return true;
}
// if we have a different number of elements
// something has changed
if (Object.keys(a).length !== Object.keys(b).length) {
return true;
}
for (let prop in a) {
if (!b[prop] || a[prop] !== b[prop]) {
return true;
}
}
return false;
};
const Hls = {
PlaylistLoader,
Playlist,
......@@ -336,7 +309,7 @@ class HlsHandler extends Component {
});
this.audioTrackChange_ = () => {
this.masterPlaylistController_.useAudio();
this.masterPlaylistController_.setupAudio();
};
this.on(this.tech_, 'play', this.play);
......@@ -436,59 +409,17 @@ class HlsHandler extends Component {
this.tech_.audioTracks().addEventListener('change', this.audioTrackChange_);
});
this.masterPlaylistController_.on('audioinfo', (e) => {
if (!videojs.browser.IS_FIREFOX ||
!this.audioInfo_ ||
!objectChanged(this.audioInfo_, e.info)) {
this.audioInfo_ = e.info;
return;
}
let error = 'had different audio properties (channels, sample rate, etc.) ' +
'or changed in some other way. This behavior is currently ' +
'unsupported in Firefox due to an issue: \n\n' +
'https://bugzilla.mozilla.org/show_bug.cgi?id=1247138\n\n';
let enabledTrack;
let defaultTrack;
this.masterPlaylistController_.audioTracks_.forEach((t) => {
if (!defaultTrack && t.default) {
defaultTrack = t;
}
if (!enabledTrack && t.enabled) {
enabledTrack = t;
}
this.masterPlaylistController_.on('selectedinitialmedia', () => {
// Add the manual rendition mix-in to HlsHandler
renditionSelectionMixin(this);
});
// they did not switch audiotracks
// blacklist the current playlist
if (!enabledTrack.getLoader(this.activeAudioGroup_())) {
error = `The rendition that we tried to switch to ${error}` +
'Unfortunately that means we will have to blacklist ' +
'the current playlist and switch to another. Sorry!';
this.masterPlaylistController_.blacklistCurrentPlaylist();
} else {
error = `The audio track '${enabledTrack.label}' that we tried to ` +
`switch to ${error} Unfortunately this means we will have to ` +
`return you to the main track '${defaultTrack.label}'. Sorry!`;
defaultTrack.enabled = true;
this.tech_.audioTracks().removeTrack(enabledTrack);
}
videojs.log.warn(error);
this.masterPlaylistController_.useAudio();
});
this.masterPlaylistController_.on('selectedinitialmedia', () => {
this.masterPlaylistController_.on('audioupdate', () => {
// clear current audioTracks
this.tech_.clearTracks('audio');
this.masterPlaylistController_.audioTracks_.forEach((track) => {
this.tech_.audioTracks().addTrack(track);
this.masterPlaylistController_.activeAudioGroup().forEach((audioTrack) => {
this.tech_.audioTracks().addTrack(audioTrack);
});
// Add the manual rendition mix-in to HlsHandler
renditionSelectionMixin(this);
});
// the bandwidth of the primary segment loader is our best
......
......@@ -7,7 +7,11 @@ import {
standardXHRResponse,
openMediaSource
} from './test-helpers.js';
import MasterPlaylistController from '../src/master-playlist-controller';
import manifests from './test-manifests.js';
import {
MasterPlaylistController,
mimeTypesForPlaylist_
} from '../src/master-playlist-controller';
/* eslint-disable no-unused-vars */
// we need this so that it can register hls with videojs
import { Hls } from '../src/videojs-contrib-hls';
......@@ -272,6 +276,37 @@ function() {
'16 bytes downloaded');
});
QUnit.test('updates the enabled track when switching audio groups', function() {
openMediaSource(this.player, this.clock);
// master
this.requests.shift().respond(200, null,
manifests.multipleAudioGroupsCombinedMain);
// media
standardXHRResponse(this.requests.shift());
// init segment
standardXHRResponse(this.requests.shift());
// video segment
standardXHRResponse(this.requests.shift());
// audio media
standardXHRResponse(this.requests.shift());
// ignore audio segment requests
this.requests.length = 0;
let mpc = this.masterPlaylistController;
let combinedPlaylist = mpc.master().playlists[0];
mpc.masterPlaylistLoader_.media(combinedPlaylist);
// updated media
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:5.0\n' +
'0.ts\n' +
'#EXT-X-ENDLIST\n');
QUnit.ok(mpc.activeAudioGroup().filter((track) => track.enabled)[0],
'enabled a track in the new audio group');
});
QUnit.test('blacklists switching from video+audio playlists to audio only', function() {
let audioPlaylist;
......@@ -386,6 +421,34 @@ function() {
QUnit.equal(this.player.tech_.hls.stats.bandwidth, 1, 'bandwidth we set above');
});
QUnit.test('blacklists the current playlist when audio changes in Firefox', function() {
videojs.browser.IS_FIREFOX = true;
// master
standardXHRResponse(this.requests.shift());
// media
standardXHRResponse(this.requests.shift());
let media = this.masterPlaylistController.media();
// initial audio config
this.masterPlaylistController.mediaSource.trigger({
type: 'audioinfo',
info: {}
});
// updated audio config
this.masterPlaylistController.mediaSource.trigger({
type: 'audioinfo',
info: {
different: true
}
});
QUnit.ok(media.excludeUntil > 0, 'blacklisted the old playlist');
QUnit.equal(this.env.log.warn.callCount, 2, 'logged two warnings');
this.env.log.warn.callCount = 0;
});
QUnit.test('updates the combined segment loader on media changes', function() {
let updates = [];
......@@ -702,3 +765,116 @@ QUnit.test('respects useCueTags option', function() {
videojs.options.hls = origHlsOptions;
});
QUnit.module('Codec to MIME Type Conversion');
QUnit.test('recognizes muxed codec configurations', function() {
QUnit.deepEqual(mimeTypesForPlaylist_({ mediaGroups: {} }, {}),
[ 'video/mp2t; codecs="avc1.4d400d, mp4a.40.2"' ],
'returns a default MIME type when no codecs are present');
QUnit.deepEqual(mimeTypesForPlaylist_({
mediaGroups: {},
playlists: []
}, {
attributes: {
CODECS: 'mp4a.40.E,avc1.deadbeef'
}
}), [
'video/mp2t; codecs="avc1.deadbeef, mp4a.40.E"'
], 'returned the parsed muxed type');
});
QUnit.test('recognizes mixed codec configurations', function() {
QUnit.deepEqual(mimeTypesForPlaylist_({
mediaGroups: {
AUDIO: {
hi: {
en: {},
es: {
uri: 'http://example.com/alt-audio.m3u8'
}
}
}
},
playlists: []
}, {
attributes: {
AUDIO: 'hi'
}
}), [
'video/mp2t; codecs="avc1.4d400d, mp4a.40.2"',
'audio/mp2t; codecs="mp4a.40.2"'
], 'returned a default muxed type with alternate audio');
QUnit.deepEqual(mimeTypesForPlaylist_({
mediaGroups: {
AUDIO: {
hi: {
eng: {},
es: {
uri: 'http://example.com/alt-audio.m3u8'
}
}
}
},
playlists: []
}, {
attributes: {
CODECS: 'mp4a.40.E,avc1.deadbeef',
AUDIO: 'hi'
}
}), [
'video/mp2t; codecs="avc1.deadbeef, mp4a.40.E"',
'audio/mp2t; codecs="mp4a.40.E"'
], 'returned a parsed muxed type with alternate audio');
});
QUnit.test('recognizes unmuxed codec configurations', function() {
QUnit.deepEqual(mimeTypesForPlaylist_({
mediaGroups: {
AUDIO: {
hi: {
eng: {
uri: 'http://example.com/eng.m3u8'
},
es: {
uri: 'http://example.com/eng.m3u8'
}
}
}
},
playlists: []
}, {
attributes: {
AUDIO: 'hi'
}
}), [
'video/mp2t; codecs="avc1.4d400d"',
'audio/mp2t; codecs="mp4a.40.2"'
], 'returned default unmuxed types');
QUnit.deepEqual(mimeTypesForPlaylist_({
mediaGroups: {
AUDIO: {
hi: {
eng: {
uri: 'http://example.com/alt-audio.m3u8'
},
es: {
uri: 'http://example.com/eng.m3u8'
}
}
}
},
playlists: []
}, {
attributes: {
CODECS: 'mp4a.40.E,avc1.deadbeef',
AUDIO: 'hi'
}
}), [
'video/mp2t; codecs="avc1.deadbeef"',
'audio/mp2t; codecs="mp4a.40.E"'
], 'returned parsed unmuxed types');
});
......
......@@ -164,6 +164,21 @@ QUnit.test('playlist loader detects if we are on lowest rendition', function() {
QUnit.ok(!loader.isLowestEnabledRendition_(), 'Detected not on lowest rendition');
});
QUnit.test('resolves media initialization segment URIs', function() {
let loader = new PlaylistLoader('video/fmp4.m3u8', this.fakeHls);
loader.load();
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-MAP:URI="main.mp4",BYTERANGE="720@0"\n' +
'#EXTINF:10,\n' +
'0.ts\n' +
'#EXT-X-ENDLIST\n');
QUnit.equal(loader.media().segments[0].map.resolvedUri, urlTo('video/main.mp4'),
'resolved init segment URI');
});
QUnit.test('recognizes absolute URIs and requests them unmodified', function() {
let loader = new PlaylistLoader('manifest/media.m3u8', this.fakeHls);
......@@ -347,6 +362,21 @@ QUnit.test('moves to HAVE_METADATA after loading a media playlist', function() {
QUnit.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
});
QUnit.test('defaults missing media groups for a media playlist', function() {
let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
loader.load();
this.requests.pop().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n');
QUnit.ok(loader.master.mediaGroups.AUDIO, 'defaulted audio');
QUnit.ok(loader.master.mediaGroups.VIDEO, 'defaulted video');
QUnit.ok(loader.master.mediaGroups['CLOSED-CAPTIONS'], 'defaulted closed captions');
QUnit.ok(loader.master.mediaGroups.SUBTITLES, 'defaulted subtitles');
});
QUnit.test('moves to HAVE_CURRENT_METADATA when refreshing the playlist', function() {
let loader = new PlaylistLoader('live.m3u8', this.fakeHls);
......
......@@ -2,12 +2,14 @@ import QUnit from 'qunit';
import SegmentLoader from '../src/segment-loader';
import videojs from 'video.js';
import xhrFactory from '../src/xhr';
import mp4probe from 'mux.js/lib/mp4/probe';
import Config from '../src/config';
import {
playlistWithDuration,
useFakeEnvironment,
useFakeMediaSource
} from './test-helpers.js';
import sinon from 'sinon';
let currentTime;
let mediaSource;
......@@ -27,6 +29,9 @@ QUnit.module('Segment Loader', {
xhr: xhrFactory()
};
this.timescale = sinon.stub(mp4probe, 'timescale');
this.startTime = sinon.stub(mp4probe, 'startTime');
currentTime = 0;
mediaSource = new videojs.MediaSource();
mediaSource.trigger('sourceopen');
......@@ -44,6 +49,8 @@ QUnit.module('Segment Loader', {
afterEach() {
this.env.restore();
this.mse.restore();
this.timescale.restore();
this.startTime.restore();
}
});
......@@ -123,7 +130,6 @@ QUnit.test('calling load should unpause', function() {
loader.pause();
loader.mimeType(this.mimeType);
sourceBuffer = mediaSource.sourceBuffers[0];
loader.load();
QUnit.equal(loader.paused(), false, 'loading unpauses');
......@@ -138,6 +144,7 @@ QUnit.test('calling load should unpause', function() {
QUnit.equal(loader.paused(), false, 'unpaused during processing');
loader.pause();
sourceBuffer = mediaSource.sourceBuffers[0];
sourceBuffer.trigger('updateend');
QUnit.equal(loader.state, 'READY', 'finished processing');
QUnit.ok(loader.paused(), 'stayed paused');
......@@ -236,6 +243,32 @@ QUnit.test('segment request timeouts reset bandwidth', function() {
QUnit.ok(isNaN(loader.roundTrip), 'reset round trip time');
});
QUnit.test('updates timestamps when segments do not start at zero', function() {
let playlist = playlistWithDuration(10);
playlist.segments.forEach((segment) => {
segment.map = {
resolvedUri: 'init.mp4',
bytes: new Uint8Array(10)
};
});
loader.playlist(playlist);
loader.mimeType('video/mp4');
loader.load();
this.startTime.returns(11);
this.clock.tick(100);
// init
this.requests[0].response = new Uint8Array(10).buffer;
this.requests.shift().respond(200, null, '');
// segment
this.requests[0].response = new Uint8Array(10).buffer;
this.requests.shift().respond(200, null, '');
QUnit.equal(loader.sourceUpdater_.timestampOffset(), -11, 'set timestampOffset');
});
QUnit.test('appending a segment triggers progress', function() {
let progresses = 0;
......@@ -482,6 +515,104 @@ QUnit.test('adjusts the playlist offset if no buffering progress is made after '
QUnit.equal(this.requests.length, 0, 'no more requests are made');
});
QUnit.test('downloads init segments if specified', function() {
let playlist = playlistWithDuration(20);
let map = {
resolvedUri: 'main.mp4',
byterange: {
length: 20,
offset: 0
}
};
playlist.segments[0].map = map;
playlist.segments[1].map = map;
loader.playlist(playlist);
loader.mimeType(this.mimeType);
loader.load();
let sourceBuffer = mediaSource.sourceBuffers[0];
QUnit.equal(this.requests.length, 2, 'made requests');
// init segment response
this.clock.tick(1);
QUnit.equal(this.requests[0].url, 'main.mp4', 'requested the init segment');
this.requests[0].response = new Uint8Array(20).buffer;
this.requests.shift().respond(200, null, '');
// 0.ts response
this.clock.tick(1);
QUnit.equal(this.requests[0].url, '0.ts',
'requested the segment');
this.requests[0].response = new Uint8Array(20).buffer;
this.requests.shift().respond(200, null, '');
// append the init segment
sourceBuffer.buffered = videojs.createTimeRanges([]);
sourceBuffer.trigger('updateend');
// append the segment
sourceBuffer.buffered = videojs.createTimeRanges([[0, 10]]);
sourceBuffer.trigger('updateend');
QUnit.equal(this.requests.length, 1, 'made a request');
QUnit.equal(this.requests[0].url, '1.ts',
'did not re-request the init segment');
});
QUnit.test('detects init segment changes and downloads it', function() {
let playlist = playlistWithDuration(20);
playlist.segments[0].map = {
resolvedUri: 'init0.mp4',
byterange: {
length: 20,
offset: 0
}
};
playlist.segments[1].map = {
resolvedUri: 'init0.mp4',
byterange: {
length: 20,
offset: 20
}
};
loader.playlist(playlist);
loader.mimeType(this.mimeType);
loader.load();
let sourceBuffer = mediaSource.sourceBuffers[0];
QUnit.equal(this.requests.length, 2, 'made requests');
// init segment response
this.clock.tick(1);
QUnit.equal(this.requests[0].url, 'init0.mp4', 'requested the init segment');
QUnit.equal(this.requests[0].headers.Range, 'bytes=0-19',
'requested the init segment byte range');
this.requests[0].response = new Uint8Array(20).buffer;
this.requests.shift().respond(200, null, '');
// 0.ts response
this.clock.tick(1);
QUnit.equal(this.requests[0].url, '0.ts',
'requested the segment');
this.requests[0].response = new Uint8Array(20).buffer;
this.requests.shift().respond(200, null, '');
// append the init segment
sourceBuffer.buffered = videojs.createTimeRanges([]);
sourceBuffer.trigger('updateend');
// append the segment
sourceBuffer.buffered = videojs.createTimeRanges([[0, 10]]);
sourceBuffer.trigger('updateend');
QUnit.equal(this.requests.length, 2, 'made requests');
QUnit.equal(this.requests[0].url, 'init0.mp4', 'requested the init segment');
QUnit.equal(this.requests[0].headers.Range, 'bytes=20-39',
'requested the init segment byte range');
QUnit.equal(this.requests[1].url, '1.ts',
'did not re-request the init segment');
});
QUnit.test('cancels outstanding requests on abort', function() {
loader.playlist(playlistWithDuration(20));
loader.mimeType(this.mimeType);
......@@ -899,16 +1030,21 @@ QUnit.test('key request timeouts reset bandwidth', function() {
QUnit.ok(isNaN(loader.roundTrip), 'reset round trip time');
});
QUnit.test('GOAL_BUFFER_LENGTH changes to 1 segment ' +
' which is already buffered, no new request is formed', function() {
QUnit.test('checks the goal buffer configuration every loading opportunity', function() {
let playlist = playlistWithDuration(20);
let defaultGoal = Config.GOAL_BUFFER_LENGTH;
let segmentInfo;
Config.GOAL_BUFFER_LENGTH = 1;
loader.playlist(playlist);
loader.mimeType(this.mimeType);
let segmentInfo = loader.checkBuffer_(videojs.createTimeRanges([[0, 1]]),
playlistWithDuration(20),
0);
loader.load();
segmentInfo = loader.checkBuffer_(videojs.createTimeRanges([[0, 1]]),
playlist,
0);
QUnit.ok(!segmentInfo, 'no request generated');
Config.GOAL_BUFFER_LENGTH = 30;
Config.GOAL_BUFFER_LENGTH = defaultGoal;
});
QUnit.module('Segment Loading Calculation', {
......@@ -1031,22 +1167,6 @@ function() {
QUnit.ok(!segmentInfo, 'no request was made');
});
QUnit.test('calculates timestampOffset for discontinuities', function() {
let segmentInfo;
let playlist;
loader.mimeType(this.mimeType);
playlist = playlistWithDuration(60);
playlist.segments[3].end = 37.9;
playlist.discontinuityStarts = [4];
playlist.segments[4].discontinuity = true;
playlist.segments[4].timeline = 1;
segmentInfo = loader.checkBuffer_(videojs.createTimeRanges([[0, 37.9]]), playlist, 36);
QUnit.equal(segmentInfo.timestampOffset, 37.9, 'placed the discontinuous segment');
});
QUnit.test('adjusts calculations based on expired time', function() {
let buffered;
let playlist;
......
......@@ -118,7 +118,13 @@ let fakeEnvironment = {
['warn', 'error'].forEach((level) => {
if (this.log && this.log[level] && this.log[level].restore) {
if (QUnit) {
QUnit.equal(this.log[level].callCount, 0, `no unexpected logs on ${level}`);
let calls = this.log[level].args.map((args) => {
return args.join(', ');
}).join('\n ');
QUnit.equal(this.log[level].callCount,
0,
'no unexpected logs at level "' + level + '":\n ' + calls);
}
this.log[level].restore();
}
......
#EXTM3U
#EXT-X-TARGETDURATION:6
#EXT-X-VERSION:7
#EXT-X-MEDIA-SEQUENCE:1
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-MAP:URI="main.mp4",BYTERANGE="604@0"
#EXTINF:5.99467,
#EXT-X-BYTERANGE:118151@604
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119253@118755
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119258@238008
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119253@357266
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119255@476519
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119253@595774
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119258@715027
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119253@834285
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119258@953538
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119254@1072796
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119254@1192050
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119257@1311304
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119258@1430561
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119258@1549819
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119254@1669077
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119257@1788331
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119258@1907588
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119259@2026846
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119257@2146105
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119254@2265362
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119258@2384616
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119258@2503874
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119257@2623132
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119254@2742389
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119253@2861643
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119258@2980896
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119254@3100154
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119254@3219408
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119258@3338662
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119253@3457920
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119258@3577173
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119253@3696431
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119258@3815684
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119258@3934942
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119254@4054200
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119254@4173454
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119253@4292708
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119255@4411961
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119257@4531216
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119254@4650473
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119257@4769727
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119259@4888984
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119257@5008243
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119257@5127500
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119259@5246757
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119253@5366016
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119258@5485269
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119258@5604527
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119253@5723785
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119255@5843038
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119257@5962293
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119257@6081550
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119258@6200807
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119259@6320065
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119257@6439324
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119254@6558581
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119258@6677835
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119257@6797093
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119254@6916350
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119257@7035604
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119255@7154861
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119253@7274116
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119254@7393369
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119254@7512623
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119253@7631877
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119258@7751130
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119258@7870388
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119258@7989646
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119253@8108904
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119258@8228157
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119258@8347415
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119253@8466673
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119259@8585926
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119257@8705185
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119254@8824442
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119258@8943696
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119253@9062954
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119259@9182207
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119257@9301466
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119258@9420723
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119389@9539981
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119265@9659370
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119533@9778635
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119868@9898168
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119140@10018036
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:118985@10137176
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:118701@10256161
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119180@10374862
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119259@10494042
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119257@10613301
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119254@10732558
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119257@10851812
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119258@10971069
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119258@11090327
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119258@11209585
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119258@11328843
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119258@11448101
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119258@11567359
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119257@11686617
main.mp4
#EXTINF:5.99467,
#EXT-X-BYTERANGE:119254@11805874
main.mp4
#EXTINF:1.13067,
#EXT-X-BYTERANGE:22563@11925128
main.mp4
#EXT-X-ENDLIST
......@@ -14,7 +14,7 @@
<!-- player stats visualization -->
<link href="stats.css" rel="stylesheet">
<script src="../switcher/js/vendor/d3.min.js"></script>
<script src="/node_modules/d3/d3.min.js"></script>
<style>
body {
......@@ -212,12 +212,6 @@
player.ready(function() {
// ------------
// Audio Track Switcher
// ------------
player.controlBar.addChild('AudioTrackButton', {}, 13);
// ------------
// Player Stats
// ------------
......