Fix Interstitials live start with short sliding window (#7799)

* Fix Interstitials live start with short sliding window (N3 or less / no seek on start)

* Fix initial startPosition and liveSyncPosition (of 0) for short playlists
Remove toFixed() in stream controller logs
Fix typo in tests
This commit is contained in:
Rob Walch
2026-04-13 13:45:07 -07:00
committed by GitHub
parent 27a0de2d49
commit b94cd21044
5 changed files with 341 additions and 195 deletions
+14 -21
View File
@@ -114,7 +114,7 @@ export default class BaseStreamController
protected bitrateTest: boolean = false;
protected lastCurrentTime: number = 0;
protected nextLoadPosition: number = 0;
protected startPosition: number = 0;
protected startPosition: number = -1;
protected startTimeOffset: number | null = null;
protected retryDate: number = 0;
protected levels: Array<Level> | null = null;
@@ -383,7 +383,8 @@ export default class BaseStreamController
this.levelLastLoaded =
this.fragCurrent =
null;
this.lastCurrentTime = this.startPosition = 0;
this.lastCurrentTime = 0;
this.startPosition = -1;
this.startFragRequested = false;
}
@@ -401,7 +402,7 @@ export default class BaseStreamController
this.log(
`Media seeking to ${
Number.isFinite(currentTime) ? currentTime.toFixed(3) : currentTime
currentTime
}, state: ${state}, ${!fowardBuffer ? 'out of' : 'in'} buffer`,
);
@@ -462,9 +463,7 @@ export default class BaseStreamController
);
if (shouldLoadParts) {
this.log(
`LL-Part loading ON after seeking to ${currentTime.toFixed(
3,
)} with buffer @${bufferEnd.toFixed(3)}`,
`LL-Part loading ON after seeking to ${currentTime} with buffer @${bufferEnd}`,
);
this.loadingParts = shouldLoadParts;
}
@@ -956,9 +955,7 @@ export default class BaseStreamController
this.log(
`Loading ${frag.type} sn: ${frag.sn} part: ${part.index} (${partIndex}/${partList.length - 1}) of ${this.fragInfo(frag, false, part)} cc: ${
frag.cc
} [${details.startSN}-${details.endSN}], target: ${parseFloat(
targetBufferTime.toFixed(3),
)}${
} [${details.startSN}-${details.endSN}], target: ${targetBufferTime}${
part.byteRange.length ? ` range:${part.byteRange.join('-')}` : ''
}`,
);
@@ -1015,9 +1012,7 @@ export default class BaseStreamController
if (isMediaFragment(frag) && this.loadingParts) {
this.log(
`LL-Part loading OFF after next part miss @${targetBufferTime.toFixed(
3,
)} Check buffer at sn: ${frag.sn} loaded parts: ${details.partList?.filter((p) => p.loaded).map((p) => `[${p.start}-${p.end}]`)}`,
`LL-Part loading OFF after next part miss @${targetBufferTime} Check buffer at sn: ${frag.sn} loaded parts: ${details.partList?.filter((p) => p.loaded).map((p) => `[${p.start}-${p.end}]`)}`,
);
this.loadingParts = false;
} else if (!frag.url) {
@@ -1028,7 +1023,7 @@ export default class BaseStreamController
this.log(
`Loading ${frag.type} sn: ${frag.sn} of ${this.fragInfo(frag, false)} cc: ${frag.cc} ${
'[' + details.startSN + '-' + details.endSN + ']'
}, target: ${parseFloat(targetBufferTime.toFixed(3))}${
}, target: ${targetBufferTime}${
frag.byteRange.length ? ` range:${frag.byteRange.join('-')}` : ''
}`,
);
@@ -1173,7 +1168,7 @@ export default class BaseStreamController
this.log(
`LL-Part loading ${
shouldLoadParts ? 'ON' : 'OFF'
} after parsing segment ending @${frag.end.toFixed(3)}`,
} after parsing segment ending @${frag.end}`,
);
this.loadingParts = shouldLoadParts;
}
@@ -1478,7 +1473,7 @@ export default class BaseStreamController
this.nextLoadPosition != startPosition
) {
this.log(
`Setting startPosition to ${startPosition.toFixed(3)} ${mainStart === -1 ? '' : `(from ${configValue}) `}based on ${reason}. live edge: ${liveSyncPosition} live frag start: ${fragStart.toFixed(3)} playlist start: ${playlistStart.toFixed(3)} buffer pos: ${pos}`,
`Setting startPosition to ${startPosition} ${mainStart === -1 ? '' : `(from ${configValue}) `}based on ${reason}. live edge: ${liveSyncPosition} live frag start: ${fragStart} playlist start: ${playlistStart} buffer pos: ${pos}`,
);
this.startPosition = this.nextLoadPosition = startPosition;
}
@@ -1864,7 +1859,7 @@ export default class BaseStreamController
alignStream(switchDetails, details, this);
const alignedSlidingStart = details.fragmentStart;
this.log(
`Live playlist sliding: ${alignedSlidingStart.toFixed(3)} start-sn: ${
`Live playlist sliding: ${alignedSlidingStart} start-sn: ${
previousDetails ? previousDetails.startSN : 'na'
}->${details.startSN} fragments: ${length}`,
);
@@ -1920,7 +1915,7 @@ export default class BaseStreamController
const liveSyncPosition = this.hls.liveSyncPosition;
startPosition = liveSyncPosition || sliding;
this.log(
`Setting startPosition to -1 to start at ${liveSyncPosition ? 'live edge' : 'playlist start'} ${startPosition.toFixed(3)}`,
`Setting startPosition to -1 to start at ${liveSyncPosition ? 'live edge' : 'playlist start'} ${startPosition}`,
);
this.startPosition = -1;
} else {
@@ -2336,11 +2331,9 @@ export default class BaseStreamController
pts: boolean = true,
part?: Part | null,
): string {
return `${this.playlistLabel()} ${frag.level} (${part ? 'part' : 'frag'}:[${((pts && !part ? frag.startPTS : (part || frag).start) ?? NaN).toFixed(3)}-${(
return `${this.playlistLabel()} ${frag.level} (${part ? 'part' : 'frag'}:[${(pts && !part ? frag.startPTS : (part || frag).start) ?? NaN}-${
(pts && !part ? frag.endPTS : (part || frag).end) ?? NaN
).toFixed(
3,
)}]${part && frag.type === 'main' ? 'INDEPENDENT=' + (part.independent ? 'YES' : 'NO') : ''})`;
}]${part && frag.type === 'main' ? 'INDEPENDENT=' + (part.independent ? 'YES' : 'NO') : ''})`;
}
private treatAsGap(frag: MediaFragment, level?: Level) {
+22 -2
View File
@@ -898,6 +898,9 @@ export default class InterstitialsController
return;
}
const backwardSeek = diff <= -0.01;
if (this.timelinePos === -1 && !this.effectivePlayingItem) {
this.checkStart();
}
this.timelinePos = currentTime;
this.bufferedPos = currentTime;
@@ -988,6 +991,10 @@ export default class InterstitialsController
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) {
@@ -1036,18 +1043,31 @@ export default class InterstitialsController
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) {
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);
}
+5 -9
View File
@@ -167,9 +167,7 @@ export default class StreamController
!skipSeekToStartPosition
) {
this.log(
`Override startPosition with lastCurrentTime @${lastCurrentTime.toFixed(
3,
)}`,
`Override startPosition with lastCurrentTime @${lastCurrentTime}`,
);
startPosition = lastCurrentTime;
}
@@ -536,7 +534,7 @@ export default class StreamController
return;
}
this.log(`Media seeked to ${currentTime.toFixed(3)}`);
this.log(`Media seeked to ${currentTime}`);
// If seeked was issued before buffer was appended do not tick immediately
if (!this.getBufferedFrag(currentTime)) {
@@ -726,11 +724,9 @@ export default class StreamController
// Only seek if ready and there is not a significant forward buffer available for playback
if (media.readyState) {
this.warn(
`Playback: ${currentTime.toFixed(
3,
)} is located too far from the end of live sliding playlist: ${end}, reset currentTime to : ${liveSyncPosition.toFixed(
3,
)}`,
`Playback: ${
currentTime
} is located too far from the end of live sliding playlist: ${end}, reset currentTime to : ${liveSyncPosition}`,
);
if (this.config.liveSyncMode === 'buffered') {
+1 -1
View File
@@ -1197,7 +1197,7 @@ export default class Hls implements HlsEventEmitter {
* @returns null prior to loading live Playlist
*/
get liveSyncPosition(): number | null {
return this.latencyController?.liveSyncPosition || null;
return this.latencyController?.liveSyncPosition ?? null;
}
/**
+299 -162
View File
@@ -5,13 +5,18 @@ import InterstitialsController from '../../../src/controller/interstitials-contr
import { Events } from '../../../src/events';
import Hls from '../../../src/hls';
import { TimelineOccupancy } from '../../../src/loader/interstitial-event';
import { LoadStats } from '../../../src/loader/load-stats';
import M3U8Parser from '../../../src/loader/m3u8-parser';
import { Level } from '../../../src/types/level';
import { PlaylistLevelType } from '../../../src/types/loader';
import { AttrList } from '../../../src/utils/attr-list';
import { MockMediaElement } from '../../mocks/mock-media';
import type { HlsConfig } from '../../../src/config';
import type AbrController from '../../../src/controller/abr-controller';
import type { InterstitialScheduleItem } from '../../../src/controller/interstitials-schedule';
import type LatencyController from '../../../src/controller/latency-controller';
import type LevelController from '../../../src/controller/level-controller';
import type StreamController from '../../../src/controller/stream-controller';
import type {
ComponentAPI,
NetworkComponentAPI,
@@ -24,15 +29,35 @@ type HlsTestable = Omit<Hls, 'networkControllers' | 'coreComponents'> & {
coreComponents: ComponentAPI[];
networkControllers: NetworkComponentAPI[];
trigger: Hls['trigger'] & sinon.SinonSpy;
abrController: AbrController;
latencyController: LatencyController;
levelController: LevelController;
streamController: StreamController;
};
class HLSTestPlayer extends Hls {
constructor(config: Partial<HlsConfig>) {
super(config);
const hlsTestable = this as unknown as HlsTestable;
hlsTestable.networkControllers.forEach((component) => component.destroy());
hlsTestable.networkControllers.forEach((component) => {
if (
component !== hlsTestable.streamController &&
component !== hlsTestable.levelController
) {
component.destroy();
}
});
hlsTestable.networkControllers.length = 0;
hlsTestable.coreComponents.forEach((component) => component.destroy());
hlsTestable.networkControllers.push(hlsTestable.streamController);
hlsTestable.coreComponents.forEach((component) => {
if (
component !== hlsTestable.abrController &&
component !== hlsTestable.latencyController &&
component !== (hlsTestable.streamController as any).fragmentTracker
) {
component.destroy();
}
});
hlsTestable.coreComponents.length = 0;
hlsTestable.on(Events.MEDIA_ATTACHING, (t, data) => {
data.media.src = '';
@@ -136,6 +161,8 @@ describe('InterstitialsController', function () {
0,
null,
);
const timeSinceLoadedStub = sinon.stub(details, 'age');
timeSinceLoadedStub.get(() => 0);
expect(details.playlistParsingError).to.equal(null);
const attrs = new AttrList({});
const level = new Level({
@@ -145,13 +172,19 @@ describe('InterstitialsController', function () {
bitrate: 0,
});
level.details = details;
(hls as any).levelController._levels[0] = level;
(hls as any).streamController.startPosition = details.live
? details.totalduration - details.targetduration * 3
: 0;
hls.trigger(Events.LEVEL_UPDATED, {
(hls.streamController as any).levels = [level];
(hls.levelController as any)._levels[0] = level;
(hls.levelController as any).currentLevelIndex = 0;
(hls.levelController as any).currentLevel = level;
hls.trigger(Events.LEVEL_LOADED, {
details,
levelInfo: level,
level: 0,
id: 0,
stats: new LoadStats(),
networkDetails: null,
deliveryDirectives: null,
withoutMultiVariant: false,
});
return details;
}
@@ -173,6 +206,7 @@ describe('InterstitialsController', function () {
HLSTestPlayer,
);
sandbox.spy(hls, 'trigger');
sandbox.stub(hls.streamController as any, 'loadFragment');
});
afterEach(function () {
@@ -196,17 +230,17 @@ fileSequence4.ts
attachMediaToHls();
hls.trigger.resetHistory();
setLoadedLevelDetails(playlist);
const insterstitials = interstitialsController.interstitialsManager;
if (!insterstitials) {
expect(insterstitials, 'interstitialsManager').to.be.an('object');
const interstitials = interstitialsController.interstitialsManager;
if (!interstitials) {
expect(interstitials, 'interstitialsManager').to.be.an('object');
return;
}
const schedule = insterstitials.schedule;
expect(insterstitials.bufferingIndex).to.equal(0, 'bufferingIndex');
expect(insterstitials.playingIndex).to.equal(0, 'playingIndex');
expect(insterstitials.events).is.an('array').which.has.lengthOf(1);
const schedule = interstitials.schedule;
expect(interstitials.bufferingIndex).to.equal(0, 'bufferingIndex');
expect(interstitials.playingIndex).to.equal(0, 'playingIndex');
expect(interstitials.events).is.an('array').which.has.lengthOf(1);
expect(schedule).is.an('array').which.has.lengthOf(2);
const interstitialEvent = insterstitials.events[0];
const interstitialEvent = interstitials.events[0];
expect(interstitialEvent.identifier).to.equal('0');
expect(interstitialEvent.restrictions.jump).to.equal(true);
expect(interstitialEvent.restrictions.skip).to.equal(true);
@@ -249,6 +283,7 @@ fileSequence4.ts
const eventsTriggered = getTriggerCalls();
expect(eventsTriggered).to.deep.equal(
[
Events.LEVEL_LOADED,
Events.LEVEL_UPDATED,
Events.INTERSTITIALS_UPDATED,
Events.INTERSTITIALS_BUFFERED_TO_BOUNDARY,
@@ -277,13 +312,13 @@ fileSequence3.ts
fileSequence4.ts
#EXT-X-ENDLIST`;
setLoadedLevelDetails(playlist);
const insterstitials = interstitialsController.interstitialsManager;
if (!insterstitials) {
expect(insterstitials, 'interstitialsManager').to.be.an('object');
const interstitials = interstitialsController.interstitialsManager;
if (!interstitials) {
expect(interstitials, 'interstitialsManager').to.be.an('object');
return;
}
const events = insterstitials.events;
const schedule = insterstitials.schedule;
const events = interstitials.events;
const schedule = interstitials.schedule;
expect(events).is.an('array').which.has.lengthOf(5);
expect(schedule)
.is.an('array')
@@ -460,13 +495,13 @@ fileSequence2.ts
fileSequence3.ts
#EXT-X-ENDLIST`;
setLoadedLevelDetails(playlist);
const insterstitials = interstitialsController.interstitialsManager;
if (!insterstitials) {
expect(insterstitials, 'interstitialsManager').to.be.an('object');
const interstitials = interstitialsController.interstitialsManager;
if (!interstitials) {
expect(interstitials, 'interstitialsManager').to.be.an('object');
return;
}
const events = insterstitials.events;
const schedule = insterstitials.schedule;
const events = interstitials.events;
const schedule = interstitials.schedule;
expect(events).is.an('array').which.has.lengthOf(5);
const scheduleDebugString = `Schedule items: ${schedule.map((item) => `[${item.event ? 'I' : 'P'}:${item.start}-${item.end}]`).join(', ')}`;
expect(schedule)
@@ -702,20 +737,20 @@ fileSequence3.mp4
attachMediaToHls();
hls.trigger.resetHistory();
setLoadedLevelDetails(playlist);
const insterstitials = interstitialsController.interstitialsManager;
if (!insterstitials) {
expect(insterstitials, 'interstitialsManager').to.be.an('object');
const interstitials = interstitialsController.interstitialsManager;
if (!interstitials) {
expect(interstitials, 'interstitialsManager').to.be.an('object');
return;
}
const schedule = insterstitials.schedule;
expect(insterstitials.bufferingIndex).to.equal(0, 'bufferingIndex');
expect(insterstitials.playingIndex).to.equal(0, 'playingIndex');
expect(insterstitials.events).is.an('array').which.has.lengthOf(2);
const schedule = interstitials.schedule;
expect(interstitials.bufferingIndex).to.equal(0, 'bufferingIndex');
expect(interstitials.playingIndex).to.equal(0, 'playingIndex');
expect(interstitials.events).is.an('array').which.has.lengthOf(2);
expect(schedule).is.an('array').which.has.lengthOf(4);
expect(insterstitials.events[0].identifier).to.equal('ad1');
expect(insterstitials.events[1].identifier).to.equal('ad2');
expect(insterstitials.events[0]).to.equal(schedule[0].event);
expect(insterstitials.events[1]).to.equal(schedule[2].event);
expect(interstitials.events[0].identifier).to.equal('ad1');
expect(interstitials.events[1].identifier).to.equal('ad2');
expect(interstitials.events[0]).to.equal(schedule[0].event);
expect(interstitials.events[1]).to.equal(schedule[2].event);
expectScheduleToInclude(schedule, [
{
@@ -803,17 +838,17 @@ fileSequence2.mp4
fileSequence3.mp4
#EXT-X-ENDLIST`;
setLoadedLevelDetails(playlist);
const insterstitials = interstitialsController.interstitialsManager;
if (!insterstitials) {
expect(insterstitials, 'interstitialsManager').to.be.an('object');
const interstitials = interstitialsController.interstitialsManager;
if (!interstitials) {
expect(interstitials, 'interstitialsManager').to.be.an('object');
return;
}
const schedule = insterstitials.schedule;
expect(insterstitials.events).is.an('array').which.has.lengthOf(2);
const schedule = interstitials.schedule;
expect(interstitials.events).is.an('array').which.has.lengthOf(2);
expect(schedule).is.an('array').which.has.lengthOf(2);
expect(insterstitials.events[0].identifier).to.equal('ad1');
expect(insterstitials.events[1].identifier).to.equal('ad2');
expect(insterstitials.events[0]).to.equal(schedule[0].event);
expect(interstitials.events[0].identifier).to.equal('ad1');
expect(interstitials.events[1].identifier).to.equal('ad2');
expect(interstitials.events[0]).to.equal(schedule[0].event);
expectScheduleToInclude(schedule, [
{
@@ -863,13 +898,13 @@ fileSequence3.ts
fileSequence4.ts
#EXT-X-ENDLIST`;
setLoadedLevelDetails(playlist);
const insterstitials = interstitialsController.interstitialsManager;
if (!insterstitials) {
expect(insterstitials, 'interstitialsManager').to.be.an('object');
const interstitials = interstitialsController.interstitialsManager;
if (!interstitials) {
expect(interstitials, 'interstitialsManager').to.be.an('object');
return;
}
const schedule = insterstitials.schedule;
const events = insterstitials.events;
const schedule = interstitials.schedule;
const events = interstitials.events;
expect(events).is.an('array').which.has.lengthOf(1);
expect(schedule).is.an('array').which.has.lengthOf(2);
expect(events[0]).to.deep.include({
@@ -878,10 +913,10 @@ fileSequence4.ts
supplementsPrimary: false,
contentMayVary: true,
});
expect(insterstitials.primary).to.include({
expect(interstitials.primary).to.include({
duration: 12,
});
expect(insterstitials.integrated).to.include({
expect(interstitials.integrated).to.include({
duration: 4,
});
expectScheduleToInclude(schedule, [
@@ -955,13 +990,13 @@ fileSequence4.ts
},
];
setLoadedLevelDetails(playlist);
const insterstitials = interstitialsController.interstitialsManager;
if (!insterstitials) {
expect(insterstitials, 'interstitialsManager').to.be.an('object');
const interstitials = interstitialsController.interstitialsManager;
if (!interstitials) {
expect(interstitials, 'interstitialsManager').to.be.an('object');
return;
}
const schedule = insterstitials.schedule;
const events = insterstitials.events;
const schedule = interstitials.schedule;
const events = interstitials.events;
expect(events).is.an('array').which.has.lengthOf(4);
expect(schedule).is.an('array').which.has.lengthOf(5);
eventAssertions.forEach((assertions, i) => {
@@ -970,10 +1005,10 @@ fileSequence4.ts
assertions,
);
});
expect(insterstitials.primary).to.include({
expect(interstitials.primary).to.include({
duration: 12,
});
expect(insterstitials.integrated).to.include({
expect(interstitials.integrated).to.include({
duration: 64,
});
expectScheduleToInclude(schedule, [
@@ -1086,21 +1121,25 @@ fileSequence5.mp4`;
it('should begin preroll on attach', function () {
setLoadedLevelDetails(playlist);
const insterstitials = interstitialsController.interstitialsManager;
if (!insterstitials) {
expect(insterstitials, 'interstitialsManager').to.be.an('object');
const interstitials = interstitialsController.interstitialsManager;
if (!interstitials) {
expect(interstitials, 'interstitialsManager').to.be.an('object');
return;
}
expect(insterstitials.events).is.an('array').which.has.lengthOf(2);
expect(insterstitials.schedule).is.an('array').which.has.lengthOf(4);
expect(interstitials.events).is.an('array').which.has.lengthOf(2);
expect(interstitials.schedule).is.an('array').which.has.lengthOf(4);
const callsWithPrerollBeforeAttach = getTriggerCalls();
expect(callsWithPrerollBeforeAttach).to.deep.equal(
[Events.LEVEL_UPDATED, Events.INTERSTITIALS_UPDATED],
[
Events.LEVEL_LOADED,
Events.LEVEL_UPDATED,
Events.INTERSTITIALS_UPDATED,
],
`Actual events before attach`,
);
hls.trigger.resetHistory();
expect(insterstitials.bufferingIndex).to.equal(-1, 'bufferingIndex');
expect(insterstitials.playingIndex).to.equal(-1, 'playingIndex');
expect(interstitials.bufferingIndex).to.equal(-1, 'bufferingIndex');
expect(interstitials.playingIndex).to.equal(-1, 'playingIndex');
attachMediaToHls();
const callsWithPrerollAfterAttach = getTriggerCalls();
const expectedEvents = [
@@ -1117,18 +1156,17 @@ fileSequence5.mp4`;
expectedEvents,
`Actual events after attach`,
);
expect(insterstitials.bufferingIndex).to.equal(0, 'bufferingIndex');
expect(insterstitials.playingIndex).to.equal(0, 'playingIndex');
expect(interstitials.bufferingIndex).to.equal(0, 'bufferingIndex');
expect(interstitials.playingIndex).to.equal(0, 'playingIndex');
expect(interstitials.interstitialPlayer, `interstitialPlayer`).to.include(
{
playingIndex: 0,
currentTime: 0,
duration: 37,
},
);
expect(
insterstitials.interstitialPlayer,
`interstitialPlayer`,
).to.include({
playingIndex: 0,
currentTime: 0,
duration: 37,
});
expect(
insterstitials.interstitialPlayer?.scheduleItem?.event,
interstitials.interstitialPlayer?.scheduleItem?.event,
`interstitialPlayer.scheduleItem`,
).to.include({ identifier: 'pre' });
});
@@ -1151,18 +1189,19 @@ fileSequence3.mp4
attachMediaToHls();
setLoadedLevelDetails(playlist);
const insterstitials = interstitialsController.interstitialsManager;
if (!insterstitials) {
expect(insterstitials, 'interstitialsManager').to.be.an('object');
const interstitials = interstitialsController.interstitialsManager;
if (!interstitials) {
expect(interstitials, 'interstitialsManager').to.be.an('object');
return;
}
expect(insterstitials.events).is.an('array').which.has.lengthOf(1);
expect(insterstitials.schedule).is.an('array').which.has.lengthOf(2);
expect(interstitials.events).is.an('array').which.has.lengthOf(1);
expect(interstitials.schedule).is.an('array').which.has.lengthOf(2);
const callsBeforeAttach = getTriggerCalls();
expect(callsBeforeAttach).to.deep.equal(
[
Events.MEDIA_ATTACHING,
Events.MEDIA_ATTACHED,
Events.LEVEL_LOADED,
Events.LEVEL_UPDATED,
Events.INTERSTITIALS_UPDATED,
Events.INTERSTITIALS_BUFFERED_TO_BOUNDARY,
@@ -1172,12 +1211,12 @@ fileSequence3.mp4
`Actual events before asset-list`,
);
hls.trigger.resetHistory();
expect(insterstitials.bufferingIndex).to.equal(0, 'bufferingIndex a');
expect(insterstitials.playingIndex).to.equal(0, 'playingIndex a');
expect(insterstitials.primary.currentTime).to.equal(0, 'timelinePos a');
expect(interstitials.bufferingIndex).to.equal(0, 'bufferingIndex a');
expect(interstitials.playingIndex).to.equal(0, 'playingIndex a');
expect(interstitials.primary.currentTime).to.equal(0, 'timelinePos a');
// Load empty asset-list
const interstitial = insterstitials.events[0];
const interstitial = interstitials.events[0];
interstitial.assetListResponse = { ASSETS: [] };
hls.trigger(Events.ASSET_LIST_LOADED, {
event: interstitial,
@@ -1195,9 +1234,9 @@ fileSequence3.mp4
],
`Actual events after asset-list`,
);
expect(insterstitials.bufferingIndex).to.equal(1, 'bufferingIndex b');
expect(insterstitials.playingIndex).to.equal(1, 'playingIndex b');
expect(insterstitials.primary.currentTime).to.equal(5, 'timelinePos b');
expect(interstitials.bufferingIndex).to.equal(1, 'bufferingIndex b');
expect(interstitials.playingIndex).to.equal(1, 'playingIndex b');
expect(interstitials.primary.currentTime).to.equal(5, 'timelinePos b');
});
it('should handle empty asset-lists without resume offset, ignoring date range tag duration', function () {
@@ -1218,18 +1257,19 @@ fileSequence3.mp4
attachMediaToHls();
setLoadedLevelDetails(playlist);
const insterstitials = interstitialsController.interstitialsManager;
if (!insterstitials) {
expect(insterstitials, 'interstitialsManager').to.be.an('object');
const interstitials = interstitialsController.interstitialsManager;
if (!interstitials) {
expect(interstitials, 'interstitialsManager').to.be.an('object');
return;
}
expect(insterstitials.events).is.an('array').which.has.lengthOf(1);
expect(insterstitials.schedule).is.an('array').which.has.lengthOf(2);
expect(interstitials.events).is.an('array').which.has.lengthOf(1);
expect(interstitials.schedule).is.an('array').which.has.lengthOf(2);
const callsBeforeAttach = getTriggerCalls();
expect(callsBeforeAttach).to.deep.equal(
[
Events.MEDIA_ATTACHING,
Events.MEDIA_ATTACHED,
Events.LEVEL_LOADED,
Events.LEVEL_UPDATED,
Events.INTERSTITIALS_UPDATED,
Events.INTERSTITIALS_BUFFERED_TO_BOUNDARY,
@@ -1239,12 +1279,12 @@ fileSequence3.mp4
`Actual events before asset-list`,
);
hls.trigger.resetHistory();
expect(insterstitials.bufferingIndex).to.equal(0, 'bufferingIndex a');
expect(insterstitials.playingIndex).to.equal(0, 'playingIndex a');
expect(insterstitials.primary.currentTime).to.equal(0, 'timelinePos a');
expect(interstitials.bufferingIndex).to.equal(0, 'bufferingIndex a');
expect(interstitials.playingIndex).to.equal(0, 'playingIndex a');
expect(interstitials.primary.currentTime).to.equal(0, 'timelinePos a');
// Load empty asset-list
const interstitial = insterstitials.events[0];
const interstitial = interstitials.events[0];
interstitial.assetListResponse = { ASSETS: [] };
hls.trigger(Events.ASSET_LIST_LOADED, {
event: interstitial,
@@ -1262,9 +1302,9 @@ fileSequence3.mp4
],
`Actual events after asset-list`,
);
expect(insterstitials.bufferingIndex).to.equal(1, 'bufferingIndex b');
expect(insterstitials.playingIndex).to.equal(1, 'playingIndex b');
expect(insterstitials.primary.currentTime).to.equal(
expect(interstitials.bufferingIndex).to.equal(1, 'bufferingIndex b');
expect(interstitials.playingIndex).to.equal(1, 'playingIndex b');
expect(interstitials.primary.currentTime).to.equal(
0,
'playback should resume primary at 0 because no interstitial played',
);
@@ -1293,21 +1333,25 @@ fileSequence6.mp4`;
// Loaded playlist (before attaching media)
setLoadedLevelDetails(playlist);
const insterstitials = interstitialsController.interstitialsManager;
if (!insterstitials) {
expect(insterstitials, 'interstitialsManager').to.be.an('object');
const interstitials = interstitialsController.interstitialsManager;
if (!interstitials) {
expect(interstitials, 'interstitialsManager').to.be.an('object');
return;
}
expect(insterstitials.events).is.an('array').which.has.lengthOf(1);
expect(insterstitials.schedule).is.an('array').which.has.lengthOf(3);
expect(interstitials.events).is.an('array').which.has.lengthOf(1);
expect(interstitials.schedule).is.an('array').which.has.lengthOf(3);
const eventsBeforeAttach = getTriggerCalls();
expect(eventsBeforeAttach).to.deep.equal(
[Events.LEVEL_UPDATED, Events.INTERSTITIALS_UPDATED],
[
Events.LEVEL_LOADED,
Events.LEVEL_UPDATED,
Events.INTERSTITIALS_UPDATED,
],
`Actual events before attach`,
);
expect(insterstitials.bufferingIndex).to.equal(-1, 'bufferingIndex');
expect(insterstitials.playingIndex).to.equal(-1, 'playingIndex');
expect(insterstitials.primary.currentTime).to.equal(0, 'timelinePos');
expect(interstitials.bufferingIndex).to.equal(-1, 'bufferingIndex');
expect(interstitials.playingIndex).to.equal(-1, 'playingIndex');
expect(interstitials.primary.currentTime).to.equal(0, 'timelinePos');
// Attach media
hls.trigger.resetHistory();
@@ -1324,13 +1368,13 @@ fileSequence6.mp4`;
expectedEvents,
`Actual events after attach`,
);
expect(insterstitials.bufferingIndex).to.equal(1, 'bufferingIndex a');
expect(insterstitials.playingIndex).to.equal(1, 'playingIndex a');
expect(insterstitials.primary.currentTime).to.equal(30, 'timelinePos a');
expect(interstitials.bufferingIndex).to.equal(1, 'bufferingIndex a');
expect(interstitials.playingIndex).to.equal(1, 'playingIndex a');
expect(interstitials.primary.currentTime).to.equal(30, 'timelinePos a');
// Load asset-list
hls.trigger.resetHistory();
const interstitial = insterstitials.events[0];
const interstitial = interstitials.events[0];
interstitial.assetListResponse = {
ASSETS: [{ URI: '', DURATION: '10' }],
};
@@ -1353,7 +1397,7 @@ fileSequence6.mp4`;
// skip to end of interstitial
hls.trigger.resetHistory();
insterstitials.skip();
interstitials.skip();
const eventsAfterSkip = getTriggerCalls();
const expectedSkipEvents = [
Events.INTERSTITIAL_ASSET_ENDED,
@@ -1368,9 +1412,9 @@ fileSequence6.mp4`;
expectedSkipEvents,
`Actual events after skip`,
);
expect(insterstitials.bufferingIndex).to.equal(2, 'bufferingIndex b');
expect(insterstitials.playingIndex).to.equal(2, 'playingIndex b');
expect(insterstitials.primary.currentTime).to.equal(
expect(interstitials.bufferingIndex).to.equal(2, 'bufferingIndex b');
expect(interstitials.playingIndex).to.equal(2, 'playingIndex b');
expect(interstitials.primary.currentTime).to.equal(
interstitial.appendInPlace ? 40.001 : 40,
'timelinePos b',
);
@@ -1421,31 +1465,32 @@ fileSequence6.mp4`;
// Loaded playlist
hls.trigger.resetHistory();
setLoadedLevelDetails(playlist);
const insterstitials = interstitialsController.interstitialsManager;
if (!insterstitials) {
expect(insterstitials, 'interstitialsManager').to.be.an('object');
const interstitials = interstitialsController.interstitialsManager;
if (!interstitials) {
expect(interstitials, 'interstitialsManager').to.be.an('object');
return;
}
expect(insterstitials.events).is.an('array').which.has.lengthOf(1);
expect(insterstitials.schedule).is.an('array').which.has.lengthOf(3);
expect(interstitials.events).is.an('array').which.has.lengthOf(1);
expect(interstitials.schedule).is.an('array').which.has.lengthOf(3);
const eventsAfterPlaylist = getTriggerCalls();
expect(eventsAfterPlaylist).to.deep.equal(
[
Events.LEVEL_LOADED,
Events.LEVEL_UPDATED,
Events.INTERSTITIALS_UPDATED,
Events.INTERSTITIALS_BUFFERED_TO_BOUNDARY,
Events.ASSET_LIST_LOADING,
Events.INTERSTITIAL_STARTED,
],
`Actual events before attach`,
`Actual events after playlist loaded`,
);
expect(insterstitials.bufferingIndex).to.equal(1, 'bufferingIndex a');
expect(insterstitials.playingIndex).to.equal(1, 'playingIndex a');
expect(insterstitials.primary.currentTime).to.equal(30, 'timelinePos a');
expect(interstitials.bufferingIndex).to.equal(1, 'bufferingIndex a');
expect(interstitials.playingIndex).to.equal(1, 'playingIndex a');
expect(interstitials.primary.currentTime).to.equal(30, 'timelinePos a');
// Load asset-list
hls.trigger.resetHistory();
const interstitial = insterstitials.events[0];
const interstitial = interstitials.events[0];
interstitial.assetListResponse = {
ASSETS: [{ URI: '', DURATION: '10' }],
};
@@ -1468,7 +1513,7 @@ fileSequence6.mp4`;
// skip to end of interstitial
hls.trigger.resetHistory();
insterstitials.skip();
interstitials.skip();
const eventsAfterSkip = getTriggerCalls();
const expectedSkipEvents = [
Events.INTERSTITIAL_ASSET_ENDED,
@@ -1483,11 +1528,11 @@ fileSequence6.mp4`;
`Actual events after skip`,
);
// Removing the CUE="ONCE" interstitial changes the `schedule` items, but does not remove it from `events`
expect(insterstitials.events).is.an('array').which.has.lengthOf(1);
expect(insterstitials.schedule).is.an('array').which.has.lengthOf(1);
expect(insterstitials.bufferingIndex).to.equal(0, 'bufferingIndex b');
expect(insterstitials.playingIndex).to.equal(0, 'playingIndex b');
expect(insterstitials.primary.currentTime).to.equal(40, 'timelinePos b');
expect(interstitials.events).is.an('array').which.has.lengthOf(1);
expect(interstitials.schedule).is.an('array').which.has.lengthOf(1);
expect(interstitials.bufferingIndex).to.equal(0, 'bufferingIndex b');
expect(interstitials.playingIndex).to.equal(0, 'playingIndex b');
expect(interstitials.primary.currentTime).to.equal(40, 'timelinePos b');
});
it('should report correct playhead position in event callbacks between items and assets', function () {
@@ -1760,7 +1805,11 @@ fileSequence6.mp4
expect(im.schedule).is.an('array').which.has.lengthOf(3);
const eventsBeforeAttach = getTriggerCalls();
expect(eventsBeforeAttach).to.deep.equal(
[Events.LEVEL_UPDATED, Events.INTERSTITIALS_UPDATED],
[
Events.LEVEL_LOADED,
Events.LEVEL_UPDATED,
Events.INTERSTITIALS_UPDATED,
],
`Actual events before attach`,
);
expect(im.bufferingIndex).to.equal(-1, 'bufferingIndex');
@@ -1958,13 +2007,13 @@ fileSequence6.mp4`;
// Loaded playlist (before attaching media)
setLoadedLevelDetails(playlist);
const insterstitials = interstitialsController.interstitialsManager;
if (!insterstitials) {
expect(insterstitials, 'interstitialsManager').to.be.an('object');
const interstitials = interstitialsController.interstitialsManager;
if (!interstitials) {
expect(interstitials, 'interstitialsManager').to.be.an('object');
return;
}
expect(insterstitials.events).is.an('array').which.has.lengthOf(1);
expect(insterstitials.schedule).is.an('array').which.has.lengthOf(3);
expect(interstitials.events).is.an('array').which.has.lengthOf(1);
expect(interstitials.schedule).is.an('array').which.has.lengthOf(3);
// Capture asset-list request
const loadSpy = sandbox.spy(hls.config.loader.prototype, 'load');
@@ -1981,6 +2030,11 @@ fileSequence6.mp4`;
Events.ASSET_LIST_LOADING,
Events.INTERSTITIAL_STARTED,
];
expect(eventsAfterAttach).to.deep.equal(
expectedEvents,
`Actual events after attach`,
);
expect(loadSpy).calledOnce;
const assetListUrl = loadSpy.getCalls()[0].args[0].url;
expect(
@@ -1989,17 +2043,14 @@ fileSequence6.mp4`;
).to.equal(
`https://example.com/mid.json?_HLS_primary_id=${primaryId}&_HLS_start_offset=10`,
);
expect(eventsAfterAttach).to.deep.equal(
expectedEvents,
`Actual events after attach`,
);
expect(insterstitials.bufferingIndex).to.equal(1, 'bufferingIndex a');
expect(insterstitials.playingIndex).to.equal(1, 'playingIndex a');
expect(insterstitials.primary.currentTime).to.equal(30, 'timelinePos a');
expect(interstitials.bufferingIndex).to.equal(1, 'bufferingIndex a');
expect(interstitials.playingIndex).to.equal(1, 'playingIndex a');
expect(interstitials.primary.currentTime).to.equal(30, 'timelinePos a');
// Load asset-list
hls.trigger.resetHistory();
const interstitial = insterstitials.events[0];
const interstitial = interstitials.events[0];
interstitial.assetListResponse = {
ASSETS: [{ URI: 'https://example.com/midroll.m3u8', DURATION: '30' }],
};
@@ -2019,31 +2070,117 @@ fileSequence6.mp4`;
],
`Actual events after asset-list`,
);
expect(insterstitials.bufferingIndex).to.equal(1, 'bufferingIndex b');
expect(insterstitials.playingIndex).to.equal(1, 'playingIndex b');
expect(insterstitials.primary.currentTime).to.equal(30, 'timelinePos b');
expect(interstitials.bufferingIndex).to.equal(1, 'bufferingIndex b');
expect(interstitials.playingIndex).to.equal(1, 'playingIndex b');
expect(interstitials.primary.currentTime).to.equal(30, 'timelinePos b');
expect(interstitials.interstitialPlayer, `interstitialPlayer`).to.include(
{
playingIndex: 0,
currentTime: 0,
duration: 30,
},
);
expect(
insterstitials.interstitialPlayer,
`interstitialPlayer`,
).to.include({
playingIndex: 0,
currentTime: 0,
duration: 30,
});
expect(
insterstitials.interstitialPlayer?.scheduleItem?.event,
interstitials.interstitialPlayer?.scheduleItem?.event,
`interstitialPlayer.scheduleItem`,
).to.include({ identifier: 'mid-live' });
expect(
insterstitials.interstitialPlayer?.assetPlayers,
interstitials.interstitialPlayer?.assetPlayers,
`interstitialPlayer.assetPlayers[]`,
).to.have.lengthOf(1);
expect(
insterstitials.interstitialPlayer?.assetPlayers[0]?.hls?.url,
interstitials.interstitialPlayer?.assetPlayers[0]?.hls?.url,
`interstitialPlayer.assetPlayers[0].hls.url`,
).to.equal(
`https://example.com/midroll.m3u8?_HLS_primary_id=${primaryId}`,
);
});
it('establishing program start without interstitials', function () {
const playlist = `#EXTM3U
#EXT-X-TARGETDURATION:3
#EXT-X-VERSION:7
#EXT-X-MEDIA-SEQUENCE:1
#EXT-X-PROGRAM-DATE-TIME:2024-02-23T15:00:00.000Z
#EXT-X-MAP:URI="fileSequence0.mp4"
#EXTINF:2,
fileSequence1.mp4
#EXTINF:1.997,
fileSequence2.mp4
#EXTINF:2.017,
fileSequence3.mp4`;
hls.trigger.resetHistory();
attachMediaToHls();
setLoadedLevelDetails(playlist);
const interstitials = interstitialsController.interstitialsManager;
if (!interstitials) {
expect(interstitials, 'interstitialsManager').to.be.an('object');
return;
}
expect(interstitials.events).is.an('array').which.has.lengthOf(0);
expect(interstitials.schedule).is.an('array').which.has.lengthOf(1);
const eventsAfterAttach = getTriggerCalls();
const expectedEvents = [
Events.MEDIA_ATTACHING,
Events.MEDIA_ATTACHED,
Events.LEVEL_LOADED,
Events.LEVEL_UPDATED,
Events.INTERSTITIALS_BUFFERED_TO_BOUNDARY,
];
expect(eventsAfterAttach).to.deep.equal(
expectedEvents,
`Actual events after attach`,
);
expect(interstitials.bufferingIndex).to.equal(0, 'bufferingIndex a');
expect(interstitials.playingIndex).to.equal(0, 'playingIndex a');
expect(interstitials.primary.currentTime).to.equal(0, 'timelinePos a');
});
it('establishing program start with a short playlist and interstitials', function () {
const playlist = `#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:3
#EXT-X-MEDIA-SEQUENCE:266
#EXT-X-DISCONTINUITY-SEQUENCE:0
#EXT-X-PROGRAM-DATE-TIME:2026-04-11T14:23:05.069Z
#EXT-X-DATERANGE:ID="ad1",CLASS="com.apple.hls.interstitial",START-DATE="2026-04-11T14:23:11.402Z",DURATION=23.000,X-ASSET-URI="https://example.com/ad1.m3u8",X-RESTRICT="SKIP,JUMP"
#EXTINF:2.0,
media_w507366714_266.ts
#EXTINF:1.997,
media_w507366714_267.ts
#EXTINF:2.017,
media_w507366714_268.ts`;
hls.trigger.resetHistory();
attachMediaToHls();
setLoadedLevelDetails(playlist);
const interstitials = interstitialsController.interstitialsManager;
if (!interstitials) {
expect(interstitials, 'interstitialsManager').to.be.an('object');
return;
}
expect(interstitials.events).is.an('array').which.has.lengthOf(1);
expect(interstitials.schedule).is.an('array').which.has.lengthOf(3);
const eventsAfterAttach = getTriggerCalls();
const expectedEvents = [
Events.MEDIA_ATTACHING,
Events.MEDIA_ATTACHED,
Events.LEVEL_LOADED,
Events.LEVEL_UPDATED,
Events.INTERSTITIALS_UPDATED,
Events.INTERSTITIALS_BUFFERED_TO_BOUNDARY,
];
expect(eventsAfterAttach).to.deep.equal(
expectedEvents,
`Actual events after attach`,
);
expect(interstitials.bufferingIndex).to.equal(0, 'bufferingIndex b');
expect(interstitials.playingIndex).to.equal(0, 'playingIndex b');
expect(interstitials.primary.currentTime).to.equal(0, 'timelinePos b');
});
});
});