vendor hls.js and play transcodes over hls
a live transcode has no length and no byte ranges, so the progressive mp4 the player used could not be seeked — a scrub restarted ffmpeg at a new offset. jellyfin's own HLS playlist is VOD and spans the whole runtime, so seeking it is a segment request. hls.js is checked in rather than installed. installs are frozen so that adding a package is a reviewed act, and a committed file also has no install-time hook, which is the vector the 2026-08-04 npm worm used. provenance, hashes and the update recipe are in vendor/README.md; the tarball sha512 matches the registry's published integrity. the sidecar now overrides VideoBitrate and MaxWidth on the TranscodingUrl jellyfin hands back, for the same reason progressivePath computes them: jellyfin resolves that bitrate from MaxStreamingBitrate (~119 Mbit, the ceiling that exists to let a stream copy through) and sets no width, which asks a CPU-only container to encode 4K. safari is deliberately not given the m3u8 — segment URIs are relative and would not carry the ?token=, and it cannot set an Authorization header the way hls.js can. progressive stays as the fallback for any browser without MSE. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,3 +2,7 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
runtime-scripts
|
runtime-scripts
|
||||||
*.min.js
|
*.min.js
|
||||||
|
|
||||||
|
# Vendored third-party sources — reformatting them would destroy the diff against upstream,
|
||||||
|
# which is the only thing that makes a checked-in library reviewable. See the README beside it.
|
||||||
|
src/workspaces/officerdev/src/apps/Jellyfin/vendor/hls.mjs
|
||||||
|
|||||||
@@ -261,17 +261,49 @@ function transcodeQuality(source: MediaSource): { videoBitrate: number; maxWidth
|
|||||||
return { videoBitrate: ENCODE_BITRATE, maxWidth: ENCODE_MAX_WIDTH };
|
return { videoBitrate: ENCODE_BITRATE, maxWidth: ENCODE_MAX_WIDTH };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fix up the HLS URL Jellyfin handed back so it carries the same quality decision as the progressive one.
|
||||||
|
*
|
||||||
|
* `TranscodingUrl` is built by the server from our `DeviceProfile`, which is why it is taken rather than
|
||||||
|
* reinvented: it already carries the segment container, the audio codec list, the play session and the
|
||||||
|
* transcode reasons, and every one of those is the server's business rather than ours. What it gets wrong for
|
||||||
|
* this machine is exactly two values — it resolves `VideoBitrate` from `MaxStreamingBitrate` (~119 Mbit, the
|
||||||
|
* ceiling that exists to let a stream COPY through) and never sets a width. For a file that can be copied that
|
||||||
|
* is right. For a file that must be re-encoded it asks a CPU-only container to encode 4K, which cannot keep up
|
||||||
|
* with playback. So those two are overwritten from `transcodeQuality`, and nothing else is touched.
|
||||||
|
*
|
||||||
|
* Verified against a real answer from this server: the URL comes back with `VideoBitrate=119616000` and no
|
||||||
|
* width, on an item whose transcode reason was `ContainerNotSupported` alone.
|
||||||
|
*/
|
||||||
|
function hlsPath(source: MediaSource): string {
|
||||||
|
const sanitized = sanitizeUpstreamPath(source.TranscodingUrl!);
|
||||||
|
const [path, search = ''] = sanitized.split('?');
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
const quality = transcodeQuality(source);
|
||||||
|
|
||||||
|
// Jellyfin's own casing here is PascalCase, and its query parsing is case-insensitive — but a duplicate key
|
||||||
|
// under a different case would still be two values, so delete before setting rather than trusting that.
|
||||||
|
for (const key of [...params.keys()]) {
|
||||||
|
const lower = key.toLowerCase();
|
||||||
|
if (lower === 'videobitrate' || lower === 'maxwidth' || lower === 'maxheight') params.delete(key);
|
||||||
|
}
|
||||||
|
params.set('VideoBitrate', String(quality.videoBitrate));
|
||||||
|
if (quality.maxWidth) params.set('MaxWidth', String(quality.maxWidth));
|
||||||
|
|
||||||
|
return `${path}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the PROGRESSIVE transcode URL — one continuous mp4 the `<video>` element can play with no library.
|
* Build the PROGRESSIVE transcode URL — one continuous mp4 the `<video>` element can play with no library.
|
||||||
*
|
*
|
||||||
* This is the deliberate fallback instead of HLS, and the reason is a dependency this repo does not have:
|
* This is the FALLBACK now, not the primary: the player prefers `hlsUrl`, whose playlist covers the whole
|
||||||
* Chromium cannot play an HLS playlist natively, so the m3u8 path needs hls.js, and `bunfig.toml` freezes
|
* runtime and therefore seeks properly. This path stays for the browser that cannot do MSE at all (iOS
|
||||||
* installs precisely so a new package is a considered act rather than a side effect of a feature. A
|
* Safari's native HLS covers itself, but a stripped-down webview may have neither), and it is why the plan
|
||||||
* progressive stream needs nothing.
|
* still carries a plain `url`.
|
||||||
*
|
*
|
||||||
* What it costs is honest and worth stating: a live transcode has no length and no byte ranges, so the
|
* What it costs is honest and worth stating: a live transcode has no length and no byte ranges, so the
|
||||||
* browser cannot seek it. The player seeks by asking for a NEW stream at an offset — which is what
|
* browser cannot seek it. The player seeks by asking for a NEW stream at an offset — which is what
|
||||||
* `startTimeTicks` is for here, and why the UI re-requests playback on every scrub.
|
* `startTimeTicks` is for here, and why the UI re-requests playback on every scrub when it falls back here.
|
||||||
*
|
*
|
||||||
* `allowVideoStreamCopy` is what keeps this cheap for the common case. An mkv whose video is already h264
|
* `allowVideoStreamCopy` is what keeps this cheap for the common case. An mkv whose video is already h264
|
||||||
* gets REMUXED, not re-encoded: the container changes, the video bytes are copied. That matters on this
|
* gets REMUXED, not re-encoded: the container changes, the video bytes are copied. That matters on this
|
||||||
@@ -313,8 +345,9 @@ function progressivePath(id: string, source: MediaSource, playSessionId: string
|
|||||||
* the fly, still `/Videos/{id}/stream`) and transcode. They are reported explicitly rather than inferred,
|
* the fly, still `/Videos/{id}/stream`) and transcode. They are reported explicitly rather than inferred,
|
||||||
* because "why is my CPU pinned" is a question the UI should be able to answer.
|
* because "why is my CPU pinned" is a question the UI should be able to answer.
|
||||||
*
|
*
|
||||||
* `hlsUrl` is returned alongside whenever Jellyfin offered one. Nothing consumes it yet — it is what the
|
* `hlsUrl` is returned alongside whenever Jellyfin offered one, and is what the player actually uses for a
|
||||||
* player switches to the day hls.js is added on purpose, and it costs nothing to carry until then.
|
* transcode — hls.js is vendored for it (`apps/Jellyfin/vendor/`). `url` remains the progressive fallback for
|
||||||
|
* a browser with no MSE, so both are carried and the player chooses.
|
||||||
*/
|
*/
|
||||||
async function playbackInfo(cfg: UpstreamConfig, id: string, req: Request, url: URL): Promise<Response> {
|
async function playbackInfo(cfg: UpstreamConfig, id: string, req: Request, url: URL): Promise<Response> {
|
||||||
const startSeconds = Number(url.searchParams.get('startSeconds') ?? '0');
|
const startSeconds = Number(url.searchParams.get('startSeconds') ?? '0');
|
||||||
@@ -384,8 +417,9 @@ async function playbackInfo(cfg: UpstreamConfig, id: string, req: Request, url:
|
|||||||
// A direct file is byte-range seekable; a live transcode is not, and the player has to restart the stream
|
// A direct file is byte-range seekable; a live transcode is not, and the player has to restart the stream
|
||||||
// at an offset instead. This flag is the difference, and it is the one thing the player cannot guess.
|
// at an offset instead. This flag is the difference, and it is the one thing the player cannot guess.
|
||||||
seekable: direct,
|
seekable: direct,
|
||||||
// Carried, not used. The day hls.js is a deliberate dependency, the player switches to this.
|
// The seekable form of a transcode: the playlist spans the whole runtime, so a scrub is a segment request
|
||||||
hlsUrl: source.TranscodingUrl ? `/_jf${sanitizeUpstreamPath(source.TranscodingUrl)}` : null,
|
// rather than a new ffmpeg. Preferred by the player whenever it is present and MSE is available.
|
||||||
|
hlsUrl: source.TranscodingUrl ? `/_jf${hlsPath(source)}` : null,
|
||||||
playSessionId,
|
playSessionId,
|
||||||
mediaSourceId: source.Id ?? null,
|
mediaSourceId: source.Id ?? null,
|
||||||
mediaSource: source,
|
mediaSource: source,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { PlaybackPlan } from './shared';
|
import type { PlaybackPlan } from './shared';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Link, useLocation, useSearchParams } from 'react-router';
|
import { Link, useLocation, useSearchParams } from 'react-router';
|
||||||
import { Loader2, Maximize2, Pause, Play, X } from 'lucide-react';
|
import { Loader2, Maximize2, Pause, Play, X } from 'lucide-react';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
@@ -13,23 +13,40 @@ import {
|
|||||||
ticksToSeconds,
|
ticksToSeconds,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
import { useJellyfinItem, usePlaybackPlan, usePlaystateReporter } from './useJellyfinData';
|
import { useJellyfinItem, usePlaybackPlan, usePlaystateReporter } from './useJellyfinData';
|
||||||
|
import Hls from './vendor/hls.mjs';
|
||||||
|
|
||||||
// The player — a full-panel overlay opened by `?play=<id>`.
|
// The player — a full-panel overlay opened by `?play=<id>`.
|
||||||
//
|
//
|
||||||
// THE ONE THING THAT MAKES THIS DIFFERENT FROM A NORMAL <video>: a transcoded stream has no length and no byte
|
// THE ONE THING THAT MAKES THIS DIFFERENT FROM A NORMAL <video>: what the server hands back is not always a
|
||||||
// ranges, so the browser cannot seek it. `plan.seekable` says which world we are in, and the two are handled
|
// file. There are three transports, and `mode` names which one is in use rather than letting the difference
|
||||||
// differently rather than pretended to be the same:
|
// leak out as mysterious behaviour:
|
||||||
//
|
//
|
||||||
// direct play / direct stream → a real file. Native controls, native seeking, `currentTime` is the position.
|
// file — direct play or direct stream. A real byte-range resource; native controls, native seeking,
|
||||||
// transcode → a live progressive mp4 starting at an offset. Seeking means asking the
|
// `currentTime` is the position.
|
||||||
// server for a NEW stream at a new offset, so the position is
|
// hls-mse — a transcode, driven by the vendored hls.js over Media Source Extensions. A real timeline
|
||||||
// `offset + currentTime` and the scrubber is ours.
|
// too: Jellyfin's playlist is VOD and spans the whole runtime, so a scrub is a segment
|
||||||
|
// request and the server moves its ffmpeg to that point.
|
||||||
|
// progressive — the fallback for a browser without MSE. One endless mp4 with no length and no ranges, so
|
||||||
|
// the browser CANNOT seek it: seeking means asking the server for a NEW stream at an offset,
|
||||||
|
// the position is `startSeconds + currentTime`, and the scrubber has to be ours.
|
||||||
//
|
//
|
||||||
// Every progress report uses that same computed position, which is why resume points are correct in both
|
// Only that last mode is the odd one, and `hasTimeline` is the single flag every other decision here reads.
|
||||||
// modes instead of being wrong by the offset in one of them.
|
//
|
||||||
|
// Safari plays an m3u8 natively and is deliberately NOT given one. Segment URIs inside the playlist are
|
||||||
|
// relative, so they inherit the playlist's path but not its `?token=` — every segment would come back 401,
|
||||||
|
// and Safari cannot set an Authorization header the way hls.js can. Where hls.js runs (including iOS 17.1+,
|
||||||
|
// via Managed Media Source) it is used; where it does not, progressive is the honest fallback.
|
||||||
|
|
||||||
const PROGRESS_INTERVAL_MS = 10_000;
|
const PROGRESS_INTERVAL_MS = 10_000;
|
||||||
|
|
||||||
|
type Mode = 'file' | 'hls-mse' | 'progressive';
|
||||||
|
|
||||||
|
function transportFor(plan: PlaybackPlan): Mode {
|
||||||
|
if (plan.seekable) return 'file';
|
||||||
|
if (plan.hlsUrl && Hls.isSupported()) return 'hls-mse';
|
||||||
|
return 'progressive';
|
||||||
|
}
|
||||||
|
|
||||||
export const VideoPlayer = ({ id }: { id: string }) => {
|
export const VideoPlayer = ({ id }: { id: string }) => {
|
||||||
const { token } = useClient();
|
const { token } = useClient();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
@@ -51,6 +68,8 @@ export const VideoPlayer = ({ id }: { id: string }) => {
|
|||||||
const positionRef = useRef(0);
|
const positionRef = useRef(0);
|
||||||
const startedRef = useRef(false);
|
const startedRef = useRef(false);
|
||||||
|
|
||||||
|
const mode = useMemo(() => (plan ? transportFor(plan) : null), [plan]);
|
||||||
|
const hasTimeline = mode !== null && mode !== 'progressive';
|
||||||
const duration = plan?.runtimeSeconds ?? ticksToSeconds(item?.RunTimeTicks);
|
const duration = plan?.runtimeSeconds ?? ticksToSeconds(item?.RunTimeTicks);
|
||||||
|
|
||||||
const sendReport = useCallback(
|
const sendReport = useCallback(
|
||||||
@@ -69,7 +88,7 @@ export const VideoPlayer = ({ id }: { id: string }) => {
|
|||||||
[id, report],
|
[id, report],
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Open (or re-open) the stream at an offset. A seek in transcode mode is exactly this call. */
|
/** Open (or re-open) the stream at an offset. In progressive mode a seek is exactly this call. */
|
||||||
const open = useCallback(
|
const open = useCallback(
|
||||||
async (startSeconds: number) => {
|
async (startSeconds: number) => {
|
||||||
// Tell the server the previous session ended before starting another, or its ffmpeg keeps running and
|
// Tell the server the previous session ended before starting another, or its ffmpeg keeps running and
|
||||||
@@ -97,6 +116,39 @@ export const VideoPlayer = ({ id }: { id: string }) => {
|
|||||||
void open(ticksToSeconds(item.UserData?.PlaybackPositionTicks));
|
void open(ticksToSeconds(item.UserData?.PlaybackPositionTicks));
|
||||||
}, [item, open]);
|
}, [item, open]);
|
||||||
|
|
||||||
|
// hls.js owns the element's buffer in `hls-mse` mode, so the element gets no `src` at all — attaching one
|
||||||
|
// alongside MSE is how you get two sources fighting over the same video.
|
||||||
|
//
|
||||||
|
// The credential goes on as a header rather than in the URL because hls.js fetches every segment itself:
|
||||||
|
// segment URIs are RELATIVE to the playlist, so a `?token=` on the playlist would not survive onto them.
|
||||||
|
// `xhrSetup` runs before hls.js opens the request and it skips its own `open` if one already happened,
|
||||||
|
// which is what makes setting a header here possible at all.
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (mode !== 'hls-mse' || !plan?.hlsUrl || !video) return;
|
||||||
|
|
||||||
|
const hls = new Hls({
|
||||||
|
startPosition: plan.startSeconds > 0 ? plan.startSeconds : -1,
|
||||||
|
xhrSetup: (xhr, url) => {
|
||||||
|
xhr.open('GET', url, true);
|
||||||
|
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Non-fatal errors are hls.js's normal weather and it recovers from them itself. A fatal one is worth one
|
||||||
|
// attempt at the documented recovery for its kind before giving the player back to the user as broken.
|
||||||
|
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||||
|
if (!data.fatal) return;
|
||||||
|
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) hls.startLoad();
|
||||||
|
else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) hls.recoverMediaError();
|
||||||
|
else setError('The stream stopped unexpectedly.');
|
||||||
|
});
|
||||||
|
|
||||||
|
hls.loadSource(`/api/jellyfin${plan.hlsUrl}`);
|
||||||
|
hls.attachMedia(video);
|
||||||
|
return () => hls.destroy();
|
||||||
|
}, [mode, plan, token]);
|
||||||
|
|
||||||
// The stop report, on the way out. Also the only place the server learns a transcode is no longer wanted.
|
// The stop report, on the way out. Also the only place the server learns a transcode is no longer wanted.
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
@@ -115,8 +167,8 @@ export const VideoPlayer = ({ id }: { id: string }) => {
|
|||||||
const onTimeUpdate = () => {
|
const onTimeUpdate = () => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if (!video || !plan) return;
|
if (!video || !plan) return;
|
||||||
// In transcode mode the element's clock starts at zero for a stream that begins mid-film.
|
// Only progressive starts the element's clock at zero for a stream that begins mid-film.
|
||||||
const next = plan.seekable ? video.currentTime : plan.startSeconds + video.currentTime;
|
const next = hasTimeline ? video.currentTime : plan.startSeconds + video.currentTime;
|
||||||
positionRef.current = next;
|
positionRef.current = next;
|
||||||
setPosition(next);
|
setPosition(next);
|
||||||
};
|
};
|
||||||
@@ -124,7 +176,7 @@ export const VideoPlayer = ({ id }: { id: string }) => {
|
|||||||
const seek = (seconds: number) => {
|
const seek = (seconds: number) => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
const clamped = Math.max(0, duration ? Math.min(seconds, duration - 1) : seconds);
|
const clamped = Math.max(0, duration ? Math.min(seconds, duration - 1) : seconds);
|
||||||
if (plan?.seekable && video) {
|
if (hasTimeline && video) {
|
||||||
video.currentTime = clamped;
|
video.currentTime = clamped;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -138,6 +190,10 @@ export const VideoPlayer = ({ id }: { id: string }) => {
|
|||||||
else video.pause();
|
else video.pause();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// `src` for everything except MSE, where hls.js feeds the element instead. The key remounts the element when
|
||||||
|
// the source genuinely changes — which in progressive mode is every scrub.
|
||||||
|
const source = plan && mode !== 'hls-mse' ? streamUrl(plan.url, token) : null;
|
||||||
|
|
||||||
const close = closeHref(pathname, params, PLAY_PARAM);
|
const close = closeHref(pathname, params, PLAY_PARAM);
|
||||||
const title = item ? episodeLabel(item) : '';
|
const title = item ? episodeLabel(item) : '';
|
||||||
|
|
||||||
@@ -167,14 +223,14 @@ export const VideoPlayer = ({ id }: { id: string }) => {
|
|||||||
</div>
|
</div>
|
||||||
) : plan ? (
|
) : plan ? (
|
||||||
<video
|
<video
|
||||||
key={plan.url}
|
key={mode === 'hls-mse' ? (plan.hlsUrl ?? plan.url) : plan.url}
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={streamUrl(plan.url, token)}
|
src={source ?? undefined}
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
autoPlay
|
autoPlay
|
||||||
// A direct file is seekable, so the browser's own controls are correct and better than ours.
|
// A real timeline means the browser's own controls are correct, and better than ours. Progressive
|
||||||
// A live transcode is not, and its controls would show a scrubber that does nothing.
|
// has none, and native controls there would show a scrubber that does nothing.
|
||||||
controls={plan.seekable}
|
controls={hasTimeline}
|
||||||
onTimeUpdate={onTimeUpdate}
|
onTimeUpdate={onTimeUpdate}
|
||||||
onPlay={() => {
|
onPlay={() => {
|
||||||
setPaused(false);
|
setPaused(false);
|
||||||
@@ -185,7 +241,9 @@ export const VideoPlayer = ({ id }: { id: string }) => {
|
|||||||
sendReport('progress', true);
|
sendReport('progress', true);
|
||||||
}}
|
}}
|
||||||
onEnded={() => sendReport('stopped')}
|
onEnded={() => sendReport('stopped')}
|
||||||
onError={() => setError('The stream stopped unexpectedly.')}
|
// hls.js reports its own errors through the ERROR event, and sets a MediaError on the element for
|
||||||
|
// ones it is already recovering from — so in MSE mode this handler would fire on the recoverable.
|
||||||
|
onError={() => mode !== 'hls-mse' && setError('The stream stopped unexpectedly.')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center text-white/60">
|
<div className="flex h-full items-center justify-center text-white/60">
|
||||||
@@ -194,7 +252,7 @@ export const VideoPlayer = ({ id }: { id: string }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{plan && !plan.seekable && !error && (
|
{plan && !hasTimeline && !error && (
|
||||||
<div className="flex items-center gap-3 px-3 py-2 text-white">
|
<div className="flex items-center gap-3 px-3 py-2 text-white">
|
||||||
<button type="button" onClick={togglePlay} aria-label={paused ? 'Play' : 'Pause'} className="shrink-0">
|
<button type="button" onClick={togglePlay} aria-label={paused ? 'Play' : 'Pause'} className="shrink-0">
|
||||||
{paused ? <Play className="h-4 w-4" fill="currentColor" /> : <Pause className="h-4 w-4" />}
|
{paused ? <Play className="h-4 w-4" fill="currentColor" /> : <Pause className="h-4 w-4" />}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Vendored hls.js
|
||||||
|
|
||||||
|
`hls.mjs` is **hls.js 1.6.16**, checked in rather than installed.
|
||||||
|
|
||||||
|
## Why it is here and not in `package.json`
|
||||||
|
|
||||||
|
Chromium cannot play an HLS playlist natively, so seeking inside a live Jellyfin transcode needs a player
|
||||||
|
library. `bunfig.toml` freezes installs (`frozenLockfile = true`) specifically so that adding a package is a
|
||||||
|
deliberate act with a reviewed lockfile diff — see `platform/CLAUDE.md` → "Installs are frozen", and the
|
||||||
|
2026-08-04 npm cache-package worm that prompted it.
|
||||||
|
|
||||||
|
Vendoring makes the trade explicit instead of implicit:
|
||||||
|
|
||||||
|
- **No install-time code.** A committed file has no `preinstall`/`postinstall` hook, which is the exact vector
|
||||||
|
that compromise used. hls.js's own tarball has no install script either — but a future version of it, or of
|
||||||
|
anything it might later depend on, could.
|
||||||
|
- **No transitive tree.** hls.js 1.6.16 publishes `dependencies: {}`, `peerDependencies: {}` and
|
||||||
|
`optionalDependencies: {}`, so nothing is being skipped by not resolving it. Its *build-time* devDependencies
|
||||||
|
do reach the affected packages (eslint 8 → file-entry-cache → flat-cache → keyv), but this artifact was
|
||||||
|
published 2026-04-13, months before those versions existed.
|
||||||
|
- **It is reviewable.** One unminified file in the diff. That is the point; do not replace it with a minified
|
||||||
|
build to save bytes.
|
||||||
|
|
||||||
|
The upstream `.d.ts` is *not* vendored — it imports a tree of internal type modules, which would undo the one
|
||||||
|
file rule. `hls.d.mts` next to it declares the API this player actually uses, by hand.
|
||||||
|
|
||||||
|
## Provenance
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Package | `hls.js@1.6.16` |
|
||||||
|
| Published | 2026-04-13 |
|
||||||
|
| Tarball | `https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz` |
|
||||||
|
| Tarball sha512 | `VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==` (matches the registry's published `dist.integrity`) |
|
||||||
|
| Extracted from | `package/dist/hls.mjs` |
|
||||||
|
| Local change | the trailing `//# sourceMappingURL=hls.mjs.map` line removed — the map is not vendored, and leaving the reference makes devtools 404 |
|
||||||
|
| `hls.mjs` sha256 | `45a4bb55e5346bf9284fa7e46b6fac7d62dce5b90394dcba9747d471940fea6e` as published; `ca442f1f963ff66607a242cc7be227b243770d86bfc3251bf675f8a342dfe798` as committed here (the two differ only by that removed line) |
|
||||||
|
| Licence | Apache-2.0 — `hls.LICENSE` |
|
||||||
|
|
||||||
|
## Updating it
|
||||||
|
|
||||||
|
```sh
|
||||||
|
V=1.6.17
|
||||||
|
curl -sO https://registry.npmjs.org/hls.js/-/hls.js-$V.tgz
|
||||||
|
# compare the sha512 against `curl -s https://registry.npmjs.org/hls.js/$V | jq -r .dist.integrity`
|
||||||
|
tar xzf hls.js-$V.tgz package/dist/hls.mjs package/LICENSE
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy `package/dist/hls.mjs` over this one, drop the `sourceMappingURL` line, update the table above, and read
|
||||||
|
the diff. A version bump that arrives with a diff you did not read is the thing this directory exists to
|
||||||
|
prevent.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
Copyright (c) 2017 Dailymotion (http://www.dailymotion.com)
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
src/remux/mp4-generator.js and src/demux/exp-golomb.ts implementation in this project
|
||||||
|
are derived from the HLS library for video.js (https://github.com/videojs/videojs-contrib-hls)
|
||||||
|
|
||||||
|
That work is also covered by the Apache 2 License, following copyright:
|
||||||
|
Copyright (c) 2013-2015 Brightcove
|
||||||
|
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Hand-written declarations for the vendored `hls.mjs`.
|
||||||
|
//
|
||||||
|
// Deliberately narrow: it declares the API this player actually calls, not hls.js's full surface. The upstream
|
||||||
|
// `.d.ts` is not vendored because it pulls in a tree of internal type modules, and the point of vendoring was
|
||||||
|
// to have ONE reviewable file. Anything added to the player that hls.js supports but this file does not gets
|
||||||
|
// declared here, in the open, rather than cast away at the call site.
|
||||||
|
|
||||||
|
export type HlsErrorData = {
|
||||||
|
fatal: boolean;
|
||||||
|
type: string;
|
||||||
|
details: string;
|
||||||
|
reason?: string;
|
||||||
|
error?: Error;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HlsLevel = { height?: number; width?: number; bitrate?: number };
|
||||||
|
|
||||||
|
export type HlsConfig = {
|
||||||
|
/** Where playback begins, in seconds. -1 means "the start", which is not the same as 0 for a live edge. */
|
||||||
|
startPosition?: number;
|
||||||
|
/** Called before `xhr.open`, so it may open the request itself and then set headers on it. */
|
||||||
|
xhrSetup?: (xhr: XMLHttpRequest, url: string) => void;
|
||||||
|
enableWorker?: boolean;
|
||||||
|
lowLatencyMode?: boolean;
|
||||||
|
maxBufferLength?: number;
|
||||||
|
maxMaxBufferLength?: number;
|
||||||
|
backBufferLength?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare class Hls {
|
||||||
|
constructor(config?: HlsConfig);
|
||||||
|
|
||||||
|
static isSupported(): boolean;
|
||||||
|
static readonly Events: {
|
||||||
|
MANIFEST_PARSED: string;
|
||||||
|
LEVEL_LOADED: string;
|
||||||
|
ERROR: string;
|
||||||
|
MEDIA_ATTACHED: string;
|
||||||
|
};
|
||||||
|
static readonly ErrorTypes: {
|
||||||
|
NETWORK_ERROR: string;
|
||||||
|
MEDIA_ERROR: string;
|
||||||
|
KEY_SYSTEM_ERROR: string;
|
||||||
|
MUX_ERROR: string;
|
||||||
|
OTHER_ERROR: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
loadSource(url: string): void;
|
||||||
|
attachMedia(media: HTMLMediaElement): void;
|
||||||
|
detachMedia(): void;
|
||||||
|
startLoad(startPosition?: number): void;
|
||||||
|
stopLoad(): void;
|
||||||
|
recoverMediaError(): void;
|
||||||
|
destroy(): void;
|
||||||
|
|
||||||
|
on(event: string, listener: (event: string, data: HlsErrorData) => void): void;
|
||||||
|
off(event: string, listener?: (event: string, data: HlsErrorData) => void): void;
|
||||||
|
|
||||||
|
readonly levels: HlsLevel[];
|
||||||
|
currentLevel: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Hls;
|
||||||
+36705
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user