UNPKG

hls.js

Version:

JavaScript HLS client using MediaSourceExtension

1,491 lines (1,422 loc) • 96.4 kB
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<void> | 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<BufferCodecsData> | 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; if (this.timelinePos === -1 && !this.effectivePlayingItem) { this.checkStart(); } 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; } if (this.timelinePos === -1 && !this.effectivePlayingItem) { this.checkStart(); } // 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.timelinePos = startPosition; if (interstitialEvents.length === 0) { this.setSchedulePosition(0); } else if (interstitialEvents[0].cue.pre) { this.log(timelineMessage('checkStart (preroll)', startPosition)); const index = schedule.findEventIndex(interstitialEvents[0].identifier); this.setSchedulePosition(index); } else if (startPosition >= 0 || !this.primaryLive) { this.log(timelineMessage('checkStart', startPosition)); const start = (this.timelinePos = startPosition > 0 ? startPosition : 0); const index = schedule.findItemIndexAtTime(start); this.setSchedulePosition(index); } else if (this.hls.liveSyncPosition === 0) { this.setSchedulePosition(0); } else { this.log('[checkStart] waiting for live start'); } } else if (effectivePlayingItem && !this.playingItem) { this.log( timelineMessage( 'checkStart (playing item)', effectivePlayingItem.start, ), ); 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.bufferin