123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031 |
- /**
- * @file playlist-controller.js
- */
- import window from 'global/window';
- import PlaylistLoader from './playlist-loader';
- import DashPlaylistLoader from './dash-playlist-loader';
- import { isEnabled, isLowestEnabledRendition } from './playlist.js';
- import SegmentLoader from './segment-loader';
- import SourceUpdater from './source-updater';
- import VTTSegmentLoader from './vtt-segment-loader';
- import * as Ranges from './ranges';
- import videojs from 'video.js';
- import { updateAdCues } from './ad-cue-tags';
- import SyncController from './sync-controller';
- import TimelineChangeController from './timeline-change-controller';
- import Decrypter from 'worker!./decrypter-worker.js';
- import Config from './config';
- import {
- parseCodecs,
- browserSupportsCodec,
- muxerSupportsCodec,
- DEFAULT_AUDIO_CODEC,
- DEFAULT_VIDEO_CODEC
- } from '@videojs/vhs-utils/es/codecs.js';
- import { codecsForPlaylist, unwrapCodecList, codecCount } from './util/codecs.js';
- import { createMediaTypes, setupMediaGroups } from './media-groups';
- import logger from './util/logger';
- import {merge, createTimeRanges} from './util/vjs-compat';
- const ABORT_EARLY_EXCLUSION_SECONDS = 60 * 2;
- let Vhs;
- // SegmentLoader stats that need to have each loader's
- // values summed to calculate the final value
- const loaderStats = [
- 'mediaRequests',
- 'mediaRequestsAborted',
- 'mediaRequestsTimedout',
- 'mediaRequestsErrored',
- 'mediaTransferDuration',
- 'mediaBytesTransferred',
- 'mediaAppends'
- ];
- const sumLoaderStat = function(stat) {
- return this.audioSegmentLoader_[stat] +
- this.mainSegmentLoader_[stat];
- };
- const shouldSwitchToMedia = function({
- currentPlaylist,
- buffered,
- currentTime,
- nextPlaylist,
- bufferLowWaterLine,
- bufferHighWaterLine,
- duration,
- bufferBasedABR,
- log
- }) {
- // we have no other playlist to switch to
- if (!nextPlaylist) {
- videojs.log.warn('We received no playlist to switch to. Please check your stream.');
- return false;
- }
- const sharedLogLine = `allowing switch ${currentPlaylist && currentPlaylist.id || 'null'} -> ${nextPlaylist.id}`;
- if (!currentPlaylist) {
- log(`${sharedLogLine} as current playlist is not set`);
- return true;
- }
- // no need to switch if playlist is the same
- if (nextPlaylist.id === currentPlaylist.id) {
- return false;
- }
- // determine if current time is in a buffered range.
- const isBuffered = Boolean(Ranges.findRange(buffered, currentTime).length);
- // If the playlist is live, then we want to not take low water line into account.
- // This is because in LIVE, the player plays 3 segments from the end of the
- // playlist, and if `BUFFER_LOW_WATER_LINE` is greater than the duration availble
- // in those segments, a viewer will never experience a rendition upswitch.
- if (!currentPlaylist.endList) {
- // For LLHLS live streams, don't switch renditions before playback has started, as it almost
- // doubles the time to first playback.
- if (!isBuffered && typeof currentPlaylist.partTargetDuration === 'number') {
- log(`not ${sharedLogLine} as current playlist is live llhls, but currentTime isn't in buffered.`);
- return false;
- }
- log(`${sharedLogLine} as current playlist is live`);
- return true;
- }
- const forwardBuffer = Ranges.timeAheadOf(buffered, currentTime);
- const maxBufferLowWaterLine = bufferBasedABR ?
- Config.EXPERIMENTAL_MAX_BUFFER_LOW_WATER_LINE : Config.MAX_BUFFER_LOW_WATER_LINE;
- // For the same reason as LIVE, we ignore the low water line when the VOD
- // duration is below the max potential low water line
- if (duration < maxBufferLowWaterLine) {
- log(`${sharedLogLine} as duration < max low water line (${duration} < ${maxBufferLowWaterLine})`);
- return true;
- }
- const nextBandwidth = nextPlaylist.attributes.BANDWIDTH;
- const currBandwidth = currentPlaylist.attributes.BANDWIDTH;
- // when switching down, if our buffer is lower than the high water line,
- // we can switch down
- if (nextBandwidth < currBandwidth && (!bufferBasedABR || forwardBuffer < bufferHighWaterLine)) {
- let logLine = `${sharedLogLine} as next bandwidth < current bandwidth (${nextBandwidth} < ${currBandwidth})`;
- if (bufferBasedABR) {
- logLine += ` and forwardBuffer < bufferHighWaterLine (${forwardBuffer} < ${bufferHighWaterLine})`;
- }
- log(logLine);
- return true;
- }
- // and if our buffer is higher than the low water line,
- // we can switch up
- if ((!bufferBasedABR || nextBandwidth > currBandwidth) && forwardBuffer >= bufferLowWaterLine) {
- let logLine = `${sharedLogLine} as forwardBuffer >= bufferLowWaterLine (${forwardBuffer} >= ${bufferLowWaterLine})`;
- if (bufferBasedABR) {
- logLine += ` and next bandwidth > current bandwidth (${nextBandwidth} > ${currBandwidth})`;
- }
- log(logLine);
- return true;
- }
- log(`not ${sharedLogLine} as no switching criteria met`);
- return false;
- };
- /**
- * the main playlist controller controller all interactons
- * between playlists and segmentloaders. At this time this mainly
- * involves a main playlist and a series of audio playlists
- * if they are available
- *
- * @class PlaylistController
- * @extends videojs.EventTarget
- */
- export class PlaylistController extends videojs.EventTarget {
- constructor(options) {
- super();
- const {
- src,
- withCredentials,
- tech,
- bandwidth,
- externVhs,
- useCueTags,
- playlistExclusionDuration,
- enableLowInitialPlaylist,
- sourceType,
- cacheEncryptionKeys,
- bufferBasedABR,
- leastPixelDiffSelector,
- captionServices
- } = options;
- if (!src) {
- throw new Error('A non-empty playlist URL or JSON manifest string is required');
- }
- let { maxPlaylistRetries } = options;
- if (maxPlaylistRetries === null || typeof maxPlaylistRetries === 'undefined') {
- maxPlaylistRetries = Infinity;
- }
- Vhs = externVhs;
- this.bufferBasedABR = Boolean(bufferBasedABR);
- this.leastPixelDiffSelector = Boolean(leastPixelDiffSelector);
- this.withCredentials = withCredentials;
- this.tech_ = tech;
- this.vhs_ = tech.vhs;
- this.sourceType_ = sourceType;
- this.useCueTags_ = useCueTags;
- this.playlistExclusionDuration = playlistExclusionDuration;
- this.maxPlaylistRetries = maxPlaylistRetries;
- this.enableLowInitialPlaylist = enableLowInitialPlaylist;
- if (this.useCueTags_) {
- this.cueTagsTrack_ = this.tech_.addTextTrack(
- 'metadata',
- 'ad-cues'
- );
- this.cueTagsTrack_.inBandMetadataTrackDispatchType = '';
- }
- this.requestOptions_ = {
- withCredentials,
- maxPlaylistRetries,
- timeout: null
- };
- this.on('error', this.pauseLoading);
- this.mediaTypes_ = createMediaTypes();
- this.mediaSource = new window.MediaSource();
- this.handleDurationChange_ = this.handleDurationChange_.bind(this);
- this.handleSourceOpen_ = this.handleSourceOpen_.bind(this);
- this.handleSourceEnded_ = this.handleSourceEnded_.bind(this);
- this.mediaSource.addEventListener('durationchange', this.handleDurationChange_);
- // load the media source into the player
- this.mediaSource.addEventListener('sourceopen', this.handleSourceOpen_);
- this.mediaSource.addEventListener('sourceended', this.handleSourceEnded_);
- // we don't have to handle sourceclose since dispose will handle termination of
- // everything, and the MediaSource should not be detached without a proper disposal
- this.seekable_ = createTimeRanges();
- this.hasPlayed_ = false;
- this.syncController_ = new SyncController(options);
- this.segmentMetadataTrack_ = tech.addRemoteTextTrack({
- kind: 'metadata',
- label: 'segment-metadata'
- }, false).track;
- this.decrypter_ = new Decrypter();
- this.sourceUpdater_ = new SourceUpdater(this.mediaSource);
- this.inbandTextTracks_ = {};
- this.timelineChangeController_ = new TimelineChangeController();
- const segmentLoaderSettings = {
- vhs: this.vhs_,
- parse708captions: options.parse708captions,
- useDtsForTimestampOffset: options.useDtsForTimestampOffset,
- captionServices,
- mediaSource: this.mediaSource,
- currentTime: this.tech_.currentTime.bind(this.tech_),
- seekable: () => this.seekable(),
- seeking: () => this.tech_.seeking(),
- duration: () => this.duration(),
- hasPlayed: () => this.hasPlayed_,
- goalBufferLength: () => this.goalBufferLength(),
- bandwidth,
- syncController: this.syncController_,
- decrypter: this.decrypter_,
- sourceType: this.sourceType_,
- inbandTextTracks: this.inbandTextTracks_,
- cacheEncryptionKeys,
- sourceUpdater: this.sourceUpdater_,
- timelineChangeController: this.timelineChangeController_,
- exactManifestTimings: options.exactManifestTimings
- };
- // The source type check not only determines whether a special DASH playlist loader
- // should be used, but also covers the case where the provided src is a vhs-json
- // manifest object (instead of a URL). In the case of vhs-json, the default
- // PlaylistLoader should be used.
- this.mainPlaylistLoader_ = this.sourceType_ === 'dash' ?
- new DashPlaylistLoader(src, this.vhs_, this.requestOptions_) :
- new PlaylistLoader(src, this.vhs_, this.requestOptions_);
- this.setupMainPlaylistLoaderListeners_();
- // setup segment loaders
- // combined audio/video or just video when alternate audio track is selected
- this.mainSegmentLoader_ =
- new SegmentLoader(merge(segmentLoaderSettings, {
- segmentMetadataTrack: this.segmentMetadataTrack_,
- loaderType: 'main'
- }), options);
- // alternate audio track
- this.audioSegmentLoader_ =
- new SegmentLoader(merge(segmentLoaderSettings, {
- loaderType: 'audio'
- }), options);
- this.subtitleSegmentLoader_ =
- new VTTSegmentLoader(merge(segmentLoaderSettings, {
- loaderType: 'vtt',
- featuresNativeTextTracks: this.tech_.featuresNativeTextTracks,
- loadVttJs: () => new Promise((resolve, reject) => {
- function onLoad() {
- tech.off('vttjserror', onError);
- resolve();
- }
- function onError() {
- tech.off('vttjsloaded', onLoad);
- reject();
- }
- tech.one('vttjsloaded', onLoad);
- tech.one('vttjserror', onError);
- // safe to call multiple times, script will be loaded only once:
- tech.addWebVttScript_();
- })
- }), options);
- this.setupSegmentLoaderListeners_();
- if (this.bufferBasedABR) {
- this.mainPlaylistLoader_.one('loadedplaylist', () => this.startABRTimer_());
- this.tech_.on('pause', () => this.stopABRTimer_());
- this.tech_.on('play', () => this.startABRTimer_());
- }
- // Create SegmentLoader stat-getters
- // mediaRequests_
- // mediaRequestsAborted_
- // mediaRequestsTimedout_
- // mediaRequestsErrored_
- // mediaTransferDuration_
- // mediaBytesTransferred_
- // mediaAppends_
- loaderStats.forEach((stat) => {
- this[stat + '_'] = sumLoaderStat.bind(this, stat);
- });
- this.logger_ = logger('pc');
- this.triggeredFmp4Usage = false;
- if (this.tech_.preload() === 'none') {
- this.loadOnPlay_ = () => {
- this.loadOnPlay_ = null;
- this.mainPlaylistLoader_.load();
- };
- this.tech_.one('play', this.loadOnPlay_);
- } else {
- this.mainPlaylistLoader_.load();
- }
- this.timeToLoadedData__ = -1;
- this.mainAppendsToLoadedData__ = -1;
- this.audioAppendsToLoadedData__ = -1;
- const event = this.tech_.preload() === 'none' ? 'play' : 'loadstart';
- // start the first frame timer on loadstart or play (for preload none)
- this.tech_.one(event, () => {
- const timeToLoadedDataStart = Date.now();
- this.tech_.one('loadeddata', () => {
- this.timeToLoadedData__ = Date.now() - timeToLoadedDataStart;
- this.mainAppendsToLoadedData__ = this.mainSegmentLoader_.mediaAppends;
- this.audioAppendsToLoadedData__ = this.audioSegmentLoader_.mediaAppends;
- });
- });
- }
- mainAppendsToLoadedData_() {
- return this.mainAppendsToLoadedData__;
- }
- audioAppendsToLoadedData_() {
- return this.audioAppendsToLoadedData__;
- }
- appendsToLoadedData_() {
- const main = this.mainAppendsToLoadedData_();
- const audio = this.audioAppendsToLoadedData_();
- if (main === -1 || audio === -1) {
- return -1;
- }
- return main + audio;
- }
- timeToLoadedData_() {
- return this.timeToLoadedData__;
- }
- /**
- * Run selectPlaylist and switch to the new playlist if we should
- *
- * @param {string} [reason=abr] a reason for why the ABR check is made
- * @private
- */
- checkABR_(reason = 'abr') {
- const nextPlaylist = this.selectPlaylist();
- if (nextPlaylist && this.shouldSwitchToMedia_(nextPlaylist)) {
- this.switchMedia_(nextPlaylist, reason);
- }
- }
- switchMedia_(playlist, cause, delay) {
- const oldMedia = this.media();
- const oldId = oldMedia && (oldMedia.id || oldMedia.uri);
- const newId = playlist.id || playlist.uri;
- if (oldId && oldId !== newId) {
- this.logger_(`switch media ${oldId} -> ${newId} from ${cause}`);
- this.tech_.trigger({type: 'usage', name: `vhs-rendition-change-${cause}`});
- }
- this.mainPlaylistLoader_.media(playlist, delay);
- }
- /**
- * Start a timer that periodically calls checkABR_
- *
- * @private
- */
- startABRTimer_() {
- this.stopABRTimer_();
- this.abrTimer_ = window.setInterval(() => this.checkABR_(), 250);
- }
- /**
- * Stop the timer that periodically calls checkABR_
- *
- * @private
- */
- stopABRTimer_() {
- // if we're scrubbing, we don't need to pause.
- // This getter will be added to Video.js in version 7.11.
- if (this.tech_.scrubbing && this.tech_.scrubbing()) {
- return;
- }
- window.clearInterval(this.abrTimer_);
- this.abrTimer_ = null;
- }
- /**
- * Get a list of playlists for the currently selected audio playlist
- *
- * @return {Array} the array of audio playlists
- */
- getAudioTrackPlaylists_() {
- const main = this.main();
- const defaultPlaylists = main && main.playlists || [];
- // if we don't have any audio groups then we can only
- // assume that the audio tracks are contained in main
- // playlist array, use that or an empty array.
- if (!main || !main.mediaGroups || !main.mediaGroups.AUDIO) {
- return defaultPlaylists;
- }
- const AUDIO = main.mediaGroups.AUDIO;
- const groupKeys = Object.keys(AUDIO);
- let track;
- // get the current active track
- if (Object.keys(this.mediaTypes_.AUDIO.groups).length) {
- track = this.mediaTypes_.AUDIO.activeTrack();
- // or get the default track from main if mediaTypes_ isn't setup yet
- } else {
- // default group is `main` or just the first group.
- const defaultGroup = AUDIO.main || groupKeys.length && AUDIO[groupKeys[0]];
- for (const label in defaultGroup) {
- if (defaultGroup[label].default) {
- track = {label};
- break;
- }
- }
- }
- // no active track no playlists.
- if (!track) {
- return defaultPlaylists;
- }
- const playlists = [];
- // get all of the playlists that are possible for the
- // active track.
- for (const group in AUDIO) {
- if (AUDIO[group][track.label]) {
- const properties = AUDIO[group][track.label];
- if (properties.playlists && properties.playlists.length) {
- playlists.push.apply(playlists, properties.playlists);
- } else if (properties.uri) {
- playlists.push(properties);
- } else if (main.playlists.length) {
- // if an audio group does not have a uri
- // see if we have main playlists that use it as a group.
- // if we do then add those to the playlists list.
- for (let i = 0; i < main.playlists.length; i++) {
- const playlist = main.playlists[i];
- if (playlist.attributes && playlist.attributes.AUDIO && playlist.attributes.AUDIO === group) {
- playlists.push(playlist);
- }
- }
- }
- }
- }
- if (!playlists.length) {
- return defaultPlaylists;
- }
- return playlists;
- }
- /**
- * Register event handlers on the main playlist loader. A helper
- * function for construction time.
- *
- * @private
- */
- setupMainPlaylistLoaderListeners_() {
- this.mainPlaylistLoader_.on('loadedmetadata', () => {
- const media = this.mainPlaylistLoader_.media();
- const requestTimeout = (media.targetDuration * 1.5) * 1000;
- // If we don't have any more available playlists, we don't want to
- // timeout the request.
- if (isLowestEnabledRendition(this.mainPlaylistLoader_.main, this.mainPlaylistLoader_.media())) {
- this.requestOptions_.timeout = 0;
- } else {
- this.requestOptions_.timeout = requestTimeout;
- }
- // if this isn't a live video and preload permits, start
- // downloading segments
- if (media.endList && this.tech_.preload() !== 'none') {
- this.mainSegmentLoader_.playlist(media, this.requestOptions_);
- this.mainSegmentLoader_.load();
- }
- setupMediaGroups({
- sourceType: this.sourceType_,
- segmentLoaders: {
- AUDIO: this.audioSegmentLoader_,
- SUBTITLES: this.subtitleSegmentLoader_,
- main: this.mainSegmentLoader_
- },
- tech: this.tech_,
- requestOptions: this.requestOptions_,
- mainPlaylistLoader: this.mainPlaylistLoader_,
- vhs: this.vhs_,
- main: this.main(),
- mediaTypes: this.mediaTypes_,
- excludePlaylist: this.excludePlaylist.bind(this)
- });
- this.triggerPresenceUsage_(this.main(), media);
- this.setupFirstPlay();
- if (!this.mediaTypes_.AUDIO.activePlaylistLoader ||
- this.mediaTypes_.AUDIO.activePlaylistLoader.media()) {
- this.trigger('selectedinitialmedia');
- } else {
- // We must wait for the active audio playlist loader to
- // finish setting up before triggering this event so the
- // representations API and EME setup is correct
- this.mediaTypes_.AUDIO.activePlaylistLoader.one('loadedmetadata', () => {
- this.trigger('selectedinitialmedia');
- });
- }
- });
- this.mainPlaylistLoader_.on('loadedplaylist', () => {
- if (this.loadOnPlay_) {
- this.tech_.off('play', this.loadOnPlay_);
- }
- let updatedPlaylist = this.mainPlaylistLoader_.media();
- if (!updatedPlaylist) {
- // exclude any variants that are not supported by the browser before selecting
- // an initial media as the playlist selectors do not consider browser support
- this.excludeUnsupportedVariants_();
- let selectedMedia;
- if (this.enableLowInitialPlaylist) {
- selectedMedia = this.selectInitialPlaylist();
- }
- if (!selectedMedia) {
- selectedMedia = this.selectPlaylist();
- }
- if (!selectedMedia || !this.shouldSwitchToMedia_(selectedMedia)) {
- return;
- }
- this.initialMedia_ = selectedMedia;
- this.switchMedia_(this.initialMedia_, 'initial');
- // Under the standard case where a source URL is provided, loadedplaylist will
- // fire again since the playlist will be requested. In the case of vhs-json
- // (where the manifest object is provided as the source), when the media
- // playlist's `segments` list is already available, a media playlist won't be
- // requested, and loadedplaylist won't fire again, so the playlist handler must be
- // called on its own here.
- const haveJsonSource = this.sourceType_ === 'vhs-json' && this.initialMedia_.segments;
- if (!haveJsonSource) {
- return;
- }
- updatedPlaylist = this.initialMedia_;
- }
- this.handleUpdatedMediaPlaylist(updatedPlaylist);
- });
- this.mainPlaylistLoader_.on('error', () => {
- const error = this.mainPlaylistLoader_.error;
- this.excludePlaylist({ playlistToExclude: error.playlist, error });
- });
- this.mainPlaylistLoader_.on('mediachanging', () => {
- this.mainSegmentLoader_.abort();
- this.mainSegmentLoader_.pause();
- });
- this.mainPlaylistLoader_.on('mediachange', () => {
- const media = this.mainPlaylistLoader_.media();
- const requestTimeout = (media.targetDuration * 1.5) * 1000;
- // If we don't have any more available playlists, we don't want to
- // timeout the request.
- if (isLowestEnabledRendition(this.mainPlaylistLoader_.main, this.mainPlaylistLoader_.media())) {
- this.requestOptions_.timeout = 0;
- } else {
- this.requestOptions_.timeout = requestTimeout;
- }
- this.mainPlaylistLoader_.load();
- // TODO: Create a new event on the PlaylistLoader that signals
- // that the segments have changed in some way and use that to
- // update the SegmentLoader instead of doing it twice here and
- // on `loadedplaylist`
- this.mainSegmentLoader_.playlist(media, this.requestOptions_);
- this.mainSegmentLoader_.load();
- this.tech_.trigger({
- type: 'mediachange',
- bubbles: true
- });
- });
- this.mainPlaylistLoader_.on('playlistunchanged', () => {
- const updatedPlaylist = this.mainPlaylistLoader_.media();
- // ignore unchanged playlists that have already been
- // excluded for not-changing. We likely just have a really slowly updating
- // playlist.
- if (updatedPlaylist.lastExcludeReason_ === 'playlist-unchanged') {
- return;
- }
- const playlistOutdated = this.stuckAtPlaylistEnd_(updatedPlaylist);
- if (playlistOutdated) {
- // Playlist has stopped updating and we're stuck at its end. Try to
- // exclude it and switch to another playlist in the hope that that
- // one is updating (and give the player a chance to re-adjust to the
- // safe live point).
- this.excludePlaylist({
- error: {
- message: 'Playlist no longer updating.',
- reason: 'playlist-unchanged'
- }
- });
- // useful for monitoring QoS
- this.tech_.trigger('playliststuck');
- }
- });
- this.mainPlaylistLoader_.on('renditiondisabled', () => {
- this.tech_.trigger({type: 'usage', name: 'vhs-rendition-disabled'});
- });
- this.mainPlaylistLoader_.on('renditionenabled', () => {
- this.tech_.trigger({type: 'usage', name: 'vhs-rendition-enabled'});
- });
- }
- /**
- * Given an updated media playlist (whether it was loaded for the first time, or
- * refreshed for live playlists), update any relevant properties and state to reflect
- * changes in the media that should be accounted for (e.g., cues and duration).
- *
- * @param {Object} updatedPlaylist the updated media playlist object
- *
- * @private
- */
- handleUpdatedMediaPlaylist(updatedPlaylist) {
- if (this.useCueTags_) {
- this.updateAdCues_(updatedPlaylist);
- }
- // TODO: Create a new event on the PlaylistLoader that signals
- // that the segments have changed in some way and use that to
- // update the SegmentLoader instead of doing it twice here and
- // on `mediachange`
- this.mainSegmentLoader_.playlist(updatedPlaylist, this.requestOptions_);
- this.updateDuration(!updatedPlaylist.endList);
- // If the player isn't paused, ensure that the segment loader is running,
- // as it is possible that it was temporarily stopped while waiting for
- // a playlist (e.g., in case the playlist errored and we re-requested it).
- if (!this.tech_.paused()) {
- this.mainSegmentLoader_.load();
- if (this.audioSegmentLoader_) {
- this.audioSegmentLoader_.load();
- }
- }
- }
- /**
- * A helper function for triggerring presence usage events once per source
- *
- * @private
- */
- triggerPresenceUsage_(main, media) {
- const mediaGroups = main.mediaGroups || {};
- let defaultDemuxed = true;
- const audioGroupKeys = Object.keys(mediaGroups.AUDIO);
- for (const mediaGroup in mediaGroups.AUDIO) {
- for (const label in mediaGroups.AUDIO[mediaGroup]) {
- const properties = mediaGroups.AUDIO[mediaGroup][label];
- if (!properties.uri) {
- defaultDemuxed = false;
- }
- }
- }
- if (defaultDemuxed) {
- this.tech_.trigger({type: 'usage', name: 'vhs-demuxed'});
- }
- if (Object.keys(mediaGroups.SUBTITLES).length) {
- this.tech_.trigger({type: 'usage', name: 'vhs-webvtt'});
- }
- if (Vhs.Playlist.isAes(media)) {
- this.tech_.trigger({type: 'usage', name: 'vhs-aes'});
- }
- if (audioGroupKeys.length &&
- Object.keys(mediaGroups.AUDIO[audioGroupKeys[0]]).length > 1) {
- this.tech_.trigger({type: 'usage', name: 'vhs-alternate-audio'});
- }
- if (this.useCueTags_) {
- this.tech_.trigger({type: 'usage', name: 'vhs-playlist-cue-tags'});
- }
- }
- shouldSwitchToMedia_(nextPlaylist) {
- const currentPlaylist = this.mainPlaylistLoader_.media() ||
- this.mainPlaylistLoader_.pendingMedia_;
- const currentTime = this.tech_.currentTime();
- const bufferLowWaterLine = this.bufferLowWaterLine();
- const bufferHighWaterLine = this.bufferHighWaterLine();
- const buffered = this.tech_.buffered();
- return shouldSwitchToMedia({
- buffered,
- currentTime,
- currentPlaylist,
- nextPlaylist,
- bufferLowWaterLine,
- bufferHighWaterLine,
- duration: this.duration(),
- bufferBasedABR: this.bufferBasedABR,
- log: this.logger_
- });
- }
- /**
- * Register event handlers on the segment loaders. A helper function
- * for construction time.
- *
- * @private
- */
- setupSegmentLoaderListeners_() {
- this.mainSegmentLoader_.on('bandwidthupdate', () => {
- // Whether or not buffer based ABR or another ABR is used, on a bandwidth change it's
- // useful to check to see if a rendition switch should be made.
- this.checkABR_('bandwidthupdate');
- this.tech_.trigger('bandwidthupdate');
- });
- this.mainSegmentLoader_.on('timeout', () => {
- if (this.bufferBasedABR) {
- // If a rendition change is needed, then it would've be done on `bandwidthupdate`.
- // Here the only consideration is that for buffer based ABR there's no guarantee
- // of an immediate switch (since the bandwidth is averaged with a timeout
- // bandwidth value of 1), so force a load on the segment loader to keep it going.
- this.mainSegmentLoader_.load();
- }
- });
- // `progress` events are not reliable enough of a bandwidth measure to trigger buffer
- // based ABR.
- if (!this.bufferBasedABR) {
- this.mainSegmentLoader_.on('progress', () => {
- this.trigger('progress');
- });
- }
- this.mainSegmentLoader_.on('error', () => {
- const error = this.mainSegmentLoader_.error();
- this.excludePlaylist({ playlistToExclude: error.playlist, error });
- });
- this.mainSegmentLoader_.on('appenderror', () => {
- this.error = this.mainSegmentLoader_.error_;
- this.trigger('error');
- });
- this.mainSegmentLoader_.on('syncinfoupdate', () => {
- this.onSyncInfoUpdate_();
- });
- this.mainSegmentLoader_.on('timestampoffset', () => {
- this.tech_.trigger({type: 'usage', name: 'vhs-timestamp-offset'});
- });
- this.audioSegmentLoader_.on('syncinfoupdate', () => {
- this.onSyncInfoUpdate_();
- });
- this.audioSegmentLoader_.on('appenderror', () => {
- this.error = this.audioSegmentLoader_.error_;
- this.trigger('error');
- });
- this.mainSegmentLoader_.on('ended', () => {
- this.logger_('main segment loader ended');
- this.onEndOfStream();
- });
- this.mainSegmentLoader_.on('earlyabort', (event) => {
- // never try to early abort with the new ABR algorithm
- if (this.bufferBasedABR) {
- return;
- }
- this.delegateLoaders_('all', ['abort']);
- this.excludePlaylist({
- error: {
- message: 'Aborted early because there isn\'t enough bandwidth to complete ' +
- 'the request without rebuffering.'
- },
- playlistExclusionDuration: ABORT_EARLY_EXCLUSION_SECONDS
- });
- });
- const updateCodecs = () => {
- if (!this.sourceUpdater_.hasCreatedSourceBuffers()) {
- return this.tryToCreateSourceBuffers_();
- }
- const codecs = this.getCodecsOrExclude_();
- // no codecs means that the playlist was excluded
- if (!codecs) {
- return;
- }
- this.sourceUpdater_.addOrChangeSourceBuffers(codecs);
- };
- this.mainSegmentLoader_.on('trackinfo', updateCodecs);
- this.audioSegmentLoader_.on('trackinfo', updateCodecs);
- this.mainSegmentLoader_.on('fmp4', () => {
- if (!this.triggeredFmp4Usage) {
- this.tech_.trigger({type: 'usage', name: 'vhs-fmp4'});
- this.triggeredFmp4Usage = true;
- }
- });
- this.audioSegmentLoader_.on('fmp4', () => {
- if (!this.triggeredFmp4Usage) {
- this.tech_.trigger({type: 'usage', name: 'vhs-fmp4'});
- this.triggeredFmp4Usage = true;
- }
- });
- this.audioSegmentLoader_.on('ended', () => {
- this.logger_('audioSegmentLoader ended');
- this.onEndOfStream();
- });
- }
- mediaSecondsLoaded_() {
- return Math.max(this.audioSegmentLoader_.mediaSecondsLoaded +
- this.mainSegmentLoader_.mediaSecondsLoaded);
- }
- /**
- * Call load on our SegmentLoaders
- */
- load() {
- this.mainSegmentLoader_.load();
- if (this.mediaTypes_.AUDIO.activePlaylistLoader) {
- this.audioSegmentLoader_.load();
- }
- if (this.mediaTypes_.SUBTITLES.activePlaylistLoader) {
- this.subtitleSegmentLoader_.load();
- }
- }
- /**
- * Re-tune playback quality level for the current player
- * conditions. This method will perform destructive actions like removing
- * already buffered content in order to readjust the currently active
- * playlist quickly. This is good for manual quality changes
- *
- * @private
- */
- fastQualityChange_(media = this.selectPlaylist()) {
- if (media === this.mainPlaylistLoader_.media()) {
- this.logger_('skipping fastQualityChange because new media is same as old');
- return;
- }
- this.switchMedia_(media, 'fast-quality');
- // Delete all buffered data to allow an immediate quality switch, then seek to give
- // the browser a kick to remove any cached frames from the previous rendtion (.04 seconds
- // ahead is roughly the minimum that will accomplish this across a variety of content
- // in IE and Edge, but seeking in place is sufficient on all other browsers)
- // Edge/IE bug: https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/14600375/
- // Chrome bug: https://bugs.chromium.org/p/chromium/issues/detail?id=651904
- this.mainSegmentLoader_.resetEverything(() => {
- // Since this is not a typical seek, we avoid the seekTo method which can cause segments
- // from the previously enabled rendition to load before the new playlist has finished loading
- if (videojs.browser.IE_VERSION || videojs.browser.IS_EDGE) {
- this.tech_.setCurrentTime(this.tech_.currentTime() + 0.04);
- } else {
- this.tech_.setCurrentTime(this.tech_.currentTime());
- }
- });
- // don't need to reset audio as it is reset when media changes
- }
- /**
- * Begin playback.
- */
- play() {
- if (this.setupFirstPlay()) {
- return;
- }
- if (this.tech_.ended()) {
- this.tech_.setCurrentTime(0);
- }
- if (this.hasPlayed_) {
- this.load();
- }
- const seekable = this.tech_.seekable();
- // if the viewer has paused and we fell out of the live window,
- // seek forward to the live point
- if (this.tech_.duration() === Infinity) {
- if (this.tech_.currentTime() < seekable.start(0)) {
- return this.tech_.setCurrentTime(seekable.end(seekable.length - 1));
- }
- }
- }
- /**
- * Seek to the latest media position if this is a live video and the
- * player and video are loaded and initialized.
- */
- setupFirstPlay() {
- const media = this.mainPlaylistLoader_.media();
- // Check that everything is ready to begin buffering for the first call to play
- // If 1) there is no active media
- // 2) the player is paused
- // 3) the first play has already been setup
- // then exit early
- if (!media || this.tech_.paused() || this.hasPlayed_) {
- return false;
- }
- // when the video is a live stream
- if (!media.endList) {
- const seekable = this.seekable();
- if (!seekable.length) {
- // without a seekable range, the player cannot seek to begin buffering at the live
- // point
- return false;
- }
- if (videojs.browser.IE_VERSION &&
- this.tech_.readyState() === 0) {
- // IE11 throws an InvalidStateError if you try to set currentTime while the
- // readyState is 0, so it must be delayed until the tech fires loadedmetadata.
- this.tech_.one('loadedmetadata', () => {
- this.trigger('firstplay');
- this.tech_.setCurrentTime(seekable.end(0));
- this.hasPlayed_ = true;
- });
- return false;
- }
- // trigger firstplay to inform the source handler to ignore the next seek event
- this.trigger('firstplay');
- // seek to the live point
- this.tech_.setCurrentTime(seekable.end(0));
- }
- this.hasPlayed_ = true;
- // we can begin loading now that everything is ready
- this.load();
- return true;
- }
- /**
- * handle the sourceopen event on the MediaSource
- *
- * @private
- */
- handleSourceOpen_() {
- // 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.tryToCreateSourceBuffers_();
- // if autoplay is enabled, begin playback. This is duplicative of
- // code in video.js but is required because play() must be invoked
- // *after* the media source has opened.
- if (this.tech_.autoplay()) {
- const playPromise = this.tech_.play();
- // Catch/silence error when a pause interrupts a play request
- // on browsers which return a promise
- if (typeof playPromise !== 'undefined' && typeof playPromise.then === 'function') {
- playPromise.then(null, (e) => {});
- }
- }
- this.trigger('sourceopen');
- }
- /**
- * handle the sourceended event on the MediaSource
- *
- * @private
- */
- handleSourceEnded_() {
- if (!this.inbandTextTracks_.metadataTrack_) {
- return;
- }
- const cues = this.inbandTextTracks_.metadataTrack_.cues;
- if (!cues || !cues.length) {
- return;
- }
- const duration = this.duration();
- cues[cues.length - 1].endTime = isNaN(duration) || Math.abs(duration) === Infinity ?
- Number.MAX_VALUE : duration;
- }
- /**
- * handle the durationchange event on the MediaSource
- *
- * @private
- */
- handleDurationChange_() {
- this.tech_.trigger('durationchange');
- }
- /**
- * Calls endOfStream on the media source when all active stream types have called
- * endOfStream
- *
- * @param {string} streamType
- * Stream type of the segment loader that called endOfStream
- * @private
- */
- onEndOfStream() {
- let isEndOfStream = this.mainSegmentLoader_.ended_;
- if (this.mediaTypes_.AUDIO.activePlaylistLoader) {
- const mainMediaInfo = this.mainSegmentLoader_.getCurrentMediaInfo_();
- // if the audio playlist loader exists, then alternate audio is active
- if (!mainMediaInfo || mainMediaInfo.hasVideo) {
- // if we do not know if the main segment loader contains video yet or if we
- // definitively know the main segment loader contains video, then we need to wait
- // for both main and audio segment loaders to call endOfStream
- isEndOfStream = isEndOfStream && this.audioSegmentLoader_.ended_;
- } else {
- // otherwise just rely on the audio loader
- isEndOfStream = this.audioSegmentLoader_.ended_;
- }
- }
- if (!isEndOfStream) {
- return;
- }
- this.stopABRTimer_();
- this.sourceUpdater_.endOfStream();
- }
- /**
- * Check if a playlist has stopped being updated
- *
- * @param {Object} playlist the media playlist object
- * @return {boolean} whether the playlist has stopped being updated or not
- */
- stuckAtPlaylistEnd_(playlist) {
- const seekable = this.seekable();
- if (!seekable.length) {
- // playlist doesn't have enough information to determine whether we are stuck
- return false;
- }
- const expired =
- this.syncController_.getExpiredTime(playlist, this.duration());
- if (expired === null) {
- return false;
- }
- // does not use the safe live end to calculate playlist end, since we
- // don't want to say we are stuck while there is still content
- const absolutePlaylistEnd = Vhs.Playlist.playlistEnd(playlist, expired);
- const currentTime = this.tech_.currentTime();
- const buffered = this.tech_.buffered();
- if (!buffered.length) {
- // return true if the playhead reached the absolute end of the playlist
- return absolutePlaylistEnd - currentTime <= Ranges.SAFE_TIME_DELTA;
- }
- const bufferedEnd = buffered.end(buffered.length - 1);
- // return true if there is too little buffer left and buffer has reached absolute
- // end of playlist
- return bufferedEnd - currentTime <= Ranges.SAFE_TIME_DELTA &&
- absolutePlaylistEnd - bufferedEnd <= Ranges.SAFE_TIME_DELTA;
- }
- /**
- * Exclude a playlist for a set amount of time, making it unavailable for selection by
- * the rendition selection algorithm, then force a new playlist (rendition) selection.
- *
- * @param {Object=} playlistToExclude
- * the playlist to exclude, defaults to the currently selected playlist
- * @param {Object=} error
- * an optional error
- * @param {number=} playlistExclusionDuration
- * an optional number of seconds to exclude the playlist
- */
- excludePlaylist({
- playlistToExclude = this.mainPlaylistLoader_.media(),
- error = {},
- playlistExclusionDuration
- }) {
- // If the `error` was generated by the playlist loader, it will contain
- // the playlist we were trying to load (but failed) and that should be
- // excluded instead of the currently selected playlist which is likely
- // out-of-date in this scenario
- playlistToExclude = playlistToExclude || this.mainPlaylistLoader_.media();
- playlistExclusionDuration = playlistExclusionDuration ||
- error.playlistExclusionDuration ||
- this.playlistExclusionDuration;
- // If there is no current playlist, then an error occurred while we were
- // trying to load the main OR while we were disposing of the tech
- if (!playlistToExclude) {
- this.error = error;
- if (this.mediaSource.readyState !== 'open') {
- this.trigger('error');
- } else {
- this.sourceUpdater_.endOfStream('network');
- }
- return;
- }
- playlistToExclude.playlistErrors_++;
- const playlists = this.mainPlaylistLoader_.main.playlists;
- const enabledPlaylists = playlists.filter(isEnabled);
- const isFinalRendition = enabledPlaylists.length === 1 && enabledPlaylists[0] === playlistToExclude;
- // Don't exclude the only playlist unless it was excluded
- // forever
- if (playlists.length === 1 && playlistExclusionDuration !== Infinity) {
- videojs.log.warn(`Problem encountered with playlist ${playlistToExclude.id}. ` +
- 'Trying again since it is the only playlist.');
- this.tech_.trigger('retryplaylist');
- // if this is a final rendition, we should delay
- return this.mainPlaylistLoader_.load(isFinalRendition);
- }
- if (isFinalRendition) {
- // Since we're on the final non-excluded playlist, and we're about to exclude
- // it, instead of erring the player or retrying this playlist, clear out the current
- // exclusion list. This allows other playlists to be attempted in case any have been
- // fixed.
- let reincluded = false;
- playlists.forEach((playlist) => {
- // skip current playlist which is about to be excluded
- if (playlist === playlistToExclude) {
- return;
- }
- const excludeUntil = playlist.excludeUntil;
- // a playlist cannot be reincluded if it wasn't excluded to begin with.
- if (typeof excludeUntil !== 'undefined' && excludeUntil !== Infinity) {
- reincluded = true;
- delete playlist.excludeUntil;
- }
- });
- if (reincluded) {
- videojs.log.warn('Removing other playlists from the exclusion list because the last ' +
- 'rendition is about to be excluded.');
- // Technically we are retrying a playlist, in that we are simply retrying a previous
- // playlist. This is needed for users relying on the retryplaylist event to catch a
- // case where the player might be stuck and looping through "dead" playlists.
- this.tech_.trigger('retryplaylist');
- }
- }
- // Exclude this playlist
- let excludeUntil;
- if (playlistToExclude.playlistErrors_ > this.maxPlaylistRetries) {
- excludeUntil = Infinity;
- } else {
- excludeUntil = Date.now() + (playlistExclusionDuration * 1000);
- }
- playlistToExclude.excludeUntil = excludeUntil;
- if (error.reason) {
- playlistToExclude.lastExcludeReason_ = error.reason;
- }
- this.tech_.trigger('excludeplaylist');
- this.tech_.trigger({type: 'usage', name: 'vhs-rendition-excluded'});
- // TODO: only load a new playlist if we're excluding the current playlist
- // If this function was called with a playlist that's not the current active playlist
- // (e.g., media().id !== playlistToExclude.id),
- // then a new playlist should not be selected and loaded, as there's nothing wrong with the current playlist.
- const nextPlaylist = this.selectPlaylist();
- if (!nextPlaylist) {
- this.error = 'Playback cannot continue. No available working or supported playlists.';
- this.trigger('error');
- return;
- }
- const logFn = error.internal ? this.logger_ : videojs.log.warn;
- const errorMessage = error.message ? (' ' + error.message) : '';
- logFn(`${(error.internal ? 'Internal problem' : 'Problem')} encountered with playlist ${playlistToExclude.id}.` +
- `${errorMessage} Switching to playlist ${nextPlaylist.id}.`);
- // if audio group changed reset audio loaders
- if (nextPlaylist.attributes.AUDIO !== playlistToExclude.attributes.AUDIO) {
- this.delegateLoaders_('audio', ['abort', 'pause']);
- }
- // if subtitle group changed reset subtitle loaders
- if (nextPlaylist.attributes.SUBTITLES !== playlistToExclude.attributes.SUBTITLES) {
- this.delegateLoaders_('subtitle', ['abort', 'pause']);
- }
- this.delegateLoaders_('main', ['abort', 'pause']);
- const delayDuration = (nextPlaylist.targetDuration / 2) * 1000 || 5 * 1000;
- const shouldDelay = typeof nextPlaylist.lastRequest === 'number' &&
- (Date.now() - nextPlaylist.lastRequest) <= delayDuration;
- // delay if it's a final rendition or if the last refresh is sooner than half targetDuration
- return this.switchMedia_(nextPlaylist, 'exclude', isFinalRendition || shouldDelay);
- }
- /**
- * Pause all segment/playlist loaders
- */
- pauseLoading() {
- this.delegateLoaders_('all', ['abort', 'pause']);
- this.stopABRTimer_();
- }
- /**
- * Call a set of functions in order on playlist loaders, segment loaders,
- * or both types of loaders.
- *
- * @param {string} filter
- * Filter loaders that should call fnNames using a string. Can be:
- * * all - run on all loaders
- * * audio - run on all audio loaders
- * * subtitle - run on all subtitle loaders
- * * main - run on the main loaders
- *
- * @param {Array|string} fnNames
- * A string or array of function names to call.
- */
- delegateLoaders_(filter, fnNames) {
- const loaders = [];
- const dontFilterPlaylist = filter === 'all';
- if (dontFilterPlaylist || filter === 'main') {
- loaders.push(this.mainPlaylistLoader_);
- }
- const mediaTypes = [];
- if (dontFilterPlaylist || filter === 'audio') {
- mediaTypes.push('AUDIO');
- }
- if (dontFilterPlaylist || filter === 'subtitle') {
- mediaTypes.push('CLOSED-CAPTIONS');
- mediaTypes.push('SUBTITLES');
- }
- mediaTypes.forEach((mediaType) => {
- const loader = this.mediaTypes_[mediaType] &&
- this.mediaTypes_[mediaType].activePlaylistLoader;
- if (loader) {
- loaders.push(loader);
- }
- });
- ['main', 'audio', 'subtitle'].forEach((name) => {
- const loader = this[`${name}SegmentLoader_`];
- if (loader && (filter === name || filter === 'all')) {
- loaders.push(loader);
- }
- });
- loaders.forEach((loader) => fnNames.forEach((fnName) => {
- if (typeof loader[fnName] === 'function') {
- loader[fnName]();
- }
- }));
- }
- /**
- * set the current time on all segment loaders
- *
- * @param {TimeRange} currentTime the current time to set
- * @return {TimeRange} the current time
- */
- setCurrentTime(currentTime) {
- const buffered = Ranges.findRange(this.tech_.buffered(), currentTime);
- if (!(this.mainPlaylistLoader_ && this.mainPlaylistLoader_.media())) {
- // return immediately if the metadata is not ready yet
- return 0;
- }
- // it's clearly an edge-case but don't thrown an error if asked to
- // seek within an empty playlist
- if (!this.mainPlaylistLoader_.media().segments) {
- return 0;
- }
- // if the seek location is already buffered, continue buffering as usual
- if (buffered && buffered.length) {
- return currentTime;
- }
- // cancel outstanding requests so we begin buffering at the new
- // location
- this.mainSegmentLoader_.resetEverything();
- this.mainSegmentLoader_.abort();
- if (this.mediaTypes_.AUDIO.activePlaylistLoader) {
- this.audioSegmentLoader_.resetEverything();
- this.audioSegmentLoader_.abort();
- }
- if (this.mediaTypes_.SUBTITLES.activePlaylistLoader) {
- this.subtitleSegmentLoader_.resetEverything();
- this.subtitleSegmentLoader_.abort();
- }
- // start segment loader loading in case they are paused
- this.load();
- }
- /**
- * get the current duration
- *
- * @return {TimeRange} the duration
- */
- duration() {
- if (!this.mainPlaylistLoader_) {
- return 0;
- }
- const media = this.mainPlaylistLoader_.media();
- if (!media) {
- // no playlists loaded yet, so can't determine a duration
- return 0;
- }
- // Don't rely on the media source for duration in the case of a live playlist since
- // setting the native MediaSource's duration to infinity ends up with consequences to
- // seekable behavior. See https://github.com/w3c/media-source/issues/5 for details.
- //
- // This is resolved in the spec by https://github.com/w3c/media-source/pull/92,
- // however, few browsers have support for setLiveSeekableRange()
- // https://developer.mozilla.org/en-US/docs/Web/API/MediaSource/setLiveSeekableRange
- //
- // Until a time when the duration of the media source can be set to infinity, and a
- // seekable range specified across browsers, just return Infinity.
- if (!media.endList) {
- return Infinity;
- }
- // Since this is a VOD video, it is safe to rely on the media source's duration (if
- // available). If it's not available, fall back to a playlist-calculated estimate.
- if (this.mediaSource) {
- return this.mediaSource.duration;
- }
- return Vhs.Playlist.duration(media);
- }
- /**
- * check the seekable range
- *
- * @return {TimeRange} the seekable range
- */
- seekable() {
- return this.seekable_;
- }
- onSyncInfoUpdate_() {
- let audioSeekable;
- // TODO check for creation of both source buffers before updating seekable
- //
- // A fix was made to this function where a check for
- // this.sourceUpdater_.hasCreatedSourceBuffers
- // was added to ensure that both source buffers were created before seekable was
- // updated. However, it originally had a bug where it was checking for a true and
- // returning early instead of checking for false. Setting it to check for false to
- // return early though created other issues. A call to play() would check for seekable
- // end without verifying that a seekable range was present. In addition, even checking
- // for that didn't solve some issues, as handleFirstPlay is sometimes worked around
- // due to a media update calling load on the segment loaders, skipping a seek to live,
- // thereby starting live streams at the beginning of the stream rather than at the end.
- //
- // This conditional should be fixed to wait for the creation of two source buffers at
- // the same time as the other sections of code are fixed to properly seek to live and
- // not throw an error due to checking for a seekable end when no seekable range exists.
- //
- // For now, fall back to the older behavior, with the understanding that the seekable
- // range may not be completely correct, leading to a suboptimal initial live point.
- if (!this.mainPlaylistLoader_) {
- return;
- }
- let media = this.mainPlaylistLoader_.media();
- if (!media) {
- return;
- }
- let expired = this.syncController_.getExpiredTime(media, this.duration());
- if (expired === null) {
- // not enough information to update seekable
- return;
- }
- const main = this.mainPlaylistLoader_.main;
- const mainSeekable = Vhs.Playlist.seekable(
- media,
- expired,
- Vhs.Playlist.liveEdgeDelay(main, media)
- );
- if (mainSeekable.length === 0) {
- return;
- }
- if (this.mediaTypes_.AUDIO.activePlaylistLoader) {
- media = this.mediaTypes_.AUDIO.activePlaylistLoader.media();
- expired = this.syncController_.getExpiredTime(media, this.duration());
- if (expired === null) {
- return;
- }
- audioSeekable = Vhs.Playlist.seekable(
- media,
- expired,
- Vhs.Playlist.liveEdgeDelay(main, media)
- );
- if (audioSeekable.length === 0) {
- return;
- }
- }
- let oldEnd;
- let oldStart;
- if (this.seekable_ && this.seekable_.length) {
- oldEnd = this.seekable_.end(0);
- oldStart = this.seekable_.start(0);
- }
- if (!audioSeekable) {
- // seekable has been calculated based on buffering video data so it
- // can be returned directly
- this.seekable_ = mainSeekable;
- } else if (audioSeekable.start(0) > mainSeekable.end(0) ||
- mainSeekable.start(0) > audioSeekable.end(0)) {
- // seekables are pretty far off, rely on main
- this.seekable_ = mainSeekable;
- } else {
- this.seekable_ = createTimeRanges([[
- (audioSeekable.start(0) > mainSeekable.start(0)) ? audioSeekable.start(0) :
- mainSeekable.start(0),
- (audioSeekable.end(0) < mainSeekable.end(0)) ? audioSeekable.end(0) :
- mainSeekable.end(0)
- ]]);
- }
- // seekable is the same as last time
- if (this.seekable_ && this.seekable_.length) {
- if (this.seekable_.end(0) === oldEnd && this.seekable_.start(0) === oldStart) {
- return;
- }
- }
- this.logger_(`seekable updated [${Ranges.printableRange(this.seekable_)}]`);
- this.tech_.trigger('seekablechanged');
- }
- /**
- * Update the player duration
- */
- updateDuration(isLive) {
- if (this.updateDuration_) {
- this.mediaSource.removeEventListener('sourceopen', this.updateDuration_);
- this.updateDuration_ = null;
- }
- if (this.mediaSource.readyState !== 'open') {
- this.updateDuration_ = this.updateDuration.bind(this, isLive);
- this.mediaSource.addEventListener('sourceopen', this.updateDuration_);
- return;
- }
- if (isLive) {
- const seekable = this.seekable();
- if (!seekable.length) {
- return;
- }
- // Even in the case of a live playlist, the native MediaSource's duration should not
- // be set to Infinity (even though this would be expected for a live playlist), since
- // setting the native MediaSource's duration to infinity ends up with consequences to
- // seekable behavior. See https://github.com/w3c/media-source/issues/5 for details.
- //
- // This is resolved in the spec by https://github.com/w3c/media-source/pull/92,
- // however, few browsers have support for setLiveSeekableRange()
- // https://developer.mozilla.org/en-US/docs/Web/API/MediaSource/setLiveSeekableRange
- //
- // Until a time when the duration of the media source can be set to infinity, and a
- // seekable range specified across browsers, the duration should be greater than or
- // equal to the last possible seekable value.
- // MediaSource duration starts as NaN
- // It is possible (and probable) that this case will never be reached for many
- // sources, since the MediaSource reports duration as the highest value without
- // accounting for timestamp offset. For example, if the timestamp offset is -100 and
- // we buffered times 0 to 100 with real times of 100 to 200, even though current
- // time will be between 0 and 100, the native media source may report the duration
- // as 200. However, since we report duration separate from the media source (as
- // Infinity), and as long as the native media source duration value is greater than
- // our reported seekable range, seeks will work as expected. The large number as
- // duration for live is actually a strategy used by some players to work around the
- // issue of live seekable ranges cited above.
- if (isNaN(this.mediaSource.duration) || this.mediaSource.duration < seekable.end(seekable.length - 1)) {
- this.sourceUpdater_.setDuration(seekable.end(seekable.length - 1));
- }
- return;
- }
- const buffered = this.tech_.buffered();
- let duration = Vhs.Playlist.duration(this.mainPlaylistLoader_.media());
- if (buffered.length > 0) {
- duration = Math.max(duration, buffered.end(buffered.length - 1));
- }
- if (this.mediaSource.duration !== duration) {
- this.sourceUpdater_.setDuration(duration);
- }
- }
- /**
- * dispose of the PlaylistController and everything
- * that it controls
- */
- dispose() {
- this.trigger('dispose');
- this.decrypter_.terminate();
- this.mainPlaylistLoader_.dispose();
- this.mainSegmentLoader_.dispose();
- if (this.loadOnPlay_) {
- this.tech_.off('play', this.loadOnPlay_);
- }
- ['AUDIO', 'SUBTITLES'].forEach((type) => {
- const groups = this.mediaTypes_[type].groups;
- for (const id in groups) {
- groups[id].forEach((group) => {
- if (group.playlistLoader) {
- group.playlistLoader.dispose();
- }
- });
- }
- });
- this.audioSegmentLoader_.dispose();
- this.subtitleSegmentLoader_.dispose();
- this.sourceUpdater_.dispose();
- this.timelineChangeController_.dispose();
- this.stopABRTimer_();
- if (this.updateDuration_) {
- this.mediaSource.removeEventListener('sourceopen', this.updateDuration_);
- }
- this.mediaSource.removeEventListener('durationchange', this.handleDurationChange_);
- // load the media source into the player
- this.mediaSource.removeEventListener('sourceopen', this.handleSourceOpen_);
- this.mediaSource.removeEventListener('sourceended', this.handleSourceEnded_);
- this.off();
- }
- /**
- * return the main playlist object if we have one
- *
- * @return {Object} the main playlist object that we parsed
- */
- main() {
- return this.mainPlaylistLoader_.main;
- }
- /**
- * return the currently selected playlist
- *
- * @return {Object} the currently selected playlist object that we parsed
- */
- media() {
- // playlist loader will not return media if it has not been fully loaded
- return this.mainPlaylistLoader_.media() || this.initialMedia_;
- }
- areMediaTypesKnown_() {
- const usingAudioLoader = !!this.mediaTypes_.AUDIO.activePlaylistLoader;
- const hasMainMediaInfo = !!this.mainSegmentLoader_.getCurrentMediaInfo_();
- // if we are not using an audio loader, then we have audio media info
- // otherwise check on the segment loader.
- const hasAudioMediaInfo = !usingAudioLoader ? true : !!this.audioSegmentLoader_.getCurrentMediaInfo_();
- // one or both loaders has not loaded sufficently to get codecs
- if (!hasMainMediaInfo || !hasAudioMediaInfo) {
- return false;
- }
- return true;
- }
- getCodecsOrExclude_() {
- const media = {
- main: this.mainSegmentLoader_.getCurrentMediaInfo_() || {},
- audio: this.audioSegmentLoader_.getCurrentMediaInfo_() || {}
- };
- const playlist = this.mainSegmentLoader_.getPendingSegmentPlaylist() || this.media();
- // set "main" media equal to video
- media.video = media.main;
- const playlistCodecs = codecsForPlaylist(this.main(), playlist);
- const codecs = {};
- const usingAudioLoader = !!this.mediaTypes_.AUDIO.activePlaylistLoader;
- if (media.main.hasVideo) {
- codecs.video = playlistCodecs.video || media.main.videoCodec || DEFAULT_VIDEO_CODEC;
- }
- if (media.main.isMuxed) {
- codecs.video += `,${playlistCodecs.audio || media.main.audioCodec || DEFAULT_AUDIO_CODEC}`;
- }
- if ((media.main.hasAudio && !media.main.isMuxed) || media.audio.hasAudio || usingAudioLoader) {
- codecs.audio = playlistCodecs.audio || media.main.audioCodec || media.audio.audioCodec || DEFAULT_AUDIO_CODEC;
- // set audio isFmp4 so we use the correct "supports" function below
- media.audio.isFmp4 = (media.main.hasAudio && !media.main.isMuxed) ? media.main.isFmp4 : media.audio.isFmp4;
- }
- // no codecs, no playback.
- if (!codecs.audio && !codecs.video) {
- this.excludePlaylist({
- playlistToExclude: playlist,
- error: { message: 'Could not determine codecs for playlist.' },
- playlistExclusionDuration: Infinity
- });
- return;
- }
- // fmp4 relies on browser support, while ts relies on muxer support
- const supportFunction = (isFmp4, codec) => (isFmp4 ? browserSupportsCodec(codec) : muxerSupportsCodec(codec));
- const unsupportedCodecs = {};
- let unsupportedAudio;
- ['video', 'audio'].forEach(function(type) {
- if (codecs.hasOwnProperty(type) && !supportFunction(media[type].isFmp4, codecs[type])) {
- const supporter = media[type].isFmp4 ? 'browser' : 'muxer';
- unsupportedCodecs[supporter] = unsupportedCodecs[supporter] || [];
- unsupportedCodecs[supporter].push(codecs[type]);
- if (type === 'audio') {
- unsupportedAudio = supporter;
- }
- }
- });
- if (usingAudioLoader && unsupportedAudio && playlist.attributes.AUDIO) {
- const audioGroup = playlist.attributes.AUDIO;
- this.main().playlists.forEach(variant => {
- const variantAudioGroup = variant.attributes && variant.attributes.AUDIO;
- if (variantAudioGroup === audioGroup && variant !== playlist) {
- variant.excludeUntil = Infinity;
- }
- });
- this.logger_(`excluding audio group ${audioGroup} as ${unsupportedAudio} does not support codec(s): "${codecs.audio}"`);
- }
- // if we have any unsupported codecs exclude this playlist.
- if (Object.keys(unsupportedCodecs).length) {
- const message = Object.keys(unsupportedCodecs).reduce((acc, supporter) => {
- if (acc) {
- acc += ', ';
- }
- acc += `${supporter} does not support codec(s): "${unsupportedCodecs[supporter].join(',')}"`;
- return acc;
- }, '') + '.';
- this.excludePlaylist({
- playlistToExclude: playlist,
- error: {
- internal: true,
- message
- },
- playlistExclusionDuration: Infinity
- });
- return;
- }
- // check if codec switching is happening
- if (
- this.sourceUpdater_.hasCreatedSourceBuffers() &&
- !this.sourceUpdater_.canChangeType()
- ) {
- const switchMessages = [];
- ['video', 'audio'].forEach((type) => {
- const newCodec = (parseCodecs(this.sourceUpdater_.codecs[type] || '')[0] || {}).type;
- const oldCodec = (parseCodecs(codecs[type] || '')[0] || {}).type;
- if (newCodec && oldCodec && newCodec.toLowerCase() !== oldCodec.toLowerCase()) {
- switchMessages.push(`"${this.sourceUpdater_.codecs[type]}" -> "${codecs[type]}"`);
- }
- });
- if (switchMessages.length) {
- this.excludePlaylist({
- playlistToExclude: playlist,
- error: {
- message: `Codec switching not supported: ${switchMessages.join(', ')}.`,
- internal: true
- },
- playlistExclusionDuration: Infinity
- });
- return;
- }
- }
- // TODO: when using the muxer shouldn't we just return
- // the codecs that the muxer outputs?
- return codecs;
- }
- /**
- * Create source buffers and exlude any incompatible renditions.
- *
- * @private
- */
- tryToCreateSourceBuffers_() {
- // media source is not ready yet or sourceBuffers are already
- // created.
- if (
- this.mediaSource.readyState !== 'open' ||
- this.sourceUpdater_.hasCreatedSourceBuffers()
- ) {
- return;
- }
- if (!this.areMediaTypesKnown_()) {
- return;
- }
- const codecs = this.getCodecsOrExclude_();
- // no codecs means that the playlist was excluded
- if (!codecs) {
- return;
- }
- this.sourceUpdater_.createSourceBuffers(codecs);
- const codecString = [codecs.video, codecs.audio].filter(Boolean).join(',');
- this.excludeIncompatibleVariants_(codecString);
- }
- /**
- * Excludes playlists with codecs that are unsupported by the muxer and browser.
- */
- excludeUnsupportedVariants_() {
- const playlists = this.main().playlists;
- const ids = [];
- // TODO: why don't we have a property to loop through all
- // playlist? Why did we ever mix indexes and keys?
- Object.keys(playlists).forEach(key => {
- const variant = playlists[key];
- // check if we already processed this playlist.
- if (ids.indexOf(variant.id) !== -1) {
- return;
- }
- ids.push(variant.id);
- const codecs = codecsForPlaylist(this.main, variant);
- const unsupported = [];
- if (codecs.audio && !muxerSupportsCodec(codecs.audio) && !browserSupportsCodec(codecs.audio)) {
- unsupported.push(`audio codec ${codecs.audio}`);
- }
- if (codecs.video && !muxerSupportsCodec(codecs.video) && !browserSupportsCodec(codecs.video)) {
- unsupported.push(`video codec ${codecs.video}`);
- }
- if (codecs.text && codecs.text === 'stpp.ttml.im1t') {
- unsupported.push(`text codec ${codecs.text}`);
- }
- if (unsupported.length) {
- variant.excludeUntil = Infinity;
- this.logger_(`excluding ${variant.id} for unsupported: ${unsupported.join(', ')}`);
- }
- });
- }
- /**
- * Exclude 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
- * stall waiting for video data if you switched from a variant with
- * video and audio to an audio-only one.
- *
- * @param {Object} media a media playlist compatible with the current
- * set of SourceBuffers. Variants in the current main playlist that
- * do not appear to have compatible codec or stream configurations
- * will be excluded from the default playlist selection algorithm
- * indefinitely.
- * @private
- */
- excludeIncompatibleVariants_(codecString) {
- const ids = [];
- const playlists = this.main().playlists;
- const codecs = unwrapCodecList(parseCodecs(codecString));
- const codecCount_ = codecCount(codecs);
- const videoDetails = codecs.video && parseCodecs(codecs.video)[0] || null;
- const audioDetails = codecs.audio && parseCodecs(codecs.audio)[0] || null;
- Object.keys(playlists).forEach((key) => {
- const variant = playlists[key];
- // check if we already processed this playlist.
- // or it if it is already excluded forever.
- if (ids.indexOf(variant.id) !== -1 || variant.excludeUntil === Infinity) {
- return;
- }
- ids.push(variant.id);
- const exclusionReasons = [];
- // get codecs from the playlist for this variant
- const variantCodecs = codecsForPlaylist(this.mainPlaylistLoader_.main, variant);
- const variantCodecCount = codecCount(variantCodecs);
- // if no codecs are listed, we cannot determine that this
- // variant is incompatible. Wait for mux.js to probe
- if (!variantCodecs.audio && !variantCodecs.video) {
- return;
- }
- // TODO: we can support this by removing the
- // old media source and creating a new one, but it will take some work.
- // The number of streams cannot change
- if (variantCodecCount !== codecCount_) {
- exclusionReasons.push(`codec count "${variantCodecCount}" !== "${codecCount_}"`);
- }
- // only exclude playlists by codec change, if codecs cannot switch
- // during playback.
- if (!this.sourceUpdater_.canChangeType()) {
- const variantVideoDetails = variantCodecs.video && parseCodecs(variantCodecs.video)[0] || null;
- const variantAudioDetails = variantCodecs.audio && parseCodecs(variantCodecs.audio)[0] || null;
- // the video codec cannot change
- if (variantVideoDetails && videoDetails && variantVideoDetails.type.toLowerCase() !== videoDetails.type.toLowerCase()) {
- exclusionReasons.push(`video codec "${variantVideoDetails.type}" !== "${videoDetails.type}"`);
- }
- // the audio codec cannot change
- if (variantAudioDetails && audioDetails && variantAudioDetails.type.toLowerCase() !== audioDetails.type.toLowerCase()) {
- exclusionReasons.push(`audio codec "${variantAudioDetails.type}" !== "${audioDetails.type}"`);
- }
- }
- if (exclusionReasons.length) {
- variant.excludeUntil = Infinity;
- this.logger_(`excluding ${variant.id}: ${exclusionReasons.join(' && ')}`);
- }
- });
- }
- updateAdCues_(media) {
- let offset = 0;
- const seekable = this.seekable();
- if (seekable.length) {
- offset = seekable.start(0);
- }
- updateAdCues(media, this.cueTagsTrack_, offset);
- }
- /**
- * Calculates the desired forward buffer length based on current time
- *
- * @return {number} Desired forward buffer length in seconds
- */
- goalBufferLength() {
- const currentTime = this.tech_.currentTime();
- const initial = Config.GOAL_BUFFER_LENGTH;
- const rate = Config.GOAL_BUFFER_LENGTH_RATE;
- const max = Math.max(initial, Config.MAX_GOAL_BUFFER_LENGTH);
- return Math.min(initial + currentTime * rate, max);
- }
- /**
- * Calculates the desired buffer low water line based on current time
- *
- * @return {number} Desired buffer low water line in seconds
- */
- bufferLowWaterLine() {
- const currentTime = this.tech_.currentTime();
- const initial = Config.BUFFER_LOW_WATER_LINE;
- const rate = Config.BUFFER_LOW_WATER_LINE_RATE;
- const max = Math.max(initial, Config.MAX_BUFFER_LOW_WATER_LINE);
- const newMax = Math.max(initial, Config.EXPERIMENTAL_MAX_BUFFER_LOW_WATER_LINE);
- return Math.min(initial + currentTime * rate, this.bufferBasedABR ? newMax : max);
- }
- bufferHighWaterLine() {
- return Config.BUFFER_HIGH_WATER_LINE;
- }
- }
|