diff --git a/package.json b/package.json index a57c3cc..e3e993c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/player", - "version": "0.3.1", + "version": "0.4.0", "description": "One web player for every source a Profullstack site serves: MP4, HLS, MPEG-2 transport streams and audio, with one control bar, on desktop, mobile, PWA and television.", "keywords": [ "video", diff --git a/src/engines/mpegts.ts b/src/engines/mpegts.ts index c8be14b..4737c24 100644 --- a/src/engines/mpegts.ts +++ b/src/engines/mpegts.ts @@ -26,21 +26,21 @@ import type { EngineContext, EngineHandle } from './types'; import { unplayableReason } from './codecs'; -/** How many times a stream is rebuilt before the reader is told it failed. */ -const MAX_RESTARTS = 3; -const RESTART_BASE_MS = 1500; +/** + * How many times a stream is rebuilt before the reader is told it failed. + * + * Five, doubling from two seconds, which is media-streamer's live TV player -- + * the one that survives an evening on the same provider lines this engine was + * dying on. Three from 1.5s came over with the port. The number matters less + * than when the budget refills, though; see the `playing` handler below. + */ +export const MAX_RESTARTS = 5; +const RESTART_BASE_MS = 2000; /** How a stall is noticed: the clock is read this often, this many times. */ const STALL_CHECK_MS = 5000; const STALL_LIMIT = 3; -/** - * Playback this long since the last restart means the trouble is over, and the - * budget goes back to full. Without it a channel that breaks once an hour spends - * its three restarts over an afternoon and then fails for good. - */ -const RECOVERED_AFTER_MS = 30_000; - export interface MpegtsOptions { /** Sent with the request; IPTV proxies authenticate by session cookie. */ withCredentials?: boolean; @@ -49,41 +49,87 @@ export interface MpegtsOptions { } /** - * mpegts.js settings for one screen or the other. + * mpegts.js settings. * - * A television is a slow decoder on a household connection: read ahead and do - * not chase the live edge, because latency chasing answers a stall by seeking - * forward, which is a stall the reader can see. A desktop wants the opposite. + * One profile, not one per screen, and that is the change rather than an + * oversight. These are media-streamer's live TV numbers, adopted wholesale + * because that player survives an evening on the same provider lines where the + * split profile was dying after a minute or two. + * + * What the split got wrong was the desktop half. It ran with no stash at all + * (`stashInitialSize: 128` -- bytes) and `liveBufferLatencyChasing: true`, on + * the reasoning that a laptop has bandwidth to spare and should therefore sit + * as close to the live edge as it can. But chasing does not wait politely: + * mpegts.js implements it by assigning to `currentTime`, which is a hard seek, + * and MSE tears down and rebuilds the decode pipeline on every one. It is + * evaluated on every appended fragment and it leaves only `MinRemain` seconds + * of buffer behind -- one second, as it was set. One second is a single jitter + * spike from an underrun; the underrun refills past the ceiling; it seeks + * again. Every cycle of that sawtooth is a visible hitch, and enough of them in + * a row exhaust the restart budget and end the stream for good. + * + * So: read ahead on every device, never chase, and demux off the main thread. + * A television was already getting all three, which is why only the desktop + * ever complained. */ -function configFor(isTv: boolean): Record { - const shared = { - // lazyLoad pauses the download once enough is buffered, which for a live - // stream means dropping the connection mid-broadcast and reconnecting. - lazyLoad: false, - // Without this the source buffer keeps every second of a three-hour stream - // in memory and the tab is killed — on a Fire TV, considerably sooner. +/** Exported for the test that pins these values; not part of the public API. */ +export function configFor(_isTv: boolean): Record { + return { + /* + * Demux on a worker thread. + * + * A transport stream at broadcast bitrate is real work, and doing it on the + * main thread means it competes with rendering the page it is playing on -- + * which shows up as dropped frames rather than as an error. mpegts.js builds + * the worker from a blob URL, so a host serving a strict CSP needs + * `worker-src blob:` for this to take; without it the library falls back and + * the only thing lost is the contention it was avoiding. + */ + enableWorker: true, + + /* + * Read ahead, on every screen. + * + * The stash sits in front of the demuxer. A transport stream arrives in + * bursts -- the provider's pacing, not the viewer's bandwidth -- so with + * nothing buffered each gap between bursts is an underrun however fast the + * connection is. 384KB is mpegts.js's own default, roughly a second. + */ + enableStashBuffer: true, + stashInitialSize: 384 * 1024, + + /* + * Never close drift by seeking. See the note above: this is the line that + * made the picture stutter and then killed the stream outright. The two + * bounds are inert while chasing is off, and are kept as the bound anyone + * re-enabling it would want rather than left to a library default. + */ + liveBufferLatencyChasing: false, + liveBufferLatencyMaxLatency: 5, + liveBufferLatencyMinRemain: 1, + + /* + * Drop what has already been watched. Without this the source buffer keeps + * every second of a three-hour broadcast in memory and the tab is killed -- + * on a Fire TV, considerably sooner than that. + */ autoCleanupSourceBuffer: true, autoCleanupMaxBackwardDuration: 30, autoCleanupMinBackwardDuration: 10, - }; - return isTv - ? { - ...shared, - enableStashBuffer: true, - stashInitialSize: 384 * 1024, - liveBufferLatencyChasing: false, - liveBufferLatencyMaxLatency: 12, - liveBufferLatencyMinRemain: 2, - } - : { - ...shared, - enableStashBuffer: false, - stashInitialSize: 128, - liveBufferLatencyChasing: true, - liveBufferLatencyMaxLatency: 6, - liveBufferLatencyMinRemain: 1, - }; + /* + * lazyLoad pauses the download once enough is buffered, which on a live + * stream means dropping the provider connection mid-broadcast and then + * reconnecting -- on a line that counts concurrent connections, the worst + * available way to idle. Off, with both durations stated anyway so there is + * no library default to inherit if it is ever turned on. + */ + lazyLoad: false, + lazyLoadMaxDuration: 60, + lazyLoadRecoverDuration: 30, + + seekType: 'range', + }; } interface MpegtsPlayer { @@ -119,7 +165,6 @@ export async function createMpegtsEngine( let restarts = 0; let restartTimer: ReturnType | null = null; let stallTimer: ReturnType | null = null; - let startedAt = 0; let lastTime = -1; let stalls = 0; @@ -188,19 +233,15 @@ export async function createMpegtsEngine( } return; } - // It is moving. If it has been moving for a while, the earlier trouble is - // over and this counts as a healthy stream again. + // It is moving, so nothing is wrong right now. The restart budget is not + // touched here -- that is the `playing` handler's job, and the difference + // is explained there. lastTime = media.currentTime; stalls = 0; - if (restarts > 0 && Date.now() - startedAt > RECOVERED_AFTER_MS) { - restarts = 0; - context.onNotice(null); - } }, STALL_CHECK_MS); }; function start(): void { - startedAt = Date.now(); player = mpegts.createPlayer( { type: 'mpegts', @@ -238,12 +279,41 @@ export async function createMpegtsEngine( context.onReady?.({ live: context.live, levels: [] }); } + /* + * A picture is the only proof worth acting on, and it refills the budget. + * + * This is the difference between a stream that recovers all evening and one + * that dies after a minute or two, and it is worth being exact about why. + * + * The budget used to come back only after RECOVERED_AFTER_MS -- thirty + * seconds of unbroken playback, measured from the last restart and checked + * only from inside the stall watcher. A channel that hiccups three times + * inside half a minute therefore spent its whole allowance and was given up + * on permanently, even though every one of those restarts had worked and the + * stream was playing again seconds later. On a provider line that drops a + * connection now and then -- which is all of them -- that is a hard ceiling + * of three hiccups per stream, and reaching it takes about a minute. + * + * media-streamer's live TV player resets on every `playing` instead, and it + * is right: `playing` fires when the media element genuinely resumed, so the + * budget is spent by failures to *recover*, not by failures. A channel that + * never plays still gives up after MAX_RESTARTS, because nothing ever fires + * this. A channel that comes back gets its allowance back, which is the only + * reading under which "three attempts" means what it sounds like. + */ + const onPlaying = (): void => { + restarts = 0; + context.onNotice(null); + }; + media.addEventListener('playing', onPlaying); + start(); return { destroy(): void { stopped = true; clearTimers(); + media.removeEventListener('playing', onPlaying); destroyPlayer(); }, levels: () => [], diff --git a/src/engines/types.ts b/src/engines/types.ts index 91111b5..3186e02 100644 --- a/src/engines/types.ts +++ b/src/engines/types.ts @@ -24,7 +24,11 @@ export interface QualityLevel { export interface EngineContext { media: HTMLMediaElement; src: string; - /** True on a television, which wants a very different buffering profile. */ + /** + * True on a television. The HLS engine still tunes on it; the transport + * stream engine no longer does -- it buffers the same way everywhere, since + * the desktop profile it used to keep separate was the one that stuttered. + */ isTv: boolean; /** * Caller's claim about whether this is a live stream. Engines that can tell diff --git a/test/mpegts-config.test.ts b/test/mpegts-config.test.ts new file mode 100644 index 0000000..afa70d2 --- /dev/null +++ b/test/mpegts-config.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { configFor, MAX_RESTARTS } from '../src/engines/mpegts'; + +/** + * These numbers are media-streamer's live TV player, adopted wholesale. + * + * They are worth pinning because the engine shipped with the opposite of most + * of them on a desktop, and the symptom was not "a slightly worse picture" but + * a stream that stuttered and then died after a minute or two on a provider + * line media-streamer plays all evening. + * + * The tests state rules rather than restating the object, so that a future + * retune has to break a claim about behaviour to break a test. + */ +describe('the mpegts buffering profile', () => { + const desktop = configFor(false); + const tv = configFor(true); + + it('is the same on every screen', () => { + // The split was the bug. A television was already getting settings that + // worked; the desktop was given their opposite on the theory that a laptop + // has bandwidth to spare, and bandwidth was never what was wrong. + expect(desktop).toEqual(tv); + }); + + it('never closes drift by seeking', () => { + /* + * The one that mattered. `liveBufferLatencyChasing` assigns to + * `currentTime`; that is a hard seek, MSE rebuilds the decode pipeline on + * each one, and it is evaluated on every appended fragment while leaving + * only `MinRemain` seconds of buffer behind. One second of buffer is a + * single jitter spike from an underrun, and the underrun refills past the + * ceiling and seeks again -- a sawtooth of visible hitches. + */ + expect(desktop.liveBufferLatencyChasing).toBe(false); + }); + + it('reads ahead rather than demuxing whatever just landed', () => { + // A transport stream arrives in bursts regardless of the viewer's + // bandwidth, so with nothing in front of the demuxer every gap between + // bursts is an underrun. This was 128 *bytes* on a desktop. + expect(desktop.enableStashBuffer).toBe(true); + expect(desktop.stashInitialSize).toBe(384 * 1024); + }); + + it('demuxes off the main thread', () => { + // Broadcast-bitrate demuxing competes with rendering the page it plays on, + // and loses as dropped frames rather than as an error. + expect(desktop.enableWorker).toBe(true); + }); + + it('never pauses the download to idle', () => { + // lazyLoad drops the provider connection mid-broadcast and reconnects, + // which on a line that counts concurrent connections is the worst + // available way to wait. + expect(desktop.lazyLoad).toBe(false); + }); + + it('still drops what has already been watched', () => { + // Otherwise a three-hour broadcast fills the source buffer and the tab is + // killed -- soonest on exactly the device this is all for. + expect(desktop.autoCleanupSourceBuffer).toBe(true); + }); + + it('allows more rebuilds than the three it came over with', () => { + expect(MAX_RESTARTS).toBe(5); + }); +}); diff --git a/test/mpegts-restart.test.ts b/test/mpegts-restart.test.ts new file mode 100644 index 0000000..28b4583 --- /dev/null +++ b/test/mpegts-restart.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { EngineContext } from '../src/engines/types'; + +/** + * When the restart budget comes back, which is the whole difference between a + * stream that recovers all evening and one that dies after a minute or two. + * + * The engine rebuilds the player rather than giving up, because a live + * transport stream changes shape mid-broadcast and MSE throws when it does. The + * question these tests pin down is not how many rebuilds are allowed but when + * the allowance is restored: it used to take thirty unbroken seconds, so three + * hiccups inside half a minute ended the stream permanently even though every + * rebuild had worked. It now refills on `playing`, so the budget is spent by + * failures to *recover*, never by failures alone. + */ + +const created = vi.hoisted( + () => + [] as { + handlers: Record void>; + destroy: () => void; + }[] +); + +vi.mock('mpegts.js', () => ({ + default: { + getFeatureList: (): { mseLivePlayback: boolean } => ({ mseLivePlayback: true }), + createPlayer: (): unknown => { + const handlers: Record void> = {}; + const destroy = vi.fn(); + created.push({ handlers, destroy }); + return { + attachMediaElement: vi.fn(), + load: vi.fn(), + destroy, + on: (event: string, fn: (...args: unknown[]) => void): void => { + handlers[event] = fn; + }, + }; + }, + Events: { MEDIA_INFO: 'media_info', ERROR: 'error' }, + }, +})); + +const { createMpegtsEngine } = await import('../src/engines/mpegts'); + +describe('the restart budget', () => { + let media: HTMLVideoElement; + let onError: ReturnType; + let context: EngineContext; + + beforeEach(() => { + vi.useFakeTimers(); + created.length = 0; + media = document.createElement('video'); + onError = vi.fn(); + context = { + media, + src: 'https://example.test/stream.ts', + isTv: false, + live: true, + onError, + onNotice: vi.fn(), + }; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + /** Break the newest player, then let its scheduled rebuild happen. */ + const breakIt = async (): Promise => { + const current = created.at(-1); + current?.handlers.error?.('NetworkError', 'detail'); + await vi.runOnlyPendingTimersAsync(); + }; + + it('gives up once the rebuilds themselves stop working', async () => { + await createMpegtsEngine(context, {}); + + // Five failures with no picture in between spends the whole allowance; the + // sixth is the one the reader is told about. + for (let i = 0; i < 5; i += 1) await breakIt(); + expect(onError).not.toHaveBeenCalled(); + + await breakIt(); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('refills the moment the picture comes back', async () => { + await createMpegtsEngine(context, {}); + + // Four failures, then the stream genuinely resumes. + for (let i = 0; i < 4; i += 1) await breakIt(); + media.dispatchEvent(new Event('playing')); + + // Under the old thirty-second rule this next failure was the fifth of five + // and the stream was over. It is now the first of a fresh allowance, so the + // reader keeps watching. + for (let i = 0; i < 5; i += 1) await breakIt(); + expect(onError).not.toHaveBeenCalled(); + }); + + it('does not need thirty seconds of playback to count as recovered', async () => { + await createMpegtsEngine(context, {}); + + for (let i = 0; i < 5; i += 1) { + await breakIt(); + // A second of picture between two failures used to be worth nothing at + // all. It is a recovery, and five of them in a row are five recoveries. + media.dispatchEvent(new Event('playing')); + await vi.advanceTimersByTimeAsync(1000); + } + + expect(onError).not.toHaveBeenCalled(); + }); + + it('stops listening once it is torn down', async () => { + const handle = await createMpegtsEngine(context, {}); + handle.destroy(); + + // A detached engine that still answered `playing` would resurrect a budget + // for a player nobody is watching. + media.dispatchEvent(new Event('playing')); + expect(context.onNotice).toHaveBeenCalledTimes(0); + }); +});