import { createDoNothingErrorAction } from './error-controller'; import { HlsAssetPlayer } from './interstitial-player'; import { type InterstitialScheduleEventItem, type InterstitialScheduleItem, type InterstitialSchedulePrimaryItem, InterstitialsSchedule, segmentToString, type TimelineType, } from './interstitials-schedule'; import { ErrorDetails, ErrorTypes } from '../errors'; import { Events } from '../events'; import { AssetListLoader } from '../loader/interstitial-asset-list'; import { ALIGNED_END_THRESHOLD_SECONDS, eventAssetToString, generateAssetIdentifier, getNextAssetIndex, type InterstitialAssetId, type InterstitialAssetItem, type InterstitialEvent, type InterstitialEventWithAssetList, TimelineOccupancy, } from '../loader/interstitial-event'; import { BufferHelper } from '../utils/buffer-helper'; import { addEventListener, removeEventListener, } from '../utils/event-listener-helper'; import { hash } from '../utils/hash'; import { Logger } from '../utils/logger'; import { isCompatibleTrackChange } from '../utils/mediasource-helper'; import { getBasicSelectionOption } from '../utils/rendition-helper'; import { stringify } from '../utils/safe-json-stringify'; import type { HlsAssetPlayerConfig, InterstitialPlayer, } from './interstitial-player'; import type Hls from '../hls'; import type { LevelDetails } from '../loader/level-details'; import type { SourceBufferName } from '../types/buffer'; import type { NetworkComponentAPI } from '../types/component-api'; import type { AssetListLoadedData, AudioTrackSwitchingData, AudioTrackUpdatedData, BufferAppendedData, BufferCodecsData, BufferFlushedData, ErrorData, LevelUpdatedData, MediaAttachedData, MediaAttachingData, MediaDetachingData, SubtitleTrackSwitchData, SubtitleTrackUpdatedData, } from '../types/events'; import type { MediaPlaylist, MediaSelection } from '../types/media-playlist'; export interface InterstitialsManager { events: InterstitialEvent[]; schedule: InterstitialScheduleItem[]; interstitialPlayer: InterstitialPlayer | null; playerQueue: HlsAssetPlayer[]; bufferingAsset: InterstitialAssetItem | null; bufferingItem: InterstitialScheduleItem | null; bufferingIndex: number; playingAsset: InterstitialAssetItem | null; playingItem: InterstitialScheduleItem | null; playingIndex: number; primary: PlayheadTimes; integrated: PlayheadTimes; skip: () => void; } export type PlayheadTimes = { bufferedEnd: number; currentTime: number; duration: number; seekableStart: number; }; function playWithCatch(media: HTMLMediaElement | null) { (media?.play() as Promise | undefined)?.catch(() => { /* no-op */ }); } function timelineMessage(label: string, time: number) { return `[${label}] Advancing timeline position to ${time}`; } export default class InterstitialsController extends Logger implements NetworkComponentAPI { private readonly HlsPlayerClass: typeof Hls; private readonly hls: Hls; private readonly assetListLoader: AssetListLoader; // Last updated LevelDetails private mediaSelection: MediaSelection | null = null; private altSelection: { audio?: MediaPlaylist; subtitles?: MediaPlaylist; } | null = null; // Media and MediaSource/SourceBuffers private media: HTMLMediaElement | null = null; private detachedData: MediaAttachingData | null = null; private requiredTracks: Partial | null = null; // Public Interface for Interstitial playback state and control private manager: InterstitialsManager | null = null; // Interstitial Asset Players private playerQueue: HlsAssetPlayer[] = []; // Timeline position tracking private bufferedPos: number = -1; private timelinePos: number = -1; // Schedule private schedule: InterstitialsSchedule | null; // Schedule playback and buffering state private playingItem: InterstitialScheduleItem | null = null; private bufferingItem: InterstitialScheduleItem | null = null; private waitingItem: InterstitialScheduleEventItem | null = null; private endedItem: InterstitialScheduleItem | null = null; private playingAsset: InterstitialAssetItem | null = null; private endedAsset: InterstitialAssetItem | null = null; private bufferingAsset: InterstitialAssetItem | null = null; private shouldPlay: boolean = false; constructor(hls: Hls, HlsPlayerClass: typeof Hls) { super('interstitials', hls.logger); this.hls = hls; this.HlsPlayerClass = HlsPlayerClass; this.assetListLoader = new AssetListLoader(hls); this.schedule = new InterstitialsSchedule( this.onScheduleUpdate, hls.logger, ); this.registerListeners(); } private registerListeners() { const hls = this.hls; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (hls) { hls.on(Events.MEDIA_ATTACHING, this.onMediaAttaching, this); hls.on(Events.MEDIA_ATTACHED, this.onMediaAttached, this); hls.on(Events.MEDIA_DETACHING, this.onMediaDetaching, this); hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this); hls.on(Events.LEVEL_UPDATED, this.onLevelUpdated, this); hls.on(Events.AUDIO_TRACK_SWITCHING, this.onAudioTrackSwitching, this); hls.on(Events.AUDIO_TRACK_UPDATED, this.onAudioTrackUpdated, this); hls.on(Events.SUBTITLE_TRACK_SWITCH, this.onSubtitleTrackSwitch, this); hls.on(Events.SUBTITLE_TRACK_UPDATED, this.onSubtitleTrackUpdated, this); hls.on(Events.EVENT_CUE_ENTER, this.onInterstitialCueEnter, this); hls.on(Events.ASSET_LIST_LOADED, this.onAssetListLoaded, this); hls.on(Events.BUFFER_APPENDED, this.onBufferAppended, this); hls.on(Events.BUFFER_FLUSHED, this.onBufferFlushed, this); hls.on(Events.BUFFERED_TO_END, this.onBufferedToEnd, this); hls.on(Events.MEDIA_ENDED, this.onMediaEnded, this); hls.on(Events.ERROR, this.onError, this); hls.on(Events.DESTROYING, this.onDestroying, this); } } private unregisterListeners() { const hls = this.hls; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (hls) { hls.off(Events.MEDIA_ATTACHING, this.onMediaAttaching, this); hls.off(Events.MEDIA_ATTACHED, this.onMediaAttached, this); hls.off(Events.MEDIA_DETACHING, this.onMediaDetaching, this); hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this); hls.off(Events.LEVEL_UPDATED, this.onLevelUpdated, this); hls.off(Events.AUDIO_TRACK_SWITCHING, this.onAudioTrackSwitching, this); hls.off(Events.AUDIO_TRACK_UPDATED, this.onAudioTrackUpdated, this); hls.off(Events.SUBTITLE_TRACK_SWITCH, this.onSubtitleTrackSwitch, this); hls.off(Events.SUBTITLE_TRACK_UPDATED, this.onSubtitleTrackUpdated, this); hls.off(Events.EVENT_CUE_ENTER, this.onInterstitialCueEnter, this); hls.off(Events.ASSET_LIST_LOADED, this.onAssetListLoaded, this); hls.off(Events.BUFFER_CODECS, this.onBufferCodecs, this); hls.off(Events.BUFFER_APPENDED, this.onBufferAppended, this); hls.off(Events.BUFFER_FLUSHED, this.onBufferFlushed, this); hls.off(Events.BUFFERED_TO_END, this.onBufferedToEnd, this); hls.off(Events.MEDIA_ENDED, this.onMediaEnded, this); hls.off(Events.ERROR, this.onError, this); hls.off(Events.DESTROYING, this.onDestroying, this); } } startLoad() { // TODO: startLoad - check for waitingItem and retry by resetting schedule this.resumeBuffering(); } stopLoad() { // TODO: stopLoad - stop all scheule.events[].assetListLoader?.abort() then delete the loaders this.pauseBuffering(); } resumeBuffering() { this.getBufferingPlayer()?.resumeBuffering(); } pauseBuffering() { this.getBufferingPlayer()?.pauseBuffering(); } destroy() { this.unregisterListeners(); this.stopLoad(); // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (this.assetListLoader) { this.assetListLoader.destroy(); } this.emptyPlayerQueue(); this.clearScheduleState(); if (this.schedule) { this.schedule.destroy(); } this.media = this.detachedData = this.mediaSelection = this.requiredTracks = this.altSelection = this.schedule = this.manager = null; // @ts-ignore this.hls = this.HlsPlayerClass = this.log = null; // @ts-ignore this.assetListLoader = null; // @ts-ignore this.onPlay = this.onPause = this.onSeeking = this.onTimeupdate = null; // @ts-ignore this.onScheduleUpdate = null; } private onDestroying() { const media = this.primaryMedia || this.media; if (media) { this.removeMediaListeners(media); } } private removeMediaListeners(media: HTMLMediaElement) { removeEventListener(media, 'play', this.onPlay); removeEventListener(media, 'pause', this.onPause); removeEventListener(media, 'seeking', this.onSeeking); removeEventListener(media, 'timeupdate', this.onTimeupdate); } private onMediaAttaching( event: Events.MEDIA_ATTACHING, data: MediaAttachingData, ) { const media = (this.media = data.media); addEventListener(media, 'seeking', this.onSeeking); addEventListener(media, 'timeupdate', this.onTimeupdate); addEventListener(media, 'play', this.onPlay); addEventListener(media, 'pause', this.onPause); } private onMediaAttached( event: Events.MEDIA_ATTACHED, data: MediaAttachedData, ) { const playingItem = this.effectivePlayingItem; const detachedMedia = this.detachedData; this.detachedData = null; if (playingItem === null) { this.checkStart(); } else if (!detachedMedia) { // Resume schedule after detached externally this.clearScheduleState(); const playingIndex = this.findItemIndex(playingItem); this.setSchedulePosition(playingIndex); } } private clearScheduleState() { this.log(`clear schedule state`); this.playingItem = this.bufferingItem = this.waitingItem = this.endedItem = this.playingAsset = this.endedAsset = this.bufferingAsset = null; } private onMediaDetaching( event: Events.MEDIA_DETACHING, data: MediaDetachingData, ) { const transferringMedia = !!data.transferMedia; const media = this.media; this.media = null; if (transferringMedia) { return; } if (media) { this.removeMediaListeners(media); } // If detachMedia is called while in an Interstitial, detach the asset player as well and reset the schedule position if (this.detachedData) { const player = this.getBufferingPlayer(); if (player) { this.log(`Removing schedule state for detachedData and ${player}`); this.playingAsset = this.endedAsset = this.bufferingAsset = this.bufferingItem = this.waitingItem = this.detachedData = null; player.detachMedia(); } this.shouldPlay = false; } } public get interstitialsManager(): InterstitialsManager | null { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!this.hls) { return null; } if (this.manager) { return this.manager; } const c = this; const effectiveBufferingItem = () => c.bufferingItem || c.waitingItem; const getAssetPlayer = (asset: InterstitialAssetItem | null) => asset ? c.getAssetPlayer(asset.identifier) : asset; const getMappedTime = ( item: InterstitialScheduleItem | null, timelineType: TimelineType, asset: InterstitialAssetItem | null, controllerField: 'bufferedPos' | 'timelinePos', assetPlayerField: 'bufferedEnd' | 'currentTime', ): number => { if (item) { let time = ( item[timelineType] as { start: number; end: number; } ).start; const interstitial = item.event; if (interstitial) { if ( timelineType === 'playout' || interstitial.timelineOccupancy !== TimelineOccupancy.Point ) { const assetPlayer = getAssetPlayer(asset); if (assetPlayer?.interstitial === interstitial) { time += assetPlayer.assetItem.startOffset + assetPlayer[assetPlayerField]; } } } else { const value = controllerField === 'bufferedPos' ? getBufferedEnd() : c[controllerField]; time += value - item.start; } return time; } return 0; }; const findMappedTime = ( primaryTime: number, timelineType: TimelineType, ): number => { if ( primaryTime !== 0 && timelineType !== 'primary' && c.schedule?.length ) { const index = c.schedule.findItemIndexAtTime(primaryTime); const item = c.schedule.items?.[index]; if (item) { const diff = item[timelineType].start - item.start; return primaryTime + diff; } } return primaryTime; }; const getBufferedEnd = (): number => { const value = c.bufferedPos; if (value === Number.MAX_VALUE) { return getMappedDuration('primary'); } return Math.max(value, 0); }; const getMappedDuration = (timelineType: TimelineType): number => { if (c.primaryDetails?.live) { // return end of last event item or playlist return c.primaryDetails.edge; } return c.schedule?.durations[timelineType] || 0; }; const seekTo = (time: number, timelineType: TimelineType) => { const item = c.effectivePlayingItem; if (item?.event?.restrictions.skip || !c.schedule) { return; } c.log(`seek to ${time} "${timelineType}"`); const playingItem = c.effectivePlayingItem; const targetIndex = c.schedule.findItemIndexAtTime(time, timelineType); const targetItem = c.schedule.items?.[targetIndex]; const bufferingPlayer = c.getBufferingPlayer(); const bufferingInterstitial = bufferingPlayer?.interstitial; const appendInPlace = bufferingInterstitial?.appendInPlace; const seekInItem = playingItem && c.itemsMatch(playingItem, targetItem); if (playingItem && (appendInPlace || seekInItem)) { // seek in asset player or primary media (appendInPlace) const assetPlayer = getAssetPlayer(c.playingAsset); const media = assetPlayer?.media || c.primaryMedia; if (media) { const currentTime = timelineType === 'primary' ? media.currentTime : getMappedTime( playingItem, timelineType, c.playingAsset, 'timelinePos', 'currentTime', ); const diff = time - currentTime; const seekToTime = (appendInPlace ? currentTime : media.currentTime) + diff; if ( seekToTime >= 0 && (!assetPlayer || appendInPlace || seekToTime <= assetPlayer.duration) ) { media.currentTime = seekToTime; return; } } } // seek out of item or asset if (targetItem) { let seekToTime = time; if (timelineType !== 'primary') { const primarySegmentStart = targetItem[timelineType].start; const diff = time - primarySegmentStart; seekToTime = targetItem.start + diff; } const targetIsPrimary = !c.isInterstitial(targetItem); if ( (!c.isInterstitial(playingItem) || playingItem.event.appendInPlace) && (targetIsPrimary || targetItem.event.appendInPlace) ) { const media = c.media || (appendInPlace ? bufferingPlayer?.media : null); if (media) { media.currentTime = seekToTime; } } else if (playingItem) { // check if an Interstitial between the current item and target item has an X-RESTRICT JUMP restriction const playingIndex = c.findItemIndex(playingItem); if (targetIndex > playingIndex) { const jumpIndex = c.schedule.findJumpRestrictedIndex( playingIndex + 1, targetIndex, ); if (jumpIndex > playingIndex) { c.setSchedulePosition(jumpIndex); return; } } let assetIndex = 0; if (targetIsPrimary) { c.timelinePos = seekToTime; c.checkBuffer(); } else { const assetList = targetItem.event.assetList; const eventTime = time - (targetItem[timelineType] || targetItem).start; for (let i = assetList.length; i--; ) { const asset = assetList[i]; if ( asset.duration && eventTime >= asset.startOffset && eventTime < asset.startOffset + asset.duration ) { assetIndex = i; break; } } } c.setSchedulePosition(targetIndex, assetIndex); } } }; const getActiveInterstitial = () => { const playingItem = c.effectivePlayingItem; if (c.isInterstitial(playingItem)) { return playingItem; } const bufferingItem = effectiveBufferingItem(); if (c.isInterstitial(bufferingItem)) { return bufferingItem; } return null; }; const interstitialPlayer: InterstitialPlayer = { get bufferedEnd() { const interstitialItem = effectiveBufferingItem(); const bufferingItem = c.bufferingItem; if (bufferingItem && bufferingItem === interstitialItem) { return ( getMappedTime( bufferingItem, 'playout', c.bufferingAsset, 'bufferedPos', 'bufferedEnd', ) - bufferingItem.playout.start || c.bufferingAsset?.startOffset || 0 ); } return 0; }, get currentTime() { const interstitialItem = getActiveInterstitial(); const playingItem = c.effectivePlayingItem; if (playingItem && playingItem === interstitialItem) { return ( getMappedTime( playingItem, 'playout', c.effectivePlayingAsset, 'timelinePos', 'currentTime', ) - playingItem.playout.start ); } return 0; }, set currentTime(time: number) { const interstitialItem = getActiveInterstitial(); const playingItem = c.effectivePlayingItem; if (playingItem && playingItem === interstitialItem) { seekTo(time + playingItem.playout.start, 'playout'); } }, get duration() { const interstitialItem = getActiveInterstitial(); if (interstitialItem) { return interstitialItem.playout.end - interstitialItem.playout.start; } return 0; }, get assetPlayers() { const assetList = getActiveInterstitial()?.event.assetList; if (assetList) { return assetList.map((asset) => c.getAssetPlayer(asset.identifier)); } return []; }, get playingIndex() { const interstitial = getActiveInterstitial()?.event; if (interstitial && c.effectivePlayingAsset) { return interstitial.findAssetIndex(c.effectivePlayingAsset); } return -1; }, get scheduleItem() { return getActiveInterstitial(); }, }; return (this.manager = { get events() { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition return c.schedule?.events?.slice(0) || []; }, get schedule() { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition return c.schedule?.items?.slice(0) || []; }, get interstitialPlayer() { if (getActiveInterstitial()) { return interstitialPlayer; } return null; }, get playerQueue() { return c.playerQueue.slice(0); }, get bufferingAsset() { return c.bufferingAsset; }, get bufferingItem() { return effectiveBufferingItem(); }, get bufferingIndex() { const item = effectiveBufferingItem(); return c.findItemIndex(item); }, get playingAsset() { return c.effectivePlayingAsset; }, get playingItem() { return c.effectivePlayingItem; }, get playingIndex() { const item = c.effectivePlayingItem; return c.findItemIndex(item); }, primary: { get bufferedEnd() { return getBufferedEnd(); }, get currentTime() { const timelinePos = c.timelinePos; return timelinePos > 0 ? timelinePos : 0; }, set currentTime(time: number) { seekTo(time, 'primary'); }, get duration() { return getMappedDuration('primary'); }, get seekableStart() { return c.primaryDetails?.fragmentStart || 0; }, }, integrated: { get bufferedEnd() { return getMappedTime( effectiveBufferingItem(), 'integrated', c.bufferingAsset, 'bufferedPos', 'bufferedEnd', ); }, get currentTime() { return getMappedTime( c.effectivePlayingItem, 'integrated', c.effectivePlayingAsset, 'timelinePos', 'currentTime', ); }, set currentTime(time: number) { seekTo(time, 'integrated'); }, get duration() { return getMappedDuration('integrated'); }, get seekableStart() { return findMappedTime( c.primaryDetails?.fragmentStart || 0, 'integrated', ); }, }, skip: () => { const item = c.effectivePlayingItem; const event = item?.event; if (event && !event.restrictions.skip) { const index = c.findItemIndex(item); if (event.appendInPlace) { const time = item.playout.start + item.event.duration; seekTo(time + 0.001, 'playout'); } else { c.advanceAfterAssetEnded(event, index, Infinity); } } }, }); } // Schedule getters private get effectivePlayingItem(): InterstitialScheduleItem | null { return this.waitingItem || this.playingItem || this.endedItem; } private get effectivePlayingAsset(): InterstitialAssetItem | null { return this.playingAsset || this.endedAsset; } private get playingLastItem(): boolean { const playingItem = this.playingItem; const items = this.schedule?.items; if (!this.playbackStarted || !playingItem || !items) { return false; } return this.findItemIndex(playingItem) === items.length - 1; } private get playbackStarted(): boolean { return this.effectivePlayingItem !== null; } // Media getters and event callbacks private get currentTime(): number | undefined { if (this.mediaSelection === null) { // Do not advance before schedule is known return undefined; } // Ignore currentTime when detached for Interstitial playback with source reset const queuedForPlayback = this.waitingItem || this.playingItem; if ( this.isInterstitial(queuedForPlayback) && !queuedForPlayback.event.appendInPlace ) { return undefined; } let media = this.media; if (!media && this.bufferingItem?.event?.appendInPlace) { // Observe detached media currentTime when appending in place media = this.primaryMedia; } const currentTime = media?.currentTime; if (currentTime === undefined || !Number.isFinite(currentTime)) { return undefined; } return currentTime; } private get primaryMedia(): HTMLMediaElement | null { return this.media || this.detachedData?.media || null; } private isInterstitial( item: InterstitialScheduleItem | null | undefined, ): item is InterstitialScheduleEventItem { return !!item?.event; } private retreiveMediaSource( assetId: InterstitialAssetId, toSegment: InterstitialScheduleItem | null, ) { const player = this.getAssetPlayer(assetId); if (player) { this.transferMediaFromPlayer(player, toSegment); } } private transferMediaFromPlayer( player: HlsAssetPlayer, toSegment: InterstitialScheduleItem | null | undefined, ) { const appendInPlace = player.interstitial.appendInPlace; const playerMedia = player.media; if (appendInPlace && playerMedia === this.primaryMedia) { this.bufferingAsset = null; if ( !toSegment || (this.isInterstitial(toSegment) && !toSegment.event.appendInPlace) ) { // MediaSource cannot be transfered back to an Interstitial that requires a source reset // no-op when toSegment is undefined if (toSegment && playerMedia) { this.detachedData = { media: playerMedia }; return; } } const attachMediaSourceData = player.transferMedia(); this.log( `transfer MediaSource from ${player} ${stringify(attachMediaSourceData)}`, ); this.detachedData = attachMediaSourceData; } else if (toSegment && playerMedia) { this.shouldPlay ||= !playerMedia.paused; } } private transferMediaTo( player: Hls | HlsAssetPlayer, media: HTMLMediaElement, ) { if (player.media === media) { return; } let attachMediaSourceData: MediaAttachingData | null = null; const primaryPlayer = this.hls; const isAssetPlayer = player !== primaryPlayer; const appendInPlace = isAssetPlayer && (player as HlsAssetPlayer).interstitial.appendInPlace; const detachedMediaSource = this.detachedData?.mediaSource; let logFromSource: string; if (primaryPlayer.media) { if (appendInPlace) { attachMediaSourceData = primaryPlayer.transferMedia(); this.detachedData = attachMediaSourceData; } logFromSource = `Primary`; } else if (detachedMediaSource) { const bufferingPlayer = this.getBufferingPlayer(); if (bufferingPlayer) { attachMediaSourceData = bufferingPlayer.transferMedia(); logFromSource = `${bufferingPlayer}`; } else { logFromSource = `detached MediaSource`; } } else { logFromSource = `detached media`; } if (!attachMediaSourceData) { if (detachedMediaSource) { attachMediaSourceData = this.detachedData; this.log( `using detachedData: MediaSource ${stringify(attachMediaSourceData)}`, ); } else if (!this.detachedData || primaryPlayer.media === media) { // Keep interstitial media transition consistent const playerQueue = this.playerQueue; if (playerQueue.length > 1) { playerQueue.forEach((queuedPlayer) => { if ( isAssetPlayer && queuedPlayer.interstitial.appendInPlace !== appendInPlace ) { const interstitial = queuedPlayer.interstitial; this.clearInterstitial(queuedPlayer.interstitial, null); interstitial.appendInPlace = false; // setter may be a no-op; // `appendInPlace` getter may still return `true` after insterstitial streaming has begun in that mode. if (interstitial.appendInPlace as boolean) { this.warn( `Could not change append strategy for queued assets ${interstitial}`, ); } } }); } this.hls.detachMedia(); this.detachedData = { media }; } } const transferring = attachMediaSourceData && 'mediaSource' in attachMediaSourceData && attachMediaSourceData.mediaSource?.readyState !== 'closed'; const dataToAttach = transferring && attachMediaSourceData ? attachMediaSourceData : media; this.log( `${transferring ? 'transfering MediaSource' : 'attaching media'} to ${ isAssetPlayer ? player : 'Primary' } from ${logFromSource} (media.currentTime: ${media.currentTime})`, ); const schedule = this.schedule; if (dataToAttach === attachMediaSourceData && schedule) { const isAssetAtEndOfSchedule = isAssetPlayer && (player as HlsAssetPlayer).assetId === schedule.assetIdAtEnd; // Prevent asset players from marking EoS on transferred MediaSource dataToAttach.overrides = { duration: schedule.duration, endOfStream: !isAssetPlayer || isAssetAtEndOfSchedule, cueRemoval: !isAssetPlayer, }; } player.attachMedia(dataToAttach); } private onPlay = () => { this.shouldPlay = true; }; private onPause = () => { this.shouldPlay = false; }; private onSeeking = () => { const currentTime = this.currentTime; if (currentTime === undefined || this.playbackDisabled || !this.schedule) { return; } const diff = currentTime - this.timelinePos; const roundingError = Math.abs(diff) < 1 / 705600000; // one flick if (roundingError) { return; } const backwardSeek = diff <= -0.01; this.timelinePos = currentTime; this.bufferedPos = currentTime; // Check if seeking out of an item const playingItem = this.playingItem; if (!playingItem) { this.checkBuffer(); return; } if (backwardSeek) { const resetCount = this.schedule.resetErrorsInRange( currentTime, currentTime - diff, ); if (resetCount) { this.updateSchedule(true); } } this.checkBuffer(); if ( (backwardSeek && currentTime < playingItem.start) || currentTime >= playingItem.end ) { const playingIndex = this.findItemIndex(playingItem); let scheduleIndex = this.schedule.findItemIndexAtTime(currentTime); if (scheduleIndex === -1) { scheduleIndex = playingIndex + (backwardSeek ? -1 : 1); this.log( `seeked ${backwardSeek ? 'back ' : ''}to position not covered by schedule ${currentTime} (resolving from ${playingIndex} to ${scheduleIndex})`, ); } if (!this.isInterstitial(playingItem) && this.media?.paused) { this.shouldPlay = false; } if (!backwardSeek) { // check if an Interstitial between the current item and target item has an X-RESTRICT JUMP restriction if (scheduleIndex > playingIndex) { const jumpIndex = this.schedule.findJumpRestrictedIndex( playingIndex + 1, scheduleIndex, ); if (jumpIndex > playingIndex) { this.setSchedulePosition(jumpIndex); return; } } } this.setSchedulePosition(scheduleIndex); return; } // Check if seeking out of an asset (assumes same item following above check) const playingAsset = this.playingAsset; if (!playingAsset) { // restart Interstitial at end if (this.playingLastItem && this.isInterstitial(playingItem)) { const restartAsset = playingItem.event.assetList[0]; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (restartAsset) { this.endedItem = this.playingItem; this.playingItem = null; this.setScheduleToAssetAtTime(currentTime, restartAsset); } } return; } const start = playingAsset.timelineStart; const duration = playingAsset.duration || 0; if ( (backwardSeek && currentTime < start) || currentTime >= start + duration ) { if (playingItem.event?.appendInPlace) { // Return SourceBuffer(s) to primary player and flush this.clearAssetPlayers(playingItem.event, playingItem); this.flushFrontBuffer(currentTime); } this.setScheduleToAssetAtTime(currentTime, playingAsset); } }; private onInterstitialCueEnter() { this.onTimeupdate(); } private onTimeupdate = () => { const currentTime = this.currentTime; if (currentTime === undefined || this.playbackDisabled) { return; } // Only allow timeupdate to advance primary position, seeking is used for jumping back // this prevents primaryPos from being reset to 0 after re-attach if (currentTime > this.timelinePos) { this.timelinePos = currentTime; if (currentTime > this.bufferedPos) { this.checkBuffer(); } } else { return; } // Check if playback has entered the next item const playingItem = this.playingItem; if (!playingItem || this.playingLastItem) { return; } if (currentTime >= playingItem.end) { this.timelinePos = playingItem.end; const playingIndex = this.findItemIndex(playingItem); this.setSchedulePosition(playingIndex + 1); } // Check if playback has entered the next asset const playingAsset = this.playingAsset; if (!playingAsset) { return; } const end = playingAsset.timelineStart + (playingAsset.duration || 0); if (currentTime >= end) { this.setScheduleToAssetAtTime(currentTime, playingAsset); } }; // Scheduling methods private checkStart() { const schedule = this.schedule; const interstitialEvents = schedule?.events; if (!interstitialEvents || this.playbackDisabled || !this.media) { return; } // Check buffered to pre-roll if (this.bufferedPos === -1) { this.bufferedPos = 0; } // Start stepping through schedule when playback begins for the first time and we have a pre-roll const timelinePos = this.timelinePos; const effectivePlayingItem = this.effectivePlayingItem; if (timelinePos === -1) { const startPosition = this.hls.startPosition; this.log(timelineMessage('checkStart', startPosition)); this.timelinePos = startPosition; if (interstitialEvents.length && interstitialEvents[0].cue.pre) { const index = schedule.findEventIndex(interstitialEvents[0].identifier); this.setSchedulePosition(index); } else if (startPosition >= 0 || !this.primaryLive) { const start = (this.timelinePos = startPosition > 0 ? startPosition : 0); const index = schedule.findItemIndexAtTime(start); this.setSchedulePosition(index); } } else if (effectivePlayingItem && !this.playingItem) { const index = schedule.findItemIndex(effectivePlayingItem); this.setSchedulePosition(index); } } private advanceAssetBuffering( item: InterstitialScheduleEventItem, assetItem: InterstitialAssetItem, ) { const interstitial = item.event; const assetListIndex = interstitial.findAssetIndex(assetItem); const nextAssetIndex = getNextAssetIndex(interstitial, assetListIndex); if (!interstitial.isAssetPastPlayoutLimit(nextAssetIndex)) { this.bufferedToEvent(item, nextAssetIndex); } else if (this.schedule) { const nextItem = this.schedule.items?.[this.findItemIndex(item) + 1]; if (nextItem) { this.bufferedToItem(nextItem); } } } private advanceAfterAssetEnded( interstitial: InterstitialEvent, index: number, assetListIndex: number, ) { const nextAssetIndex = getNextAssetIndex(interstitial, assetListIndex); if (!interstitial.isAssetPastPlayoutLimit(nextAssetIndex)) { // Advance to next asset list item if (interstitial.appendInPlace) { const assetItem = interstitial.assetList[nextAssetIndex] as | InterstitialAssetItem | undefined; if (assetItem) { this.advanceInPlace(assetItem.timelineStart); } } this.setSchedulePosition(index, nextAssetIndex); } else if (this.schedule) { // Advance to next schedule segment // check if we've reached the end of the program const scheduleItems = this.schedule.items; if (scheduleItems) { const nextIndex = index + 1; const scheduleLength = scheduleItems.length; if (nextIndex >= scheduleLength) { this.setSchedulePosition(-1); return; } const resumptionTime = interstitial.resumeTime; if (this.timelinePos < resumptionTime) { this.log(timelineMessage('advanceAfterAssetEnded', resumptionTime)); this.timelinePos = resumptionTime; if (interstitial.appendInPlace) { this.advanceInPlace(resumptionTime); } this.checkBuffer(this.bufferedPos < resumptionTime); } this.setSchedulePosition(nextIndex); } } } private setScheduleToAssetAtTime( time: number, playingAsset: InterstitialAssetItem, ) { const schedule = this.schedule; if (!schedule) { return; } const parentIdentifier = playingAsset.parentIdentifier; const interstitial = schedule.getEvent(parentIdentifier); if (interstitial) { const itemIndex = schedule.findEventIndex(parentIdentifier); const assetListIndex = schedule.findAssetIndex(interstitial, time); this.advanceAfterAssetEnded(interstitial, itemIndex, assetListIndex - 1); } } private setSchedulePosition(index: number, assetListIndex?: number) { const scheduleItems = this.schedule?.items; if (!scheduleItems || this.playbackDisabled) { return; } const scheduledItem = index >= 0 ? scheduleItems[index] : null; this.log( `setSchedulePosition ${index}, ${assetListIndex} (${scheduledItem ? segmentToString(scheduledItem) : scheduledItem}) pos: ${this.timelinePos}`, ); // Cleanup current item / asset const currentItem = this.waitingItem || this.playingItem; const playingLastItem = this.playingLastItem; if (this.isInterstitial(currentItem)) { const interstitial = currentItem.event; const playingAsset = this.playingAsset; const assetId = playingAsset?.identifier; const player = assetId ? this.getAssetPlayer(assetId) : null; if ( player && assetId && (!this.eventItemsMatch(currentItem, scheduledItem) || (assetListIndex !== undefined && assetId !== interstitial.assetList[assetListIndex].identifier)) ) { const playingAssetListIndex = interstitial.findAssetIndex(playingAsset); this.log( `INTERSTITIAL_ASSET_ENDED ${playingAssetListIndex + 1}/${interstitial.assetList.length} ${eventAssetToString(playingAsset)}`, ); this.endedAsset = playingAsset; this.playingAsset = null; this.hls.trigger(Events.INTERSTITIAL_ASSET_ENDED, { asset: playingAsset, assetListIndex: playingAssetListIndex, event: interstitial, schedule: scheduleItems.slice(0), scheduleIndex: index, player, }); if (currentItem !== this.playingItem) { // Schedule change occured on INTERSTITIAL_ASSET_ENDED if ( this.itemsMatch(currentItem, this.playingItem) && // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition !this.playingAsset // INTERSTITIAL_ASSET_ENDED side-effect ) { this.advanceAfterAssetEnded( interstitial, this.findItemIndex(this.playingItem), playingAssetListIndex, ); } // Navigation occured on INTERSTITIAL_ASSET_ENDED return; } this.retreiveMediaSource(assetId, scheduledItem); if (player.media && !this.detachedData?.mediaSource) { player.detachMedia(); } } if (!this.eventItemsMatch(currentItem, scheduledItem)) { this.endedItem = currentItem; this.playingItem = null; this.log( `INTERSTITIAL_ENDED ${interstitial} ${segmentToString(currentItem)}`, ); interstitial.hasPlayed = true; this.hls.trigger(Events.INTERSTITIAL_ENDED, { event: interstitial, schedule: scheduleItems.slice(0), scheduleIndex: index, }); // Exiting an Interstitial if (interstitial.cue.once) { // Remove interstitial with CUE attribute value of ONCE after it has played this.updateSchedule(); const updatedScheduleItems = this.schedule?.items; if (scheduledItem && updatedScheduleItems) { const updatedIndex = this.findItemIndex(scheduledItem); this.advanceSchedule( updatedIndex, updatedScheduleItems, assetListIndex, currentItem, playingLastItem, ); } return; } } } this.advanceSchedule( index, scheduleItems, assetListIndex, currentItem, playingLastItem, ); } private advanceSchedule( index: number, scheduleItems: InterstitialScheduleItem[], assetListIndex: number | undefined, currentItem: InterstitialScheduleItem | null, playedLastItem: boolean, ) { const schedule = this.schedule; if (!schedule) { return; } const scheduledItem = scheduleItems[index] || null; const media = this.primaryMedia; // Cleanup out of range Interstitials const playerQueue = this.playerQueue; if (playerQueue.length) { playerQueue.forEach((player) => { const interstitial = player.interstitial; const queuedIndex = schedule.findEventIndex(interstitial.identifier); if (queuedIndex < index || queuedIndex > index + 1) { this.clearInterstitial(interstitial, scheduledItem); } }); } // Setup scheduled item if (this.isInterstitial(scheduledItem)) { this.timelinePos = Math.min( Math.max(this.timelinePos, scheduledItem.start), scheduledItem.end, ); // Handle Interstitial const interstitial = scheduledItem.event; // find asset index if (assetListIndex === undefined) { assetListIndex = schedule.findAssetIndex( interstitial, this.timelinePos, ); const assetIndexCandidate = getNextAssetIndex( interstitial, assetListIndex - 1, ); if ( interstitial.isAssetPastPlayoutLimit(assetIndexCandidate) || (interstitial.appendInPlace && this.timelinePos === scheduledItem.end) ) { this.advanceAfterAssetEnded(interstitial, index, assetListIndex); return; } assetListIndex = assetIndexCandidate; } // Ensure Interstitial is enqueued const waitingItem = this.waitingItem; if (!this.assetsBuffered(scheduledItem, media)) { this.setBufferingItem(scheduledItem); } let player = this.preloadAssets(interstitial, assetListIndex); if (!this.eventItemsMatch(scheduledItem, waitingItem || currentItem)) { this.waitingItem = scheduledItem; this.log( `INTERSTITIAL_STARTED ${segmentToString(scheduledItem)} ${interstitial.appendInPlace ? 'append in place' : ''}`, ); this.hls.trigger(Events.INTERSTITIAL_STARTED, { event: interstitial, schedule: scheduleItems.slice(0), scheduleIndex: index, }); } if (!interstitial.assetListLoaded) { // Waiting at end of primary content segment // Expect setSchedulePosition to be called again once ASSET-LIST is loaded this.log(`Waiting for ASSET-LIST to complete loading ${interstitial}`); return; } if (interstitial.assetListLoader) { interstitial.assetListLoader.destroy(); interstitial.assetListLoader = undefined; } if (!media) { this.log( `Waiting for attachMedia to start Interstitial ${interstitial}`, ); return; } // Update schedule and asset list position now that it can start this.waitingItem = this.endedItem = null; this.playingItem = scheduledItem; // If asset-list is empty or missing asset index, advance to next item const assetItem = interstitial.assetList[assetListIndex] as | InterstitialAssetItem | undefined; if (!assetItem) { this.advanceAfterAssetEnded(interstitial, index, assetListIndex || 0); return; } // Start Interstitial Playback if (!player) { player = this.getAssetPlayer(assetItem.identifier); } if (player === null || player.destroyed) { const assetListLength = interstitial.assetList.length; this.warn( `asset ${ assetListIndex + 1 }/${assetListLength} player destroyed ${interstitial}`, ); player = this.createAssetPlayer( interstitial, assetItem, assetListIndex, ); player.loadSource(); } if (!this.eventItemsMatch(scheduledItem, this.bufferingItem)) { if (interstitial.appendInPlace && this.isAssetBuffered(assetItem)) { return; } } this.startAssetPlayer( player, assetListIndex, scheduleItems, index, media, ); if (this.shouldPlay) { playWithCatch(player.media); } } else if (scheduledItem) { this.resumePrimary(scheduledItem, index, currentItem); if (this.shouldPlay) { playWithCatch(this.hls.media); } } else if (playedLastItem && this.isInterstitial(currentItem)) { // Maintain playingItem state at end of schedule (setSchedulePosition(-1) called to end program) // this allows onSeeking handler to update schedule position this.endedItem = null; this.playingItem = currentItem; if (!currentItem.event.appendInPlace) { // Media must be re-attached to resume primary schedule if not sharing source this.attachPrimary(schedule.durations.primary, null); } } } private get playbackDisabled(): boolean { return this.hls.config.enableInterstitialPlayback === false; } private get primaryDetails(): LevelDetails | undefined { return this.mediaSelection?.main.details; } private get primaryLive(): boolean { return !!this.primaryDetails?.live; } private resumePrimary( scheduledItem: InterstitialSchedulePrimaryItem, index: number, fromItem: InterstitialScheduleItem | null, ) { this.playingItem = scheduledItem; this.playingAsset = this.endedAsset = null; this.waitingItem = this.endedItem = null; this.bufferedToItem(scheduledItem); this.log(`resuming ${segmentToString(scheduledItem)}`); if (!this.detachedData?.mediaSource) { let timelinePos = this.timelinePos; if ( timelinePos < scheduledItem.start || timelinePos >= scheduledItem.end ) { timelinePos = this.getPrimaryResumption(scheduledItem, index); this.log(timelineMessage('resumePrimary', timelinePos)); this.timelinePos = timelinePos; } this.attachPrimary(timelinePos, scheduledItem); } if (!fromItem) { return; } const scheduleItems = this.schedule?.items; if (!scheduleItems) { return; } this.log(`INTERSTITIALS_PRIMARY_RESUMED ${segmentToString(scheduledItem)}`); this.hls.trigger(Events.INTERSTITIALS_PRIMARY_RESUMED, { schedule: scheduleItems.slice(0), scheduleIndex: index, }); this.checkBuffer(); } private getPrimaryResumption( scheduledItem: InterstitialSchedulePrimaryItem, index: number, ): number { const itemStart = scheduledItem.start; if (this.primaryLive) { const details = this.primaryDetails; if (index === 0) { return this.hls.startPosition; } else if ( details && (itemStart < details.fragmentStart || itemStart > details.edge) ) { return this.hls.liveSyncPosition || -1; } } return itemStart; } private isAssetBuffered(asset: InterstitialAssetItem): boolean { const player = this.getAssetPlayer(asset.identifier); if (player?.hls) { return player.hls.bufferedToEnd; } const bufferInfo = BufferHelper.bufferInfo( this.primaryMedia, this.timelinePos, 0, ); return bufferInfo.end + 1 >= asset.timelineStart + (asset.duration || 0); } private attachPrimary( timelinePos: number, item: InterstitialSchedulePrimaryItem | null, skipSeekToStartPosition?: boolean, ) { if (item) { this.setBufferingItem(item); } else { this.bufferingItem = this.playingItem; } this.bufferingAsset = null; const media = this.primaryMedia; if (!media) { return; } const hls = this.hls; if (hls.media) { this.checkBuffer(); } else { this.transferMediaTo(hls, media); if (skipSeekToStartPosition) { this.startLoadingPrimaryAt(timelinePos, skipSeekToStartPosition); } } if (!skipSeekToStartPosition) { // Set primary position to resume time this.log(timelineMessage('attachPrimary', timelinePos)); this.timelinePos = timelinePos; this.startLoadingPrimaryAt(timelinePos, skipSeekToStartPosition); } } private startLoadingPrimaryAt( timelinePos: number, skipSeekToStartPosition?: boolean, ) { const hls = this.hls; if ( !hls.loadingEnabled || !hls.media || Math.abs( (hls.mainForwardBufferInfo?.start || hls.media.currentTime) - timelinePos, ) > 0.5 ) { hls.startLoad(timelinePos, skipSeekToStartPosition); } else if (!hls.bufferingEnabled) { hls.resumeBuffering(); } } // HLS.js event callbacks private onManifestLoading() { this.stopLoad(); this.schedule?.reset(); this.emptyPlayerQueue(); this.clearScheduleState(); this.shouldPlay = false; this.bufferedPos = this.timelinePos = -1; this.mediaSelection = this.altSelection = this.manager = this.requiredTracks = null; // BUFFER_CODECS listener added here for buffer-controller to handle it first where it adds tracks this.hls.off(Events.BUFFER_CODECS, this.onBufferCodecs, this); this.hls.on(Events.BUFFER_CODECS, this.onBufferCodecs, this); } private onLevelUpdated(event: Events.LEVEL_UPDATED, data: LevelUpdatedData) { if (data.level === -1 || !this.schedule) { // level was removed return; } const main = this.hls.levels[data.level]; if (!main.details) { return; } const currentSelection = { ...(this.mediaSelection || this.altSelection), main, }; this.mediaSelection = currentSelection; this.schedule.parseInterstitialDateRanges( currentSelection, this.hls.config.interstitialAppendInPlace, ); if (!this.effectivePlayingItem && this.schedule.items) { this.checkStart(); } } private onAudioTrackUpdated( event: Events.AUDIO_TRACK_UPDATED, data: AudioTrackUpdatedData, ) { const audio = this.hls.audioTracks[data.id]; const previousSelection = this.mediaSelection; if (!previousSelection) { this.altSelection = { ...this.altSelection, audio }; return; } const currentSelection = { ...previousSelection, audio }; this.mediaSelection = currentSelection; } private onSubtitleTrackUpdated( event: Events.SUBTITLE_TRACK_UPDATED, data: SubtitleTrackUpdatedData, ) { const subtitles = this.hls.subtitleTracks[data.id]; const previousSelection = this.mediaSelection; if (!previousSelection) { this.altSelection = { ...this.altSelection, subtitles }; return; } const currentSelection = { ...previousSelection, subtitles }; this.mediaSelection = currentSelection; } private onAudioTrackSwitching( event: Events.AUDIO_TRACK_SWITCHING, data: AudioTrackSwitchingData, ) { const audioOption = getBasicSelectionOption(data); this.playerQueue.forEach( ({ hls }) => hls && (hls.setAudioOption(data) || hls.setAudioOption(audioOption)), ); } private onSubtitleTrackSwitch( event: Events.SUBTITLE_TRACK_SWITCH, data: SubtitleTrackSwitchData, ) { const subtitleOption = getBasicSelectionOption(data); this.playerQueue.forEach( ({ hls }) => hls && (hls.setSubtitleOption(data) || (data.id !== -1 && hls.setSubtitleOption(subtitleOption))), ); } private onBufferCodecs(event: Events.BUFFER_CODECS, data: BufferCodecsData) { const requiredTracks = data.tracks; if (requiredTracks) { this.requiredTracks = requiredTracks; } } private onBufferAppended( event: Events.BUFFER_APPENDED, data: BufferAppendedData, ) { this.checkBuffer(); } private onBufferFlushed( event: Events.BUFFER_FLUSHED, data: BufferFlushedData, ) { const playingItem = this.playingItem; if ( playingItem && !this.itemsMatch(playingItem, this.bufferingItem) && !this.isInterstitial(playingItem) ) { const timelinePos = this.timelinePos; this.bufferedPos = timelinePos; this.checkBuffer(); } } private onBufferedToEnd(event: Events.BUFFERED_TO_END) { if (!this.schedule) { return; } // Buffered to post-roll const interstitialEvents = this.schedule.events; if (this.bufferedPos < Number.MAX_VALUE && interstitialEvents) { for (let i = 0; i < interstitialEvents.length; i++) { const interstitial = interstitialEvents[i]; if (interstitial.cue.post) { const scheduleIndex = this.schedule.findEventIndex( interstitial.identifier, ); const item = this.schedule.items?.[scheduleIndex]; if ( this.isInterstitial(item) && this.eventItemsMatch(item, this.bufferingItem) ) { this.bufferedToItem(item, 0); } break; } } this.bufferedPos = Number.MAX_VALUE; } } private onMediaEnded(event: Events.MEDIA_ENDED) { const playingItem = this.playingItem; if (!this.playingLastItem && playingItem) { const playingIndex = this.findItemIndex(playingItem); this.setSchedulePosition(playingIndex + 1); } else { this.shouldPlay = false; } } // Schedule update callback private onScheduleUpdate = ( removedInterstitials: InterstitialEvent[], previousItems: InterstitialScheduleItem[] | null, ) => { const schedule = this.schedule; if (!schedule) { return; } const playingItem = this.playingItem; const interstitialEvents = schedule.events || []; const scheduleItems = schedule.items || []; const durations = schedule.durations; const removedIds = removedInterstitials.map( (interstitial) => interstitial.identifier, ); const interstitialsUpdated = !!( interstitialEvents.length || removedIds.length ); if (interstitialsUpdated || previousItems) { this.log( `INTERSTITIALS_UPDATED (${ interstitialEvents.length }): ${interstitialEvents} Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timelinePos}`, ); } if (removedIds.length) { this.log(`Removed events ${removedIds}`); } // Update schedule item references // Do not replace Interstitial playingItem without a match - used for INTERSTITIAL_ASSET_ENDED and INTERSTITIAL_ENDED let updatedPlayingItem: InterstitialScheduleItem | null = null; let updatedBufferingItem: InterstitialScheduleItem | null = null; if (playingItem) { updatedPlayingItem = this.updateItem(playingItem, this.timelinePos); if (this.itemsMatch(playingItem, updatedPlayingItem)) { this.playingItem = updatedPlayingItem; } else { this.waitingItem = this.endedItem = null; } } // Clear waitingItem if it has been removed from the schedule this.waitingItem = this.updateItem(this.waitingItem); this.endedItem = this.updateItem(this.endedItem); // Do not replace Interstitial bufferingItem without a match - used for transfering media element or source const bufferingItem = this.bufferingItem; if (bufferingItem) { updatedBufferingItem = this.updateItem(bufferingItem, this.bufferedPos); if (this.itemsMatch(bufferingItem, updatedBufferingItem)) { this.bufferingItem = updatedBufferingItem; } else if (bufferingItem.event) { // Interstitial removed from schedule (Live -> VOD or other scenario where Start Date is outside the range of VOD Playlist) this.bufferingItem = this.playingItem; this.clearInterstitial(bufferingItem.event, null); } } removedInterstitials.forEach((interstitial) => { interstitial.assetList.forEach((asset) => { this.clearAssetPlayer(asset.identifier, null); }); }); this.playerQueue.forEach((player) => { if (player.interstitial.appendInPlace) { const timelineStart = player.assetItem.timelineStart; const diff = player.timelineOffset - timelineStart; if (diff) { try { player.timelineOffset = timelineStart; } catch (e) { if (Math.abs(diff) > ALIGNED_END_THRESHOLD_SECONDS) { this.warn( `${e} ("${player.assetId}" ${player.timelineOffset}->${timelineStart})`, ); } } } } }); if (interstitialsUpdated || previousItems) { this.hls.trigger(Events.INTERSTITIALS_UPDATED, { events: interstitialEvents.slice(0), schedule: scheduleItems.slice(0), durations, removedIds, }); if ( this.isInterstitial(playingItem) && removedIds.includes(playingItem.event.identifier) ) { this.warn( `Interstitial "${playingItem.event.identifier}" removed while playing`, ); this.primaryFallback(playingItem.event); return; } if (playingItem) { this.trimInPlace(updatedPlayingItem, playingItem); } if (bufferingItem && updatedBufferingItem !== updatedPlayingItem) { this.trimInPlace(updatedBufferingItem, bufferingItem); } // Check if buffered to new Interstitial event boundary // (Live update publishes Interstitial with new segment) this.checkBuffer(); } }; private updateItem( previousItem: T | null, time?: number, ): T | null { // find item in this.schedule.items; const items = this.schedule?.items; if (previousItem && items) { const index = this.findItemIndex(previousItem, time); return (items[index] as T | undefined) || null; } return null; } private trimInPlace( updatedItem: InterstitialScheduleItem | null, itemBeforeUpdate: InterstitialScheduleItem, ) { if ( this.isInterstitial(updatedItem) && updatedItem.event.appendInPlace && itemBeforeUpdate.end - updatedItem.end > 0.25 ) { updatedItem.event.assetList.forEach((asset, index) => { if (updatedItem.event.isAssetPastPlayoutLimit(index)) { this.clearAssetPlayer(asset.identifier, null); } }); const flushStart = updatedItem.end + 0.25; const bufferInfo = BufferHelper.bufferInfo( this.primaryMedia, flushStart, 0, ); if ( bufferInfo.end > flushStart || (bufferInfo.nextStart || 0) > flushStart ) { this.log( `trim buffered interstitial ${segmentToString(updatedItem)} (was ${segmentToString(itemBeforeUpdate)})`, ); const skipSeekToStartPosition = true; this.attachPrimary(flushStart, null, skipSeekToStartPosition); this.flushFrontBuffer(flushStart); } } } private itemsMatch( a: InterstitialScheduleItem, b: InterstitialScheduleItem | null | undefined, ): boolean { return ( !!b && (a === b || (a.event && b.event && this.eventItemsMatch(a, b)) || (!a.event && !b.event && this.findItemIndex(a) === this.findItemIndex(b))) ); } private eventItemsMatch( a: InterstitialScheduleEventItem, b: InterstitialScheduleItem | null | undefined, ): boolean { return !!b && (a === b || a.event.identifier === b.event?.identifier); } private findItemIndex( item: InterstitialScheduleItem | null, time?: number, ): number { return item && this.schedule ? this.schedule.findItemIndex(item, time) : -1; } private updateSchedule(forceUpdate: boolean = false) { const mediaSelection = this.mediaSelection; if (!mediaSelection) { return; } this.schedule?.updateSchedule(mediaSelection, [], forceUpdate); } // Schedule buffer control private checkBuffer(starved?: boolean) { const items = this.schedule?.items; if (!items) { return; } // Find when combined forward buffer change reaches next schedule segment const bufferInfo = BufferHelper.bufferInfo( this.primaryMedia, this.timelinePos, 0, ); if (starved) { this.bufferedPos = this.timelinePos; } starved ||= bufferInfo.len < 1; this.updateBufferedPos(bufferInfo.end, items, starved); } private updateBufferedPos( bufferEnd: number, items: InterstitialScheduleItem[], bufferIsEmpty?: boolean, ) { const schedule = this.schedule; const bufferingItem = this.bufferingItem; if (this.bufferedPos > bufferEnd || !schedule) { return; } if (items.length === 1 && this.itemsMatch(items[0], bufferingItem)) { this.bufferedPos = bufferEnd; return; } const playingItem = this.playingItem; const playingIndex = this.findItemIndex(playingItem); let bufferEndIndex = schedule.findItemIndexAtTime(bufferEnd); if (this.bufferedPos < bufferEnd) { const bufferingIndex = this.findItemIndex(bufferingItem); const nextToBufferIndex = Math.min(bufferingIndex + 1, items.length - 1); const nextItemToBuffer = items[nextToBufferIndex]; if ( (bufferEndIndex === -1 && bufferingItem && bufferEnd >= bufferingItem.end) || (nextItemToBuffer.event?.appendInPlace && bufferEnd + 0.01 >= nextItemToBuffer.start) ) { bufferEndIndex = nextToBufferIndex; } if (this.isInterstitial(bufferingItem)) { const interstitial = bufferingItem.event; if ( nextToBufferIndex - playingIndex > 1 && interstitial.appendInPlace === false ) { // do not advance buffering item past Interstitial that requires source reset return; } if ( interstitial.assetList.length === 0 && interstitial.assetListLoader ) { // do not advance buffering item past Interstitial loading asset-list return; } } this.bufferedPos = bufferEnd; if (bufferEndIndex > bufferingIndex && bufferEndIndex > playingIndex) { this.bufferedToItem(nextItemToBuffer); } else { // allow more time than distance from edge for assets to load const details = this.primaryDetails; if ( this.primaryLive && details && bufferEnd > details.edge - details.targetduration && nextItemToBuffer.start < details.edge + this.hls.config.interstitialLiveLookAhead && this.isInterstitial(nextItemToBuffer) ) { this.preloadAssets(nextItemToBuffer.event, 0); } } } else if ( bufferIsEmpty && playingItem && !this.itemsMatch(playingItem, bufferingItem) ) { if (bufferEndIndex === playingIndex) { this.bufferedToItem(playingItem); } else if (bufferEndIndex === playingIndex + 1) { this.bufferedToItem(items[bufferEndIndex]); } } } private assetsBuffered( item: InterstitialScheduleEventItem, media: HTMLMediaElement | null, ): boolean { const assetList = item.event.assetList; if (assetList.length === 0) { return false; } return !item.event.assetList.some((asset) => { const player = this.getAssetPlayer(asset.identifier); return !player?.bufferedInPlaceToEnd(media); }); } private setBufferingItem( item: InterstitialScheduleItem, ): InterstitialScheduleItem | null { const bufferingLast = this.bufferingItem; const schedule = this.schedule; if (!this.itemsMatch(item, bufferingLast) && schedule) { const { items, events } = schedule; if (!items || !events) { return bufferingLast; } const isInterstitial = this.isInterstitial(item); const bufferingPlayer = this.getBufferingPlayer(); this.bufferingItem = item; this.bufferedPos = Math.max( item.start, Math.min(item.end, this.timelinePos), ); const timeRemaining = bufferingPlayer ? bufferingPlayer.remaining : bufferingLast ? bufferingLast.end - this.timelinePos : 0; this.log( `INTERSTITIALS_BUFFERED_TO_BOUNDARY ${segmentToString(item)}` + (bufferingLast ? ` (${timeRemaining.toFixed(2)} remaining)` : ''), ); if (!this.playbackDisabled) { if (isInterstitial) { const bufferIndex = schedule.findAssetIndex( item.event, this.bufferedPos, ); // primary fragment loading will exit early in base-stream-controller while `bufferingItem` is set to an Interstitial block item.event.assetList.forEach((asset, i) => { const player = this.getAssetPlayer(asset.identifier); if (player) { if (i === bufferIndex) { player.loadSource(); } player.resumeBuffering(); } }); } else { this.hls.resumeBuffering(); this.playerQueue.forEach((player) => player.pauseBuffering()); } } this.hls.trigger(Events.INTERSTITIALS_BUFFERED_TO_BOUNDARY, { events: events.slice(0), schedule: items.slice(0), bufferingIndex: this.findItemIndex(item), playingIndex: this.findItemIndex(this.playingItem), }); } else if (this.bufferingItem !== item) { this.bufferingItem = item; } return bufferingLast; } private bufferedToItem( item: InterstitialScheduleItem, assetListIndex: number = 0, ) { const bufferingLast = this.setBufferingItem(item); if (this.playbackDisabled) { return; } if (this.isInterstitial(item)) { // Ensure asset list is loaded this.bufferedToEvent(item, assetListIndex); } else if (bufferingLast !== null) { // If primary player is detached, it is also stopped, restart loading at primary position this.bufferingAsset = null; const detachedData = this.detachedData; if (detachedData) { if (detachedData.mediaSource) { const skipSeekToStartPosition = true; this.attachPrimary(item.start, item, skipSeekToStartPosition); } else { this.preloadPrimary(item); } } else { // If not detached seek to resumption point this.preloadPrimary(item); } } } private preloadPrimary(item: InterstitialSchedulePrimaryItem) { const index = this.findItemIndex(item); const timelinePos = this.getPrimaryResumption(item, index); this.startLoadingPrimaryAt(timelinePos); } private bufferedToEvent( item: InterstitialScheduleEventItem, assetListIndex: number, ) { const interstitial = item.event; const neverLoaded = interstitial.assetList.length === 0 && !interstitial.assetListLoader; const playOnce = interstitial.cue.once; if (neverLoaded || !playOnce) { // Buffered to Interstitial boundary const player = this.preloadAssets(interstitial, assetListIndex); if (player?.interstitial.appendInPlace) { const media = this.primaryMedia; if (media) { this.bufferAssetPlayer(player, media); } } } } private preloadAssets( interstitial: InterstitialEvent, assetListIndex: number, ): HlsAssetPlayer | null { const uri = interstitial.assetUrl; const assetListLength = interstitial.assetList.length; const neverLoaded = assetListLength === 0 && !interstitial.assetListLoader; const playOnce = interstitial.cue.once; if (neverLoaded) { const timelineStart = interstitial.timelineStart; if (interstitial.appendInPlace) { const playingItem = this.playingItem; if ( !this.isInterstitial(playingItem) && playingItem?.nextEvent?.identifier === interstitial.identifier ) { this.flushFrontBuffer(timelineStart + 0.25); } } let hlsStartOffset; let liveStartPosition = 0; if (!this.playingItem && this.primaryLive) { liveStartPosition = this.hls.startPosition; if (liveStartPosition === -1) { liveStartPosition = this.hls.liveSyncPosition || 0; } } if ( liveStartPosition && !(interstitial.cue.pre || interstitial.cue.post) ) { const startOffset = liveStartPosition - timelineStart; if (startOffset > 0) { hlsStartOffset = Math.round(startOffset * 1000) / 1000; } } this.log( `Load interstitial asset ${assetListIndex + 1}/${uri ? 1 : assetListLength} ${interstitial}${ hlsStartOffset ? ` live-start: ${liveStartPosition} start-offset: ${hlsStartOffset}` : '' }`, ); if (uri) { return this.createAsset( interstitial, 0, 0, timelineStart, interstitial.duration, uri, ); } const assetListLoader = this.assetListLoader.loadAssetList( interstitial as InterstitialEventWithAssetList, hlsStartOffset, ); if (assetListLoader) { interstitial.assetListLoader = assetListLoader; } } else if (!playOnce && assetListLength) { // Re-buffered to Interstitial boundary, re-create asset player(s) for (let i = assetListIndex; i < assetListLength; i++) { const asset = interstitial.assetList[i]; const playerIndex = this.getAssetPlayerQueueIndex(asset.identifier); if ( (playerIndex === -1 || this.playerQueue[playerIndex].destroyed) && !asset.error ) { this.createAssetPlayer(interstitial, asset, i); } } const asset = interstitial.assetList[assetListIndex]; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (asset) { const player = this.getAssetPlayer(asset.identifier); if (player) { player.loadSource(); } return player; } } return null; } private flushFrontBuffer(startOffset: number) { // Force queued flushing of all buffers const requiredTracks = this.requiredTracks; if (!requiredTracks) { return; } this.log(`Removing front buffer starting at ${startOffset}`); const sourceBufferNames = Object.keys(requiredTracks); sourceBufferNames.forEach((type: SourceBufferName) => { this.hls.trigger(Events.BUFFER_FLUSHING, { startOffset, endOffset: Infinity, type, }); }); } // Interstitial Asset Player control private getAssetPlayerQueueIndex(assetId: InterstitialAssetId): number { const playerQueue = this.playerQueue; for (let i = 0; i < playerQueue.length; i++) { if (assetId === playerQueue[i].assetId) { return i; } } return -1; } private getAssetPlayer(assetId: InterstitialAssetId): HlsAssetPlayer | null { const index = this.getAssetPlayerQueueIndex(assetId); return this.playerQueue[index] || null; } private getBufferingPlayer(): HlsAssetPlayer | null { const { playerQueue, primaryMedia } = this; if (primaryMedia) { for (let i = 0; i < playerQueue.length; i++) { if (playerQueue[i].media === primaryMedia) { return playerQueue[i]; } } } return null; } private createAsset( interstitial: InterstitialEvent, assetListIndex: number, startOffset: number, timelineStart: number, duration: number, uri: string, ): HlsAssetPlayer { const assetItem: InterstitialAssetItem = { parentIdentifier: interstitial.identifier, identifier: generateAssetIdentifier(interstitial, uri, assetListIndex), duration, startOffset, timelineStart, uri, }; return this.createAssetPlayer(interstitial, assetItem, assetListIndex); } private createAssetPlayer( interstitial: InterstitialEvent, assetItem: InterstitialAssetItem, assetListIndex: number, ): HlsAssetPlayer { const primary = this.hls; const userConfig = primary.userConfig; let videoPreference = userConfig.videoPreference; const currentLevel = primary.loadLevelObj || primary.levels[primary.currentLevel]; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (videoPreference || currentLevel) { videoPreference = Object.assign({}, videoPreference); if (currentLevel.videoCodec) { videoPreference.videoCodec = currentLevel.videoCodec; } // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (currentLevel.videoRange) { videoPreference.allowedVideoRanges = [currentLevel.videoRange]; } } const selectedAudio = primary.audioTracks[primary.audioTrack]; const selectedSubtitle = primary.subtitleTracks[primary.subtitleTrack]; let startPosition = 0; if (this.primaryLive || interstitial.appendInPlace) { const timePastStart = this.timelinePos - assetItem.timelineStart; if (timePastStart > 1) { const duration = assetItem.duration; if (duration && timePastStart < duration) { startPosition = timePastStart; } } } const assetId = assetItem.identifier; const playerConfig: HlsAssetPlayerConfig = { ...userConfig, maxMaxBufferLength: Math.min(180, primary.config.maxMaxBufferLength), autoStartLoad: true, startFragPrefetch: true, primarySessionId: primary.sessionId, assetPlayerId: assetId, abrEwmaDefaultEstimate: primary.bandwidthEstimate, interstitialsController: undefined, startPosition, liveDurationInfinity: false, testBandwidth: false, videoPreference, audioPreference: (selectedAudio as MediaPlaylist | undefined) || userConfig.audioPreference, subtitlePreference: (selectedSubtitle as MediaPlaylist | undefined) || userConfig.subtitlePreference, }; // TODO: limit maxMaxBufferLength in asset players to prevent QEE if (interstitial.appendInPlace) { interstitial.appendInPlaceStarted = true; if (assetItem.timelineStart) { playerConfig.timelineOffset = assetItem.timelineStart; } } const cmcd = playerConfig.cmcd; if (cmcd?.sessionId && cmcd.contentId) { playerConfig.cmcd = Object.assign({}, cmcd, { contentId: hash(assetItem.uri), }); } if (this.getAssetPlayer(assetId)) { this.warn( `Duplicate date range identifier ${interstitial} and asset ${assetId}`, ); } const player = new HlsAssetPlayer( this.HlsPlayerClass, playerConfig, interstitial, assetItem, ); this.playerQueue.push(player); interstitial.assetList[assetListIndex] = assetItem; // Listen for LevelDetails and PTS change to update duration let initialDuration = true; const updateAssetPlayerDetails = (details: LevelDetails) => { if (details.live) { const error = new Error( `Interstitials MUST be VOD assets ${interstitial}`, ); const errorData: ErrorData = { fatal: true, type: ErrorTypes.OTHER_ERROR, details: ErrorDetails.INTERSTITIAL_ASSET_ITEM_ERROR, error, }; const scheduleIndex = this.schedule?.findEventIndex(interstitial.identifier) || -1; this.handleAssetItemError( errorData, interstitial, scheduleIndex, assetListIndex, error.message, ); return; } // Get time at end of last fragment const duration = details.edge - details.fragmentStart; const currentAssetDuration = assetItem.duration; if ( initialDuration || currentAssetDuration === null || duration > currentAssetDuration ) { initialDuration = false; this.log( `Interstitial asset "${assetId}" duration change ${currentAssetDuration} > ${duration}`, ); assetItem.duration = duration; // Update schedule with new event and asset duration this.updateSchedule(); } }; player.on(Events.LEVEL_UPDATED, (event, { details }) => updateAssetPlayerDetails(details), ); player.on(Events.LEVEL_PTS_UPDATED, (event, { details }) => updateAssetPlayerDetails(details), ); player.on(Events.EVENT_CUE_ENTER, () => this.onInterstitialCueEnter()); const onBufferCodecs = ( event: Events.BUFFER_CODECS, data: BufferCodecsData, ) => { const inQueuPlayer = this.getAssetPlayer(assetId); if (inQueuPlayer && data.tracks) { inQueuPlayer.off(Events.BUFFER_CODECS, onBufferCodecs); inQueuPlayer.tracks = data.tracks; const media = this.primaryMedia; if ( this.bufferingAsset === inQueuPlayer.assetItem && media && !inQueuPlayer.media ) { this.bufferAssetPlayer(inQueuPlayer, media); } } }; player.on(Events.BUFFER_CODECS, onBufferCodecs); const bufferedToEnd = () => { const inQueuPlayer = this.getAssetPlayer(assetId); this.log(`buffered to end of asset ${inQueuPlayer}`); if (!inQueuPlayer || !this.schedule) { return; } // Preload at end of asset const scheduleIndex = this.schedule.findEventIndex( interstitial.identifier, ); const item = this.schedule.items?.[scheduleIndex]; if (this.isInterstitial(item)) { this.advanceAssetBuffering(item, assetItem); } }; player.on(Events.BUFFERED_TO_END, bufferedToEnd); const endedWithAssetIndex = (assetIndex) => { return () => { const inQueuPlayer = this.getAssetPlayer(assetId); if (!inQueuPlayer || !this.schedule) { return; } this.shouldPlay = true; const scheduleIndex = this.schedule.findEventIndex( interstitial.identifier, ); this.advanceAfterAssetEnded(interstitial, scheduleIndex, assetIndex); }; }; player.once(Events.MEDIA_ENDED, endedWithAssetIndex(assetListIndex)); player.once(Events.PLAYOUT_LIMIT_REACHED, endedWithAssetIndex(Infinity)); player.on(Events.ERROR, (event: Events.ERROR, data: ErrorData) => { if (!this.schedule) { return; } const inQueuPlayer = this.getAssetPlayer(assetId); if (data.details === ErrorDetails.BUFFER_STALLED_ERROR) { if (inQueuPlayer?.appendInPlace) { this.handleInPlaceStall(interstitial); return; } this.onTimeupdate(); this.checkBuffer(true); return; } this.handleAssetItemError( data, interstitial, this.schedule.findEventIndex(interstitial.identifier), assetListIndex, `Asset player error ${data.error} ${interstitial}`, ); }); player.on(Events.DESTROYING, () => { const inQueuPlayer = this.getAssetPlayer(assetId); if (!inQueuPlayer || !this.schedule) { return; } const error = new Error(`Asset player destroyed unexpectedly ${assetId}`); const errorData: ErrorData = { fatal: true, type: ErrorTypes.OTHER_ERROR, details: ErrorDetails.INTERSTITIAL_ASSET_ITEM_ERROR, error, }; this.handleAssetItemError( errorData, interstitial, this.schedule.findEventIndex(interstitial.identifier), assetListIndex, error.message, ); }); this.log( `INTERSTITIAL_ASSET_PLAYER_CREATED ${eventAssetToString(assetItem)}`, ); this.hls.trigger(Events.INTERSTITIAL_ASSET_PLAYER_CREATED, { asset: assetItem, assetListIndex, event: interstitial, player, }); return player; } private clearInterstitial( interstitial: InterstitialEvent, toSegment: InterstitialScheduleItem | null, ) { this.clearAssetPlayers(interstitial, toSegment); // Remove asset list and resolved duration interstitial.reset(); } private clearAssetPlayers( interstitial: InterstitialEvent, toSegment: InterstitialScheduleItem | null, ) { interstitial.assetList.forEach((asset) => { this.clearAssetPlayer(asset.identifier, toSegment); }); } private resetAssetPlayer(assetId: InterstitialAssetId) { // Reset asset player so that it's timeline can be adjusted without reloading the MVP const playerIndex = this.getAssetPlayerQueueIndex(assetId); if (playerIndex !== -1) { this.log(`reset asset player "${assetId}" after error`); const player = this.playerQueue[playerIndex]; this.transferMediaFromPlayer(player, null); player.resetDetails(); } } private clearAssetPlayer( assetId: InterstitialAssetId, toSegment: InterstitialScheduleItem | null, ) { const playerIndex = this.getAssetPlayerQueueIndex(assetId); if (playerIndex !== -1) { const player = this.playerQueue[playerIndex]; this.log( `clear ${player} toSegment: ${toSegment ? segmentToString(toSegment) : toSegment}`, ); this.transferMediaFromPlayer(player, toSegment); this.playerQueue.splice(playerIndex, 1); player.destroy(); } } private emptyPlayerQueue() { let player: HlsAssetPlayer | undefined; while ((player = this.playerQueue.pop())) { player.destroy(); } this.playerQueue = []; } private startAssetPlayer( player: HlsAssetPlayer, assetListIndex: number, scheduleItems: InterstitialScheduleItem[], scheduleIndex: number, media: HTMLMediaElement, ) { const { interstitial, assetItem, assetId } = player; const assetListLength = interstitial.assetList.length; const playingAsset = this.playingAsset; this.endedAsset = null; this.playingAsset = assetItem; if (!playingAsset || playingAsset.identifier !== assetId) { if (playingAsset) { // Exiting another Interstitial asset this.clearAssetPlayer( playingAsset.identifier, scheduleItems[scheduleIndex], ); delete playingAsset.error; } this.log( `INTERSTITIAL_ASSET_STARTED ${assetListIndex + 1}/${assetListLength} ${eventAssetToString(assetItem)}`, ); this.hls.trigger(Events.INTERSTITIAL_ASSET_STARTED, { asset: assetItem, assetListIndex, event: interstitial, schedule: scheduleItems.slice(0), scheduleIndex, player, }); } // detach media and attach to interstitial player if it does not have another element attached this.bufferAssetPlayer(player, media); } private bufferAssetPlayer(player: HlsAssetPlayer, media: HTMLMediaElement) { if (!this.schedule) { return; } const { interstitial, assetItem } = player; const scheduleIndex = this.schedule.findEventIndex(interstitial.identifier); const item = this.schedule.items?.[scheduleIndex]; if (!item) { return; } player.loadSource(); this.setBufferingItem(item); this.bufferingAsset = assetItem; const bufferingPlayer = this.getBufferingPlayer(); if (bufferingPlayer === player) { return; } const appendInPlaceNext = interstitial.appendInPlace; if ( appendInPlaceNext && bufferingPlayer?.interstitial.appendInPlace === false ) { // Media is detached and not available to append in place return; } const activeTracks = bufferingPlayer?.tracks || this.detachedData?.tracks || this.requiredTracks; if (appendInPlaceNext && assetItem !== this.playingAsset) { // Do not buffer another item if tracks are unknown or incompatible if (!player.tracks) { this.log(`Waiting for track info before buffering ${player}`); return; } if ( activeTracks && !isCompatibleTrackChange(activeTracks, player.tracks) ) { const error = new Error( `Asset ${eventAssetToString(assetItem)} SourceBuffer tracks ('${Object.keys(player.tracks)}') are not compatible with primary content tracks ('${Object.keys(activeTracks)}')`, ); const errorData: ErrorData = { fatal: true, type: ErrorTypes.OTHER_ERROR, details: ErrorDetails.INTERSTITIAL_ASSET_ITEM_ERROR, error, }; const assetListIndex = interstitial.findAssetIndex(assetItem); this.handleAssetItemError( errorData, interstitial, scheduleIndex, assetListIndex, error.message, ); return; } } this.transferMediaTo(player, media); } private handleInPlaceStall(interstitial: InterstitialEvent) { const schedule = this.schedule; const media = this.primaryMedia; if (!schedule || !media) { return; } const currentTime = media.currentTime; const foundAssetIndex = schedule.findAssetIndex(interstitial, currentTime); const stallingAsset = interstitial.assetList[foundAssetIndex] as | InterstitialAssetItem | undefined; if (stallingAsset) { const player = this.getAssetPlayer(stallingAsset.identifier); if (player) { const assetCurrentTime = player.currentTime || currentTime - stallingAsset.timelineStart; const distanceFromEnd = player.duration - assetCurrentTime; this.warn( `Stalled at ${assetCurrentTime} of ${assetCurrentTime + distanceFromEnd} in ${player} ${interstitial} (media.currentTime: ${currentTime})`, ); if ( assetCurrentTime && (distanceFromEnd / media.playbackRate < 0.5 || player.bufferedInPlaceToEnd(media)) && player.hls ) { const scheduleIndex = schedule.findEventIndex( interstitial.identifier, ); this.advanceAfterAssetEnded( interstitial, scheduleIndex, foundAssetIndex, ); } } } } private advanceInPlace(time: number) { const media = this.primaryMedia; if (media && media.currentTime < time) { media.currentTime = time; } } private handleAssetItemError( data: ErrorData, interstitial: InterstitialEvent, scheduleIndex: number, assetListIndex: number, errorMessage: string, ) { if (data.details === ErrorDetails.BUFFER_STALLED_ERROR) { return; } const assetItem = (interstitial.assetList[assetListIndex] || null) as InterstitialAssetItem | null; this.warn( `INTERSTITIAL_ASSET_ERROR ${assetItem ? eventAssetToString(assetItem) : assetItem} ${data.error}`, ); if (!this.schedule) { return; } const assetId = assetItem?.identifier || ''; const playerIndex = this.getAssetPlayerQueueIndex(assetId); const player = this.playerQueue[playerIndex] || null; const items = this.schedule.items; const interstitialAssetError = Object.assign({}, data, { fatal: false, errorAction: createDoNothingErrorAction(true), asset: assetItem, assetListIndex, event: interstitial, schedule: items, scheduleIndex, player, }); this.hls.trigger(Events.INTERSTITIAL_ASSET_ERROR, interstitialAssetError); if (!data.fatal) { return; } const playingAsset = this.playingAsset; const bufferingAsset = this.bufferingAsset; const error = new Error(errorMessage); if (assetItem) { this.clearAssetPlayer(assetId, null); assetItem.error = error; } // If all assets in interstitial fail, mark the interstitial with an error if (!interstitial.assetList.some((asset) => !asset.error)) { interstitial.error = error; } else { // Reset level details and reload/parse media playlists to align with updated schedule for (let i = assetListIndex; i < interstitial.assetList.length; i++) { this.resetAssetPlayer(interstitial.assetList[i].identifier); } } this.updateSchedule(true); if (interstitial.error) { this.primaryFallback(interstitial); } else if (playingAsset && playingAsset.identifier === assetId) { this.advanceAfterAssetEnded(interstitial, scheduleIndex, assetListIndex); } else if ( bufferingAsset && bufferingAsset.identifier === assetId && this.isInterstitial(this.bufferingItem) ) { this.advanceAssetBuffering(this.bufferingItem, bufferingAsset); } } private primaryFallback(interstitial: InterstitialEvent) { // Fallback to Primary by on current or future events by updating schedule to skip errored interstitials/assets const flushStart = interstitial.timelineStart; const playingItem = this.effectivePlayingItem; let timelinePos = this.timelinePos; // Update schedule now that interstitial/assets are flagged with `error` for fallback if (playingItem) { this.log( `Fallback to primary from event "${interstitial.identifier}" start: ${ flushStart } pos: ${timelinePos} playing: ${segmentToString( playingItem, )} error: ${interstitial.error}`, ); if (timelinePos === -1) { timelinePos = this.hls.startPosition; } const newPlayingItem = this.updateItem(playingItem, timelinePos); if (this.itemsMatch(playingItem, newPlayingItem)) { this.clearInterstitial(interstitial, null); } if (interstitial.appendInPlace) { this.attachPrimary(flushStart, null); this.flushFrontBuffer(flushStart); } } else if (timelinePos === -1) { this.checkStart(); return; } if (!this.schedule) { return; } const scheduleIndex = this.schedule.findItemIndexAtTime(timelinePos); this.setSchedulePosition(scheduleIndex); } // Asset List loading private onAssetListLoaded( event: Events.ASSET_LIST_LOADED, data: AssetListLoadedData, ) { const interstitial = data.event; const interstitialId = interstitial.identifier; const assets = data.assetListResponse.ASSETS; if (!this.schedule?.hasEvent(interstitialId)) { // Interstitial with id was removed return; } const eventStart = interstitial.timelineStart; const previousDuration = interstitial.duration; let sumDuration = 0; assets.forEach((asset, assetListIndex) => { const duration = parseFloat(asset.DURATION); this.createAsset( interstitial, assetListIndex, sumDuration, eventStart + sumDuration, duration, asset.URI, ); sumDuration += duration; }); interstitial.duration = sumDuration; this.log( `Loaded asset-list with duration: ${sumDuration} (was: ${previousDuration}) ${interstitial}`, ); const waitingItem = this.waitingItem; const waitingForItem = waitingItem?.event.identifier === interstitialId; // Update schedule now that asset.DURATION(s) are parsed this.updateSchedule(); const bufferingEvent = this.bufferingItem?.event; // If buffer reached Interstitial, start buffering first asset if (waitingForItem) { // Advance schedule when waiting for asset list data to play const scheduleIndex = this.schedule.findEventIndex(interstitialId); const item = this.schedule.items?.[scheduleIndex]; if (item) { if (!this.playingItem && this.timelinePos > item.end) { // Abandon if new duration is reduced enough to land playback in primary start const index = this.schedule.findItemIndexAtTime(this.timelinePos); if (index !== scheduleIndex) { interstitial.error = new Error( `Interstitial ${assets.length ? 'no longer within playback range' : 'asset-list is empty'} ${this.timelinePos} ${interstitial}`, ); this.log(interstitial.error.message); this.updateSchedule(true); this.primaryFallback(interstitial); return; } } this.setBufferingItem(item); } this.setSchedulePosition(scheduleIndex); } else if (bufferingEvent?.identifier === interstitialId) { const assetItem = interstitial.assetList[0]; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (assetItem) { const player = this.getAssetPlayer(assetItem.identifier); if (bufferingEvent.appendInPlace) { // If buffering (but not playback) has reached this item transfer media-source const media = this.primaryMedia; if (player && media) { this.bufferAssetPlayer(player, media); } } else if (player) { player.loadSource(); } } } } private onError(event: Events.ERROR, data: ErrorData) { if (!this.schedule) { return; } switch (data.details) { case ErrorDetails.ASSET_LIST_PARSING_ERROR: case ErrorDetails.ASSET_LIST_LOAD_ERROR: case ErrorDetails.ASSET_LIST_LOAD_TIMEOUT: { const interstitial = data.interstitial; if (interstitial) { this.updateSchedule(true); this.primaryFallback(interstitial); } break; } case ErrorDetails.BUFFER_STALLED_ERROR: { const stallingItem = this.endedItem || this.waitingItem || this.playingItem; if ( this.isInterstitial(stallingItem) && stallingItem.event.appendInPlace ) { this.handleInPlaceStall(stallingItem.event); return; } this.log( `Primary player stall @${this.timelinePos} bufferedPos: ${this.bufferedPos}`, ); this.onTimeupdate(); this.checkBuffer(true); break; } } } }