A request hls.js got no answer to in time (no status) was taken for a server away: a new session, the same first segment lost again, a restart every half minute that never ended, since every session answer reset the away window. Measured on prod: a work network at ~4 Mbit/s against 11-16 MB remux segments. A timeout now resumes loading in the same session; the away window ends only when media arrives (or a direct stream answers), and runs out in a 'too slow' error. A segment gets 30 s to its first byte. After 5 s of stalling on a link measured slower than the stream, the player says so with both rates.
96 lines
4.4 KiB
TypeScript
96 lines
4.4 KiB
TypeScript
import Hls from "hls.js";
|
||
import type { HlsHandlers, HlsPort } from "./hlsEngine";
|
||
|
||
// The one place hls.js is constructed for the Plex player: the engine (hlsEngine.ts) sees only the
|
||
// narrow `HlsPort`, so its tests run without hls.js or a media pipeline.
|
||
export function createHlsPort(handlers: HlsHandlers): HlsPort | null {
|
||
if (!Hls.isSupported()) return null;
|
||
// startPosition: 0 — our HLS is a growing EVENT playlist with no ENDLIST (see _HLS_TAIL in
|
||
// stream.py), which hls.js classifies as LIVE. Its default (startPosition: -1) then starts playback
|
||
// at the live edge and hugs it, so it never builds a forward buffer (rebuffer spinners), and on a
|
||
// resume/seek session the segments' absolute PTS (-copyts, offset by the server's -ss) don't line
|
||
// up with the live-edge timeline → 0 fragments load → a permanent stall. But the seek-restart model
|
||
// means fragment 0 IS the requested content position (`?t=` positioned the server ffmpeg there),
|
||
// so it always starts VOD-style at 0 and buffers forward up to maxBufferLength.
|
||
//
|
||
// No subtitle or caption controllers (a controller left unset is never built): the subtitles are
|
||
// a <track> of ours, out of band, and hls.js empties the cues of EVERY text track on the element
|
||
// when a manifest loads or the media attaches/detaches — ours included, so a VTT that loaded before
|
||
// the manifest showed nothing for the rest of the session.
|
||
//
|
||
// A fragment gets 30 s to its first byte, not hls.js's 10: a remux segment is the source's own
|
||
// bitrate (11–16 MB measured on prod), and on a slow link (a work network at ~4 Mbit/s) the small
|
||
// audio segment beside it waits behind it longer than 10 s — five such waits ended the load. The
|
||
// rest is hls.js's default policy.
|
||
const hls = new Hls({
|
||
maxBufferLength: 30,
|
||
enableWorker: true,
|
||
startPosition: 0,
|
||
fragLoadPolicy: {
|
||
default: {
|
||
maxTimeToFirstByteMs: 30_000,
|
||
maxLoadTimeMs: 120_000,
|
||
timeoutRetry: { maxNumRetry: 4, retryDelayMs: 0, maxRetryDelayMs: 0 },
|
||
errorRetry: { maxNumRetry: 6, retryDelayMs: 1000, maxRetryDelayMs: 8000 },
|
||
},
|
||
},
|
||
subtitleTrackController: undefined,
|
||
subtitleStreamController: undefined,
|
||
timelineController: undefined,
|
||
});
|
||
hls.on(Hls.Events.MANIFEST_PARSED, () => handlers.onManifestParsed());
|
||
hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, () => handlers.onAudioTracksUpdated());
|
||
// The delivered bitrate is measured per fragment: the master playlist's own BANDWIDTH is not a
|
||
// measurement (see plexStats.ts). When its download ran says how fast the link delivers.
|
||
hls.on(Hls.Events.FRAG_LOADED, (_e, data) => {
|
||
const l = data.frag.stats?.loading;
|
||
handlers.onFragLoaded({
|
||
type: data.frag.type,
|
||
bytes: data.frag.stats?.total || data.frag.stats?.loaded || 0,
|
||
durationS: data.frag.duration,
|
||
loading: l && l.end > l.start ? { start: l.start, end: l.end } : undefined,
|
||
});
|
||
});
|
||
hls.on(Hls.Events.ERROR, (_e, data) =>
|
||
handlers.onError({
|
||
fatal: data.fatal,
|
||
network: data.type === Hls.ErrorTypes.NETWORK_ERROR,
|
||
status: data.response?.code ?? null,
|
||
// hls.js names every no-answer-in-time failure `…TimeOut` (fragLoadTimeOut, levelLoadTimeOut,
|
||
// audioTrackLoadTimeOut, manifestLoadTimeOut, …).
|
||
timeout: data.details.endsWith("TimeOut"),
|
||
type: data.type,
|
||
details: data.details,
|
||
}),
|
||
);
|
||
return {
|
||
load(url, media) {
|
||
hls.loadSource(url);
|
||
hls.attachMedia(media);
|
||
},
|
||
destroy: () => hls.destroy(),
|
||
// hls.js's own recovery after a fatal network error: load again from where the media is. The
|
||
// position is passed: with none, a session that never ticked would start at the live edge (see
|
||
// `startPosition: 0` above).
|
||
resume: () => hls.startLoad(hls.media?.currentTime ?? 0),
|
||
getAudioTrack: () => hls.audioTrack,
|
||
setAudioTrack(track) {
|
||
hls.audioTrack = track;
|
||
},
|
||
levelInfo() {
|
||
// currentLevel is -1 while auto-ABR hasn't settled; loadLevel then gives the res/bitrate.
|
||
const idx = hls.currentLevel >= 0 ? hls.currentLevel : hls.loadLevel;
|
||
const lv = idx >= 0 ? hls.levels?.[idx] : undefined;
|
||
return {
|
||
index: hls.currentLevel,
|
||
total: hls.levels?.length ?? 0,
|
||
width: lv?.width,
|
||
height: lv?.height,
|
||
bitrate: lv?.bitrate,
|
||
videoCodec: lv?.videoCodec,
|
||
audioCodec: lv?.audioCodec,
|
||
};
|
||
},
|
||
};
|
||
}
|