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",
......
......@@ -5,35 +5,159 @@ import PlaylistLoader from './playlist-loader';
import SegmentLoader from './segment-loader';
import Ranges from './ranges';
import videojs from 'video.js';
import HlsAudioTrack from './hls-audio-track';
import AdCueTags from './ad-cue-tags';
// 5 minute blacklist
const BLACKLIST_DURATION = 5 * 60 * 1000;
let Hls;
/**
* 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 (a[prop] !== b[prop]) {
return true;
}
}
return false;
};
/**
* Parses a codec string to retrieve the number of codecs specified,
* the video codec and object type indicator, and the audio profile.
*
* @private
*/
const parseCodecs = function(codecs) {
let result = {
codecCount: 0,
videoCodec: null,
videoObjectTypeIndicator: null,
audioProfile: null
};
let parsed;
result.codecCount = codecs.split(',').length;
result.codecCount = result.codecCount || 2;
// parse the video codec but ignore the version
result.videoCodec = (/(^|\s|,)+(avc1)[^ ,]*/i).exec(codecs);
result.videoCodec = result.videoCodec && result.videoCodec[2];
// parse the video codec
parsed = (/(^|\s|,)+(avc1)([^ ,]*)/i).exec(codecs);
if (parsed) {
result.videoCodec = parsed[2];
result.videoObjectTypeIndicator = parsed[3];
}
// parse the last field of the audio codec
result.audioProfile = (/(^|\s|,)+mp4a.\d+\.(\d+)/i).exec(codecs);
result.audioProfile =
(/(^|\s|,)+mp4a.[0-9A-Fa-f]+\.([0-9A-Fa-f]+)/i).exec(codecs);
result.audioProfile = result.audioProfile && result.audioProfile[2];
return result;
};
/**
* Calculates the MIME type strings for a working configuration of
* SourceBuffers to play variant streams in a master playlist. If
* there is no possible working configuration, an empty array will be
* returned.
*
* @param master {Object} the m3u8 object for the master playlist
* @param media {Object} the m3u8 object for the variant playlist
* @return {Array} the MIME type strings. If the array has more than
* one entry, the first element should be applied to the video
* SourceBuffer and the second to the audio SourceBuffer.
*
* @private
*/
export const mimeTypesForPlaylist_ = function(master, media) {
let container = 'mp2t';
let codecs = {
videoCodec: 'avc1',
videoObjectTypeIndicator: '.4d400d',
audioProfile: '2'
};
let audioGroup = [];
let mediaAttributes;
let previousGroup = null;
if (!media) {
// not enough information, return an error
return [];
}
// An initialization segment means the media playlists is an iframe
// playlist or is using the mp4 container. We don't currently
// support iframe playlists, so assume this is signalling mp4
// fragments.
// the existence check for segments can be removed once
// https://github.com/videojs/m3u8-parser/issues/8 is closed
if (media.segments && media.segments.length && media.segments[0].map) {
container = 'mp4';
}
// if the codecs were explicitly specified, use them instead of the
// defaults
mediaAttributes = media.attributes || {};
if (mediaAttributes.CODECS) {
let parsedCodecs = parseCodecs(mediaAttributes.CODECS);
Object.keys(parsedCodecs).forEach((key) => {
codecs[key] = parsedCodecs[key] || codecs[key];
});
}
if (master.mediaGroups.AUDIO) {
audioGroup = master.mediaGroups.AUDIO[mediaAttributes.AUDIO];
}
// if audio could be muxed or unmuxed, use mime types appropriate
// for both scenarios
for (let groupId in audioGroup) {
if (previousGroup && (!!audioGroup[groupId].uri !== !!previousGroup.uri)) {
// one source buffer with muxed video and audio and another for
// the alternate audio
return [
'video/' + container + '; codecs="' +
codecs.videoCodec + codecs.videoObjectTypeIndicator + ', mp4a.40.' + codecs.audioProfile + '"',
'audio/' + container + '; codecs="mp4a.40.' + codecs.audioProfile + '"'
];
}
previousGroup = audioGroup[groupId];
}
// if all video and audio is unmuxed, use two single-codec mime
// types
if (previousGroup && previousGroup.uri) {
return [
'video/' + container + '; codecs="' +
codecs.videoCodec + codecs.videoObjectTypeIndicator + '"',
'audio/' + container + '; codecs="mp4a.40.' + codecs.audioProfile + '"'
];
}
// all video and audio are muxed, use a dual-codec mime type
return [
'video/' + container + '; codecs="' +
codecs.videoCodec + codecs.videoObjectTypeIndicator +
', mp4a.40.' + codecs.audioProfile + '"'
];
};
/**
* the master playlist controller controller all interactons
* between playlists and segmentloaders. At this time this mainly
* involves a master playlist and a series of audio playlists
......@@ -42,18 +166,24 @@ const parseCodecs = function(codecs) {
* @class MasterPlaylistController
* @extends videojs.EventTarget
*/
export default class MasterPlaylistController extends videojs.EventTarget {
constructor({
url,
withCredentials,
mode,
tech,
bandwidth,
externHls,
useCueTags
}) {
export class MasterPlaylistController extends videojs.EventTarget {
constructor(options) {
super();
let {
url,
withCredentials,
mode,
tech,
bandwidth,
externHls,
useCueTags
} = options;
if (!url) {
throw new Error('A non-empty playlist URL is required');
}
Hls = externHls;
this.withCredentials = withCredentials;
......@@ -74,8 +204,12 @@ export default class MasterPlaylistController extends videojs.EventTarget {
timeout: null
};
this.audioGroups_ = {};
this.mediaSource = new videojs.MediaSource({ mode });
this.mediaSource.on('audioinfo', (e) => this.trigger(e));
this.audioinfo_ = null;
this.mediaSource.on('audioinfo', this.handleAudioinfoUpdate_.bind(this));
// load the media source into the player
this.mediaSource.addEventListener('sourceopen', this.handleSourceOpen_.bind(this));
......@@ -90,17 +224,28 @@ export default class MasterPlaylistController extends videojs.EventTarget {
bandwidth
};
// setup playlist loaders
this.masterPlaylistLoader_ = new PlaylistLoader(url, this.hls_, this.withCredentials);
this.setupMasterPlaylistLoaderListeners_();
this.audioPlaylistLoader_ = null;
// setup segment loaders
// combined audio/video or just video when alternate audio track is selected
this.mainSegmentLoader_ = new SegmentLoader(segmentLoaderOptions);
// alternate audio track
this.audioSegmentLoader_ = new SegmentLoader(segmentLoaderOptions);
this.setupSegmentLoaderListeners_();
if (!url) {
throw new Error('A non-empty playlist URL is required');
}
this.masterPlaylistLoader_ = new PlaylistLoader(url, this.hls_, this.withCredentials);
this.masterPlaylistLoader_.start();
}
/**
* Register event handlers on the master playlist loader. A helper
* function for construction time.
*
* @private
*/
setupMasterPlaylistLoaderListeners_() {
this.masterPlaylistLoader_.on('loadedmetadata', () => {
let media = this.masterPlaylistLoader_.media();
let requestTimeout = (this.masterPlaylistLoader_.targetDuration * 1.5) * 1000;
......@@ -115,9 +260,19 @@ export default class MasterPlaylistController extends videojs.EventTarget {
this.mainSegmentLoader_.load();
}
this.setupSourceBuffer_();
try {
this.setupSourceBuffers_();
} catch (e) {
videojs.log.warn('Failed to create SourceBuffers', e);
return this.mediaSource.endOfStream('decode');
}
this.setupFirstPlay();
this.useAudio();
this.fillAudioTracks_();
this.setupAudio();
this.trigger('audioupdate');
this.trigger('selectedinitialmedia');
});
this.masterPlaylistLoader_.on('loadedplaylist', () => {
......@@ -128,9 +283,6 @@ export default class MasterPlaylistController extends videojs.EventTarget {
// select the initial variant
this.initialMedia_ = this.selectPlaylist();
this.masterPlaylistLoader_.media(this.initialMedia_);
this.fillAudioTracks_();
this.trigger('selectedinitialmedia');
return;
}
......@@ -166,6 +318,8 @@ export default class MasterPlaylistController extends videojs.EventTarget {
this.masterPlaylistLoader_.on('mediachange', () => {
let media = this.masterPlaylistLoader_.media();
let requestTimeout = (this.masterPlaylistLoader_.targetDuration * 1.5) * 1000;
let activeAudioGroup;
let activeTrack;
// If we don't have any more available playlists, we don't want to
// timeout the request.
......@@ -183,12 +337,29 @@ export default class MasterPlaylistController extends videojs.EventTarget {
this.mainSegmentLoader_.expired(this.masterPlaylistLoader_.expired_);
this.mainSegmentLoader_.load();
// if the audio group has changed, a new audio track has to be
// enabled
activeAudioGroup = this.activeAudioGroup();
activeTrack = activeAudioGroup.filter((track) => track.enabled)[0];
if (!activeTrack) {
this.setupAudio();
this.trigger('audioupdate');
}
this.tech_.trigger({
type: 'mediachange',
bubbles: true
});
});
}
/**
* Register event handlers on the segment loaders. A helper function
* for construction time.
*
* @private
*/
setupSegmentLoaderListeners_() {
this.mainSegmentLoader_.on('progress', () => {
// figure out what stream the next segment should be downloaded from
// with the updated bandwidth information
......@@ -206,10 +377,50 @@ export default class MasterPlaylistController extends videojs.EventTarget {
'. Switching back to default.');
this.audioSegmentLoader_.abort();
this.audioPlaylistLoader_ = null;
this.useAudio();
this.setupAudio();
});
}
handleAudioinfoUpdate_(event) {
if (!videojs.browser.IS_FIREFOX ||
!this.audioInfo_ ||
!objectChanged(this.audioInfo_, event.info)) {
this.audioInfo_ = event.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 enabledIndex =
this.activeAudioGroup()
.map((track) => track.enabled)
.indexOf(true);
let enabledTrack = this.activeAudioGroup()[enabledIndex];
let defaultTrack = this.activeAudioGroup().filter((track) => {
return track.properties_ && track.properties_.default;
})[0];
// they did not switch audiotracks
// blacklist the current playlist
if (!this.audioPlaylistLoader_) {
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.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.activeAudioGroup().splice(enabledIndex, 1);
this.trigger('audioupdate');
}
this.masterPlaylistLoader_.load();
videojs.log.warn(error);
this.setupAudio();
}
/**
......@@ -264,34 +475,33 @@ export default class MasterPlaylistController extends videojs.EventTarget {
Object.keys(mediaGroups.AUDIO).length === 0 ||
this.mode_ !== 'html5') {
// "main" audio group, track name "default"
mediaGroups.AUDIO = {main: {default: {default: true }}};
mediaGroups.AUDIO = { main: { default: { default: true }}};
}
let tracks = {};
for (let mediaGroup in mediaGroups.AUDIO) {
if (!this.audioGroups_[mediaGroup]) {
this.audioGroups_[mediaGroup] = [];
}
for (let label in mediaGroups.AUDIO[mediaGroup]) {
let properties = mediaGroups.AUDIO[mediaGroup][label];
// if the track already exists add a new "location"
// since tracks in different mediaGroups are actually the same
// track with different locations to download them from
if (tracks[label]) {
tracks[label].addLoader(mediaGroup, properties.resolvedUri);
continue;
}
let track = new HlsAudioTrack(videojs.mergeOptions(properties, {
hls: this.hls_,
withCredentials: this.withCredential,
mediaGroup,
let track = new videojs.AudioTrack({
id: label,
kind: properties.default ? 'main' : 'alternative',
enabled: false,
language: properties.language,
label
}));
});
tracks[label] = track;
this.audioTracks_.push(track);
track.properties_ = properties;
this.audioGroups_[mediaGroup].push(track);
}
}
// enable the default active track
(this.activeAudioGroup().filter((audioTrack) => {
return audioTrack.properties_.default;
})[0] || this.activeAudioGroup()[0]).enabled = true;
}
/**
......@@ -305,81 +515,74 @@ export default class MasterPlaylistController extends videojs.EventTarget {
}
/**
* Get the current active Media Group for Audio
* given the selected playlist and its attributes
* Returns the audio group for the currently active primary
* media playlist.
*/
activeAudioGroup() {
let media = this.masterPlaylistLoader_.media();
let mediaGroup = 'main';
let videoPlaylist = this.masterPlaylistLoader_.media();
let result;
if (media && media.attributes && media.attributes.AUDIO) {
mediaGroup = media.attributes.AUDIO;
if (videoPlaylist.attributes && videoPlaylist.attributes.AUDIO) {
result = this.audioGroups_[videoPlaylist.attributes.AUDIO];
}
return mediaGroup;
return result || this.audioGroups_.main;
}
/**
* Use any audio track that we have, and start to load it
* Determine the correct audio rendition based on the active
* AudioTrack and initialize a PlaylistLoader and SegmentLoader if
* necessary. This method is called once automatically before
* playback begins to enable the default audio track and should be
* invoked again if the track is changed.
*/
useAudio() {
let track;
this.audioTracks_.forEach((t) => {
if (!track && t.enabled) {
track = t;
}
});
setupAudio() {
// determine whether seperate loaders are required for the audio
// rendition
let audioGroup = this.activeAudioGroup();
let track = audioGroup.filter((audioTrack) => {
return audioTrack.enabled;
})[0];
// called too early or no track is enabled
if (!track) {
return;
track = audioGroup.filter((audioTrack) => {
return audioTrack.properties_.default;
})[0] || audioGroup[0];
track.enabled = true;
}
// Pause any alternative audio
// stop playlist and segment loading for audio
if (this.audioPlaylistLoader_) {
this.audioPlaylistLoader_.pause();
this.audioPlaylistLoader_.dispose();
this.audioPlaylistLoader_ = null;
this.audioSegmentLoader_.pause();
}
this.audioSegmentLoader_.pause();
this.audioSegmentLoader_.clearBuffer();
// If the audio track for the active audio group has
// a playlist loader than it is an alterative audio track
// otherwise it is a part of the mainSegmenLoader
let loader = track.getLoader(this.activeAudioGroup());
if (!loader) {
this.mainSegmentLoader_.clearBuffer();
if (!track.properties_.resolvedUri) {
return;
}
// TODO: it may be better to create the playlist loader here
// when we can change an audioPlaylistLoaders src
this.audioPlaylistLoader_ = loader;
if (this.audioPlaylistLoader_.started) {
this.audioPlaylistLoader_.load();
this.audioSegmentLoader_.load();
this.audioSegmentLoader_.clearBuffer();
return;
}
// startup playlist and segment loaders for the enabled audio
// track
this.audioPlaylistLoader_ = new PlaylistLoader(track.properties_.resolvedUri,
this.hls_,
this.withCredentials);
this.audioPlaylistLoader_.start();
this.audioPlaylistLoader_.on('loadedmetadata', () => {
/* eslint-disable no-shadow */
let media = this.audioPlaylistLoader_.media();
/* eslint-enable no-shadow */
let audioPlaylist = this.audioPlaylistLoader_.media();
this.audioSegmentLoader_.playlist(media, this.requestOptions_);
this.addMimeType_(this.audioSegmentLoader_, 'mp4a.40.2', media);
this.audioSegmentLoader_.playlist(audioPlaylist, this.requestOptions_);
// if the video is already playing, or if this isn't a live video and preload
// permits, start downloading segments
if (!this.tech_.paused() ||
(media.endList && this.tech_.preload() !== 'none')) {
(audioPlaylist.endList && this.tech_.preload() !== 'none')) {
this.audioSegmentLoader_.load();
}
if (!media.endList) {
if (!audioPlaylist.endList) {
// trigger the playlist loader to start "expired time"-tracking
this.audioPlaylistLoader_.trigger('firstplay');
}
......@@ -406,12 +609,8 @@ export default class MasterPlaylistController extends videojs.EventTarget {
videojs.log.warn('Problem encountered loading the alternate audio track' +
'. Switching back to default.');
this.audioSegmentLoader_.abort();
this.audioPlaylistLoader_ = null;
this.useAudio();
this.setupAudio();
});
this.audioSegmentLoader_.clearBuffer();
this.audioPlaylistLoader_.start();
}
/**
......@@ -502,7 +701,12 @@ export default class MasterPlaylistController extends videojs.EventTarget {
// Only attempt to create the source buffer if none already exist.
// handleSourceOpen is also called when we are "re-opening" a source buffer
// after `endOfStream` has been called (in response to a seek for instance)
this.setupSourceBuffer_();
try {
this.setupSourceBuffers_();
} catch (e) {
videojs.log.warn('Failed to create Source Buffers', e);
return this.mediaSource.endOfStream('decode');
}
// if autoplay is enabled, begin playback. This is duplicative of
// code in video.js but is required because play() must be invoked
......@@ -707,11 +911,8 @@ export default class MasterPlaylistController extends videojs.EventTarget {
*/
dispose() {
this.masterPlaylistLoader_.dispose();
this.audioTracks_.forEach((track) => {
track.dispose();
});
this.audioTracks_.length = 0;
this.mainSegmentLoader_.dispose();
this.audioSegmentLoader_.dispose();
}
......@@ -739,8 +940,9 @@ export default class MasterPlaylistController extends videojs.EventTarget {
*
* @private
*/
setupSourceBuffer_() {
setupSourceBuffers_() {
let media = this.masterPlaylistLoader_.media();
let mimeTypes;
// wait until a media playlist is available and the Media Source is
// attached
......@@ -748,7 +950,17 @@ export default class MasterPlaylistController extends videojs.EventTarget {
return;
}
this.addMimeType_(this.mainSegmentLoader_, 'avc1.4d400d, mp4a.40.2', media);
mimeTypes = mimeTypesForPlaylist_(this.masterPlaylistLoader_.master, media);
if (mimeTypes.length < 1) {
this.error =
'No compatible SourceBuffer configuration for the variant stream:' +
media.resolvedUri;
return this.mediaSource.endOfStream('decode');
}
this.mainSegmentLoader_.mimeType(mimeTypes[0]);
if (mimeTypes[1]) {
this.audioSegmentLoader_.mimeType(mimeTypes[1]);
}
// exclude any incompatible variant streams from future playlist
// selection
......@@ -756,27 +968,6 @@ export default class MasterPlaylistController extends videojs.EventTarget {
}
/**
* add a time type to a segmentLoader
*
* @param {SegmentLoader} segmentLoader the segmentloader to work on
* @param {String} codecs to use by default
* @param {Object} the parsed media object
* @private
*/
addMimeType_(segmentLoader, defaultCodecs, media) {
let mimeType = 'video/mp2t';
// if the codecs were explicitly specified, pass them along to the
// source buffer
if (media.attributes && media.attributes.CODECS) {
mimeType += '; codecs="' + media.attributes.CODECS + '"';
} else {
mimeType += '; codecs="' + defaultCodecs + '"';
}
segmentLoader.mimeType(mimeType);
}
/**
* Blacklist playlists that are known to be codec or
* stream-incompatible with the SourceBuffer configuration. For
* instance, Media Source Extensions would cause the video element to
......@@ -811,7 +1002,12 @@ export default class MasterPlaylistController extends videojs.EventTarget {
};
if (variant.attributes && variant.attributes.CODECS) {
variantCodecs = parseCodecs(variant.attributes.CODECS);
let codecString = variant.attributes.CODECS;
variantCodecs = parseCodecs(codecString);
if (!MediaSource.isTypeSupported('video/mp4; codecs="' + codecString + '"')) {
variant.excludeUntil = Infinity;
}
}
// if the streams differ in the presence or absence of audio or
......@@ -831,6 +1027,7 @@ export default class MasterPlaylistController extends videojs.EventTarget {
(audioProfile === '5' && variantCodecs.audioProfile !== '5')) {
variant.excludeUntil = Infinity;
}
});
}
......
......@@ -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
......
......@@ -6,6 +6,7 @@ import {getMediaIndexForTime_ as getMediaIndexForTime, duration} from './playlis
import videojs from 'video.js';
import SourceUpdater from './source-updater';
import {Decrypter} from 'aes-decrypter';
import mp4probe from 'mux.js/lib/mp4/probe';
import Config from './config';
import window from 'global/window';
......@@ -105,6 +106,21 @@ const segmentXhrHeaders = function(segment) {
};
/**
* Returns a unique string identifier for a media initialization
* segment.
*/
const initSegmentId = function(initSegment) {
let byterange = initSegment.byterange || {
length: Infinity,
offset: 0
};
return [
byterange.length, byterange.offset, initSegment.resolvedUri
].join(',');
};
/**
* An object that manages segment loading and appending.
*
* @class SegmentLoader
......@@ -134,23 +150,31 @@ export default class SegmentLoader extends videojs.EventTarget {
this.roundTrip = NaN;
this.resetStats_();
// private properties
// private settings
this.hasPlayed_ = settings.hasPlayed;
this.currentTime_ = settings.currentTime;
this.seekable_ = settings.seekable;
this.seeking_ = settings.seeking;
this.setCurrentTime_ = settings.setCurrentTime;
this.mediaSource_ = settings.mediaSource;
this.hls_ = settings.hls;
// private instance variables
this.checkBufferTimeout_ = null;
this.error_ = void 0;
this.expired_ = 0;
this.timeCorrection_ = 0;
this.currentTimeline_ = -1;
this.zeroOffset_ = NaN;
this.xhr_ = null;
this.pendingSegment_ = null;
this.mimeType_ = null;
this.sourceUpdater_ = null;
this.hls_ = settings.hls;
this.xhrOptions_ = null;
this.activeInitSegmentId_ = null;
this.initSegments_ = {};
}
/**
......@@ -214,6 +238,7 @@ export default class SegmentLoader extends videojs.EventTarget {
* load a playlist and start to fill the buffer
*/
load() {
// un-pause
this.monitorBuffer_();
// if we don't have a playlist yet, keep waiting for one to be
......@@ -222,6 +247,11 @@ export default class SegmentLoader extends videojs.EventTarget {
return;
}
// if all the configuration is ready, initialize and begin loading
if (this.state === 'INIT' && this.mimeType_) {
return this.init_();
}
// if we're in the middle of processing a segment already, don't
// kick off an additional segment request
if (!this.sourceUpdater_ ||
......@@ -240,17 +270,17 @@ export default class SegmentLoader extends videojs.EventTarget {
* @param {PlaylistLoader} media the playlist to set on the segment loader
*/
playlist(media, options = {}) {
if (!media) {
return;
}
this.playlist_ = media;
this.xhrOptions_ = options;
// if we were unpaused but waiting for a playlist, start
// buffering now
if (this.sourceUpdater_ &&
media &&
this.state === 'INIT' &&
!this.paused()) {
this.state = 'READY';
return this.fillBuffer_();
if (this.mimeType_ && this.state === 'INIT' && !this.paused()) {
return this.init_();
}
}
......@@ -293,24 +323,23 @@ export default class SegmentLoader extends videojs.EventTarget {
* @param {String} mimeType the mime type string to use
*/
mimeType(mimeType) {
// TODO Allow source buffers to be re-created with different mime-types
if (!this.sourceUpdater_) {
this.sourceUpdater_ = new SourceUpdater(this.mediaSource_, mimeType);
this.clearBuffer();
// if we were unpaused but waiting for a sourceUpdater, start
// buffering now
if (this.playlist_ &&
this.state === 'INIT' &&
!this.paused()) {
this.state = 'READY';
return this.fillBuffer_();
}
if (this.mimeType_) {
return;
}
this.mimeType_ = mimeType;
// if we were unpaused but waiting for a sourceUpdater, start
// buffering now
if (this.playlist_ &&
this.state === 'INIT' &&
!this.paused()) {
this.init_();
}
}
/**
* asynchronously/recursively monitor the buffer
* As long as the SegmentLoader is in the READY state, periodically
* invoke fillBuffer_().
*
* @private
*/
......@@ -350,7 +379,6 @@ export default class SegmentLoader extends videojs.EventTarget {
let bufferedTime;
let currentBufferedEnd;
let timestampOffset = this.sourceUpdater_.timestampOffset();
let segment;
let mediaIndex;
......@@ -390,21 +418,6 @@ export default class SegmentLoader extends videojs.EventTarget {
}
segment = playlist.segments[mediaIndex];
let startOfSegment = duration(playlist,
playlist.mediaSequence + mediaIndex,
this.expired_);
// We will need to change timestampOffset of the sourceBuffer if either of
// the following conditions are true:
// - The segment.timeline !== this.currentTimeline
// (we are crossing a discontinuity somehow)
// - The "timestampOffset" for the start of this segment is less than
// the currently set timestampOffset
if (segment.timeline !== this.currentTimeline_ ||
startOfSegment < this.sourceUpdater_.timestampOffset()) {
timestampOffset = startOfSegment;
}
return {
// resolve the segment URL relative to the playlist
uri: segment.resolvedUri,
......@@ -422,7 +435,7 @@ export default class SegmentLoader extends videojs.EventTarget {
buffered: null,
// The target timestampOffset for this segment when we append it
// to the source buffer
timestampOffset,
timestampOffset: NaN,
// The timeline that the segment is in
timeline: segment.timeline,
// The expected duration of the segment in seconds
......@@ -445,6 +458,18 @@ export default class SegmentLoader extends videojs.EventTarget {
}
/**
* Once all the starting parameters have been specified, begin
* operation. This method should only be invoked from the INIT
* state.
*/
init_() {
this.state = 'READY';
this.sourceUpdater_ = new SourceUpdater(this.mediaSource_, this.mimeType_);
this.clearBuffer();
return this.fillBuffer_();
}
/**
* fill the buffer with segements unless the
* sourceBuffers are currently updating
*
......@@ -456,26 +481,37 @@ export default class SegmentLoader extends videojs.EventTarget {
}
// see if we need to begin loading immediately
let request = this.checkBuffer_(this.sourceUpdater_.buffered(),
this.playlist_,
this.currentTime_(),
this.timestampOffset_);
let segmentInfo = this.checkBuffer_(this.sourceUpdater_.buffered(),
this.playlist_,
this.currentTime_());
if (!request) {
if (!segmentInfo) {
return;
}
if (request.mediaIndex === this.playlist_.segments.length - 1 &&
if (segmentInfo.mediaIndex === this.playlist_.segments.length - 1 &&
this.mediaSource_.readyState === 'ended' &&
!this.seeking_()) {
return;
}
let segment = this.playlist_.segments[request.mediaIndex];
let segment = this.playlist_.segments[segmentInfo.mediaIndex];
let startOfSegment = duration(this.playlist_,
this.playlist_.mediaSequence + request.mediaIndex,
this.playlist_.mediaSequence + segmentInfo.mediaIndex,
this.expired_);
// We will need to change timestampOffset of the sourceBuffer if either of
// the following conditions are true:
// - The segment.timeline !== this.currentTimeline
// (we are crossing a discontinuity)
// - The "timestampOffset" for the start of this segment is less than
// the currently set timestampOffset
segmentInfo.timestampOffset = this.sourceUpdater_.timestampOffset();
if (segment.timeline !== this.currentTimeline_ ||
startOfSegment < this.sourceUpdater_.timestampOffset()) {
segmentInfo.timestampOffset = startOfSegment;
}
// Sanity check the segment-index determining logic by calcuating the
// percentage of the chosen segment that is buffered. If more than 90%
// of the segment is buffered then fetching it will likely not help in
......@@ -499,7 +535,7 @@ export default class SegmentLoader extends videojs.EventTarget {
return;
}
this.loadSegment_(request);
this.loadSegment_(segmentInfo);
}
/**
......@@ -565,6 +601,7 @@ export default class SegmentLoader extends videojs.EventTarget {
loadSegment_(segmentInfo) {
let segment;
let keyXhr;
let initSegmentXhr;
let segmentXhr;
let removeToTime = 0;
......@@ -576,6 +613,7 @@ export default class SegmentLoader extends videojs.EventTarget {
segment = segmentInfo.playlist.segments[segmentInfo.mediaIndex];
// optionally, request the decryption key
if (segment.key) {
let keyRequestOptions = videojs.mergeOptions(this.xhrOptions_, {
uri: segment.key.resolvedUri,
......@@ -585,6 +623,18 @@ export default class SegmentLoader extends videojs.EventTarget {
keyXhr = this.hls_.xhr(keyRequestOptions, this.handleResponse_.bind(this));
}
// optionally, request the associated media init segment
if (segment.map &&
!this.initSegments_[initSegmentId(segment.map)]) {
let initSegmentOptions = videojs.mergeOptions(this.xhrOptions_, {
uri: segment.map.resolvedUri,
responseType: 'arraybuffer',
headers: segmentXhrHeaders(segment.map)
});
initSegmentXhr = this.hls_.xhr(initSegmentOptions,
this.handleResponse_.bind(this));
}
this.pendingSegment_ = segmentInfo;
let segmentRequestOptions = videojs.mergeOptions(this.xhrOptions_, {
......@@ -597,6 +647,7 @@ export default class SegmentLoader extends videojs.EventTarget {
this.xhr_ = {
keyXhr,
initSegmentXhr,
segmentXhr,
abort() {
if (this.segmentXhr) {
......@@ -605,6 +656,12 @@ export default class SegmentLoader extends videojs.EventTarget {
this.segmentXhr.abort();
this.segmentXhr = null;
}
if (this.initSegmentXhr) {
// Prevent error handler from running.
this.initSegmentXhr.onreadystatechange = null;
this.initSegmentXhr.abort();
this.initSegmentXhr = null;
}
if (this.keyXhr) {
// Prevent error handler from running.
this.keyXhr.onreadystatechange = null;
......@@ -630,7 +687,9 @@ export default class SegmentLoader extends videojs.EventTarget {
// timeout of previously aborted request
if (!this.xhr_ ||
(request !== this.xhr_.segmentXhr && request !== this.xhr_.keyXhr)) {
(request !== this.xhr_.segmentXhr &&
request !== this.xhr_.keyXhr &&
request !== this.xhr_.initSegmentXhr)) {
return;
}
......@@ -721,7 +780,14 @@ export default class SegmentLoader extends videojs.EventTarget {
]);
}
if (!this.xhr_.segmentXhr && !this.xhr_.keyXhr) {
if (request === this.xhr_.initSegmentXhr) {
// the init segment request is no longer outstanding
this.xhr_.initSegmentXhr = null;
segment.map.bytes = new Uint8Array(request.response);
this.initSegments_[initSegmentId(segment.map)] = segment.map;
}
if (!this.xhr_.segmentXhr && !this.xhr_.keyXhr && !this.xhr_.initSegmentXhr) {
this.xhr_ = null;
this.processResponse_();
}
......@@ -751,6 +817,18 @@ export default class SegmentLoader extends videojs.EventTarget {
segmentInfo = this.pendingSegment_;
segment = segmentInfo.playlist.segments[segmentInfo.mediaIndex];
// some videos don't start from presentation time zero
// if that is the case, set the timestamp offset on the first
// segment to adjust them so that it is not necessary to seek
// before playback can begin
if (segment.map && isNaN(this.zeroOffset_)) {
let timescales = mp4probe.timescale(segment.map.bytes);
let startTime = mp4probe.startTime(timescales, segmentInfo.bytes);
this.zeroOffset_ = startTime;
segmentInfo.timestampOffset -= startTime;
}
if (segment.key) {
// this is an encrypted segment
// incrementally decrypt the segment
......@@ -776,16 +854,33 @@ export default class SegmentLoader extends videojs.EventTarget {
*/
handleSegment_() {
let segmentInfo;
let segment;
this.state = 'APPENDING';
segmentInfo = this.pendingSegment_;
segmentInfo.buffered = this.sourceUpdater_.buffered();
segment = segmentInfo.playlist.segments[segmentInfo.mediaIndex];
this.currentTimeline_ = segmentInfo.timeline;
if (segmentInfo.timestampOffset !== this.sourceUpdater_.timestampOffset()) {
this.sourceUpdater_.timestampOffset(segmentInfo.timestampOffset);
}
// if the media initialization segment is changing, append it
// before the content segment
if (segment.map) {
let initId = initSegmentId(segment.map);
if (!this.activeInitSegmentId_ ||
this.activeInitSegmentId_ !== initId) {
let initSegment = this.initSegments_[initId];
this.sourceUpdater_.appendBuffer(initSegment.bytes, () => {
this.activeInitSegmentId_ = initId;
});
}
}
this.sourceUpdater_.appendBuffer(segmentInfo.bytes,
this.handleUpdateEnd_.bind(this));
}
......
......@@ -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;
}
});
// 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', () => {
// Add the manual rendition mix-in to HlsHandler
renditionSelectionMixin(this);
});
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();
}
......
......@@ -90,6 +90,9 @@ QUnit.module('HLS', {
this.old.Decrypt = videojs.Hls.Decrypter;
videojs.Hls.Decrypter = function() {};
// save and restore browser detection for the Firefox-specific tests
this.old.IS_FIREFOX = videojs.browser.IS_FIREFOX;
// setup a player
this.player = createPlayer();
},
......@@ -104,6 +107,7 @@ QUnit.module('HLS', {
videojs.Hls.supportsNativeHls = this.old.NativeHlsSupport;
videojs.Hls.Decrypter = this.old.Decrypt;
videojs.browser.IS_FIREFOX = this.old.IS_FIREFOX;
this.player.dispose();
}
......@@ -260,11 +264,11 @@ QUnit.test('codecs are passed to the source buffer', function() {
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:CODECS="video, audio"\n' +
'#EXT-X-STREAM-INF:CODECS="avc1.dd00dd, mp4a.40.f"\n' +
'media.m3u8\n');
standardXHRResponse(this.requests.shift());
QUnit.equal(codecs.length, 1, 'created a source buffer');
QUnit.equal(codecs[0], 'video/mp2t; codecs="video, audio"', 'specified the codecs');
QUnit.equal(codecs[0], 'video/mp2t; codecs="avc1.dd00dd, mp4a.40.f"', 'specified the codecs');
});
QUnit.test('including HLS as a tech does not error', function() {
......@@ -862,7 +866,7 @@ QUnit.test('does not blacklist compatible AAC codec strings', function() {
'#EXTM3U\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=1,CODECS="avc1.4d400d,mp4a.40.2"\n' +
'media.m3u8\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=10,CODECS="avc1.4d400d,mp4a.40.3"\n' +
'#EXT-X-STREAM-INF:BANDWIDTH=10,CODECS="avc1.4d400d,not-an-audio-codec"\n' +
'media1.m3u8\n');
// media
......@@ -870,10 +874,10 @@ QUnit.test('does not blacklist compatible AAC codec strings', function() {
master = this.player.tech_.hls.playlists.master;
QUnit.strictEqual(typeof master.playlists[0].excludeUntil,
'undefined',
'did not blacklist');
QUnit.strictEqual(typeof master.playlists[1].excludeUntil,
'undefined',
'did not blacklist');
'did not blacklist mp4a.40.2');
QUnit.strictEqual(master.playlists[1].excludeUntil,
Infinity,
'blacklisted invalid audio codec');
// verify stats
QUnit.equal(this.player.tech_.hls.stats.bandwidth, 1, 'bandwidth set above');
......@@ -1453,6 +1457,7 @@ QUnit.test('re-emits mediachange events', function() {
type: 'application/vnd.apple.mpegurl'
});
openMediaSource(this.player, this.clock);
standardXHRResponse(this.requests.shift());
this.player.tech_.hls.playlists.trigger('mediachange');
QUnit.strictEqual(mediaChanges, 1, 'fired mediachange');
......@@ -1713,7 +1718,7 @@ QUnit.test('resolves relative key URLs against the playlist', function() {
'resolves the key URL');
});
QUnit.test('adds 1 default audio track if we have not parsed any, and the playlist is loaded', function() {
QUnit.test('adds 1 default audio track if we have not parsed any and the playlist is loaded', function() {
this.player.src({
src: 'manifest/master.m3u8',
type: 'application/vnd.apple.mpegurl'
......@@ -1725,12 +1730,11 @@ QUnit.test('adds 1 default audio track if we have not parsed any, and the playli
// master
standardXHRResponse(this.requests.shift());
// media
standardXHRResponse(this.requests.shift());
QUnit.equal(this.player.audioTracks().length, 1, 'one audio track after load');
QUnit.ok(this.player.audioTracks()[0] instanceof HlsAudioTrack, 'audio track is an hls audio track');
// verify stats
QUnit.equal(this.player.tech_.hls.stats.bandwidth, 4194304, 'default');
QUnit.equal(this.player.audioTracks()[0].label, 'default', 'set the label');
});
QUnit.test('adds 1 default audio track if in flash mode', function() {
......@@ -1754,9 +1758,11 @@ QUnit.test('adds 1 default audio track if in flash mode', function() {
// master
standardXHRResponse(this.requests.shift());
// media
standardXHRResponse(this.requests.shift());
QUnit.equal(this.player.audioTracks().length, 1, 'one audio track after load');
QUnit.ok(this.player.audioTracks()[0] instanceof HlsAudioTrack, 'audio track is an hls audio track');
QUnit.equal(this.player.audioTracks()[0].label, 'default', 'set the label');
videojs.options.hls = hlsOptions;
});
......@@ -1773,64 +1779,22 @@ QUnit.test('adds audio tracks if we have parsed some from a playlist', function(
// master
standardXHRResponse(this.requests.shift());
let hls = this.player.tech_.hls;
let hlsAudioTracks = hls.masterPlaylistController_.audioTracks_;
// media
standardXHRResponse(this.requests.shift());
let vjsAudioTracks = this.player.audioTracks();
QUnit.equal(hlsAudioTracks.length, 3, '3 active hls tracks');
QUnit.equal(vjsAudioTracks.length, 3, '3 active vjs tracks');
QUnit.equal(vjsAudioTracks[0].enabled, true, 'default track is enabled');
QUnit.equal(hlsAudioTracks[0].enabled, true, 'default track is enabled');
vjsAudioTracks[1].enabled = true;
QUnit.equal(hlsAudioTracks[1].enabled, true, 'new track is enabled on hls');
QUnit.equal(vjsAudioTracks[1].enabled, true, 'new track is enabled on vjs');
QUnit.equal(vjsAudioTracks[0].enabled, false, 'main track is disabled');
QUnit.equal(hlsAudioTracks[0].enabled, false, 'main track is disabled');
hlsAudioTracks[2].enabled = true;
QUnit.equal(hlsAudioTracks[2].enabled, true, 'new track is enabled on hls');
QUnit.equal(vjsAudioTracks[2].enabled, true, 'new track is enabled on vjs');
QUnit.equal(vjsAudioTracks[1].enabled, false, 'main track is disabled');
QUnit.equal(hlsAudioTracks[1].enabled, false, 'main track is disabled');
// verify stats
QUnit.equal(this.player.tech_.hls.stats.bandwidth, 4194304, 'default');
});
QUnit.test('audio info from audioinfo event is stored on hls', function() {
// force non-firefox as firefox has specific behavior
let oldIsFirefox = videojs.browser.IS_FIREFOX;
videojs.browser.IS_FIREFOX = false;
this.player.src({
src: 'manifest/multipleAudioGroups.m3u8',
type: 'application/vnd.apple.mpegurl'
});
let hls = this.player.tech_.hls;
let mpc = hls.masterPlaylistController_;
let info = {foo: 'bar'};
QUnit.ok(!hls.audioInfo_, 'hls has no audioInfo_');
mpc.trigger({type: 'audioinfo', info});
QUnit.equal(hls.audioInfo_, info, 'hls has the info from the event');
info = {bar: 'foo'};
mpc.trigger({type: 'audioinfo', info});
QUnit.equal(hls.audioInfo_, info, 'hls has the new info from the event');
videojs.browser.IS_FIREFOX = oldIsFirefox;
});
QUnit.test('audioinfo changes with three tracks, enabled track is blacklisted and removed', function() {
let oldIsFirefox = videojs.browser.IS_FIREFOX;
let at = this.player.audioTracks();
QUnit.test('when audioinfo changes on an independent audio track in Firefox, the enabled track is blacklisted and removed', function() {
let audioTracks = this.player.audioTracks();
let oldLabel;
videojs.browser.IS_FIREFOX = true;
this.player.src({
......@@ -1840,54 +1804,34 @@ QUnit.test('audioinfo changes with three tracks, enabled track is blacklisted an
let hls = this.player.tech_.hls;
let mpc = hls.masterPlaylistController_;
QUnit.equal(at.length, 0, 'zero audio tracks at load time');
QUnit.ok(!hls.audioInfo_, 'no audio info on hls');
openMediaSource(this.player, this.clock);
// master
standardXHRResponse(this.requests.shift());
// media
standardXHRResponse(this.requests.shift());
QUnit.equal(at.length, 3, 'three audio track after load');
QUnit.ok(!hls.audioInfo_, 'no audio info on hls');
let defaultTrack;
mpc.audioTracks_.forEach((t) => {
if (!defaultTrack && t.default) {
defaultTrack = t;
}
});
QUnit.equal(audioTracks.length, 3, 'three audio track after load');
let blacklistPlaylistCalls = 0;
let info = {foo: 'bar'};
// noop as there is no real playlist
mpc.useAudio = () => {};
let defaultTrack = mpc.activeAudioGroup().filter((track) => {
return track.properties_.default;
})[0];
// initial audio info
mpc.trigger({type: 'audioinfo', info});
QUnit.equal(hls.audioInfo_, info, 'hls has the info from the event');
hls.mediaSource.trigger({ type: 'audioinfo', info: { foo: 'bar' }});
oldLabel = audioTracks[1].label;
// simulate audio info change and mock things
let oldLabel = at[1].label;
at[1].enabled = true;
mpc.blacklistCurrentPlaylist = () => blacklistPlaylistCalls++;
mpc.trigger({type: 'audioinfo', info: {bar: 'foo'}});
audioTracks[1].enabled = true;
hls.mediaSource.trigger({ type: 'audioinfo', info: { bar: 'foo' }});
QUnit.equal(hls.audioInfo_, info, 'hls did not store the changed audio info');
QUnit.equal(at.length, 2, 'two audio tracks after bad audioinfo change');
QUnit.notEqual(at[1].label, oldLabel, 'audio track at index 1 is not the same');
QUnit.equal(audioTracks.length, 2, 'two audio tracks after bad audioinfo change');
QUnit.notEqual(audioTracks[1].label, oldLabel, 'audio track at index 1 is not the same');
QUnit.equal(defaultTrack.enabled, true, 'default track is enabled again');
QUnit.equal(blacklistPlaylistCalls, 0, 'blacklist was not called on playlist');
QUnit.equal(this.env.log.warn.calls, 1, 'firefox issue warning logged');
videojs.browser.IS_FIREFOX = oldIsFirefox;
// verify stats
QUnit.equal(this.player.tech_.hls.stats.bandwidth, 4194304, 'default');
});
QUnit.test('audioinfo changes with one track, blacklist playlist', function() {
let oldIsFirefox = videojs.browser.IS_FIREFOX;
let at = this.player.audioTracks();
let audioTracks = this.player.audioTracks();
videojs.browser.IS_FIREFOX = true;
this.player.src({
......@@ -1895,35 +1839,28 @@ QUnit.test('audioinfo changes with one track, blacklist playlist', function() {
type: 'application/vnd.apple.mpegurl'
});
QUnit.equal(at.length, 0, 'zero audio tracks at load time');
QUnit.equal(audioTracks.length, 0, 'zero audio tracks at load time');
openMediaSource(this.player, this.clock);
standardXHRResponse(this.requests.shift());
standardXHRResponse(this.requests.shift());
QUnit.equal(at.length, 1, 'one audio track after load');
QUnit.equal(audioTracks.length, 1, 'one audio track after load');
let mpc = this.player.tech_.hls.masterPlaylistController_;
let blacklistPlaylistCalls = 0;
let oldMedia = mpc.media();
mpc.blacklistCurrentPlaylist = () => blacklistPlaylistCalls++;
// noop as there is no real playlist
mpc.useAudio = () => {};
mpc.trigger({type: 'audioinfo', info: {foo: 'bar'}});
// initial audio info
mpc.mediaSource.trigger({type: 'audioinfo', info: { foo: 'bar' }});
// simulate audio info change in main track
mpc.trigger({type: 'audioinfo', info: {bar: 'foo'}});
QUnit.equal(at.length, 1, 'still have one audio track');
QUnit.equal(blacklistPlaylistCalls, 1, 'blacklist was called on playlist');
QUnit.equal(this.env.log.warn.calls, 1, 'firefox issue warning logged');
videojs.browser.IS_FIREFOX = oldIsFirefox;
mpc.mediaSource.trigger({type: 'audioinfo', info: { bar: 'foo' }});
// verify stats
QUnit.equal(this.player.tech_.hls.stats.bandwidth, 4194304, 'default');
QUnit.equal(audioTracks.length, 1, 'still have one audio track');
QUnit.ok(oldMedia.excludeUntil > 0, 'blacklisted old playlist');
QUnit.equal(this.env.log.warn.calls, 2, 'firefox issue warning logged');
});
QUnit.test('audioinfo changes with three tracks, default is enabled, blacklisted playlist', function() {
let oldIsFirefox = videojs.browser.IS_FIREFOX;
let at = this.player.audioTracks();
QUnit.test('changing audioinfo for muxed audio blacklists the current playlist in Firefox', function() {
let audioTracks = this.player.audioTracks();
videojs.browser.IS_FIREFOX = true;
this.player.src({
......@@ -1931,45 +1868,48 @@ QUnit.test('audioinfo changes with three tracks, default is enabled, blacklisted
type: 'application/vnd.apple.mpegurl'
});
QUnit.equal(at.length, 0, 'zero audio tracks at load time');
QUnit.equal(audioTracks.length, 0, 'zero audio tracks at load time');
openMediaSource(this.player, this.clock);
standardXHRResponse(this.requests.shift());
standardXHRResponse(this.requests.shift());
QUnit.equal(at.length, 3, 'three audio track after load');
let hls = this.player.tech_.hls;
let mpc = hls.masterPlaylistController_;
// force audio group with combined audio to enabled
mpc.activeAudioGroup = () => 'audio-lo';
let defaultTrack;
mpc.audioTracks_.forEach((t) => {
if (!defaultTrack && t.default) {
defaultTrack = t;
}
});
// master
standardXHRResponse(this.requests.shift());
// video media
standardXHRResponse(this.requests.shift());
// video segments
standardXHRResponse(this.requests.shift());
standardXHRResponse(this.requests.shift());
// audio media
standardXHRResponse(this.requests.shift());
// ignore audio requests
this.requests.length = 0;
QUnit.equal(audioTracks.length, 3, 'three audio track after load');
let blacklistPlaylistCalls = 0;
// force audio group with combined audio to enabled
mpc.masterPlaylistLoader_.media(mpc.master().playlists[0]);
this.requests.shift().respond(200, null,
'#EXTM3U\n' +
'#EXTINF:10,\n' +
'0.ts\n' +
'#EXT-X-ENDLIST\n');
// noop as there is no real playlist
mpc.useAudio = () => {};
let defaultTrack = mpc.activeAudioGroup().filter((track) => {
return track.properties_.default;
})[0];
let oldPlaylist = mpc.media();
// initial audio info
mpc.trigger({type: 'audioinfo', info: {foo: 'bar'}});
mpc.mediaSource.trigger({type: 'audioinfo', info: { foo: 'bar' }});
// simulate audio info change and mock things
mpc.blacklistCurrentPlaylist = () => blacklistPlaylistCalls++;
mpc.trigger({type: 'audioinfo', info: {bar: 'foo'}});
// simulate audio info change
mpc.mediaSource.trigger({type: 'audioinfo', info: { bar: 'foo' }});
QUnit.equal(at.length, 3, 'three audio tracks after bad audioinfo change');
audioTracks = this.player.audioTracks();
QUnit.equal(audioTracks.length, 3, 'three audio tracks after bad audioinfo change');
QUnit.equal(defaultTrack.enabled, true, 'default audio still enabled');
QUnit.equal(blacklistPlaylistCalls, 1, 'blacklist was called on playlist');
QUnit.equal(this.env.log.warn.calls, 1, 'firefox issue warning logged');
videojs.browser.IS_FIREFOX = oldIsFirefox;
// verify stats
QUnit.equal(this.player.tech_.hls.stats.bandwidth, 4194304, 'default');
QUnit.ok(oldPlaylist.excludeUntil > 0, 'blacklisted the old playlist');
QUnit.equal(this.env.log.warn.calls, 2, 'firefox issue warning logged');
});
QUnit.test('cleans up the buffer when loading live segments', function() {
......@@ -2125,75 +2065,62 @@ QUnit.test('when mediaGroup changes enabled track should not change', function()
src: 'manifest/multipleAudioGroups.m3u8',
type: 'application/vnd.apple.mpegurl'
});
QUnit.equal(this.player.audioTracks().length, 0, 'zero audio tracks at load time');
openMediaSource(this.player, this.clock);
// master
standardXHRResponse(this.requests.shift());
// video media
standardXHRResponse(this.requests.shift());
let hls = this.player.tech_.hls;
let mpc = hls.masterPlaylistController_;
let audioTracks = this.player.audioTracks();
QUnit.equal(audioTracks.length, 3, 'three audio tracks after load');
let trackOne = audioTracks[0];
let trackTwo = audioTracks[1];
let trackThree = audioTracks[2];
QUnit.equal(trackOne.enabled, true, 'track one enabled after load');
QUnit.equal(audioTracks[0].enabled, true, 'track one enabled after load');
let oldMediaGroup = hls.playlists.media().attributes.AUDIO;
// clear out any outstanding requests
this.requests.length = 0;
// force mpc to select a playlist from a new media group
hls.selectPlaylist = () => {
let playlist;
mpc.masterPlaylistLoader_.media(mpc.master().playlists[0]);
hls.playlists.master.playlists.forEach((p) => {
if (!playlist && p.attributes.AUDIO !== oldMediaGroup) {
playlist = p;
}
});
return playlist;
};
// TODO extra segment requests!!!
this.requests.shift();
this.requests.shift();
// video media
standardXHRResponse(this.requests.shift());
// select a new mediaGroup
hls.masterPlaylistController_.blacklistCurrentPlaylist();
while (this.requests.length > 0) {
standardXHRResponse(this.requests.shift());
}
QUnit.notEqual(oldMediaGroup, hls.playlists.media().attributes.AUDIO, 'selected a new playlist');
QUnit.equal(this.env.log.warn.calls, 1, 'logged warning for blacklist');
audioTracks = this.player.audioTracks();
QUnit.equal(audioTracks.length, 3, 'three audio tracks after mediaGroup Change');
QUnit.equal(audioTracks[0], trackOne, 'track one did not change');
QUnit.equal(audioTracks[1], trackTwo, 'track two did not change');
QUnit.equal(audioTracks[2], trackThree, 'track three did not change');
QUnit.equal(audioTracks.length, 3, 'three audio tracks after changing mediaGroup');
QUnit.ok(audioTracks[0].properties_.default, 'track one should be the default');
QUnit.ok(audioTracks[0].enabled, 'enabled the default track');
QUnit.notOk(audioTracks[1].enabled, 'disabled track two');
QUnit.notOk(audioTracks[2].enabled, 'disabled track three');
trackTwo.enabled = true;
QUnit.equal(trackOne.enabled, false, 'track 1 - now disabled');
QUnit.equal(trackTwo.enabled, true, 'track 2 - now enabled');
QUnit.equal(trackThree.enabled, false, 'track 3 - disabled');
audioTracks[1].enabled = true;
QUnit.notOk(audioTracks[0].enabled, 'disabled track one');
QUnit.ok(audioTracks[1].enabled, 'enabled track two');
QUnit.notOk(audioTracks[2].enabled, 'disabled track three');
oldMediaGroup = hls.playlists.media().attributes.AUDIO;
// select a new mediaGroup
hls.masterPlaylistController_.blacklistCurrentPlaylist();
while (this.requests.length > 0) {
standardXHRResponse(this.requests.shift());
}
QUnit.notEqual(oldMediaGroup, hls.playlists.media().attributes.AUDIO, 'selected a new playlist');
QUnit.equal(this.env.log.warn.calls, 1, 'logged warning for blacklist');
QUnit.equal(audioTracks.length, 3, 'three audio tracks after mediaGroup Change');
QUnit.equal(audioTracks[0], trackOne, 'track one did not change');
QUnit.equal(audioTracks[1], trackTwo, 'track two did not change');
QUnit.equal(audioTracks[2], trackThree, 'track three did not change');
// clear out any outstanding requests
this.requests.length = 0;
// swap back to the old media group
// this playlist is already loaded so no new requests are made
mpc.masterPlaylistLoader_.media(mpc.master().playlists[3]);
QUnit.equal(trackOne.enabled, false, 'track 1 - still disabled');
QUnit.equal(trackTwo.enabled, true, 'track 2 - still enabled');
QUnit.equal(trackThree.enabled, false, 'track 3 - disabled');
QUnit.notEqual(oldMediaGroup, hls.playlists.media().attributes.AUDIO, 'selected a new playlist');
audioTracks = this.player.audioTracks();
// verify stats
QUnit.equal(this.player.tech_.hls.stats.bandwidth, 4194304, 'default');
QUnit.equal(audioTracks.length, 3, 'three audio tracks after reverting mediaGroup');
QUnit.ok(audioTracks[0].properties_.default, 'track one should be the default');
QUnit.notOk(audioTracks[0].enabled, 'the default track is still disabled');
QUnit.ok(audioTracks[1].enabled, 'track two is still enabled');
QUnit.notOk(audioTracks[2].enabled, 'track three is still disabled');
});
QUnit.test('Allows specifying the beforeRequest function on the player', function() {
......
#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
// ------------
......