/** * @file master-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.worker.js'; import Config from './config'; import { parseCodecs, browserSupportsCodec, muxerSupportsCodec, DEFAULT_AUDIO_CODEC, DEFAULT_VIDEO_CODEC } from '@videojs/vhs-utils/dist/codecs.js'; import { codecsForPlaylist } from './util/codecs.js'; import { createMediaTypes, setupMediaGroups } from './media-groups'; import logger from './util/logger'; const ABORT_EARLY_BLACKLIST_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' ]; const sumLoaderStat = function(stat) { return this.audioSegmentLoader_[stat] + this.mainSegmentLoader_[stat]; }; const shouldSwitchToMedia = function({ currentPlaylist, nextPlaylist, forwardBuffer, bufferLowWaterLine, duration, 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; } // 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) { return true; } // 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 < Config.MAX_BUFFER_LOW_WATER_LINE) { return true; } // we want to switch down to lower resolutions quickly to continue playback, but if (nextPlaylist.attributes.BANDWIDTH < currentPlaylist.attributes.BANDWIDTH) { return true; } // ensure we have some buffer before we switch up to prevent us running out of // buffer while loading a higher rendition. if (forwardBuffer >= bufferLowWaterLine) { return true; } return false; }; /** * 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 * if they are available * * @class MasterPlaylistController * @extends videojs.EventTarget */ export class MasterPlaylistController extends videojs.EventTarget { constructor(options) { super(); const { src, handleManifestRedirects, withCredentials, tech, bandwidth, externVhs, useCueTags, blacklistDuration, enableLowInitialPlaylist, sourceType, cacheEncryptionKeys, handlePartialData } = options; if (!src) { throw new Error('A non-empty playlist URL or JSON manifest string is required'); } Vhs = externVhs; this.withCredentials = withCredentials; this.tech_ = tech; this.vhs_ = tech.vhs; this.sourceType_ = sourceType; this.useCueTags_ = useCueTags; this.blacklistDuration = blacklistDuration; this.enableLowInitialPlaylist = enableLowInitialPlaylist; if (this.useCueTags_) { this.cueTagsTrack_ = this.tech_.addTextTrack( 'metadata', 'ad-cues' ); this.cueTagsTrack_.inBandMetadataTrackDispatchType = ''; } this.requestOptions_ = { withCredentials, handleManifestRedirects, 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_ = videojs.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_, 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, handlePartialData, sourceUpdater: this.sourceUpdater_, timelineChangeController: this.timelineChangeController_ }; // 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.masterPlaylistLoader_ = this.sourceType_ === 'dash' ? new DashPlaylistLoader(src, this.vhs_, this.requestOptions_) : new PlaylistLoader(src, this.vhs_, this.requestOptions_); this.setupMasterPlaylistLoaderListeners_(); // setup segment loaders // combined audio/video or just video when alternate audio track is selected this.mainSegmentLoader_ = new SegmentLoader(videojs.mergeOptions(segmentLoaderSettings, { segmentMetadataTrack: this.segmentMetadataTrack_, loaderType: 'main' }), options); // alternate audio track this.audioSegmentLoader_ = new SegmentLoader(videojs.mergeOptions(segmentLoaderSettings, { loaderType: 'audio' }), options); this.subtitleSegmentLoader_ = new VTTSegmentLoader(videojs.mergeOptions(segmentLoaderSettings, { loaderType: 'vtt', featuresNativeTextTracks: this.tech_.featuresNativeTextTracks }), options); this.setupSegmentLoaderListeners_(); // Create SegmentLoader stat-getters // mediaRequests_ // mediaRequestsAborted_ // mediaRequestsTimedout_ // mediaRequestsErrored_ // mediaTransferDuration_ // mediaBytesTransferred_ loaderStats.forEach((stat) => { this[stat + '_'] = sumLoaderStat.bind(this, stat); }); this.logger_ = logger('MPC'); this.triggeredFmp4Usage = false; this.masterPlaylistLoader_.load(); } /** * Register event handlers on the master playlist loader. A helper * function for construction time. * * @private */ setupMasterPlaylistLoaderListeners_() { this.masterPlaylistLoader_.on('loadedmetadata', () => { const media = this.masterPlaylistLoader_.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.masterPlaylistLoader_.master, this.masterPlaylistLoader_.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_, masterPlaylistLoader: this.masterPlaylistLoader_, vhs: this.vhs_, master: this.master(), mediaTypes: this.mediaTypes_, blacklistCurrentPlaylist: this.blacklistCurrentPlaylist.bind(this) }); this.triggerPresenceUsage_(this.master(), 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.masterPlaylistLoader_.on('loadedplaylist', () => { let updatedPlaylist = this.masterPlaylistLoader_.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(); } this.initialMedia_ = selectedMedia; this.masterPlaylistLoader_.media(this.initialMedia_); // 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.masterPlaylistLoader_.on('error', () => { this.blacklistCurrentPlaylist(this.masterPlaylistLoader_.error); }); this.masterPlaylistLoader_.on('mediachanging', () => { this.mainSegmentLoader_.abort(); this.mainSegmentLoader_.pause(); }); this.masterPlaylistLoader_.on('mediachange', () => { const media = this.masterPlaylistLoader_.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.masterPlaylistLoader_.master, this.masterPlaylistLoader_.media())) { this.requestOptions_.timeout = 0; } else { this.requestOptions_.timeout = requestTimeout; } // 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.masterPlaylistLoader_.on('playlistunchanged', () => { const updatedPlaylist = this.masterPlaylistLoader_.media(); const playlistOutdated = this.stuckAtPlaylistEnd_(updatedPlaylist); if (playlistOutdated) { // Playlist has stopped updating and we're stuck at its end. Try to // blacklist 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.blacklistCurrentPlaylist({ message: 'Playlist no longer updating.' }); // useful for monitoring QoS this.tech_.trigger('playliststuck'); } }); this.masterPlaylistLoader_.on('renditiondisabled', () => { this.tech_.trigger({type: 'usage', name: 'vhs-rendition-disabled'}); this.tech_.trigger({type: 'usage', name: 'hls-rendition-disabled'}); }); this.masterPlaylistLoader_.on('renditionenabled', () => { this.tech_.trigger({type: 'usage', name: 'vhs-rendition-enabled'}); this.tech_.trigger({type: 'usage', name: 'hls-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_(master, media) { const mediaGroups = master.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'}); this.tech_.trigger({type: 'usage', name: 'hls-demuxed'}); } if (Object.keys(mediaGroups.SUBTITLES).length) { this.tech_.trigger({type: 'usage', name: 'vhs-webvtt'}); this.tech_.trigger({type: 'usage', name: 'hls-webvtt'}); } if (Vhs.Playlist.isAes(media)) { this.tech_.trigger({type: 'usage', name: 'vhs-aes'}); this.tech_.trigger({type: 'usage', name: 'hls-aes'}); } if (audioGroupKeys.length && Object.keys(mediaGroups.AUDIO[audioGroupKeys[0]]).length > 1) { this.tech_.trigger({type: 'usage', name: 'vhs-alternate-audio'}); this.tech_.trigger({type: 'usage', name: 'hls-alternate-audio'}); } if (this.useCueTags_) { this.tech_.trigger({type: 'usage', name: 'vhs-playlist-cue-tags'}); this.tech_.trigger({type: 'usage', name: 'hls-playlist-cue-tags'}); } } /** * Register event handlers on the segment loaders. A helper function * for construction time. * * @private */ setupSegmentLoaderListeners_() { this.mainSegmentLoader_.on('bandwidthupdate', () => { const nextPlaylist = this.selectPlaylist(); const currentPlaylist = this.masterPlaylistLoader_.media(); const buffered = this.tech_.buffered(); const forwardBuffer = buffered.length ? buffered.end(buffered.length - 1) - this.tech_.currentTime() : 0; const bufferLowWaterLine = this.bufferLowWaterLine(); if (shouldSwitchToMedia({ currentPlaylist, nextPlaylist, forwardBuffer, bufferLowWaterLine, duration: this.duration(), log: this.logger_ })) { this.masterPlaylistLoader_.media(nextPlaylist); } this.tech_.trigger('bandwidthupdate'); }); this.mainSegmentLoader_.on('progress', () => { this.trigger('progress'); }); this.mainSegmentLoader_.on('error', () => { this.blacklistCurrentPlaylist(this.mainSegmentLoader_.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.tech_.trigger({type: 'usage', name: 'hls-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', () => { this.blacklistCurrentPlaylist({ message: 'Aborted early because there isn\'t enough bandwidth to complete the ' + 'request without rebuffering.' }, ABORT_EARLY_BLACKLIST_SECONDS); }); const updateCodecs = () => { if (!this.sourceUpdater_.ready()) { 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.tech_.trigger({type: 'usage', name: 'hls-fmp4'}); this.triggeredFmp4Usage = true; } }); this.audioSegmentLoader_.on('fmp4', () => { if (!this.triggeredFmp4Usage) { this.tech_.trigger({type: 'usage', name: 'vhs-fmp4'}); this.tech_.trigger({type: 'usage', name: 'hls-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 without performing destructive actions, like * removing already buffered content * * @private */ smoothQualityChange_(media = this.selectPlaylist()) { if (media === this.masterPlaylistLoader_.media()) { return; } this.masterPlaylistLoader_.media(media); this.mainSegmentLoader_.resetLoader(); // don't need to reset audio as it is reset when media changes } /** * 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.masterPlaylistLoader_.media()) { return; } this.masterPlaylistLoader_.media(media); // 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.masterPlaylistLoader_.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) { // if the audio playlist loader exists, then alternate audio is active if (!this.mainSegmentLoader_.currentMediaInfo_ || this.mainSegmentLoader_.currentMediaInfo_.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.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; } /** * Blacklists a playlist when an error occurs for a set amount of time * making it unavailable for selection by the rendition selection algorithm * and then forces a new playlist (rendition) selection. * * @param {Object=} error an optional error that may include the playlist * to blacklist * @param {number=} blacklistDuration an optional number of seconds to blacklist the * playlist */ blacklistCurrentPlaylist(error = {}, blacklistDuration) { // 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 // blacklisted instead of the currently selected playlist which is likely // out-of-date in this scenario const currentPlaylist = error.playlist || this.masterPlaylistLoader_.media(); blacklistDuration = blacklistDuration || error.blacklistDuration || this.blacklistDuration; // If there is no current playlist, then an error occurred while we were // trying to load the master OR while we were disposing of the tech if (!currentPlaylist) { this.error = error; if (this.mediaSource.readyState !== 'open') { this.trigger('error'); } else { this.sourceUpdater_.endOfStream('network'); } return; } const playlists = this.masterPlaylistLoader_.master.playlists; const enabledPlaylists = playlists.filter(isEnabled); const isFinalRendition = enabledPlaylists.length === 1 && enabledPlaylists[0] === currentPlaylist; // Don't blacklist the only playlist unless it was blacklisted // forever if (playlists.length === 1 && blacklistDuration !== Infinity) { videojs.log.warn(`Problem encountered with playlist ${currentPlaylist.id}. ` + 'Trying again since it is the only playlist.'); this.tech_.trigger('retryplaylist'); return this.masterPlaylistLoader_.load(isFinalRendition); } if (isFinalRendition) { // Since we're on the final non-blacklisted playlist, and we're about to blacklist // it, instead of erring the player or retrying this playlist, clear out the current // blacklist. 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 blacklisted if (playlist === currentPlaylist) { 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'); } } // Blacklist this playlist currentPlaylist.excludeUntil = Date.now() + (blacklistDuration * 1000); this.tech_.trigger('blacklistplaylist'); this.tech_.trigger({type: 'usage', name: 'vhs-rendition-blacklisted'}); this.tech_.trigger({type: 'usage', name: 'hls-rendition-blacklisted'}); // TODO: should we select a new playlist if this blacklist wasn't for the currentPlaylist? // Would be something like media().id !=== currentPlaylist.id and we would need something // like `pendingMedia` in playlist loaders to check against that too. This will prevent us // from loading a new playlist on any blacklist. // Select a new 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 ${currentPlaylist.id}.` + `${errorMessage} Switching to playlist ${nextPlaylist.id}.`); // if audio group changed reset audio loaders if (nextPlaylist.attributes.AUDIO !== currentPlaylist.attributes.AUDIO) { this.delegateLoaders_('audio', ['abort', 'pause']); } // if subtitle group changed reset subtitle loaders if (nextPlaylist.attributes.SUBTITLES !== currentPlaylist.attributes.SUBTITLES) { this.delegateLoaders_('subtitle', ['abort', 'pause']); } this.delegateLoaders_('main', ['abort', 'pause']); return this.masterPlaylistLoader_.media(nextPlaylist, isFinalRendition); } /** * Pause all segment/playlist loaders */ pauseLoading() { this.delegateLoaders_('all', ['abort', 'pause']); } /** * 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/master 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.masterPlaylistLoader_); } 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.masterPlaylistLoader_ && this.masterPlaylistLoader_.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.masterPlaylistLoader_.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.masterPlaylistLoader_) { return 0; } const media = this.masterPlaylistLoader_.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; if (!this.masterPlaylistLoader_) { return; } let media = this.masterPlaylistLoader_.media(); if (!media) { return; } let expired = this.syncController_.getExpiredTime(media, this.duration()); if (expired === null) { // not enough information to update seekable return; } const suggestedPresentationDelay = this.masterPlaylistLoader_.master.suggestedPresentationDelay; const mainSeekable = Vhs.Playlist.seekable(media, expired, suggestedPresentationDelay); 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, suggestedPresentationDelay); 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_ = videojs.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.masterPlaylistLoader_.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 MasterPlaylistController and everything * that it controls */ dispose() { this.trigger('dispose'); this.decrypter_.terminate(); this.masterPlaylistLoader_.dispose(); this.mainSegmentLoader_.dispose(); ['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(); 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 master playlist object if we have one * * @return {Object} the master playlist object that we parsed */ master() { return this.masterPlaylistLoader_.master; } /** * 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.masterPlaylistLoader_.media() || this.initialMedia_; } areMediaTypesKnown_() { const usingAudioLoader = !!this.mediaTypes_.AUDIO.activePlaylistLoader; // one or both loaders has not loaded sufficently to get codecs if (!this.mainSegmentLoader_.currentMediaInfo_ || (usingAudioLoader && !this.audioSegmentLoader_.currentMediaInfo_)) { return false; } return true; } getCodecsOrExclude_() { const media = { main: this.mainSegmentLoader_.currentMediaInfo_ || {}, audio: this.audioSegmentLoader_.currentMediaInfo_ || {} }; // set "main" media equal to video media.video = media.main; const playlistCodecs = codecsForPlaylist(this.master(), this.media()); 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.blacklistCurrentPlaylist({ playlist: this.media(), message: 'Could not determine codecs for playlist.', blacklistDuration: 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 && this.media().attributes.AUDIO) { const audioGroup = this.media().attributes.AUDIO; this.master().playlists.forEach(variant => { const variantAudioGroup = variant.attributes && variant.attributes.AUDIO; if (variantAudioGroup === audioGroup && variant !== this.media()) { variant.excludeUntil = Infinity; } }); this.logger_(`excluding audio group ${audioGroup} as ${unsupportedAudio} does not support codec(s): "${codecs.audio}"`); } // if we have any unsupported codecs blacklist 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.blacklistCurrentPlaylist({ playlist: this.media(), internal: true, message, blacklistDuration: Infinity }); return; } // check if codec switching is happening if (this.sourceUpdater_.ready() && !this.sourceUpdater_.canChangeType()) { const switchMessages = []; ['video', 'audio'].forEach((type) => { const newCodec = (parseCodecs(this.sourceUpdater_.codecs[type] || '')[type] || {}).type; const oldCodec = (parseCodecs(codecs[type] || '')[type] || {}).type; if (newCodec && oldCodec && newCodec.toLowerCase() !== oldCodec.toLowerCase()) { switchMessages.push(`"${this.sourceUpdater_.codecs[type]}" -> "${codecs[type]}"`); } }); if (switchMessages.length) { this.blacklistCurrentPlaylist({ playlist: this.media(), message: `Codec switching not supported: ${switchMessages.join(', ')}.`, blacklistDuration: Infinity, internal: true }); 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_.ready()) { 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_() { this.master().playlists.forEach(variant => { const codecs = codecsForPlaylist(this.master, variant); if (codecs.audio && !muxerSupportsCodec(codecs.audio) && !browserSupportsCodec(codecs.audio)) { variant.excludeUntil = Infinity; } if (codecs.video && !muxerSupportsCodec(codecs.video) && !browserSupportsCodec(codecs.video)) { variant.excludeUntil = Infinity; } }); } /** * 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 * 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 master 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 codecs = parseCodecs(codecString); const codecCount = Object.keys(codecs).length; this.master().playlists.forEach((variant) => { // skip variants that are already blacklisted forever if (variant.excludeUntil === Infinity) { return; } /* TODO: Decide whether two codecs should be assumed here. * Right now, for playlists that don't specify codecs, VHS assumes * that there are two (one for audio and one for video). * Although this is often the case, this may lead to broken behavior * if the playlist only has one codec. It may be better in the future * to decide at time of segment download how many tracks there are and * determine the proper codecs. This will come at a cost of potentially * more bandwidth, but will be a more robust approach than the assumption here. */ let variantCodecs = {}; let variantCodecCount = 2; const blacklistReasons = []; // get codecs from the playlist for this variant const variantCodecStrings = codecsForPlaylist(this.masterPlaylistLoader_.master, variant); if (variantCodecStrings.audio || variantCodecStrings.video) { const variantCodecString = [variantCodecStrings.video, variantCodecStrings.audio] .filter(Boolean) .join(','); variantCodecs = parseCodecs(variantCodecString); variantCodecCount = Object.keys(variantCodecs).length; } // 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) { blacklistReasons.push(`codec count "${variantCodecCount}" !== "${codecCount}"`); variant.excludeUntil = Infinity; } // only exclude playlists by codec change, if codecs cannot switch // during playback. if (!this.sourceUpdater_.canChangeType()) { // the video codec cannot change if (variantCodecs.video && codecs.video && variantCodecs.video.type.toLowerCase() !== codecs.video.type.toLowerCase()) { blacklistReasons.push(`video codec "${variantCodecs.video.type}" !== "${codecs.video.type}"`); variant.excludeUntil = Infinity; } // the audio codec cannot change if (variantCodecs.audio && codecs.audio && variantCodecs.audio.type.toLowerCase() !== codecs.audio.type.toLowerCase()) { variant.excludeUntil = Infinity; blacklistReasons.push(`audio codec "${variantCodecs.audio.type}" !== "${codecs.audio.type}"`); } } if (blacklistReasons.length) { this.logger_(`blacklisting ${variant.id}: ${blacklistReasons.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); return Math.min(initial + currentTime * rate, max); } }