Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions chrome/player/players/hls/HLSPlayer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,14 @@ export default class HLSPlayer extends EventEmitter {
enableWebVTT: false,
enableIMSC1: false,
enableCEA708Captions: false,
// Interstitials (EXT-X-DATERANGE CLASS="com.apple.hls.interstitial", ad breaks): hls.js
// plays each in a player of its own made from this config, FastStream's loader and level
// control included, and FastStream drives none of it. Measured (hls-interstitials.e2e.mjs):
// a break that replaces part of the stream made the video skip the stream's first seconds
// and end inside the break; one inserted before it played no ad, though hls.js fetched the
// ad's playlist. Off, hls.js plays the stream itself, in order, as the HLS spec has a
// player without interstitials do.
enableInterstitialPlayback: false,
stretchShortVideoTrack: false,
maxAudioFramesDrift: 1,
forceKeyFrameOnDiscontinuity: true,
Expand Down
143 changes: 143 additions & 0 deletions tests/e2e/specs/hls-interstitials.e2e.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// An HLS stream with an interstitial: an EXT-X-DATERANGE of CLASS "com.apple.hls.interstitial"
// whose X-ASSET-URI is another stream, the ad, played at that point of the timeline.
//
// hls.js plays one in a player of its own, built from the same config, so it loaded through
// FastStream's loader and level control, which drive none of it. Measured before
// enableInterstitialPlayback went off (HLSPlayer), on the hls-ts fixture's 9 s with a 9 s ad:
// a break that replaces part of the stream (no X-RESUME-OFFSET, as live ad insertion has it)
// made the video skip from 0 to 9.9 s within a second and end at 13; plain hls.js played the
// stream to 4 s, then the ad. A break inserted before the stream (X-RESUME-OFFSET=0, CUE="PRE")
// played no ad in FastStream, though hls.js fetched the ad's playlist. One inserted into the
// stream already played the stream itself (hls.js's schedule never moved); its test keeps it
// that way. Now hls.js plays the stream itself, in order, as the HLS spec has a player without
// interstitials do, and never asks for the ad.

import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';

import {browser, expect} from '@wdio/globals';

const PORT = 41898;
const ORIGIN = `http://127.0.0.1:${PORT}`;
const SEGMENTS = path.resolve(import.meta.dirname, '../fixtures/hls-ts');
// The hls-ts fixture's segments (fixtures/hls-ts/index.m3u8): 9 s.
const DURATIONS = [2, 2, 2, 2, 1];
const START = '2026-01-01T00:00:00.000Z';

let server;
let requests = [];

/**
* A VOD playlist of the hls-ts fixture's segments, under a path of its own.
* @param {string} prefix - main or ad.
* @param {string[]} [tags] - Lines after EXT-X-PROGRAM-DATE-TIME.
* @return {string}
*/
function playlist(prefix, tags = []) {
const lines = ['#EXTM3U', '#EXT-X-VERSION:3', '#EXT-X-TARGETDURATION:2', '#EXT-X-PLAYLIST-TYPE:VOD',
'#EXT-X-MEDIA-SEQUENCE:0', `#EXT-X-PROGRAM-DATE-TIME:${START}`, ...tags];
DURATIONS.forEach((d, i) => lines.push(`#EXTINF:${d.toFixed(6)},`, `${ORIGIN}/${prefix}/seg-${String(i).padStart(3, '0')}.ts`));
lines.push('#EXT-X-ENDLIST');
return lines.join('\n') + '\n';
}

/**
* The interstitial: the ad stream, 9 s, at a time of the stream.
* @param {string} start - Its START-DATE.
* @param {string} [attributes] - More attributes.
* @return {string}
*/
const adBreak = (start, attributes = '') => '#EXT-X-DATERANGE:ID="ad",CLASS="com.apple.hls.interstitial",' +
`START-DATE="${start}",DURATION=9,X-ASSET-URI="${ORIGIN}/ad.m3u8"${attributes}`;

const PLAYLISTS = {
'/preroll.m3u8': () => playlist('main', [adBreak(START, ',X-RESUME-OFFSET=0,CUE="PRE"')]),
'/midroll.m3u8': () => playlist('main', [adBreak('2026-01-01T00:00:04.000Z', ',X-RESUME-OFFSET=0')]),
'/midroll-replacing.m3u8': () => playlist('main', [adBreak('2026-01-01T00:00:04.000Z')]),
'/ad.m3u8': () => playlist('ad'),
};

/**
* Plays a stream to its end, noting about every second where the video is and when the page
* saw it there.
* @param {string} name - Its path.
* @return {Promise<{samples: Array<{time: number, at: number}>, ended: boolean, duration: number,
* error: ?string}>} at: the page's clock, in seconds.
*/
async function playThrough(name) {
requests = [];
await browser.url(`/player/index.html?t=${Date.now()}#${ORIGIN}${name}`);
const samples = [];
let last = null;
const end = Date.now() + 45000;
while (Date.now() < end) {
last = await browser.execute(() => {
const client = window.fastStream;
const video = client?.player?.getVideo();
if (!video) return null;
if (video.paused && !video.ended) video.play().catch(() => {});
return {
time: video.currentTime, at: performance.now() / 1000, ended: video.ended, duration: video.duration,
error: client.interfaceController?.statusManager?.statusMessages?.get('error')?.message || null,
};
});
if (last) samples.push({time: last.time, at: last.at});
if (last?.ended || last?.error) break;
await browser.pause(1000);
}
return {samples, ended: !!last?.ended, duration: last?.duration ?? NaN, error: last?.error ?? null};
}

describe('An HLS stream with an interstitial (an ad break)', function() {
before(async function() {
server = http.createServer((req, res) => {
const url = new URL(req.url, ORIGIN);
requests.push(url.pathname);
const headers = {'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin'};
if (PLAYLISTS[url.pathname]) {
res.writeHead(200, {...headers, 'Content-Type': 'application/vnd.apple.mpegurl'});
res.end(PLAYLISTS[url.pathname]());
return;
}
const m = /^\/(main|ad)\/(seg-\d+\.ts)$/.exec(url.pathname);
const file = m && path.join(SEGMENTS, m[2]);
if (!file || !fs.existsSync(file)) {
res.writeHead(404, headers);
res.end();
return;
}
res.writeHead(200, {...headers, 'Content-Type': 'video/mp2t'});
res.end(fs.readFileSync(file));
});
await new Promise((resolve, reject) => {
server.on('error', reject);
server.listen(PORT, '127.0.0.1', resolve);
});
});

after(async function() {
await new Promise((resolve) => server.close(resolve));
});

for (const [name, what] of [
['/preroll.m3u8', 'inserted before the stream'],
['/midroll.m3u8', 'inserted into the stream'],
['/midroll-replacing.m3u8', 'replacing part of the stream'],
]) {
it(`plays the stream itself, in order, past a break ${what}, and never asks for the ad`, async function() {
const {samples, ended, duration, error} = await playThrough(name);
expect(error).toBe(null);
expect(ended).toBe(true);
// The stream's 9 s, not a timeline with the ad's 9 in it.
expect(duration).toBeCloseTo(9, 0);
// Never back, and never further on than the page's clock went (a jump), with a second
// to spare for a frame that lands between two samples.
const jumps = samples.slice(1).map((s, i) => ({
from: samples[i].time, to: s.time, ahead: (s.time - samples[i].time) - (s.at - samples[i].at),
})).filter((step) => step.to < step.from - 0.05 || step.ahead > 1);
expect(jumps).toEqual([]);
expect(requests.filter((p) => p.startsWith('/ad'))).toEqual([]);
});
}
});
Loading