UNPKG

@videojs/http-streaming

Version:

Play back HLS and DASH with Video.js, even where it's not natively supported

1,491 lines (1,253 loc) 88.5 kB
/** * @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'; import { addMetadata, createMetadataTrackIfNotExists, addDateRangeMetadata } from './util/text-tracks'; import ContentSteeringController from './content-steering-controller'; import { bufferToHexString } from './util/string.js'; const ABORT_EARLY_EXCLUSION_SECONDS = 10; 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, experimentalUseMMS } = 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.player_ = options.player_; 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(); if (experimentalUseMMS && window.ManagedMediaSource) { // Airplay source not yet implemented. Remote playback must be disabled. this.tech_.el_.disableRemotePlayback = true; this.mediaSource = new window.ManagedMediaSource(); videojs.log('Using ManagedMediaSource'); } else if (window.MediaSource) { this.mediaSource = new window.MediaSource(); } this.handleDurationChange_ = this.handleDurationChange_.bind(this); this.handleSourceOpen_ = this.handleSourceOpen_.bind(this); this.handleSourceEnded_ = this.handleSourceEnded_.bind(this); this.load = this.load.bind(this); this.pause = this.pause.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_); this.mediaSource.addEventListener('startstreaming', this.load); this.mediaSource.addEventListener('endstreaming', this.pause); // 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(); this.keyStatusMap_ = new Map(); 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, addMetadataToTextTrack: this.addMetadataToTextTrack.bind(this) }; // 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_, merge(this.requestOptions_, { addMetadataToTextTrack: this.addMetadataToTextTrack.bind(this) })) : new PlaylistLoader(src, this.vhs_, merge(this.requestOptions_, { addDateRangesToTextTrack: this.addDateRangesToTextTrack_.bind(this) })); 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); const getBandwidth = () => { return this.mainSegmentLoader_.bandwidth; }; this.contentSteeringController_ = new ContentSteeringController(this.vhs_.xhr, getBandwidth); 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 && (playlist.id || playlist.uri); if (oldId && oldId !== newId) { this.logger_(`switch media ${oldId} -> ${newId} from ${cause}`); const metadata = { renditionInfo: { id: newId, bandwidth: playlist.attributes.BANDWIDTH, resolution: playlist.attributes.RESOLUTION, codecs: playlist.attributes.CODECS }, cause }; this.trigger({type: 'renditionselected', metadata}); this.tech_.trigger({type: 'usage', name: `vhs-rendition-change-${cause}`}); } this.mainPlaylistLoader_.media(playlist, delay); } /** * A function that ensures we switch our playlists inside of `mediaTypes` * to match the current `serviceLocation` provided by the contentSteering controller. * We want to check media types of `AUDIO`, `SUBTITLES`, and `CLOSED-CAPTIONS`. * * This should only be called on a DASH playback scenario while using content steering. * This is necessary due to differences in how media in HLS manifests are generally tied to * a video playlist, where in DASH that is not always the case. */ switchMediaForDASHContentSteering_() { ['AUDIO', 'SUBTITLES', 'CLOSED-CAPTIONS'].forEach((type) => { const mediaType = this.mediaTypes_[type]; const activeGroup = mediaType ? mediaType.activeGroup() : null; const pathway = this.contentSteeringController_.getPathway(); if (activeGroup && pathway) { // activeGroup can be an array or a single group const mediaPlaylists = activeGroup.length ? activeGroup[0].playlists : activeGroup.playlists; const dashMediaPlaylists = mediaPlaylists.filter((p) => p.attributes.serviceLocation === pathway); // Switch the current active playlist to the correct CDN if (dashMediaPlaylists.length) { this.mediaTypes_[type].activePlaylistLoader.media(dashMediaPlaylists[0]); } } }); } /** * 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) { // Add content steering listeners on first load and init. this.attachContentSteeringListeners_(); this.initContentSteeringController_(); // 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; } if (this.sourceType_ === 'dash') { // we don't want to re-request the same hls playlist right after it was changed 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_.pause(); this.mainSegmentLoader_.playlist(media, this.requestOptions_); if (this.waitingForFastQualityPlaylistReceived_) { this.runFastQualitySwitch_(); } else { 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'}); }); const playlistLoaderEvents = [ 'manifestrequeststart', 'manifestrequestcomplete', 'manifestparsestart', 'manifestparsecomplete', 'playlistrequeststart', 'playlistrequestcomplete', 'playlistparsestart', 'playlistparsecomplete', 'renditiondisabled', 'renditionenabled' ]; playlistLoaderEvents.forEach((eventName) => { this.mainPlaylistLoader_.on(eventName, (metadata) => { // trigger directly on the player to ensure early events are fired. this.player_.trigger({...metadata}); }); }); } /** * 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_.pause(); this.mainSegmentLoader_.playlist(updatedPlaylist, this.requestOptions_); if (this.waitingForFastQualityPlaylistReceived_) { this.runFastQualitySwitch_(); } 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(); }); // There is the possibility of the video segment and the audio segment // at a current time to be on different timelines. When this occurs, the player // forwards playback to a point where these two segment types are back on the same // timeline. This time will be just after the end of the audio segment that is on // a previous timeline. this.timelineChangeController_.on('audioTimelineBehind', () => { const segmentInfo = this.audioSegmentLoader_.pendingSegment_; if (!segmentInfo || !segmentInfo.segment || !segmentInfo.segment.syncInfo) { return; } // Update the current time to just after the faulty audio segment. // This moves playback to a spot where both audio and video segments // are on the same timeline. const newTime = segmentInfo.segment.syncInfo.end + 0.01; this.tech_.setCurrentTime(newTime); }); 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(); }); const segmentLoaderEvents = [ 'segmentselected', 'segmentloadstart', 'segmentloaded', 'segmentkeyloadstart', 'segmentkeyloadcomplete', 'segmentdecryptionstart', 'segmentdecryptioncomplete', 'segmenttransmuxingstart', 'segmenttransmuxingcomplete', 'segmenttransmuxingtrackinfoavailable', 'segmenttransmuxingtiminginfoavailable', 'segmentappendstart', 'appendsdone', 'bandwidthupdated', 'timelinechange', 'codecschange' ]; segmentLoaderEvents.forEach((eventName) => { this.mainSegmentLoader_.on(eventName, (metadata) => { this.player_.trigger({...metadata}); }); this.audioSegmentLoader_.on(eventName, (metadata) => { this.player_.trigger({...metadata}); }); this.subtitleSegmentLoader_.on(eventName, (metadata) => { this.player_.trigger({...metadata}); }); }); } 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(); } } /** * Call pause on our SegmentLoaders */ pause() { this.mainSegmentLoader_.pause(); if (this.mediaTypes_.AUDIO.activePlaylistLoader) { this.audioSegmentLoader_.pause(); } if (this.mediaTypes_.SUBTITLES.activePlaylistLoader) { this.subtitleSegmentLoader_.pause(); } } /** * 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 && media === this.mainPlaylistLoader_.media()) { this.logger_('skipping fastQualityChange because new media is same as old'); return; } this.switchMedia_(media, 'fast-quality'); // we would like to avoid race condition when we call fastQuality, // reset everything and start loading segments from prev segments instead of new because new playlist is not received yet this.waitingForFastQualityPlaylistReceived_ = true; } runFastQualitySwitch_() { this.waitingForFastQualityPlaylistReceived_ = false; // Delete all buffered data to allow an immediate quality switch. this.mainSegmentLoader_.pause(); this.mainSegmentLoader_.resetEverything(() => { this.mainSegmentLoader_.load(); }); // 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 and/or has a start time if (!media.endList || media.start) { const seekable = this.seekable(); if (!seekable.length) { // without a seekable range, the player cannot seek to begin buffering at the // live or start point return false; } const seekableEnd = seekable.end(0); let startPoint = seekableEnd; if (media.start) { const offset = media.start.timeOffset; if (offset < 0) { startPoint = Math.max(seekableEnd + offset, seekable.start(0)); } else { startPoint = Math.min(seekableEnd, offset); } } // trigger firstplay to inform the source handler to ignore the next seek event this.trigger('firstplay'); // seek to the live point this.tech_.setCurrentTime(startPoint); } 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) { // If we're content steering, try other pathways. if (this.main().contentSteering) { const pathway = this.pathwayAttribute_(playlistToExclude); // Ignore at least 1 steering manifest refresh. const reIncludeDelay = this.contentSteeringController_.steeringManifest.ttl * 1000; this.contentSteeringController_.excludePathway(pathway); this.excludeThenChangePathway_(); setTimeout(() => { this.contentSteeringController_.addAvailablePathway(pathway); }, reIncludeDelay); return; } // 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', 'p