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 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, }; }, }; }