download
raw
95.8 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;
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<T extends InterstitialScheduleItem>(
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;
}
}
}
}

Xet Storage Details

Size:
95.8 kB
·
Xet hash:
f5ba3b328115f66769cb742abd54ac6b0ecd3dfc87e9ab51ce4643c977e051bb

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.