hls.js
Version:
JavaScript HLS client using MediaSourceExtension
1,491 lines (1,422 loc) • 96.4 kB
text/typescript
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