mirror of
https://github.com/video-dev/hls.js.git
synced 2026-06-16 13:34:44 +00:00
* Fix live join following preroll, including `_HLS_start_offset` on following midroll asset-list requests Fixes #7845 * Make live preroll resumption offset relative to live start Added rolling playlist update to test
2527 lines
82 KiB
TypeScript
2527 lines
82 KiB
TypeScript
import { config as chaiConfig, expect, use } from 'chai';
|
|
import sinon from 'sinon';
|
|
import sinonChai from 'sinon-chai';
|
|
import InterstitialsController from '../../../src/controller/interstitials-controller';
|
|
import {
|
|
type InterstitialScheduleItem,
|
|
segmentToString,
|
|
} from '../../../src/controller/interstitials-schedule';
|
|
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 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,
|
|
} from '../../../src/types/component-api';
|
|
|
|
use(sinonChai);
|
|
chaiConfig.truncateThreshold = 0;
|
|
|
|
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) => {
|
|
if (
|
|
component !== hlsTestable.streamController &&
|
|
component !== hlsTestable.levelController
|
|
) {
|
|
component.destroy();
|
|
}
|
|
});
|
|
hlsTestable.networkControllers.length = 0;
|
|
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 = '';
|
|
});
|
|
hlsTestable.on(Events.MEDIA_DETACHING, () => {
|
|
const media = hlsTestable.media;
|
|
if (media) {
|
|
media.removeAttribute('src');
|
|
media.load();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function expectItemToHaveProperties(
|
|
schedule: InterstitialScheduleItem[],
|
|
itemIndex: number,
|
|
expected: Record<string, number | string | object | null>,
|
|
note?: string,
|
|
) {
|
|
const item = schedule[itemIndex];
|
|
Object.keys(expected).forEach((key) => {
|
|
// Use deep equals on all properties except for InterstitialEvents ('event' and 'nextEvent')
|
|
if (key === 'event' || key === 'nextEvent' || key === 'previousEvent') {
|
|
expect(item, `${note || 'properties'}: Schedule Index ${itemIndex}`)
|
|
.to.be.an('object')
|
|
.which.has.property(key);
|
|
const debug =
|
|
`${note || 'properties'}: Schedule Index ${itemIndex} ['${key}']:` +
|
|
JSON.stringify(
|
|
item,
|
|
(key, value) =>
|
|
key === 'nextEvent' || key === 'previousEvent' || key === 'event'
|
|
? `${key} <${value ? value.identifier : value}>`
|
|
: value,
|
|
2,
|
|
);
|
|
const expectedValue = expected[key];
|
|
if (expectedValue === null) {
|
|
expect(item[key], debug).is.null;
|
|
} else {
|
|
expect(item[key], debug).includes(expectedValue);
|
|
}
|
|
} else {
|
|
expect(item, `${note || 'properties'}: Schedule Index ${itemIndex}`)
|
|
.to.be.an('object')
|
|
.which.has.property(key)
|
|
.which.deep.equals(
|
|
expected[key],
|
|
`${note || 'properties'}: Schedule Index ${itemIndex} ['${key}']:` +
|
|
JSON.stringify(
|
|
item,
|
|
(key, value) =>
|
|
key === 'nextEvent' ||
|
|
key === 'previousEvent' ||
|
|
key === 'event'
|
|
? `${key} <${value ? value.identifier : value}>`
|
|
: typeof value !== 'number'
|
|
? value
|
|
: Number.isFinite(value)
|
|
? value
|
|
: `<${value}>`,
|
|
2,
|
|
),
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
function expectScheduleToInclude(
|
|
schedule: InterstitialScheduleItem[],
|
|
itemAssertions: Record<string, number | string | object | null>[],
|
|
note?: string,
|
|
) {
|
|
note = `${note || 'schedule-length'}: ${scheduleToString(schedule)}`;
|
|
expect(schedule, note).to.have.lengthOf(itemAssertions.length);
|
|
itemAssertions.forEach((assertion, i) => {
|
|
expectItemToHaveProperties(schedule, i, assertion, note);
|
|
});
|
|
}
|
|
|
|
function scheduleToString(schedule: InterstitialScheduleItem[]) {
|
|
return schedule.map((item) => segmentToString(item)).join(', ');
|
|
}
|
|
|
|
describe('InterstitialsController', function () {
|
|
const sandbox = sinon.createSandbox();
|
|
let hls: HlsTestable;
|
|
let interstitialsController: InterstitialsController;
|
|
|
|
function getTriggerCalls() {
|
|
return hls.trigger.getCalls().map((_call) => _call.args[0]);
|
|
}
|
|
|
|
function attachMediaToHls() {
|
|
const media = new MockMediaElement();
|
|
hls.attachMedia(media as unknown as HTMLMediaElement);
|
|
(hls as any).bufferController.media = media;
|
|
hls.trigger(Events.MEDIA_ATTACHED, {
|
|
media: media as unknown as HTMLMediaElement,
|
|
mediaSource: {} as any,
|
|
});
|
|
return media;
|
|
}
|
|
|
|
function setLoadedLevelDetails(playlist: string) {
|
|
const details = M3U8Parser.parseLevelPlaylist(
|
|
playlist,
|
|
'http://example.com/playlist.m3u8',
|
|
0,
|
|
PlaylistLevelType.MAIN,
|
|
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({
|
|
name: '',
|
|
url: '',
|
|
attrs,
|
|
bitrate: 0,
|
|
});
|
|
level.details = details;
|
|
(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;
|
|
}
|
|
|
|
beforeEach(function () {
|
|
hls = new HLSTestPlayer({
|
|
// debug: true,
|
|
debug: {
|
|
trace: () => null,
|
|
debug: () => null,
|
|
log: () => null,
|
|
warn: () => null,
|
|
info: () => null,
|
|
error: () => null,
|
|
},
|
|
}) as unknown as HlsTestable;
|
|
interstitialsController = new InterstitialsController(
|
|
hls as unknown as Hls,
|
|
HLSTestPlayer,
|
|
);
|
|
sandbox.spy(hls, 'trigger');
|
|
sandbox.stub(hls.streamController as any, 'loadFragment');
|
|
});
|
|
|
|
afterEach(function () {
|
|
hls.destroy();
|
|
interstitialsController.destroy();
|
|
sandbox.restore();
|
|
});
|
|
|
|
describe('Interstitial Parsing and Schedule', function () {
|
|
it('should parse Interstitial Events from LevelDetails and produce a schedule', function () {
|
|
const playlist = `#EXTM3U
|
|
#EXT-X-TARGETDURATION:6
|
|
#EXT-X-VERSION:6
|
|
#EXT-X-PROGRAM-DATE-TIME:2020-01-01T11:00:00.000Z
|
|
#EXT-X-DATERANGE:ID="0",CLASS="com.apple.hls.interstitial",START-DATE="2020-01-01T11:00:04.000Z",DURATION=30,X-ASSET-URI="https://example.com/ad1.m3u8",X-RESTRICT="SKIP,JUMP",X-SNAP="OUT,IN"
|
|
#EXTINF:6,
|
|
fileSequence3.ts
|
|
#EXTINF:6,
|
|
fileSequence4.ts
|
|
#EXT-X-ENDLIST`;
|
|
attachMediaToHls();
|
|
hls.trigger.resetHistory();
|
|
setLoadedLevelDetails(playlist);
|
|
const interstitials = interstitialsController.interstitialsManager;
|
|
if (!interstitials) {
|
|
expect(interstitials, 'interstitialsManager').to.be.an('object');
|
|
return;
|
|
}
|
|
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 = interstitials.events[0];
|
|
expect(interstitialEvent.identifier).to.equal('0');
|
|
expect(interstitialEvent.restrictions.jump).to.equal(true);
|
|
expect(interstitialEvent.restrictions.skip).to.equal(true);
|
|
expect(interstitialEvent.snapOptions.out).to.equal(true);
|
|
expect(interstitialEvent.snapOptions.in).to.equal(true);
|
|
expectScheduleToInclude(schedule, [
|
|
{
|
|
previousEvent: null,
|
|
nextEvent: {
|
|
identifier: '0',
|
|
},
|
|
start: 0,
|
|
end: 6,
|
|
playout: {
|
|
start: 0,
|
|
end: 6,
|
|
},
|
|
integrated: {
|
|
start: 0,
|
|
end: 6,
|
|
},
|
|
},
|
|
{
|
|
event: {
|
|
identifier: '0',
|
|
},
|
|
start: 6,
|
|
end: 36,
|
|
playout: {
|
|
start: 6,
|
|
end: 36,
|
|
},
|
|
integrated: {
|
|
start: 6,
|
|
end: 6,
|
|
},
|
|
},
|
|
]);
|
|
expect(interstitialEvent).to.equal(schedule[1].event);
|
|
const eventsTriggered = getTriggerCalls();
|
|
expect(eventsTriggered).to.deep.equal(
|
|
[
|
|
Events.LEVEL_LOADED,
|
|
Events.LEVEL_UPDATED,
|
|
Events.INTERSTITIALS_UPDATED,
|
|
Events.INTERSTITIALS_BUFFERED_TO_BOUNDARY,
|
|
],
|
|
`Actual events after asset-list`,
|
|
);
|
|
});
|
|
|
|
it('X-RESUME-OFFSET values of interstitials scheduled at the same time are cumulative', function () {
|
|
const playlist = `#EXTM3U
|
|
#EXT-X-TARGETDURATION:10
|
|
#EXT-X-VERSION:6
|
|
#EXT-X-PROGRAM-DATE-TIME:2020-01-01T11:00:00.000Z
|
|
#EXT-X-DATERANGE:ID="0",CLASS="com.apple.hls.interstitial",START-DATE="2020-01-01T11:00:00.000Z",DURATION=5,X-ASSET-URI="https://example.com/index1.m3u8",X-TIMELINE-OCCUPIES=RANGE
|
|
#EXT-X-DATERANGE:ID="1",CLASS="com.apple.hls.interstitial",START-DATE="2020-01-01T11:00:00.000Z",DURATION=25,X-ASSET-URI="https://example.com/index2.m3u8",X-TIMELINE-OCCUPIES=RANGE
|
|
#EXTINF:10,
|
|
fileSequence1.ts
|
|
#EXTINF:10,
|
|
fileSequence2.ts
|
|
#EXTINF:10,
|
|
fileSequence3.ts
|
|
#EXT-X-DATERANGE:ID="2",CLASS="com.apple.hls.interstitial",START-DATE="2020-01-01T11:00:31.000Z",DURATION=3,X-RESUME-OFFSET=0,X-ASSET-URI="https://example.com/index3.m3u8",X-TIMELINE-OCCUPIES=RANGE
|
|
#EXT-X-DATERANGE:ID="3",CLASS="com.apple.hls.interstitial",START-DATE="2020-01-01T11:00:31.000Z",DURATION=3,X-ASSET-URI="https://example.com/index4.m3u8",X-TIMELINE-OCCUPIES=RANGE
|
|
#EXT-X-DATERANGE:ID="4",CLASS="com.apple.hls.interstitial",START-DATE="2020-01-01T11:00:31.000Z",DURATION=3,X-RESUME-OFFSET=4,X-ASSET-URI="https://example.com/index5.m3u8",X-TIMELINE-OCCUPIES=RANGE
|
|
#EXTINF:10,
|
|
fileSequence4.ts
|
|
#EXT-X-ENDLIST`;
|
|
setLoadedLevelDetails(playlist);
|
|
const interstitials = interstitialsController.interstitialsManager;
|
|
if (!interstitials) {
|
|
expect(interstitials, 'interstitialsManager').to.be.an('object');
|
|
return;
|
|
}
|
|
const events = interstitials.events;
|
|
const schedule = interstitials.schedule;
|
|
expect(events).is.an('array').which.has.lengthOf(5);
|
|
expect(schedule)
|
|
.is.an('array')
|
|
.which.has.lengthOf(
|
|
7,
|
|
`Schedule items: ${schedule.map((item) => `${item.start}-${item.end}`).join(', ')}`,
|
|
);
|
|
const eventAssertions = [
|
|
{
|
|
startTime: 0,
|
|
duration: 5,
|
|
resumeOffset: NaN,
|
|
resumeTime: 5,
|
|
},
|
|
{
|
|
startTime: 0,
|
|
duration: 25,
|
|
resumeOffset: NaN,
|
|
resumeTime: 30,
|
|
},
|
|
{
|
|
startTime: 31,
|
|
duration: 3,
|
|
resumeOffset: 0,
|
|
resumeTime: 31,
|
|
},
|
|
{
|
|
startTime: 31,
|
|
duration: 3,
|
|
resumeOffset: NaN,
|
|
resumeTime: 34,
|
|
},
|
|
{
|
|
startTime: 31,
|
|
duration: 3,
|
|
resumeOffset: 4,
|
|
resumeTime: 38,
|
|
},
|
|
];
|
|
eventAssertions.forEach((assertions, i) => {
|
|
expect(events[i].identifier).to.equal('' + i);
|
|
expect(events[i], `Interstitial Event "${i}"`).to.deep.include(
|
|
assertions,
|
|
);
|
|
});
|
|
expectScheduleToInclude(schedule, [
|
|
{
|
|
event: {
|
|
identifier: '0',
|
|
},
|
|
start: 0,
|
|
end: 5,
|
|
playout: {
|
|
start: 0,
|
|
end: 5,
|
|
},
|
|
integrated: {
|
|
start: 0,
|
|
end: 5,
|
|
},
|
|
},
|
|
{
|
|
event: {
|
|
identifier: '1',
|
|
},
|
|
start: 5,
|
|
end: 30,
|
|
playout: {
|
|
start: 5,
|
|
end: 30,
|
|
},
|
|
integrated: {
|
|
start: 5,
|
|
end: 30,
|
|
},
|
|
},
|
|
{
|
|
previousEvent: {
|
|
identifier: '1',
|
|
},
|
|
nextEvent: {
|
|
identifier: '2',
|
|
},
|
|
start: 30,
|
|
end: 31,
|
|
playout: {
|
|
start: 30,
|
|
end: 31,
|
|
},
|
|
integrated: {
|
|
start: 30,
|
|
end: 31,
|
|
},
|
|
},
|
|
{
|
|
event: {
|
|
identifier: '2',
|
|
},
|
|
start: 31,
|
|
end: 31,
|
|
playout: {
|
|
start: 31,
|
|
end: 34,
|
|
},
|
|
integrated: {
|
|
start: 31,
|
|
end: 34,
|
|
},
|
|
},
|
|
{
|
|
event: {
|
|
identifier: '3',
|
|
},
|
|
start: 31,
|
|
end: 34,
|
|
playout: {
|
|
start: 34,
|
|
end: 37,
|
|
},
|
|
integrated: {
|
|
start: 34,
|
|
end: 37,
|
|
},
|
|
},
|
|
{
|
|
event: {
|
|
identifier: '4',
|
|
},
|
|
start: 34,
|
|
end: 38,
|
|
playout: {
|
|
start: 37,
|
|
end: 40,
|
|
},
|
|
integrated: {
|
|
start: 37,
|
|
end: 40,
|
|
},
|
|
},
|
|
{
|
|
previousEvent: {
|
|
identifier: '4',
|
|
},
|
|
nextEvent: null,
|
|
start: 38,
|
|
end: 40,
|
|
playout: {
|
|
start: 40,
|
|
end: 42,
|
|
},
|
|
integrated: {
|
|
start: 40,
|
|
end: 42,
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('negative X-RESUME-OFFSET values', function () {
|
|
const playlist = `#EXTM3U
|
|
#EXT-X-TARGETDURATION:10
|
|
#EXT-X-VERSION:6
|
|
#EXT-X-PROGRAM-DATE-TIME:2020-01-01T11:00:00.000Z
|
|
#EXT-X-DATERANGE:ID="1",CLASS="com.apple.hls.interstitial",START-DATE="2020-01-01T11:00:05.000Z",DURATION=10,X-RESUME-OFFSET=-5,X-ASSET-URI="https://example.com/index1.m3u8"
|
|
#EXTINF:10,
|
|
fileSequence1.ts
|
|
#EXT-X-DATERANGE:ID="2",CLASS="com.apple.hls.interstitial",START-DATE="2020-01-01T11:00:10.000Z",DURATION=5,X-ASSET-URI="https://example.com/index2.m3u8"
|
|
#EXT-X-DATERANGE:ID="3",CLASS="com.apple.hls.interstitial",START-DATE="2020-01-01T11:00:10.000Z",DURATION=5,X-RESUME-OFFSET=-3,X-ASSET-URI="https://example.com/index3.m3u8"
|
|
#EXTINF:10,
|
|
fileSequence2.ts
|
|
#EXT-X-DATERANGE:ID="4",CLASS="com.apple.hls.interstitial",START-DATE="2020-01-01T11:00:20.000Z",DURATION=5,X-RESUME-OFFSET=-1,X-ASSET-URI="https://example.com/index4.m3u8"
|
|
#EXT-X-DATERANGE:ID="5",CLASS="com.apple.hls.interstitial",START-DATE="2020-01-01T11:00:20.000Z",DURATION=5,X-ASSET-URI="https://example.com/index5.m3u8"
|
|
#EXTINF:10,
|
|
fileSequence3.ts
|
|
#EXT-X-ENDLIST`;
|
|
setLoadedLevelDetails(playlist);
|
|
const interstitials = interstitialsController.interstitialsManager;
|
|
if (!interstitials) {
|
|
expect(interstitials, 'interstitialsManager').to.be.an('object');
|
|
return;
|
|
}
|
|
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)
|
|
.is.an('array')
|
|
.which.has.lengthOf(9, scheduleDebugString);
|
|
[
|
|
'primary',
|
|
'1',
|
|
'primary',
|
|
'2',
|
|
'3',
|
|
'primary',
|
|
'4',
|
|
'5',
|
|
'primary',
|
|
].forEach((typeOrIdentifier, i) => {
|
|
if (typeOrIdentifier === 'primary') {
|
|
expect(
|
|
schedule[i],
|
|
`Expected to find a primary segment at index ${i}: ${scheduleDebugString}`,
|
|
).to.have.property('nextEvent');
|
|
} else {
|
|
expect(
|
|
schedule[i],
|
|
`Expected to find an Interstitial at index ${i}: ${scheduleDebugString}`,
|
|
)
|
|
.to.have.property('event')
|
|
.which.has.property('identifier')
|
|
.which.equals(typeOrIdentifier);
|
|
}
|
|
});
|
|
const eventAssertions = [
|
|
{
|
|
startTime: 5,
|
|
duration: 10,
|
|
resumeOffset: -5,
|
|
resumeTime: 0,
|
|
},
|
|
{
|
|
startTime: 10,
|
|
duration: 5,
|
|
resumeOffset: NaN,
|
|
resumeTime: 15,
|
|
},
|
|
{
|
|
startTime: 10,
|
|
duration: 5,
|
|
cumulativeDuration: 5,
|
|
resumeOffset: -3,
|
|
resumeTime: 12,
|
|
},
|
|
{
|
|
startTime: 20,
|
|
duration: 5,
|
|
resumeOffset: -1,
|
|
resumeTime: 19,
|
|
},
|
|
{
|
|
startTime: 20,
|
|
duration: 5,
|
|
resumeOffset: NaN,
|
|
resumeTime: 24,
|
|
},
|
|
];
|
|
eventAssertions.forEach((assertions, i) => {
|
|
expect(events[i].identifier).to.equal('' + (i + 1));
|
|
expect(
|
|
events[i],
|
|
`Interstitial Event "${events[i].identifier}"`,
|
|
).to.deep.include(assertions);
|
|
});
|
|
expectScheduleToInclude(schedule, [
|
|
{
|
|
previousEvent: null,
|
|
nextEvent: {
|
|
identifier: '1',
|
|
},
|
|
start: 0,
|
|
end: 5,
|
|
playout: {
|
|
start: 0,
|
|
end: 5,
|
|
},
|
|
integrated: {
|
|
start: 0,
|
|
end: 5,
|
|
},
|
|
},
|
|
{
|
|
event: {
|
|
identifier: '1',
|
|
},
|
|
start: 5,
|
|
end: 0,
|
|
playout: {
|
|
start: 5,
|
|
end: 15,
|
|
},
|
|
integrated: {
|
|
start: 5,
|
|
end: 5,
|
|
},
|
|
},
|
|
{
|
|
previousEvent: {
|
|
identifier: '1',
|
|
},
|
|
nextEvent: {
|
|
identifier: '2',
|
|
},
|
|
start: 0,
|
|
end: 10,
|
|
playout: {
|
|
start: 15,
|
|
end: 25,
|
|
},
|
|
integrated: {
|
|
start: 5,
|
|
end: 15,
|
|
},
|
|
},
|
|
{
|
|
event: {
|
|
identifier: '2',
|
|
},
|
|
start: 10,
|
|
end: 15,
|
|
playout: {
|
|
start: 25,
|
|
end: 30,
|
|
},
|
|
integrated: {
|
|
start: 15,
|
|
end: 15,
|
|
},
|
|
},
|
|
{
|
|
event: {
|
|
identifier: '3',
|
|
},
|
|
start: 15,
|
|
end: 12,
|
|
playout: {
|
|
start: 30,
|
|
end: 35,
|
|
},
|
|
integrated: {
|
|
start: 15,
|
|
end: 15,
|
|
},
|
|
},
|
|
{
|
|
nextEvent: {
|
|
identifier: '4',
|
|
},
|
|
start: 12,
|
|
end: 20,
|
|
playout: {
|
|
start: 35,
|
|
end: 43,
|
|
},
|
|
integrated: {
|
|
start: 15,
|
|
end: 23,
|
|
},
|
|
},
|
|
{
|
|
event: {
|
|
identifier: '4',
|
|
},
|
|
start: 20,
|
|
end: 19,
|
|
playout: {
|
|
start: 43,
|
|
end: 48,
|
|
},
|
|
integrated: {
|
|
start: 23,
|
|
end: 23,
|
|
},
|
|
},
|
|
{
|
|
event: {
|
|
identifier: '5',
|
|
},
|
|
start: 19,
|
|
end: 24,
|
|
playout: {
|
|
start: 48,
|
|
end: 53,
|
|
},
|
|
integrated: {
|
|
start: 23,
|
|
end: 23,
|
|
},
|
|
},
|
|
{
|
|
previousEvent: {
|
|
identifier: '5',
|
|
},
|
|
nextEvent: null,
|
|
start: 24,
|
|
end: 30,
|
|
playout: {
|
|
start: 53,
|
|
end: 59,
|
|
},
|
|
integrated: {
|
|
start: 23,
|
|
end: 29,
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should schedule preroll (CUE="PRE") interstitials at the start of the program (VOD default 0) with correct start and resume time for all events', function () {
|
|
const playlist = `#EXTM3U
|
|
#EXT-X-TARGETDURATION:10
|
|
#EXT-X-VERSION:10
|
|
#EXT-X-MEDIA-SEQUENCE:1
|
|
#EXT-X-INDEPENDENT-SEGMENTS
|
|
#EXT-X-PROGRAM-DATE-TIME:2021-01-04T05:00:00.000Z
|
|
#EXT-X-DATERANGE:ID="ad1",CLASS="com.apple.hls.interstitial",START-DATE="2021-01-04T05:00:05.000Z",DURATION=15,X-ASSET-LIST="https://example.com/asset_list.json",X-RESUME-OFFSET=0,X-CUE="PRE"
|
|
#EXT-X-DATERANGE:ID="ad2",CLASS="com.apple.hls.interstitial",START-DATE="2021-01-04T05:00:10.000Z",DURATION=30,X-ASSET-LIST="https://example.com/asset_list.json",X-RESUME-OFFSET=0,X-TIMELINE-OCCUPIES="RANGE"
|
|
#EXT-X-MAP:URI="fileSequence0.mp4"
|
|
#EXTINF:10,
|
|
fileSequence1.mp4
|
|
#EXTINF:10,
|
|
fileSequence2.mp4
|
|
#EXTINF:10,
|
|
fileSequence3.mp4
|
|
#EXT-X-ENDLIST`;
|
|
attachMediaToHls();
|
|
hls.trigger.resetHistory();
|
|
setLoadedLevelDetails(playlist);
|
|
const interstitials = interstitialsController.interstitialsManager;
|
|
if (!interstitials) {
|
|
expect(interstitials, 'interstitialsManager').to.be.an('object');
|
|
return;
|
|
}
|
|
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(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, [
|
|
{
|
|
event: {
|
|
identifier: 'ad1',
|
|
},
|
|
start: 0,
|
|
end: 0,
|
|
playout: {
|
|
start: 0,
|
|
end: 15,
|
|
},
|
|
integrated: {
|
|
start: 0,
|
|
end: 0,
|
|
},
|
|
},
|
|
{
|
|
previousEvent: {
|
|
identifier: 'ad1',
|
|
},
|
|
nextEvent: {
|
|
identifier: 'ad2',
|
|
},
|
|
start: 0,
|
|
end: 10,
|
|
playout: {
|
|
start: 15,
|
|
end: 25,
|
|
},
|
|
integrated: {
|
|
start: 0,
|
|
end: 10,
|
|
},
|
|
},
|
|
{
|
|
event: {
|
|
identifier: 'ad2',
|
|
},
|
|
start: 10,
|
|
end: 10,
|
|
playout: {
|
|
start: 25,
|
|
end: 55,
|
|
},
|
|
integrated: {
|
|
start: 10,
|
|
end: 40,
|
|
},
|
|
},
|
|
{
|
|
previousEvent: {
|
|
identifier: 'ad2',
|
|
},
|
|
nextEvent: null,
|
|
start: 10,
|
|
end: 30,
|
|
playout: {
|
|
start: 55,
|
|
end: 75,
|
|
},
|
|
integrated: {
|
|
start: 40,
|
|
end: 60,
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should exclude date ranges that start after the end of the primary playlist from the schedule and schedule item references', function () {
|
|
const playlist = `#EXTM3U
|
|
#EXT-X-TARGETDURATION:10
|
|
#EXT-X-VERSION:10
|
|
#EXT-X-MEDIA-SEQUENCE:1
|
|
#EXT-X-INDEPENDENT-SEGMENTS
|
|
#EXT-X-PROGRAM-DATE-TIME:2021-01-04T05:00:00.000Z
|
|
#EXT-X-DATERANGE:ID="ad1",CLASS="com.apple.hls.interstitial",START-DATE="2021-01-04T05:00:00.000Z",DURATION=15,X-ASSET-LIST="https://example.com/asset_list.json",X-RESUME-OFFSET=0
|
|
#EXT-X-DATERANGE:ID="ad2",CLASS="com.apple.hls.interstitial",START-DATE="2021-01-04T05:00:50.000Z",DURATION=30,X-ASSET-LIST="https://example.com/asset_list.json",X-RESUME-OFFSET=0
|
|
#EXT-X-MAP:URI="fileSequence0.mp4"
|
|
#EXTINF:10,
|
|
fileSequence1.mp4
|
|
#EXTINF:10,
|
|
fileSequence2.mp4
|
|
#EXTINF:10,
|
|
fileSequence3.mp4
|
|
#EXT-X-ENDLIST`;
|
|
setLoadedLevelDetails(playlist);
|
|
const interstitials = interstitialsController.interstitialsManager;
|
|
if (!interstitials) {
|
|
expect(interstitials, 'interstitialsManager').to.be.an('object');
|
|
return;
|
|
}
|
|
const schedule = interstitials.schedule;
|
|
expect(interstitials.events).is.an('array').which.has.lengthOf(2);
|
|
expect(schedule).is.an('array').which.has.lengthOf(2);
|
|
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, [
|
|
{
|
|
event: {
|
|
identifier: 'ad1',
|
|
},
|
|
start: 0,
|
|
end: 0,
|
|
playout: {
|
|
start: 0,
|
|
end: 15,
|
|
},
|
|
integrated: {
|
|
start: 0,
|
|
end: 0,
|
|
},
|
|
},
|
|
{
|
|
previousEvent: {
|
|
identifier: 'ad1',
|
|
},
|
|
nextEvent: null,
|
|
start: 0,
|
|
end: 30,
|
|
playout: {
|
|
start: 15,
|
|
end: 45,
|
|
},
|
|
integrated: {
|
|
start: 0,
|
|
end: 30,
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
describe('should parse timeline style and content may vary', function () {
|
|
it('default values', function () {
|
|
const playlist = `#EXTM3U
|
|
#EXT-X-TARGETDURATION:6
|
|
#EXT-X-VERSION:6
|
|
#EXT-X-PROGRAM-DATE-TIME:2020-01-01T11:00:00.000Z
|
|
#EXT-X-DATERANGE:ID="0",CLASS="com.apple.hls.interstitial",START-DATE="2020-01-01T11:00:04.000Z",DURATION=30,X-ASSET-URI="http://example.com/ad1.m3u8"
|
|
#EXTINF:6,
|
|
fileSequence3.ts
|
|
#EXTINF:6,
|
|
fileSequence4.ts
|
|
#EXT-X-ENDLIST`;
|
|
setLoadedLevelDetails(playlist);
|
|
const interstitials = interstitialsController.interstitialsManager;
|
|
if (!interstitials) {
|
|
expect(interstitials, 'interstitialsManager').to.be.an('object');
|
|
return;
|
|
}
|
|
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({
|
|
identifier: '0',
|
|
timelineOccupancy: TimelineOccupancy.Point,
|
|
supplementsPrimary: false,
|
|
contentMayVary: true,
|
|
});
|
|
expect(interstitials.primary).to.include({
|
|
duration: 12,
|
|
});
|
|
expect(interstitials.integrated).to.include({
|
|
duration: 4,
|
|
});
|
|
expectScheduleToInclude(schedule, [
|
|
{
|
|
nextEvent: {
|
|
identifier: '0',
|
|
},
|
|
start: 0,
|
|
end: 4,
|
|
playout: {
|
|
start: 0,
|
|
end: 4,
|
|
},
|
|
integrated: {
|
|
start: 0,
|
|
end: 4,
|
|
},
|
|
},
|
|
{
|
|
event: {
|
|
identifier: '0',
|
|
},
|
|
start: 4,
|
|
end: 34,
|
|
playout: {
|
|
start: 4,
|
|
end: 34,
|
|
},
|
|
integrated: {
|
|
start: 4,
|
|
end: 4,
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('attribute values', function () {
|
|
const playlist = `#EXTM3U
|
|
#EXT-X-TARGETDURATION:6
|
|
#EXT-X-VERSION:6
|
|
#EXT-X-PROGRAM-DATE-TIME:2020-01-01T11:00:00.000Z
|
|
#EXT-X-DATERANGE:ID="0",CLASS="com.apple.hls.interstitial",START-DATE="2020-01-01T11:00:04.000Z",DURATION=30,X-ASSET-URI="http://example.com/ad1.m3u8",X-TIMELINE-STYLE="HIGHLIGHT",X-TIMELINE-OCCUPIES="POINT"
|
|
#EXT-X-DATERANGE:ID="1",CLASS="com.apple.hls.interstitial",START-DATE="2020-01-01T11:00:04.000Z",DURATION=30,X-ASSET-URI="http://example.com/ad1.m3u8",X-TIMELINE-STYLE="HIGHLIGHT",X-TIMELINE-OCCUPIES="RANGE",X-CONTENT-MAY-VARY="YES"
|
|
#EXT-X-DATERANGE:ID="2",CLASS="com.apple.hls.interstitial",START-DATE="2020-01-01T11:00:04.000Z",DURATION=30,X-ASSET-URI="http://example.com/ad1.m3u8",X-TIMELINE-STYLE="PRIMARY",X-TIMELINE-OCCUPIES="RANGE",X-CONTENT-MAY-VARY="NO"
|
|
#EXT-X-DATERANGE:ID="3",CLASS="com.apple.hls.interstitial",START-DATE="2020-01-01T11:00:04.000Z",DURATION=30,X-ASSET-URI="http://example.com/ad1.m3u8",X-CONTENT-MAY-VARY="NO"
|
|
#EXTINF:6,
|
|
fileSequence3.ts
|
|
#EXTINF:6,
|
|
fileSequence4.ts
|
|
#EXT-X-ENDLIST`;
|
|
const eventAssertions = [
|
|
{
|
|
timelineOccupancy: TimelineOccupancy.Point,
|
|
supplementsPrimary: false,
|
|
contentMayVary: true,
|
|
},
|
|
{
|
|
timelineOccupancy: TimelineOccupancy.Range,
|
|
supplementsPrimary: false,
|
|
contentMayVary: true,
|
|
},
|
|
{
|
|
timelineOccupancy: TimelineOccupancy.Range,
|
|
supplementsPrimary: true,
|
|
contentMayVary: false,
|
|
},
|
|
{
|
|
timelineOccupancy: TimelineOccupancy.Point,
|
|
supplementsPrimary: false,
|
|
contentMayVary: false,
|
|
},
|
|
];
|
|
setLoadedLevelDetails(playlist);
|
|
const interstitials = interstitialsController.interstitialsManager;
|
|
if (!interstitials) {
|
|
expect(interstitials, 'interstitialsManager').to.be.an('object');
|
|
return;
|
|
}
|
|
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) => {
|
|
expect(events[i].identifier).to.equal('' + i);
|
|
expect(events[i], `Interstitial Event "${i}"`).to.deep.include(
|
|
assertions,
|
|
);
|
|
});
|
|
expect(interstitials.primary).to.include({
|
|
duration: 12,
|
|
});
|
|
expect(interstitials.integrated).to.include({
|
|
duration: 64,
|
|
});
|
|
expectScheduleToInclude(schedule, [
|
|
{
|
|
previousEvent: null,
|
|
nextEvent: {
|
|
identifier: '0',
|
|
},
|
|
start: 0,
|
|
end: 4,
|
|
playout: {
|
|
start: 0,
|
|
end: 4,
|
|
},
|
|
integrated: {
|
|
start: 0,
|
|
end: 4,
|
|
},
|
|
},
|
|
{
|
|
event: {
|
|
identifier: '0',
|
|
},
|
|
start: 4,
|
|
end: 34,
|
|
playout: {
|
|
start: 4,
|
|
end: 34,
|
|
},
|
|
integrated: {
|
|
start: 4,
|
|
end: 4,
|
|
},
|
|
},
|
|
{
|
|
event: {
|
|
identifier: '1',
|
|
},
|
|
start: 34,
|
|
end: 64,
|
|
playout: {
|
|
start: 34,
|
|
end: 64,
|
|
},
|
|
integrated: {
|
|
start: 4,
|
|
end: 34,
|
|
},
|
|
},
|
|
{
|
|
event: {
|
|
identifier: '2',
|
|
},
|
|
start: 64,
|
|
end: 94,
|
|
playout: {
|
|
start: 64,
|
|
end: 94,
|
|
},
|
|
integrated: {
|
|
start: 34,
|
|
end: 64,
|
|
},
|
|
},
|
|
{
|
|
event: {
|
|
identifier: '3',
|
|
},
|
|
start: 94,
|
|
end: 124,
|
|
playout: {
|
|
start: 94,
|
|
end: 124,
|
|
},
|
|
integrated: {
|
|
start: 64,
|
|
end: 64,
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Interstitial Playback and API Events', function () {
|
|
const playlist = `#EXTM3U
|
|
#EXT-X-TARGETDURATION:10
|
|
#EXT-X-VERSION:7
|
|
#EXT-X-MEDIA-SEQUENCE:1
|
|
#EXT-X-INDEPENDENT-SEGMENTS
|
|
#EXT-X-PROGRAM-DATE-TIME:2024-02-23T15:00:00.000Z
|
|
#EXT-X-DATERANGE:ID="pre",CLASS="com.apple.hls.interstitial",START-DATE="2024-02-23T15:00:00.000Z",DURATION=37,X-ASSET-URI="https://example.com/pre.m3u8",X-RESUME-OFFSET=0,CUE="PRE"
|
|
#EXT-X-MAP:URI="fileSequence0.mp4"
|
|
#EXTINF:9.20920,
|
|
#EXT-X-BITRATE:1701
|
|
fileSequence1.mp4
|
|
#EXTINF:8.37503,
|
|
#EXT-X-BITRATE:1810
|
|
fileSequence2.mp4
|
|
#EXTINF:8.80880,
|
|
#EXT-X-BITRATE:1824
|
|
fileSequence3.mp4
|
|
#EXT-X-DATERANGE:ID="mid-30",CLASS="com.apple.hls.interstitial",START-DATE="2024-02-23T15:00:30.000Z",DURATION=16,X-ASSET-LIST="https://example.com/mid-list.json"
|
|
#EXTINF:9.70970,
|
|
#EXT-X-BITRATE:1768
|
|
fileSequence4.mp4
|
|
#EXTINF:9.10910,
|
|
#EXT-X-BITRATE:621
|
|
fileSequence5.mp4`;
|
|
|
|
it('should begin preroll on attach', function () {
|
|
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(2);
|
|
expect(interstitials.schedule).is.an('array').which.has.lengthOf(4);
|
|
const callsWithPrerollBeforeAttach = getTriggerCalls();
|
|
expect(callsWithPrerollBeforeAttach).to.deep.equal(
|
|
[
|
|
Events.LEVEL_LOADED,
|
|
Events.LEVEL_UPDATED,
|
|
Events.INTERSTITIALS_UPDATED,
|
|
],
|
|
`Actual events before attach`,
|
|
);
|
|
hls.trigger.resetHistory();
|
|
expect(interstitials.bufferingIndex).to.equal(-1, 'bufferingIndex');
|
|
expect(interstitials.playingIndex).to.equal(-1, 'playingIndex');
|
|
attachMediaToHls();
|
|
const callsWithPrerollAfterAttach = getTriggerCalls();
|
|
const expectedEvents = [
|
|
Events.MEDIA_ATTACHING,
|
|
Events.MEDIA_ATTACHED,
|
|
Events.INTERSTITIALS_BUFFERED_TO_BOUNDARY,
|
|
Events.INTERSTITIAL_ASSET_PLAYER_CREATED,
|
|
Events.INTERSTITIAL_STARTED,
|
|
Events.INTERSTITIAL_ASSET_STARTED,
|
|
Events.MEDIA_DETACHING,
|
|
Events.MEDIA_DETACHED,
|
|
];
|
|
expect(callsWithPrerollAfterAttach).to.deep.equal(
|
|
expectedEvents,
|
|
`Actual events after attach`,
|
|
);
|
|
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(
|
|
interstitials.interstitialPlayer?.scheduleItem?.event,
|
|
`interstitialPlayer.scheduleItem`,
|
|
).to.include({ identifier: 'pre' });
|
|
});
|
|
|
|
it('should handle empty asset-lists with resume offset', function () {
|
|
const playlist = `#EXTM3U
|
|
#EXT-X-TARGETDURATION:10
|
|
#EXT-X-VERSION:7
|
|
#EXT-X-MEDIA-SEQUENCE:1
|
|
#EXT-X-PROGRAM-DATE-TIME:2024-02-23T15:00:00.000Z
|
|
#EXT-X-DATERANGE:ID="start",CLASS="com.apple.hls.interstitial",START-DATE="2024-02-23T15:00:00.000Z",DURATION=5,X-ASSET-LIST="https://example.com/empty.json",X-RESUME-OFFSET=5
|
|
#EXT-X-MAP:URI="fileSequence0.mp4"
|
|
#EXTINF:5,
|
|
fileSequence1.mp4
|
|
#EXTINF:5,
|
|
fileSequence2.mp4
|
|
#EXTINF:5,
|
|
fileSequence3.mp4
|
|
#EXT-X-ENDLIST`;
|
|
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(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,
|
|
Events.ASSET_LIST_LOADING,
|
|
Events.INTERSTITIAL_STARTED,
|
|
],
|
|
`Actual events before asset-list`,
|
|
);
|
|
hls.trigger.resetHistory();
|
|
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 = interstitials.events[0];
|
|
interstitial.assetListResponse = { ASSETS: [] };
|
|
hls.trigger(Events.ASSET_LIST_LOADED, {
|
|
event: interstitial,
|
|
assetListResponse: interstitial.assetListResponse,
|
|
networkDetails: new Response('ok'),
|
|
});
|
|
const callsAfterAttach = getTriggerCalls();
|
|
expect(callsAfterAttach).to.deep.equal(
|
|
[
|
|
Events.ASSET_LIST_LOADED,
|
|
Events.INTERSTITIALS_UPDATED,
|
|
Events.INTERSTITIAL_ENDED,
|
|
Events.INTERSTITIALS_BUFFERED_TO_BOUNDARY,
|
|
Events.INTERSTITIALS_PRIMARY_RESUMED,
|
|
],
|
|
`Actual events after asset-list`,
|
|
);
|
|
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 () {
|
|
const playlist = `#EXTM3U
|
|
#EXT-X-TARGETDURATION:10
|
|
#EXT-X-VERSION:7
|
|
#EXT-X-MEDIA-SEQUENCE:1
|
|
#EXT-X-PROGRAM-DATE-TIME:2024-02-23T15:00:00.000Z
|
|
#EXT-X-DATERANGE:ID="start",CLASS="com.apple.hls.interstitial",START-DATE="2024-02-23T15:00:00.000Z",DURATION=5,X-ASSET-LIST="https://example.com/empty.json"
|
|
#EXT-X-MAP:URI="fileSequence0.mp4"
|
|
#EXTINF:5,
|
|
fileSequence1.mp4
|
|
#EXTINF:5,
|
|
fileSequence2.mp4
|
|
#EXTINF:5,
|
|
fileSequence3.mp4
|
|
#EXT-X-ENDLIST`;
|
|
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(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,
|
|
Events.ASSET_LIST_LOADING,
|
|
Events.INTERSTITIAL_STARTED,
|
|
],
|
|
`Actual events before asset-list`,
|
|
);
|
|
hls.trigger.resetHistory();
|
|
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 = interstitials.events[0];
|
|
interstitial.assetListResponse = { ASSETS: [] };
|
|
hls.trigger(Events.ASSET_LIST_LOADED, {
|
|
event: interstitial,
|
|
assetListResponse: interstitial.assetListResponse,
|
|
networkDetails: new Response('ok'),
|
|
});
|
|
const callsAfterAttach = getTriggerCalls();
|
|
expect(callsAfterAttach).to.deep.equal(
|
|
[
|
|
Events.ASSET_LIST_LOADED,
|
|
Events.INTERSTITIALS_UPDATED,
|
|
Events.INTERSTITIAL_ENDED,
|
|
Events.INTERSTITIALS_BUFFERED_TO_BOUNDARY,
|
|
Events.INTERSTITIALS_PRIMARY_RESUMED,
|
|
],
|
|
`Actual events after asset-list`,
|
|
);
|
|
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',
|
|
);
|
|
});
|
|
|
|
it('should resume at start plus resumption-offset (start + duration and attach after level updated)', function () {
|
|
const playlist = `#EXTM3U
|
|
#EXT-X-TARGETDURATION:10
|
|
#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:10,
|
|
fileSequence1.mp4
|
|
#EXTINF:10,
|
|
fileSequence2.mp4
|
|
#EXTINF:10,
|
|
fileSequence3.mp4
|
|
#EXT-X-DATERANGE:ID="mid-10",CLASS="com.apple.hls.interstitial",START-DATE="2024-02-23T15:00:30.000Z",DURATION=10,X-ASSET-LIST="https://example.com/mid.json"
|
|
#EXTINF:10,
|
|
fileSequence4.mp4
|
|
#EXTINF:10,
|
|
fileSequence5.mp4
|
|
#EXTINF:10,
|
|
fileSequence6.mp4`;
|
|
|
|
// Loaded playlist (before attaching media)
|
|
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 eventsBeforeAttach = getTriggerCalls();
|
|
expect(eventsBeforeAttach).to.deep.equal(
|
|
[
|
|
Events.LEVEL_LOADED,
|
|
Events.LEVEL_UPDATED,
|
|
Events.INTERSTITIALS_UPDATED,
|
|
],
|
|
`Actual events before attach`,
|
|
);
|
|
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();
|
|
attachMediaToHls();
|
|
const eventsAfterAttach = getTriggerCalls();
|
|
const expectedEvents = [
|
|
Events.MEDIA_ATTACHING,
|
|
Events.MEDIA_ATTACHED,
|
|
Events.INTERSTITIALS_BUFFERED_TO_BOUNDARY,
|
|
Events.ASSET_LIST_LOADING,
|
|
Events.INTERSTITIAL_STARTED,
|
|
];
|
|
expect(eventsAfterAttach).to.deep.equal(
|
|
expectedEvents,
|
|
`Actual events after attach`,
|
|
);
|
|
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 = interstitials.events[0];
|
|
interstitial.assetListResponse = {
|
|
ASSETS: [{ URI: '', DURATION: '10' }],
|
|
};
|
|
hls.trigger(Events.ASSET_LIST_LOADED, {
|
|
event: interstitial,
|
|
assetListResponse: interstitial.assetListResponse,
|
|
networkDetails: new Response('ok'),
|
|
});
|
|
const callsAfterAttach = getTriggerCalls();
|
|
expect(callsAfterAttach).to.deep.equal(
|
|
[
|
|
Events.ASSET_LIST_LOADED,
|
|
Events.INTERSTITIAL_ASSET_PLAYER_CREATED,
|
|
Events.INTERSTITIAL_ASSET_STARTED,
|
|
Events.MEDIA_DETACHING,
|
|
Events.MEDIA_DETACHED,
|
|
],
|
|
`Actual events after asset-list`,
|
|
);
|
|
|
|
// skip to end of interstitial
|
|
hls.trigger.resetHistory();
|
|
interstitials.skip();
|
|
const eventsAfterSkip = getTriggerCalls();
|
|
const expectedSkipEvents = [
|
|
Events.INTERSTITIAL_ASSET_ENDED,
|
|
Events.INTERSTITIAL_ENDED,
|
|
Events.INTERSTITIALS_BUFFERED_TO_BOUNDARY,
|
|
].concat(
|
|
interstitial.appendInPlace
|
|
? [Events.INTERSTITIALS_PRIMARY_RESUMED]
|
|
: [Events.MEDIA_ATTACHING, Events.INTERSTITIALS_PRIMARY_RESUMED],
|
|
);
|
|
expect(eventsAfterSkip).to.deep.equal(
|
|
expectedSkipEvents,
|
|
`Actual events after skip`,
|
|
);
|
|
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',
|
|
);
|
|
});
|
|
|
|
it('should resume at start plus resumption-offset (start + duration w/ CUE="ONCE" and attach on start)', function () {
|
|
const playlist = `#EXTM3U
|
|
#EXT-X-TARGETDURATION:10
|
|
#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:10,
|
|
fileSequence1.mp4
|
|
#EXTINF:10,
|
|
fileSequence2.mp4
|
|
#EXTINF:10,
|
|
fileSequence3.mp4
|
|
#EXT-X-DATERANGE:ID="mid-10",CLASS="com.apple.hls.interstitial",START-DATE="2024-02-23T15:00:30.000Z",DURATION=10,X-ASSET-LIST="https://example.com/mid.json",CUE="ONCE"
|
|
#EXTINF:10,
|
|
fileSequence4.mp4
|
|
#EXTINF:10,
|
|
fileSequence5.mp4
|
|
#EXTINF:10,
|
|
fileSequence6.mp4`;
|
|
|
|
// Attach media
|
|
attachMediaToHls();
|
|
expect(
|
|
interstitialsController.interstitialsManager,
|
|
'interstitialsManager before level updated',
|
|
)
|
|
.to.deep.include({
|
|
events: [],
|
|
schedule: [],
|
|
playerQueue: [],
|
|
})
|
|
.which.has.property('primary')
|
|
.which.includes({ bufferedEnd: 0, currentTime: 0, duration: 0 });
|
|
|
|
const eventsAfterAttach = getTriggerCalls();
|
|
const expectedEvents = [Events.MEDIA_ATTACHING, Events.MEDIA_ATTACHED];
|
|
expect(eventsAfterAttach).to.deep.equal(
|
|
expectedEvents,
|
|
`Actual events after attach`,
|
|
);
|
|
|
|
// Loaded playlist
|
|
hls.trigger.resetHistory();
|
|
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 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 after playlist loaded`,
|
|
);
|
|
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 = interstitials.events[0];
|
|
interstitial.assetListResponse = {
|
|
ASSETS: [{ URI: '', DURATION: '10' }],
|
|
};
|
|
hls.trigger(Events.ASSET_LIST_LOADED, {
|
|
event: interstitial,
|
|
assetListResponse: interstitial.assetListResponse,
|
|
networkDetails: new Response('ok'),
|
|
});
|
|
const callsAfterAttach = getTriggerCalls();
|
|
expect(callsAfterAttach).to.deep.equal(
|
|
[
|
|
Events.ASSET_LIST_LOADED,
|
|
Events.INTERSTITIAL_ASSET_PLAYER_CREATED,
|
|
Events.INTERSTITIAL_ASSET_STARTED,
|
|
Events.MEDIA_DETACHING,
|
|
Events.MEDIA_DETACHED,
|
|
],
|
|
`Actual events after asset-list`,
|
|
);
|
|
|
|
// skip to end of interstitial
|
|
hls.trigger.resetHistory();
|
|
interstitials.skip();
|
|
const eventsAfterSkip = getTriggerCalls();
|
|
const expectedSkipEvents = [
|
|
Events.INTERSTITIAL_ASSET_ENDED,
|
|
Events.INTERSTITIAL_ENDED,
|
|
Events.INTERSTITIALS_UPDATED, // removed Interstitial with CUE="ONCE"
|
|
Events.INTERSTITIALS_BUFFERED_TO_BOUNDARY,
|
|
Events.MEDIA_ATTACHING,
|
|
Events.INTERSTITIALS_PRIMARY_RESUMED,
|
|
];
|
|
expect(eventsAfterSkip).to.deep.equal(
|
|
expectedSkipEvents,
|
|
`Actual events after skip`,
|
|
);
|
|
// Removing the CUE="ONCE" interstitial changes the `schedule` items, but does not remove it from `events`
|
|
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 () {
|
|
const playlist = `#EXTM3U
|
|
#EXT-X-TARGETDURATION:10
|
|
#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:10,
|
|
fileSequence1.mp4
|
|
#EXTINF:10,
|
|
fileSequence2.mp4
|
|
#EXTINF:10,
|
|
fileSequence3.mp4
|
|
#EXT-X-DATERANGE:ID="mid-10",CLASS="com.apple.hls.interstitial",START-DATE="2024-02-23T15:00:30.000Z",DURATION=15,X-RESUME-OFFSET=10,X-TIMELINE-OCCUPIES=RANGE,X-ASSET-LIST="https://example.com/mid.json"
|
|
#EXTINF:10,
|
|
fileSequence4.mp4
|
|
#EXTINF:10,
|
|
fileSequence5.mp4
|
|
#EXTINF:10,
|
|
fileSequence6.mp4
|
|
#EXT-X-ENDLIST`;
|
|
|
|
const im = interstitialsController.interstitialsManager;
|
|
if (!im) {
|
|
expect(im, 'interstitialsManager').to.be.an('object');
|
|
return;
|
|
}
|
|
const primary = 'primary';
|
|
const integrated = 'integrated';
|
|
const interstitialPlayer = 'interstitialPlayer';
|
|
const expectIm = (property: string, context: string) =>
|
|
expect(im[property], `interstitialsManager.${property} @${context}`);
|
|
const expectAssetPlayer = (assetListIndex: number, context: string) => {
|
|
const assetPlayersPath =
|
|
'interstitialsManager.interstitialPlayer.assetPlayers';
|
|
expectIm(interstitialPlayer, context)
|
|
.to.have.property('assetPlayers')
|
|
.which.is.an('array')
|
|
.that.has.lengthOf.above(assetListIndex, assetPlayersPath);
|
|
return expect(
|
|
im.interstitialPlayer?.assetPlayers[assetListIndex],
|
|
`${assetPlayersPath}[${assetListIndex}] @${context}`,
|
|
).to.be.an('object');
|
|
};
|
|
const logIm = (context: string) =>
|
|
hls.logger.info(
|
|
`primary.currentTime ${im.primary.currentTime | 0} intg.currentTime ${im.integrated.currentTime | 0} pi: ${im.playingIndex} bi: ${im.bufferingIndex} @${context}`,
|
|
);
|
|
|
|
hls.on(Events.INTERSTITIALS_UPDATED, (t) => {
|
|
logIm(t);
|
|
expectIm(primary, t).to.include({ currentTime: 0, bufferedEnd: 0 });
|
|
expectIm(integrated, t).to.include({ currentTime: 0, bufferedEnd: 0 });
|
|
expectIm(interstitialPlayer, t).to.be.null;
|
|
});
|
|
hls.on(Events.INTERSTITIALS_BUFFERED_TO_BOUNDARY, (t, data) => {
|
|
const bufferingIndex = data.bufferingIndex;
|
|
const e = `${t}_${bufferingIndex}`;
|
|
logIm(e);
|
|
if (bufferingIndex === 0) {
|
|
// buffered to primary
|
|
expectIm(primary, e).to.include({ currentTime: 0, bufferedEnd: 0 });
|
|
expectIm(integrated, e).to.include({
|
|
currentTime: 0,
|
|
bufferedEnd: 0,
|
|
});
|
|
expectIm(interstitialPlayer, e).to.be.null;
|
|
} else if (bufferingIndex === 1) {
|
|
// buffered to interstitial
|
|
expectIm(primary, e).to.include({ currentTime: 30, bufferedEnd: 30 });
|
|
expectIm(integrated, e).to.include({
|
|
currentTime: 30,
|
|
bufferedEnd: 30,
|
|
});
|
|
expectIm(interstitialPlayer, e).to.include({
|
|
playingIndex: -1,
|
|
currentTime: 0,
|
|
duration: 15,
|
|
});
|
|
} else if (bufferingIndex === 2) {
|
|
// buffered to primary (interstitial ended)
|
|
expectIm(primary, e).to.include({ currentTime: 40, bufferedEnd: 40 });
|
|
expectIm(integrated, e).to.include({
|
|
currentTime: 45,
|
|
bufferedEnd: 45,
|
|
});
|
|
expectIm(interstitialPlayer, e).to.be.null;
|
|
}
|
|
});
|
|
hls.on(Events.INTERSTITIAL_STARTED, (t) => {
|
|
logIm(t);
|
|
expectIm(primary, t).to.include({ currentTime: 30, bufferedEnd: 30 });
|
|
expectIm(integrated, t).to.include({
|
|
currentTime: 30,
|
|
bufferedEnd: 30,
|
|
});
|
|
expectIm(interstitialPlayer, t).to.not.be.null;
|
|
expect(im.interstitialPlayer?.assetPlayers).to.have.lengthOf(0);
|
|
expectIm(interstitialPlayer, t).to.include({
|
|
playingIndex: -1,
|
|
currentTime: 0,
|
|
duration: 15,
|
|
});
|
|
});
|
|
hls.on(Events.ASSET_LIST_LOADED, (t) => {
|
|
logIm(t);
|
|
expectIm(primary, t).to.include({ currentTime: 30, bufferedEnd: 30 });
|
|
expectIm(integrated, t).to.include({
|
|
currentTime: 30,
|
|
bufferedEnd: 30,
|
|
});
|
|
expectIm(interstitialPlayer, t).to.not.be.null;
|
|
expect(im.interstitialPlayer?.assetPlayers).to.have.lengthOf(3);
|
|
expectIm(interstitialPlayer, t).to.include({
|
|
playingIndex: 0,
|
|
currentTime: 0,
|
|
duration: 15,
|
|
});
|
|
});
|
|
hls.on(Events.INTERSTITIAL_ASSET_PLAYER_CREATED, (t, data) => {
|
|
const assetListIndex = data.assetListIndex;
|
|
const e = `${t}_${assetListIndex}`;
|
|
logIm(e);
|
|
expectIm(primary, e).to.include({ currentTime: 30 });
|
|
expectIm(integrated, e).to.include({ currentTime: 30 });
|
|
expectIm(interstitialPlayer, e).to.not.be.null;
|
|
expectAssetPlayer(assetListIndex, e).to.include({
|
|
currentTime: 0,
|
|
bufferedEnd: 0,
|
|
duration: 5,
|
|
interstitialId: 'mid-10',
|
|
startOffset: assetListIndex * 5,
|
|
destroyed: false,
|
|
});
|
|
});
|
|
hls.on(Events.INTERSTITIAL_ASSET_STARTED, (t, data) => {
|
|
const assetListIndex = data.assetListIndex;
|
|
const e = `${t}_${assetListIndex}`;
|
|
logIm(e);
|
|
if (assetListIndex === 0) {
|
|
expectIm(primary, e).to.include({ currentTime: 30, bufferedEnd: 30 });
|
|
expectIm(integrated, e).to.include({
|
|
currentTime: 30,
|
|
bufferedEnd: 30,
|
|
});
|
|
} else if (assetListIndex === 1) {
|
|
expectIm(primary, e).to.include({ currentTime: 30, bufferedEnd: 30 });
|
|
expectIm(integrated, e).to.include({
|
|
currentTime: 35,
|
|
bufferedEnd: 35,
|
|
});
|
|
} else if (assetListIndex === 2) {
|
|
expectIm(primary, e).to.include({ currentTime: 30, bufferedEnd: 30 });
|
|
expectIm(integrated, e).to.include({
|
|
currentTime: 40,
|
|
bufferedEnd: 40,
|
|
});
|
|
}
|
|
expectIm(interstitialPlayer, e).to.not.be.null;
|
|
expectIm(interstitialPlayer, e).to.include({
|
|
playingIndex: assetListIndex,
|
|
currentTime: assetListIndex * 5,
|
|
duration: 15,
|
|
});
|
|
expectAssetPlayer(assetListIndex, e).to.include({
|
|
currentTime: 0,
|
|
bufferedEnd: 0,
|
|
duration: 5,
|
|
interstitialId: 'mid-10',
|
|
startOffset: assetListIndex * 5,
|
|
destroyed: false,
|
|
});
|
|
});
|
|
hls.on(Events.INTERSTITIAL_ASSET_ENDED, (t, data) => {
|
|
const assetListIndex = data.assetListIndex;
|
|
const e = `${t}_${assetListIndex}`;
|
|
logIm(e);
|
|
if (assetListIndex === 0) {
|
|
expectIm(primary, e).to.include({ currentTime: 30, bufferedEnd: 30 });
|
|
expectIm(integrated, e).to.include({
|
|
currentTime: 35,
|
|
bufferedEnd: 35,
|
|
});
|
|
} else if (assetListIndex === 1) {
|
|
expectIm(primary, e).to.include({ currentTime: 30, bufferedEnd: 30 });
|
|
expectIm(integrated, e).to.include({
|
|
currentTime: 40,
|
|
bufferedEnd: 40,
|
|
});
|
|
} else if (assetListIndex === 2) {
|
|
expectIm(primary, e).to.include({ currentTime: 40, bufferedEnd: 40 });
|
|
expectIm(integrated, e).to.include({
|
|
currentTime: 45,
|
|
bufferedEnd: 45,
|
|
});
|
|
}
|
|
expectIm(interstitialPlayer, t).to.not.be.null;
|
|
expectIm(interstitialPlayer, t).to.include({
|
|
playingIndex: assetListIndex,
|
|
currentTime: 5 + assetListIndex * 5,
|
|
duration: 15,
|
|
});
|
|
expectAssetPlayer(assetListIndex, t).to.include({
|
|
currentTime: 5,
|
|
bufferedEnd: 5,
|
|
duration: 5,
|
|
interstitialId: 'mid-10',
|
|
startOffset: assetListIndex * 5,
|
|
destroyed: false,
|
|
});
|
|
});
|
|
hls.on(Events.INTERSTITIAL_ENDED, (t) => {
|
|
logIm(t);
|
|
expectIm(primary, t).to.include({ currentTime: 40, bufferedEnd: 40 });
|
|
expectIm(integrated, t).to.include({
|
|
currentTime: 45,
|
|
bufferedEnd: 45,
|
|
});
|
|
expectIm(interstitialPlayer, t).to.not.be.null;
|
|
});
|
|
hls.on(Events.INTERSTITIALS_PRIMARY_RESUMED, (t) => {
|
|
logIm(t);
|
|
expectIm(primary, t).to.include({ currentTime: 40, bufferedEnd: 40 });
|
|
expectIm(integrated, t).to.include({
|
|
currentTime: 45,
|
|
bufferedEnd: 45,
|
|
});
|
|
expectIm(interstitialPlayer, t).to.be.null;
|
|
});
|
|
hls.on(Events.MEDIA_ATTACHING, (t) => {
|
|
const playingIndex = im.playingIndex;
|
|
logIm(`${t} playingIndex ${playingIndex}`);
|
|
if (playingIndex < 2) {
|
|
expectIm(primary, t).to.include({ currentTime: 0, bufferedEnd: 0 });
|
|
expectIm(integrated, t).to.include({
|
|
currentTime: 0,
|
|
bufferedEnd: 0,
|
|
});
|
|
expectIm(interstitialPlayer, t).to.be.null;
|
|
} else {
|
|
expectIm(primary, t).to.include({ currentTime: 40, bufferedEnd: 40 });
|
|
expectIm(integrated, t).to.include({
|
|
currentTime: 45,
|
|
bufferedEnd: 45,
|
|
});
|
|
expectIm(interstitialPlayer, t).to.be.null;
|
|
}
|
|
});
|
|
hls.on(Events.MEDIA_ATTACHED, (t) => {
|
|
logIm(t);
|
|
expectIm(primary, t).to.include({ currentTime: 0, bufferedEnd: 0 });
|
|
expectIm(integrated, t).to.include({ currentTime: 0, bufferedEnd: 0 });
|
|
expectIm(interstitialPlayer, t).to.be.null;
|
|
});
|
|
hls.once(Events.MEDIA_DETACHING, (t) => {
|
|
logIm(t);
|
|
expectIm(primary, t).to.include({ currentTime: 30, bufferedEnd: 30 });
|
|
expectIm(integrated, t).to.include({
|
|
currentTime: 30,
|
|
bufferedEnd: 30,
|
|
});
|
|
});
|
|
|
|
// Loaded playlist (before attaching media)
|
|
setLoadedLevelDetails(playlist);
|
|
|
|
expect(im.events).is.an('array').which.has.lengthOf(1);
|
|
expect(im.schedule).is.an('array').which.has.lengthOf(3);
|
|
const eventsBeforeAttach = getTriggerCalls();
|
|
expect(eventsBeforeAttach).to.deep.equal(
|
|
[
|
|
Events.LEVEL_LOADED,
|
|
Events.LEVEL_UPDATED,
|
|
Events.INTERSTITIALS_UPDATED,
|
|
],
|
|
`Actual events before attach`,
|
|
);
|
|
expect(im.bufferingIndex).to.equal(-1, 'bufferingIndex');
|
|
expect(im.playingIndex).to.equal(-1, 'playingIndex');
|
|
expectIm(primary, 'before attach').to.include({
|
|
currentTime: 0,
|
|
bufferedEnd: 0,
|
|
});
|
|
expectIm(integrated, 'before attach').to.include({
|
|
currentTime: 0,
|
|
bufferedEnd: 0,
|
|
});
|
|
|
|
// Attach media
|
|
hls.trigger.resetHistory();
|
|
const media = attachMediaToHls();
|
|
media.__timeUpdate(10);
|
|
logIm('timeupdate-10');
|
|
expectIm(primary, 'media@10').to.include({
|
|
currentTime: 10,
|
|
bufferedEnd: 10,
|
|
});
|
|
expectIm(integrated, 'media@10').to.include({
|
|
currentTime: 10,
|
|
bufferedEnd: 10,
|
|
});
|
|
const eventsAfterAttach = getTriggerCalls();
|
|
const expectedEvents = [
|
|
Events.MEDIA_ATTACHING,
|
|
Events.MEDIA_ATTACHED,
|
|
Events.INTERSTITIALS_BUFFERED_TO_BOUNDARY,
|
|
];
|
|
expect(eventsAfterAttach).to.deep.equal(
|
|
expectedEvents,
|
|
`Actual events after attach`,
|
|
);
|
|
// Advance to interstitial
|
|
hls.trigger.resetHistory();
|
|
media.__timeUpdate(30);
|
|
logIm('timeupdate-30');
|
|
|
|
const eventsAfterPlayback = getTriggerCalls();
|
|
const expectedEventsAfterPlayback = [
|
|
Events.INTERSTITIALS_BUFFERED_TO_BOUNDARY,
|
|
Events.ASSET_LIST_LOADING,
|
|
Events.INTERSTITIAL_STARTED,
|
|
];
|
|
expect(eventsAfterPlayback).to.deep.equal(
|
|
expectedEventsAfterPlayback,
|
|
`Actual events after playback to interstitial`,
|
|
);
|
|
expect(im.bufferingIndex).to.equal(1, 'bufferingIndex a');
|
|
expect(im.playingIndex).to.equal(1, 'playingIndex a');
|
|
expectIm(primary, 'media@30').to.include({ currentTime: 30 });
|
|
expectIm(integrated, 'media@30').to.include({ currentTime: 30 });
|
|
|
|
// Load asset-list
|
|
hls.trigger.resetHistory();
|
|
const interstitial = im.events[0];
|
|
interstitial.assetListResponse = {
|
|
ASSETS: [
|
|
{ URI: 'http://example.com/a.m3u8', DURATION: '5' },
|
|
{ URI: 'http://example.com/b.m3u8', DURATION: '5' },
|
|
{ URI: 'http://example.com/c.m3u8', DURATION: '5' },
|
|
],
|
|
};
|
|
hls.trigger(Events.ASSET_LIST_LOADED, {
|
|
event: interstitial,
|
|
assetListResponse: interstitial.assetListResponse,
|
|
networkDetails: new Response('ok'),
|
|
});
|
|
const callsAfterAssetsLoaded = getTriggerCalls();
|
|
expect(callsAfterAssetsLoaded).to.deep.equal(
|
|
[
|
|
Events.ASSET_LIST_LOADED,
|
|
Events.INTERSTITIAL_ASSET_PLAYER_CREATED,
|
|
Events.INTERSTITIAL_ASSET_PLAYER_CREATED,
|
|
Events.INTERSTITIAL_ASSET_PLAYER_CREATED,
|
|
Events.INTERSTITIAL_ASSET_STARTED,
|
|
Events.MEDIA_DETACHING,
|
|
Events.MEDIA_DETACHED,
|
|
],
|
|
`Actual events after asset-list`,
|
|
);
|
|
expect(im.interstitialPlayer, `interstitialPlayer`).to.not.be.null;
|
|
|
|
// Advance assets
|
|
const advanceAsset = (
|
|
sequence: string,
|
|
assetIndex: number,
|
|
integratedTimePlusThree: number,
|
|
) => {
|
|
hls.trigger.resetHistory();
|
|
|
|
media.__timeUpdate(media.currentTime + 3);
|
|
logIm(`${sequence} asset@3`);
|
|
expectIm(primary, `${sequence} asset@3`).to.include({
|
|
currentTime: 30,
|
|
});
|
|
expectIm(integrated, `${sequence} asset@3`).to.include({
|
|
currentTime: integratedTimePlusThree,
|
|
});
|
|
expectIm(
|
|
interstitialPlayer,
|
|
`interstitialPlayer ${sequence} asset@3`,
|
|
).to.include({
|
|
playingIndex: assetIndex,
|
|
currentTime: integratedTimePlusThree - 30,
|
|
duration: 15,
|
|
});
|
|
media.__timeUpdate(media.currentTime + 2);
|
|
const assetPlayerHls =
|
|
im.interstitialPlayer?.assetPlayers[assetIndex]?.hls;
|
|
expect(assetPlayerHls, 'asset player is defined').to.not.be.null;
|
|
// end asset playback
|
|
assetPlayerHls?.trigger(Events.MEDIA_ENDED, {
|
|
stalled: false,
|
|
});
|
|
const eventsBetweenAssets = getTriggerCalls();
|
|
const expectedEventsBetweensAssets = [
|
|
Events.INTERSTITIAL_ASSET_ENDED,
|
|
Events.INTERSTITIAL_ASSET_STARTED,
|
|
];
|
|
expect(eventsBetweenAssets).to.deep.equal(
|
|
expectedEventsBetweensAssets,
|
|
`Actual events after ${sequence} asset`,
|
|
);
|
|
};
|
|
// To second asset
|
|
advanceAsset('first', 0, 33);
|
|
// To third asset
|
|
advanceAsset('second', 1, 38);
|
|
|
|
// Advance to primary
|
|
hls.trigger.resetHistory();
|
|
|
|
media.__timeUpdate(media.currentTime + 5);
|
|
expectIm(primary, `third asset@5`).to.include({ currentTime: 30 });
|
|
expectIm(integrated, `third asset@5`).to.include({ currentTime: 45 });
|
|
expectIm(
|
|
interstitialPlayer,
|
|
`interstitialPlayer third asset@5`,
|
|
).to.include({ playingIndex: 2, currentTime: 15, duration: 15 });
|
|
|
|
const assetPlayerHls = im.interstitialPlayer?.assetPlayers[2]?.hls;
|
|
expect(assetPlayerHls, 'last asset player is defined').to.not.be.null;
|
|
// end last asset playback
|
|
assetPlayerHls?.trigger(Events.MEDIA_ENDED, {
|
|
stalled: false,
|
|
});
|
|
const eventsAfterLastAsset = getTriggerCalls();
|
|
const expectedEndLastAssetEvents = [
|
|
Events.INTERSTITIAL_ASSET_ENDED,
|
|
Events.INTERSTITIAL_ENDED,
|
|
Events.INTERSTITIALS_BUFFERED_TO_BOUNDARY,
|
|
Events.MEDIA_ATTACHING,
|
|
Events.INTERSTITIALS_PRIMARY_RESUMED,
|
|
];
|
|
expect(eventsAfterLastAsset).to.deep.equal(
|
|
expectedEndLastAssetEvents,
|
|
`Actual events after last asset`,
|
|
);
|
|
expect(im.bufferingIndex).to.equal(2, 'bufferingIndex after skip');
|
|
expect(im.playingIndex).to.equal(2, 'playingIndex after skip');
|
|
expectIm(primary, `after break`).to.include({ currentTime: 40 });
|
|
expectIm(integrated, `after break`).to.include({ currentTime: 45 });
|
|
media.__timeUpdate(50);
|
|
expectIm(primary, `media@50`).to.include({ currentTime: 50 });
|
|
expectIm(integrated, `media@50`).to.include({ currentTime: 55 });
|
|
logIm('timeupdate-50');
|
|
});
|
|
});
|
|
|
|
describe('Live start', function () {
|
|
it('request asset-list with _HLS_start_offset when joining', function () {
|
|
const playlist = `#EXTM3U
|
|
#EXT-X-TARGETDURATION:10
|
|
#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:10,
|
|
fileSequence1.mp4
|
|
#EXTINF:10,
|
|
fileSequence2.mp4
|
|
#EXT-X-DATERANGE:ID="mid-live",CLASS="com.apple.hls.interstitial",START-DATE="2024-02-23T15:00:20.000Z",DURATION=30,X-ASSET-LIST="https://example.com/mid.json"
|
|
#EXTINF:10,
|
|
fileSequence3.mp4
|
|
#EXTINF:10,
|
|
fileSequence4.mp4
|
|
#EXTINF:10,
|
|
fileSequence5.mp4
|
|
#EXTINF:10,
|
|
fileSequence6.mp4`;
|
|
|
|
// Loaded playlist (before attaching media)
|
|
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);
|
|
|
|
// Capture asset-list request
|
|
const loadSpy = sandbox.spy(hls.config.loader.prototype, 'load');
|
|
const primaryId = hls.sessionId;
|
|
|
|
// Attach media
|
|
hls.trigger.resetHistory();
|
|
attachMediaToHls();
|
|
const eventsAfterAttach = getTriggerCalls();
|
|
const expectedEvents = [
|
|
Events.MEDIA_ATTACHING,
|
|
Events.MEDIA_ATTACHED,
|
|
Events.INTERSTITIALS_BUFFERED_TO_BOUNDARY,
|
|
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(
|
|
assetListUrl,
|
|
'_HLS_primary_id and _HLS_start_offset match',
|
|
).to.equal(
|
|
`https://example.com/mid.json?_HLS_primary_id=${primaryId}&_HLS_start_offset=10`,
|
|
);
|
|
|
|
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 = interstitials.events[0];
|
|
interstitial.assetListResponse = {
|
|
ASSETS: [{ URI: 'https://example.com/midroll.m3u8', DURATION: '30' }],
|
|
};
|
|
hls.trigger(Events.ASSET_LIST_LOADED, {
|
|
event: interstitial,
|
|
assetListResponse: interstitial.assetListResponse,
|
|
networkDetails: new Response('ok'),
|
|
});
|
|
const eventsAfterAssetListLoaded = getTriggerCalls();
|
|
expect(eventsAfterAssetListLoaded).to.deep.equal(
|
|
[
|
|
Events.ASSET_LIST_LOADED,
|
|
Events.INTERSTITIAL_ASSET_PLAYER_CREATED,
|
|
Events.INTERSTITIAL_ASSET_STARTED,
|
|
Events.MEDIA_DETACHING,
|
|
Events.MEDIA_DETACHED,
|
|
],
|
|
`Actual events after asset-list`,
|
|
);
|
|
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(
|
|
interstitials.interstitialPlayer?.scheduleItem?.event,
|
|
`interstitialPlayer.scheduleItem`,
|
|
).to.include({ identifier: 'mid-live' });
|
|
expect(
|
|
interstitials.interstitialPlayer?.assetPlayers,
|
|
`interstitialPlayer.assetPlayers[]`,
|
|
).to.have.lengthOf(1);
|
|
expect(
|
|
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');
|
|
});
|
|
});
|
|
|
|
describe('#7845 Live start folloing preroll', function () {
|
|
describe('resumes at live-edge', function () {
|
|
const playlist = `#EXTM3U
|
|
#EXT-X-TARGETDURATION:6
|
|
#EXT-X-VERSION:7
|
|
#EXT-X-MEDIA-SEQUENCE:1
|
|
#EXT-X-PROGRAM-DATE-TIME:2024-02-23T15:00:00.000Z
|
|
#EXT-X-DATERANGE:ID="pre-resume-at-live",CLASS="com.apple.hls.interstitial",START-DATE="2024-02-23T15:00:00.000Z",CUE="PRE,ONCE",DURATION=15,X-ASSET-URI="https://example.com/pre.m3u8?_HLS_interstitial_id=pre-resume-at-live"
|
|
#EXT-X-MAP:URI="fileSequence0.mp4"
|
|
#EXTINF:6.000,
|
|
fileSequence1.mp4
|
|
#EXTINF:6.000,
|
|
fileSequence2.mp4
|
|
#EXTINF:6.000,
|
|
fileSequence3.mp4
|
|
#EXTINF:6.000,
|
|
fileSequence4.mp4
|
|
#EXT-X-DATERANGE:ID="mid-starts-at-24",CLASS="com.apple.hls.interstitial",START-DATE="2024-02-23T15:00:24.000Z",X-TIMELINE-OCCUPIES="RANGE",DURATION=10,X-ASSET-LIST="https://example.com/mid-list.json?_HLS_interstitial_id=mid-starts-at-24"
|
|
#EXTINF:6.000,
|
|
fileSequence5.mp4
|
|
#EXTINF:6.000,
|
|
fileSequence6.mp4
|
|
#EXTINF:6.000,
|
|
fileSequence7.mp4
|
|
#EXTINF:6.000,
|
|
fileSequence8.mp4`;
|
|
|
|
it('should include _HLS_start_offset in midroll asset-list request, following preroll with implicit resumption offset', function () {
|
|
setLoadedLevelDetails(playlist);
|
|
const interstitials = interstitialsController.interstitialsManager;
|
|
if (!interstitials) {
|
|
expect(interstitials, 'interstitialsManager').to.be.an('object');
|
|
return;
|
|
}
|
|
expect(interstitials.bufferingIndex).to.equal(-1, 'bufferingIndex');
|
|
expect(interstitials.playingIndex).to.equal(-1, 'playingIndex');
|
|
expect(interstitials.events).is.an('array').which.has.lengthOf(2);
|
|
expectScheduleToInclude(
|
|
interstitials.schedule,
|
|
[
|
|
{
|
|
event: {
|
|
identifier: 'pre-resume-at-live',
|
|
},
|
|
start: 0,
|
|
end: 30,
|
|
playout: { start: 0, end: 15 },
|
|
integrated: { start: 0, end: 0 },
|
|
},
|
|
{
|
|
event: {
|
|
identifier: 'mid-starts-at-24',
|
|
},
|
|
start: 24,
|
|
end: 34,
|
|
playout: { start: 15, end: 25 },
|
|
integrated: { start: 0, end: 10 },
|
|
},
|
|
{
|
|
previousEvent: {
|
|
identifier: 'mid-starts-at-24',
|
|
},
|
|
nextEvent: null,
|
|
start: 34,
|
|
end: Infinity,
|
|
playout: { start: 25, end: Infinity },
|
|
integrated: { start: 10, end: Infinity },
|
|
},
|
|
],
|
|
'before-preroll',
|
|
);
|
|
|
|
attachMediaToHls();
|
|
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: 15,
|
|
});
|
|
expect(
|
|
interstitials.interstitialPlayer?.scheduleItem?.event,
|
|
`interstitialPlayer.scheduleItem`,
|
|
).to.include({ identifier: 'pre-resume-at-live' });
|
|
|
|
expect(interstitials.events[1].assetListLoader).to.equal(undefined);
|
|
|
|
// Capture asset-list request
|
|
const loadSpy = sandbox.spy(hls.config.loader.prototype, 'load');
|
|
const primaryId = hls.sessionId;
|
|
|
|
// skip preroll to advance schedule
|
|
interstitials.skip();
|
|
|
|
expect(loadSpy).calledOnce;
|
|
const assetListUrl = loadSpy.getCalls()[0].args[0].url;
|
|
expect(
|
|
assetListUrl,
|
|
'_HLS_primary_id and _HLS_start_offset match',
|
|
).to.equal(
|
|
`https://example.com/mid-list.json?_HLS_interstitial_id=mid-starts-at-24&_HLS_primary_id=${primaryId}&_HLS_start_offset=6`,
|
|
);
|
|
|
|
// Removing the CUE="ONCE" interstitial changes the `schedule` items, but does not remove it from `events`
|
|
expect(interstitials.events).is.an('array').which.has.lengthOf(2);
|
|
expectScheduleToInclude(
|
|
interstitials.schedule,
|
|
[
|
|
{
|
|
previousEvent: null,
|
|
nextEvent: {
|
|
identifier: 'mid-starts-at-24',
|
|
},
|
|
start: 0,
|
|
end: 24,
|
|
playout: { start: 0, end: 24 },
|
|
integrated: { start: 0, end: 24 },
|
|
},
|
|
{
|
|
event: {
|
|
identifier: 'mid-starts-at-24',
|
|
},
|
|
start: 24,
|
|
end: 34,
|
|
playout: { start: 24, end: 34 },
|
|
integrated: { start: 24, end: 34 },
|
|
},
|
|
{
|
|
previousEvent: {
|
|
identifier: 'mid-starts-at-24',
|
|
},
|
|
nextEvent: null,
|
|
start: 34,
|
|
end: Infinity,
|
|
playout: { start: 34, end: Infinity },
|
|
integrated: { start: 34, end: Infinity },
|
|
},
|
|
],
|
|
'after-preroll',
|
|
);
|
|
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');
|
|
});
|
|
});
|
|
|
|
describe('resumes at X-RESUME-OFFSET=15 (t + 15)', function () {
|
|
const playlist = `#EXTM3U
|
|
#EXT-X-TARGETDURATION:10
|
|
#EXT-X-VERSION:7
|
|
#EXT-X-MEDIA-SEQUENCE:1
|
|
#EXT-X-PROGRAM-DATE-TIME:2024-02-23T15:00:00.000Z
|
|
#EXT-X-DATERANGE:ID="pre-resume-at-t15",CLASS="com.apple.hls.interstitial",START-DATE="2024-02-23T15:00:00.000Z",CUE="PRE,ONCE",DURATION=15,X-RESUME-OFFSET=15,X-ASSET-URI="https://example.com/pre.m3u8?_HLS_interstitial_id=pre-resume-at-t15"
|
|
#EXT-X-MAP:URI="fileSequence0.mp4"
|
|
#EXTINF:10.000,
|
|
fileSequence-0.s
|
|
#EXTINF:10.000,
|
|
fileSequence-10.s
|
|
#EXTINF:10.000,
|
|
fileSequence-20.s
|
|
#EXTINF:10.000,
|
|
fileSequence-30.s
|
|
#EXTINF:10.000,
|
|
fileSequence-40.s
|
|
#EXTINF:10.000,
|
|
fileSequence-50.s
|
|
# ends with 60s duration, live start @30 (-n3)
|
|
#EXT-X-DATERANGE:ID="mid-starts-at-40",CLASS="com.apple.hls.interstitial",START-DATE="2024-02-23T15:00:40.000Z",X-TIMELINE-OCCUPIES="RANGE",DURATION=10,X-ASSET-LIST="https://example.com/mid-list.json?_HLS_interstitial_id=mid-starts-at-40"
|
|
`;
|
|
|
|
const playlist2 = `#EXTM3U
|
|
#EXT-X-TARGETDURATION:10
|
|
#EXT-X-VERSION:7
|
|
#EXT-X-MEDIA-SEQUENCE:1
|
|
#EXT-X-PROGRAM-DATE-TIME:2024-02-23T15:00:00.000Z
|
|
#EXT-X-DATERANGE:ID="pre-resume-at-t15",CLASS="com.apple.hls.interstitial",START-DATE="2024-02-23T15:00:00.000Z",CUE="PRE,ONCE",DURATION=15,X-RESUME-OFFSET=15,X-ASSET-URI="https://example.com/pre.m3u8?_HLS_interstitial_id=pre-resume-at-t15"
|
|
#EXT-X-MAP:URI="fileSequence0.mp4"
|
|
#EXTINF:10.000,
|
|
fileSequence-0.s
|
|
#EXTINF:10.000,
|
|
fileSequence-10.s
|
|
#EXTINF:10.000,
|
|
fileSequence-20.s
|
|
#EXTINF:10.000,
|
|
fileSequence-30.s
|
|
#EXTINF:10.000,
|
|
fileSequence-40.s
|
|
#EXTINF:10.000,
|
|
fileSequence-50.s
|
|
#EXT-X-DATERANGE:ID="mid-starts-at-40",CLASS="com.apple.hls.interstitial",START-DATE="2024-02-23T15:00:40.000Z",X-TIMELINE-OCCUPIES="RANGE",DURATION=10,X-ASSET-LIST="https://example.com/mid-list.json?_HLS_interstitial_id=mid-starts-at-40"
|
|
#EXTINF:10.000,
|
|
fileSequence-60.s
|
|
`;
|
|
|
|
it('should include _HLS_start_offset in midroll asset-list request, following preroll with explicit resumption offset', function () {
|
|
attachMediaToHls();
|
|
const details = 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(2);
|
|
expectScheduleToInclude(
|
|
interstitials.schedule,
|
|
[
|
|
{
|
|
event: {
|
|
identifier: 'pre-resume-at-t15',
|
|
},
|
|
start: 0,
|
|
end: 45,
|
|
playout: { start: 0, end: 15 },
|
|
integrated: { start: 0, end: 0 },
|
|
},
|
|
{
|
|
event: {
|
|
identifier: 'mid-starts-at-40',
|
|
},
|
|
start: 40,
|
|
end: 50,
|
|
playout: { start: 15, end: 25 },
|
|
integrated: { start: 0, end: 10 },
|
|
},
|
|
{
|
|
previousEvent: {
|
|
identifier: 'mid-starts-at-40',
|
|
},
|
|
nextEvent: null,
|
|
start: 50,
|
|
end: Infinity,
|
|
playout: { start: 25, end: Infinity },
|
|
integrated: { start: 10, end: Infinity },
|
|
},
|
|
],
|
|
'before-preroll',
|
|
);
|
|
|
|
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: 15,
|
|
});
|
|
expect(
|
|
interstitials.interstitialPlayer?.scheduleItem?.event,
|
|
`interstitialPlayer.scheduleItem`,
|
|
).to.include({ identifier: 'pre-resume-at-t15' });
|
|
|
|
expect(interstitials.events[1].assetListLoader).to.equal(undefined);
|
|
|
|
// Capture asset-list request
|
|
const loadSpy = sandbox.spy(hls.config.loader.prototype, 'load');
|
|
const primaryId = hls.sessionId;
|
|
|
|
// Advance playlists
|
|
const timeSinceLoadedStub = sinon.stub(details, 'age');
|
|
timeSinceLoadedStub.get(() => 15);
|
|
|
|
const details2 = setLoadedLevelDetails(playlist2);
|
|
const timeSinceLoadedStub2 = sinon.stub(details2, 'age');
|
|
timeSinceLoadedStub2.get(() => 5);
|
|
|
|
// skip preroll to advance schedule
|
|
interstitials.skip();
|
|
|
|
expect(loadSpy).calledOnce;
|
|
const assetListUrl = loadSpy.getCalls()[0].args[0].url;
|
|
expect(
|
|
assetListUrl,
|
|
'_HLS_primary_id and _HLS_start_offset match',
|
|
).to.equal(
|
|
`https://example.com/mid-list.json?_HLS_interstitial_id=mid-starts-at-40&_HLS_primary_id=${primaryId}&_HLS_start_offset=5`,
|
|
);
|
|
|
|
// Removing the CUE="ONCE" interstitial changes the `schedule` items, but does not remove it from `events`
|
|
expect(interstitials.events).is.an('array').which.has.lengthOf(2);
|
|
expectScheduleToInclude(
|
|
interstitials.schedule,
|
|
[
|
|
{
|
|
previousEvent: null,
|
|
nextEvent: {
|
|
identifier: 'mid-starts-at-40',
|
|
},
|
|
start: 0,
|
|
end: 40,
|
|
playout: { start: 0, end: 40 },
|
|
integrated: { start: 0, end: 40 },
|
|
},
|
|
{
|
|
event: {
|
|
identifier: 'mid-starts-at-40',
|
|
},
|
|
start: 40,
|
|
end: 50,
|
|
playout: { start: 40, end: 50 },
|
|
integrated: { start: 40, end: 50 },
|
|
},
|
|
{
|
|
previousEvent: {
|
|
identifier: 'mid-starts-at-40',
|
|
},
|
|
nextEvent: null,
|
|
start: 50,
|
|
end: Infinity,
|
|
playout: { start: 50, end: Infinity },
|
|
integrated: { start: 50, end: Infinity },
|
|
},
|
|
],
|
|
'after-preroll',
|
|
);
|
|
expect(interstitials.bufferingIndex).to.equal(1, 'bufferingIndex b');
|
|
expect(interstitials.playingIndex).to.equal(1, 'playingIndex b');
|
|
expect(interstitials.primary.currentTime).to.equal(40, 'timelinePos b');
|
|
});
|
|
});
|
|
});
|
|
});
|