Files
siftlode/frontend/src/lib/hlsPort.ts
T
peter 57a81c5643 fix(plex): subtitles survive hls.js and a remounted track
hls.js emptied the cues of every text track on the element when a manifest
loaded or the media attached, our own <track> included, so a VTT that loaded
first showed nothing for the session; its subtitle and caption controllers are
no longer built. A remounted default <track> was switched to showing by the
browser after the hide had run, drawing the cues natively under ours; the hide
now follows every added track and mode change.
2026-10-08 19:35:47 +02:00

74 lines
3.1 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.
const hls = new Hls({
maxBufferLength: 30,
enableWorker: true,
startPosition: 0,
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).
hls.on(Hls.Events.FRAG_LOADED, (_e, data) =>
handlers.onFragLoaded({
type: data.frag.type,
bytes: data.frag.stats?.total || data.frag.stats?.loaded || 0,
durationS: data.frag.duration,
}),
);
hls.on(Hls.Events.ERROR, (_e, data) =>
handlers.onError({
fatal: data.fatal,
network: data.type === Hls.ErrorTypes.NETWORK_ERROR,
status: data.response?.code ?? null,
type: data.type,
details: data.details,
}),
);
return {
load(url, media) {
hls.loadSource(url);
hls.attachMedia(media);
},
destroy: () => hls.destroy(),
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,
};
},
};
}