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:
2026-08-04 18:52:15 +00:00
co-authored by Claude Opus 5
parent 9990c04008
commit 7732ca1da5
7 changed files with 36973 additions and 30 deletions
+4
View File
@@ -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
+43 -9
View File
@@ -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;
File diff suppressed because it is too large Load Diff