[playback] Add HLS support and stop blaming stale MediaErrors

bitvid attached every hosted source with `videoEl.src = url`, which only plays
HLS on Safari-family browsers. Firefox 146 fails with MEDIA_ERR_SRC_NOT_SUPPORTED
(canPlayType returns ""), so every .m3u8 note -- e.g. anything published by
nostube/slidestr -- was unplayable there and the URL-health badge showed
"offline". Chrome/Brave 149 happen to play HLS natively, which is why this
looked browser-specific.

Vendor hls.js (npm run build:hls) and route HLS sources through it:

- js/services/hlsPlayback.js owns detection, engine choice and the hls.js
  lifecycle. Engine choice keys off MediaSource, NOT canPlayType: Chrome
  returns "maybe" for every HLS type, so a native-first branch would hang.
  Native is used only when MSE is genuinely unavailable.
- The bundle is the FULL build, not hls.js/light -- light drops the alternate
  audio stream controller and real ladders demux audio into an EXT-X-MEDIA
  group. It is lazy-imported only when a .m3u8 is encountered, so ordinary
  MP4/WebTorrent playback never pays the ~500KB.
- modalCoordinator tears the instance down first; clearing `src` alone leaves
  the transmuxing worker and segment fetches running after the modal closes.
- urlHealthController probes HLS by fetching the playlist for #EXTM3U. The old
  bare-<video> probe could not load it and on Chrome emitted no events at all,
  burning the full timeout on every HLS card.
- HLS_PLAYBACK_START_TIMEOUT widens the 3s start window, which fired
  mid-startup: the bundle, master playlist, variant playlist, init segment and
  first media segment are four-plus serial round trips.

Also fixes two bugs found while verifying this:

- A stale MediaError was reported as the current failure. Teardown clears `src`
  and calls load(), leaving MEDIA_ERR_SRC_NOT_SUPPORTED ("Empty src attribute")
  on the element; a later watchdog fallback rendered that as "Hosted playback
  failed: source not supported or blocked." over a stream that was playing
  fine. Only a MediaError produced by the current attempt is surfaced now.
  Sources are also bound before the watchdogs are armed, so hls.js's own
  attachMedia() src swap cannot read as a playback failure.
- `audio/x-mpegurl` is a legacy alias for an HLS playlist of any kind, not an
  audio-only assertion. isAudioOnlyVideoObject misfiled HLS video ladders
  published with it as podcasts and dropped them from every video feed.

Verified end-to-end in Chromium, Chrome 149, Brave 149 and Firefox 146 against
a live HLS stream: Firefox goes from "No playable source found." to playing.
Rollback: stop calling attachHlsSource() and the previous `src` assignment
returns; see docs/playback-fallback.md.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 5 committed 2026-07-28 13:58:55 -04:00
1 parent 726c0d26ed
commit 4404728410
17 files changed
+1722 -13

No files matched your search

+125
View File
@@ -94,8 +94,133 @@ sequenceDiagram
end end
``` ```
## HLS (.m3u8) sources
Hosted sources whose path ends in `.m3u8` are HLS playlists and cannot be
assigned to `videoEl.src` on every browser. `js/services/hlsPlayback.js` owns
the decision and the hls.js lifecycle.
**Engine choice.** hls.js (MSE) wins whenever `MediaSource` can back it; native
playback is used only when MSE is unavailable. Do NOT branch on `canPlayType`
alone — measured behavior differs sharply:
| Browser | `canPlayType("application/vnd.apple.mpegurl")` | bare `<video src=…m3u8>` |
| --- | --- | --- |
| Firefox 146 | `""` | `MEDIA_ERR_SRC_NOT_SUPPORTED` (code 4) |
| Chrome 149 | `"maybe"` | plays natively |
Before this existed, Firefox reported `No playable source found.` for every HLS
note (and the URL-health badge showed `❌ CDN`), while Chrome happened to work.
**Wiring.**
- `js/services/playbackService.js` — `attemptHostedPlayback` routes HLS
candidates through `attachHlsSource()`; `resetVideoElement()` calls
`detachHls()` before clearing `src`.
- `js/app/modalCoordinator.js` — `teardownVideoElement` calls `detachHls()`
first; otherwise the transmuxer worker and segment fetches outlive the modal.
- `js/ui/urlHealthController.js` — probes HLS by fetching the playlist and
checking for `#EXTM3U` instead of using a bare `<video>` (which cannot load it
and, on Chrome, emits no events at all — burning the whole probe timeout).
- `HLS_PLAYBACK_START_TIMEOUT` (`js/constants.js`) widens the 3s start window,
which fires mid-startup for HLS: the bundle, master playlist, variant
playlist, init segment and first media segment are serial round trips.
**Vendoring.** `npm run build:hls` regenerates `vendor/hls.bundle.min.js` from
the pinned devDependency. It is the FULL hls.js build, not `hls.js/light` —
light drops the alternate-audio stream controller, and real ladders (e.g.
nostube/slidestr) demux audio into its own `EXT-X-MEDIA` rendition group. The
bundle is lazy-imported only when an HLS URL is encountered, so ordinary
MP4/WebTorrent playback never pays for it.
**Rollback.** Stop calling `attachHlsSource()` from `playbackService.js` /
`playbackCoordinator.js` and the previous `videoEl.src = url` behavior returns;
revert the `urlHealthController.js` branch to restore the old element probe.
Nothing else imports `hlsPlayback.js`, and the vendored bundle is inert once
unreferenced.
## Implementation notes ## Implementation notes
- The playback session is responsible for cleanup and emits events (`status`, `sourcechange`, `error`, `finished`) that `bitvidApp` listens to for UI updates. - The playback session is responsible for cleanup and emits events (`status`, `sourcechange`, `error`, `finished`) that `bitvidApp` listens to for UI updates.
- `bitvidApp.playVideoWithFallback` keeps the current video card and modal state in sync with the normalized magnet values so copy actions and stats reflect the actual playback payload. - `bitvidApp.playVideoWithFallback` keeps the current video card and modal state in sync with the normalized magnet values so copy actions and stats reflect the actual playback payload.
## Link previews (NIP-92 imeta + per-video OpenGraph)
Two independent surfaces make a shared bitvid video render richly.
**1. Inside nostr clients — `js/nostrEventSchemas.js`.**
`buildShareEvent()` appends a NIP-92 `imeta` entry describing the thumbnail URL
that the note body ends with. Many clients only inline such a URL when an imeta
entry backs it; without it the viewer sees a bare link. The media type is
inferred from the extension (`inferImageMimeTypeFromUrl`, `js/utils/mime.js`)
and OMITTED when unknown — a wrong `m` is worse than a missing one, because
clients use it to decide whether to render inline at all. Extensionless
Blossom-style URLs therefore ship `url` + `alt` only.
**2. Everywhere else — `api/og.js` + `api/_lib/ogRenderer.mjs`.**
`index.html` carries one set of site-wide OG tags, so every `/?v=<nevent>` link
previewed as the same generic card. Crawlers don't run JS, so this is rendered
server-side on demand.
- **Nothing is pre-generated or stored.** One stateless function serves all
videos; the video is a query param, not a path. `dist/` is byte-identical
with or without it. The only caching is the CDN edge (`s-maxage=300`) plus a
per-container in-memory memo of the admin lists.
- **Never load-bearing.** Only crawler user-agents are rewritten to it
(`vercel.json`), humans get the untouched SPA, and a static/nsite deploy with
no functions keeps working — it just serves the site-wide card. Rollback is
deleting the `rewrites` block.
- **Player card, not just an image.** It emits `twitter:player` / `og:video:url`
pointing at `embed.html?pointer=<nevent>`, so a shared link plays inline on
Twitter/Discord/Telegram. `frame-ancestors *` (`_headers`) already permits it.
- **Fails closed.** A crawler never authenticates, so anything emitted is public
forever in third-party caches. `isPubliclyShareable()` requires an
affirmatively public video: `deleted`, `isPrivate`, `isNsfw`/`content-warning`,
blacklisted authors, and — while whitelist mode is on — an unavailable or
empty whitelist all fall back to the generic card.
- **Both event shapes.** `parseVideoEvent()` reads bitvid's kind-30078 v3 JSON
AND the NIP-71 tag shape (kinds 21/22/34235/34236) that nostube/slidestr
publish, where the title lives in a `title` tag and the thumbnail in an
`imeta image` entry.
- **Timeouts.** A cold pool's first relay read measured ~3s, so the budget is
4s (Twitter allows ~5s); warm containers answer in <600ms because the admin
lists are memoized for 5 minutes.
Config is imported from `js/config.js`, `js/nostr/toolkit.js` and
`js/nostrEventSchemas.js` rather than duplicated, and `tests/og-renderer.test.mjs`
fails if that regresses to hardcoded literals.
### Quoting the NIP-71 mirror
When a video has a NIP-71 mirror, the share note embeds `nostr:naddr1…` for it.
Nostr clients render that as a **native video quote card** — the closest thing
to a rich preview that does not depend on any client fetching OpenGraph tags
(most never do; the OG endpoint above mainly serves Twitter/Discord/Telegram).
The mirror is addressable (kind 34235/34236, author = video pubkey,
`d` = `videoRootId`), so `js/nostr/mirrorPointer.js` derives the coordinate with
no relay lookup, and the pointer survives the author editing the video — a plain
`nevent` would rot on the next replace.
Rules that keep this from looking worse than the plain link:
- **Verify before pointing.** `ShareNostrController.resolveMirrorPointer()`
confirms the mirror exists via `nip71MirrorService.findMirror()` before
embedding anything. A pointer to a missing event renders as a broken/empty
quote box. Every failure path — service disabled, lookup throws, lookup times
out (2.5s), no `videoRootId` — falls back to the thumbnail-URL note.
- **Use the observed kind.** `resolveMirrorKind()` prefers the kinds actually
seen on relays over the portrait heuristic. Only when nothing was observed does
it fall back to the SAME rule the mirror builder uses (`nip71Mirror.js`:
`hasDims && height > width`), so a derived pointer matches what would be
published rather than guessing independently.
- **Never emit both images.** While quoting, the raw thumbnail URL is dropped
from the body and the `imeta` tag is skipped — the quote card already shows the
thumbnail, and keeping both rendered the same image twice.
- An `a` tag (`kind:pubkey:d`) links the note to the quoted coordinate for
quote-aware clients and indexers.
Known limitation: a client rendering the quote card still needs to play the
video, and mirrors whose `imeta url` is an `.m3u8` hit the same HLS wall bitvid
did — the thumbnail and title render, playback depends on the client.
+9 -2
View File
@@ -3,11 +3,13 @@
/** /**
* Modal open/close lifecycle and controller routing. * Modal open/close lifecycle and controller routing.
* *
* All module-level dependencies are injected from the Application * Stateful collaborators (clients, services, app state) are injected from the
* composition root rather than imported at module scope. * Application composition root rather than imported at module scope. Pure,
* stateless helper modules are imported directly, as in the other coordinators.
* *
* Methods use `this` which is bound to the Application instance. * Methods use `this` which is bound to the Application instance.
*/ */
import { detachHls } from "../services/hlsPlayback.js";
/** /**
* @param {object} deps - Injected dependencies. * @param {object} deps - Injected dependencies.
@@ -562,6 +564,11 @@ export function createModalCoordinator(deps) {
`[teardownVideoElement] Resetting video (replaceNode=${replaceNode}) readyState=${videoElement.readyState} networkState=${videoElement.networkState} src=${describeSource()}` `[teardownVideoElement] Resetting video (replaceNode=${replaceNode}) readyState=${videoElement.readyState} networkState=${videoElement.networkState} src=${describeSource()}`
); );
// Destroy any hls.js instance FIRST. It owns a MediaSource, a transmuxing
// worker and in-flight segment fetches; clearing `src` without destroying
// it leaves those running (and downloading) after the modal is closed.
safe(() => detachHls(videoElement));
safe(() => videoElement.pause()); safe(() => videoElement.pause());
safe(() => { safe(() => {
+25 -3
View File
@@ -3,11 +3,17 @@
/** /**
* URL-first + magnet fallback playback pipeline. * URL-first + magnet fallback playback pipeline.
* *
* All module-level dependencies are injected from the Application * Stateful collaborators (clients, services, app state) are injected from the
* composition root rather than imported at module scope. * Application composition root rather than imported at module scope. Pure,
* stateless helper modules are imported directly, as in the other coordinators.
* *
* Methods use `this` which is bound to the Application instance. * Methods use `this` which is bound to the Application instance.
*/ */
import {
attachHlsSource,
detachHls,
isHlsUrl,
} from "../services/hlsPlayback.js";
/** /**
* Normalize a deep-link / caller "force this playback source" value to the * Normalize a deep-link / caller "force this playback source" value to the
@@ -355,7 +361,23 @@ export function createPlaybackCoordinator(deps) {
return false; return false;
} }
target.src = sanitizedUrl; // HLS playlists cannot be assigned to `src` outside Safari; route them
// through hls.js/MSE (see js/services/hlsPlayback.js).
if (isHlsUrl(sanitizedUrl)) {
const handle = await attachHlsSource(target, sanitizedUrl, {
log: (...args) => devLogger.log(...args),
});
if (!handle) {
devLogger.warn(
"[playHttp] HLS source could not be attached:",
sanitizedUrl
);
return false;
}
} else {
detachHls(target);
target.src = sanitizedUrl;
}
try { try {
await target.play(); await target.play();
+11
View File
@@ -189,6 +189,17 @@ export const DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD =
export const DEFAULT_TRUST_SEED_NPUBS = SANITIZED_DEFAULT_TRUST_SEED_NPUBS; export const DEFAULT_TRUST_SEED_NPUBS = SANITIZED_DEFAULT_TRUST_SEED_NPUBS;
export const PLAYBACK_START_TIMEOUT = SANITIZED_DEFAULT_PLAYBACK_START_TIMEOUT; export const PLAYBACK_START_TIMEOUT = SANITIZED_DEFAULT_PLAYBACK_START_TIMEOUT;
// HLS needs a bigger start window than a progressive MP4. Before the first
// frame can render, hls.js must lazy-load its (vendored) bundle, fetch the
// master playlist, fetch the chosen variant playlist, then fetch the init
// segment and the first media segment — four or more serial round trips versus
// the single ranged GET a plain MP4 needs. The 3s default fires mid-startup and
// reports a perfectly healthy stream as "No playable source found."
export const HLS_PLAYBACK_START_TIMEOUT = Math.max(
SANITIZED_DEFAULT_PLAYBACK_START_TIMEOUT,
15000
);
const DEFAULT_FLAGS = Object.freeze({ const DEFAULT_FLAGS = Object.freeze({
URL_FIRST_ENABLED: DEFAULT_PLAYBACK_SOURCE !== "torrent", // try URL before magnet in the player URL_FIRST_ENABLED: DEFAULT_PLAYBACK_SOURCE !== "torrent", // try URL before magnet in the player
FEATURE_WATCH_HISTORY_V2: true, FEATURE_WATCH_HISTORY_V2: true,
+14
View File
@@ -1465,6 +1465,17 @@ function inferMimeTypeFromUrl(url) {
// (kind 30078) can carry `imeta` variants with an `m <mime>` field just like // (kind 30078) can carry `imeta` variants with an `m <mime>` field just like
// NIP-71 events; a podcast/music note advertises `m audio/*`. Mirrors the // NIP-71 events; a podcast/music note advertises `m audio/*`. Mirrors the
// video/audio split in nip71IngestAdapter.js so the native path stays consistent. // video/audio split in nip71IngestAdapter.js so the native path stays consistent.
// HLS playlist MIME types. `audio/mpegurl` / `audio/x-mpegurl` are LEGACY
// aliases for an HLS playlist of any kind — they do NOT assert audio-only
// content, and a publisher using one for a video ladder would otherwise be
// misfiled as a podcast and dropped from the video feeds by
// isAudioOnlyVideoObject(). A playlist is a container, so it votes for neither
// side and we let the URL/`imeta m video/*` signals decide.
// Kept inline (not imported from js/services/hlsPlayback.js) so this parser
// stays free of any DOM/logger dependency — it also runs inside workers.
const HLS_PLAYLIST_MIME_PATTERN =
/^(?:application|audio|video)\/(?:x-|vnd\.apple\.)?mpegurl$/i;
function detectImetaMediaKinds(tags = []) { function detectImetaMediaKinds(tags = []) {
let hasVideo = false; let hasVideo = false;
let hasAudio = false; let hasAudio = false;
@@ -1482,6 +1493,9 @@ function detectImetaMediaKinds(tags = []) {
continue; continue;
} }
const mime = trimmed.replace(/^m\s+/i, "").trim().toLowerCase(); const mime = trimmed.replace(/^m\s+/i, "").trim().toLowerCase();
if (HLS_PLAYLIST_MIME_PATTERN.test(mime.split(";")[0].trim())) {
continue;
}
if (mime.startsWith("video/")) { if (mime.startsWith("video/")) {
hasVideo = true; hasVideo = true;
} else if (mime.startsWith("audio/")) { } else if (mime.startsWith("audio/")) {
+418
View File
@@ -0,0 +1,418 @@
// HLS (.m3u8) playback support.
//
// bitvid historically attached every hosted source with `videoEl.src = url`.
// That works for progressive MP4/WebM but NOT for HLS: only Safari/iOS can play
// an .m3u8 natively, so on Chrome/Firefox the element fails immediately with
// MEDIA_ERR_SRC_NOT_SUPPORTED ("Hosted playback failed: source not supported or
// blocked."). Nostr clients that publish bitvid's v3 schema with HLS ladders
// (e.g. nostube/slidestr) were therefore unplayable everywhere but Safari.
//
// This module owns the decision + the hls.js lifecycle. hls.js is vendored
// (scripts/build-hls.mjs) and lazy-imported ONLY when an HLS URL is actually
// encountered, so the ~500KB bundle never touches a normal MP4/WebTorrent load.
//
// Rollback: stop calling attachHlsSource() from playbackService/playbackCoordinator
// and the old `videoEl.src = url` behavior returns; nothing else imports this.
import { devLogger } from "../utils/logger.js";
// Vendored, pinned hls.js bundle (scripts/build-hls.mjs). Lazy-imported.
const HLS_BUNDLE_URL = "../../vendor/hls.bundle.min.js";
// Canonical + legacy HLS media types. Servers are wildly inconsistent here —
// the slidestr CDN serves playlists as `text/plain` — so the URL extension is
// the primary signal and the MIME type is only ever corroborating evidence.
export const HLS_MIME_TYPES = Object.freeze([
"application/vnd.apple.mpegurl",
"application/x-mpegurl",
"audio/mpegurl",
"audio/x-mpegurl",
"application/mpegurl",
"vnd.apple.mpegurl",
]);
/**
* True when the URL's *path* ends in .m3u8. Query strings and fragments are
* ignored (signed CDN URLs routinely carry `?token=...`), and a bare .m3u8
* substring elsewhere in the URL must not match.
*/
export function isHlsUrl(url) {
const trimmed = typeof url === "string" ? url.trim() : "";
if (!trimmed) {
return false;
}
// Strip fragment then query, without `new URL()` — relative/odd inputs must
// not throw, and bitvid never runs URL parsers over user-supplied media
// strings (see the magnet rules in AGENTS.md).
const path = trimmed.split("#")[0].split("?")[0];
return /\.m3u8$/i.test(path);
}
/** True when a declared MIME type is one of the HLS playlist types. */
export function isHlsMimeType(mimeType) {
const normalized =
typeof mimeType === "string" ? mimeType.trim().toLowerCase() : "";
if (!normalized) {
return false;
}
const bare = normalized.split(";")[0].trim();
return HLS_MIME_TYPES.includes(bare);
}
/** Combined check used by callers that may have an imeta `m` hint. */
export function isHlsSource({ url = "", mimeType = "" } = {}) {
return isHlsUrl(url) || isHlsMimeType(mimeType);
}
/**
* Whether `canPlayType` claims native HLS support.
*
* DO NOT branch on this alone. Chrome LIES here: Chrome 149 returns "maybe" for
* every HLS MIME type, but assigning an .m3u8 to `video.src` then fires NO
* events at all — not `loadeddata`, not even `error`. The element simply hangs,
* which is exactly the silent "nothing happens" failure this module exists to
* fix (and why the URL-health probe timed out instead of erroring, leaving the
* card visible but unplayable). Use prefersNativeHls() to make decisions.
*/
export function supportsNativeHls(videoElement) {
const el =
videoElement ||
(typeof document !== "undefined" ? document.createElement("video") : null);
if (!el || typeof el.canPlayType !== "function") {
return false;
}
for (const type of HLS_MIME_TYPES) {
try {
if (el.canPlayType(type)) {
return true;
}
} catch (err) {
// canPlayType must never throw, but never let a probe kill playback.
}
}
return false;
}
// A representative baseline codec: every MSE implementation that can run hls.js
// supports fragmented-MP4 H.264. This mirrors what Hls.isSupported() checks, so
// we can predict the engine choice WITHOUT paying to load the bundle (the
// URL-health probe needs the answer for every visible card).
const MSE_PROBE_TYPE = 'video/mp4; codecs="avc1.42E01E"';
/** True when Media Source Extensions can back hls.js in this browser. */
export function supportsMse() {
const MediaSourceImpl =
(typeof globalThis !== "undefined" &&
(globalThis.MediaSource ||
globalThis.ManagedMediaSource ||
globalThis.WebKitMediaSource)) ||
null;
if (!MediaSourceImpl || typeof MediaSourceImpl.isTypeSupported !== "function") {
return false;
}
try {
return MediaSourceImpl.isTypeSupported(MSE_PROBE_TYPE);
} catch (err) {
return false;
}
}
/**
* Whether to use the browser's own HLS implementation instead of hls.js.
*
* Only when MSE is genuinely unavailable (older iOS Safari, some TV browsers).
* hls.js is the default everywhere else — this is hls.js's own recommended
* ordering, and on Chrome it is mandatory: see supportsNativeHls() for the
* canPlayType lie that makes a native-first branch hang forever.
*/
export function prefersNativeHls(videoElement) {
return !supportsMse() && supportsNativeHls(videoElement);
}
let hlsEnginePromise = null;
/**
* Lazy-import the vendored hls.js bundle. Cached across calls (including the
* rejection case, deliberately reset so a transient network failure can retry).
*/
export async function loadHlsEngine() {
if (!hlsEnginePromise) {
hlsEnginePromise = import(HLS_BUNDLE_URL)
.then((mod) => mod?.default || mod?.Hls || null)
.catch((err) => {
hlsEnginePromise = null;
devLogger.warn("[hlsPlayback] Failed to load hls.js bundle:", err);
return null;
});
}
return hlsEnginePromise;
}
/** Test seam: drop the cached engine promise. */
export function __resetHlsEngineCacheForTests() {
hlsEnginePromise = null;
}
// videoEl -> active hls.js instance. A WeakMap keeps teardown paths (which only
// ever hold the element) from having to thread an extra handle around, and lets
// a discarded element be collected without leaking the instance.
const attachedInstances = new WeakMap();
export function getAttachedHls(videoElement) {
return videoElement ? attachedInstances.get(videoElement) || null : null;
}
/**
* Tear down any hls.js instance bound to this element. Safe to call
* unconditionally on every reset path — it is a no-op for non-HLS playback.
*/
export function detachHls(videoElement) {
if (!videoElement) {
return false;
}
const hls = attachedInstances.get(videoElement);
if (!hls) {
return false;
}
attachedInstances.delete(videoElement);
try {
hls.destroy();
} catch (err) {
devLogger.warn("[hlsPlayback] hls.destroy() threw during detach:", err);
}
return true;
}
function describeHlsError(data) {
const type = data?.type || "unknown";
const details = data?.details || "unknown";
if (data?.type === "networkError") {
return "Hosted playback failed: the HLS stream could not be reached (network or CORS).";
}
if (data?.type === "mediaError") {
return "Hosted playback failed: this HLS stream uses a codec your browser cannot decode.";
}
return `Hosted playback failed: HLS error (${type}/${details}).`;
}
/**
* Attach `url` to `videoElement`, using native HLS where available and hls.js
* (MSE) otherwise.
*
* Resolves once the manifest is parsed — the meaningful "this is playable"
* signal for both the player and the URL-health probe. Callers are still
* responsible for calling play(); this only wires up the source.
*
* Returns null when HLS cannot be supported at all (no native support and MSE
* unavailable), so the caller can fall back to its normal `src` assignment and
* surface a real media error.
*
* `createEngine` is a test seam: it resolves the hls.js constructor and defaults
* to the lazy import of the vendored bundle. Tests inject a fake so the MSE
* branch can be specified without a real MediaSource.
*
* @returns {Promise<{mode: "native"|"mse", destroy: () => void}|null>}
*/
export async function attachHlsSource(
videoElement,
url,
{
onFatalError,
log = () => {},
manifestTimeoutMs = 15000,
createEngine = loadHlsEngine,
} = {}
) {
if (!videoElement) {
return null;
}
const trimmed = typeof url === "string" ? url.trim() : "";
if (!trimmed) {
return null;
}
// Always clear a previous instance before rebinding — reusing the modal's
// <video> across videos is the norm in bitvid.
detachHls(videoElement);
const useNative = () => {
log("[hlsPlayback] Using native HLS playback.");
videoElement.src = trimmed;
return { mode: "native", destroy: () => {} };
};
// Native only when MSE genuinely cannot back hls.js. Never trust canPlayType
// on its own — Chrome claims HLS support and then hangs silently.
if (prefersNativeHls(videoElement)) {
return useNative();
}
const Hls = await createEngine();
if (!Hls || typeof Hls.isSupported !== "function" || !Hls.isSupported()) {
// Last resort: if the bundle failed to load but the browser claims native
// support, an attempt beats a guaranteed failure.
if (supportsNativeHls(videoElement)) {
log("[hlsPlayback] hls.js unavailable; falling back to native HLS.");
return useNative();
}
log("[hlsPlayback] hls.js unavailable/unsupported; cannot play HLS.");
return null;
}
const hls = new Hls({
enableWorker: true,
lowLatencyMode: false,
// Keep a bounded back-buffer so long VOD (this note is ~1h) does not grow
// the SourceBuffer without limit on low-memory devices.
backBufferLength: 90,
});
attachedInstances.set(videoElement, hls);
let settled = false;
let networkRetries = 0;
let mediaRecoveries = 0;
return new Promise((resolve) => {
let timeoutId = null;
const finish = (value) => {
if (settled) {
return;
}
settled = true;
if (timeoutId) {
clearTimeout(timeoutId);
}
resolve(value);
};
const fail = (message) => {
// Only tear down once we've given up; recoverable errors keep the
// instance alive so hls.js can heal itself.
detachHls(videoElement);
if (typeof onFatalError === "function") {
onFatalError(message);
}
finish(null);
};
if (Number.isFinite(manifestTimeoutMs) && manifestTimeoutMs > 0) {
timeoutId = setTimeout(() => {
if (settled) {
return;
}
log("[hlsPlayback] Manifest parse timed out.");
fail("Hosted playback failed: the HLS manifest did not load in time.");
}, manifestTimeoutMs);
}
hls.on(Hls.Events.MANIFEST_PARSED, (_event, data) => {
log(
`[hlsPlayback] Manifest parsed; ${data?.levels?.length ?? 0} playable level(s).`
);
// hls.js drops levels whose codecs MSE cannot decode (e.g. an HEVC
// rendition on Firefox). Zero survivors means nothing is playable.
if (Array.isArray(data?.levels) && data.levels.length === 0) {
fail(
"Hosted playback failed: no HLS rendition uses a codec your browser supports."
);
return;
}
finish({ mode: "mse", destroy: () => detachHls(videoElement) });
});
hls.on(Hls.Events.ERROR, (_event, data) => {
if (!data?.fatal) {
log(
`[hlsPlayback] Non-fatal HLS error (${data?.type}/${data?.details}); continuing.`
);
return;
}
log(`[hlsPlayback] Fatal HLS error (${data?.type}/${data?.details}).`);
if (data.type === Hls.ErrorTypes.NETWORK_ERROR && networkRetries < 2) {
networkRetries += 1;
log(`[hlsPlayback] Retrying load (attempt ${networkRetries}).`);
try {
hls.startLoad();
return;
} catch (err) {
log("[hlsPlayback] startLoad() threw during recovery.", err);
}
}
if (data.type === Hls.ErrorTypes.MEDIA_ERROR && mediaRecoveries < 2) {
mediaRecoveries += 1;
log(`[hlsPlayback] Recovering media error (attempt ${mediaRecoveries}).`);
try {
if (mediaRecoveries === 2 && typeof hls.swapAudioCodec === "function") {
hls.swapAudioCodec();
}
hls.recoverMediaError();
return;
} catch (err) {
log("[hlsPlayback] recoverMediaError() threw during recovery.", err);
}
}
fail(describeHlsError(data));
});
try {
hls.attachMedia(videoElement);
hls.loadSource(trimmed);
} catch (err) {
log("[hlsPlayback] attachMedia/loadSource threw:", err);
fail("Hosted playback failed: the HLS stream could not be attached.");
}
});
}
/**
* Cheap playability check for the URL-health badge that does NOT pull in hls.js.
*
* On MSE browsers hls.js fetches the playlist with XHR, so a CORS-readable
* `#EXTM3U` response is exactly the condition for playability — and a bare
* <video> probe (the old path) would report every HLS URL as dead. On Safari,
* native playback needs no CORS, so the caller should keep using the element
* probe there.
*
* @returns {Promise<"ok"|"error"|"timeout">}
*/
export async function probeHlsManifest(url, timeoutMs = 4000) {
const trimmed = typeof url === "string" ? url.trim() : "";
if (!trimmed || typeof fetch !== "function") {
return "error";
}
const controller =
typeof AbortController === "function" ? new AbortController() : null;
let timedOut = false;
const timeoutId =
Number.isFinite(timeoutMs) && timeoutMs > 0
? setTimeout(() => {
timedOut = true;
controller?.abort();
}, timeoutMs)
: null;
try {
const response = await fetch(trimmed, {
method: "GET",
cache: "no-store",
signal: controller?.signal,
});
if (!response.ok) {
return "error";
}
const text = await response.text();
return text.trimStart().startsWith("#EXTM3U") ? "ok" : "error";
} catch (err) {
return timedOut ? "timeout" : "error";
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
+91 -8
View File
@@ -1,5 +1,8 @@
import { userLogger } from "../utils/logger.js"; import { userLogger } from "../utils/logger.js";
import { PLAYBACK_START_TIMEOUT } from "../constants.js"; import {
HLS_PLAYBACK_START_TIMEOUT,
PLAYBACK_START_TIMEOUT,
} from "../constants.js";
import { getCurrentVideo, setCurrentVideo } from "../state/appState.js"; import { getCurrentVideo, setCurrentVideo } from "../state/appState.js";
import { import {
consumeAutoplayFallbackMute, consumeAutoplayFallbackMute,
@@ -9,6 +12,7 @@ import {
SimpleEventEmitter, SimpleEventEmitter,
extractWebSeedsFromMagnet, extractWebSeedsFromMagnet,
} from "./playbackHelpers.js"; } from "./playbackHelpers.js";
import { attachHlsSource, detachHls, isHlsUrl } from "./hlsPlayback.js";
// js/services/playbackService.js // js/services/playbackService.js
/** /**
@@ -855,6 +859,10 @@ class PlaybackSession extends SimpleEventEmitter {
const resetVideoElement = () => { const resetVideoElement = () => {
if (!activeVideoEl) return; if (!activeVideoEl) return;
// Kill any hls.js instance BEFORE touching src/load(): a live instance
// keeps appending to the MediaSource and would fight the reset (and
// leak its worker) when we move to the next mirror or to WebTorrent.
detachHls(activeVideoEl);
try { try {
activeVideoEl.pause(); activeVideoEl.pause();
} catch (err) { } catch (err) {
@@ -1055,6 +1063,11 @@ class PlaybackSession extends SimpleEventEmitter {
}; };
}); });
// Whatever MediaError the element already carries from a previous
// load. Identity comparison works because starting a new load clears
// `error` to null and a fresh failure allocates a new MediaError.
const staleMediaError = activeVideoEl.error || null;
const attachWatchdogs = ({ stallMs = 8000 } = {}) => { const attachWatchdogs = ({ stallMs = 8000 } = {}) => {
this.registerWatchdogs(activeVideoEl, { this.registerWatchdogs(activeVideoEl, {
stallMs, stallMs,
@@ -1071,7 +1084,17 @@ class PlaybackSession extends SimpleEventEmitter {
attachWatchdogs({ stallMs: 0 }); attachWatchdogs({ stallMs: 0 });
return; return;
} }
const errorMessage = getHostedVideoErrorMessage(activeVideoEl); // Only report a MediaError this attempt actually produced.
// teardownVideoElement()/resetVideoElement() clear `src` and
// call load(), which leaves a stale MEDIA_ERR_SRC_NOT_SUPPORTED
// ("Empty src attribute") on the element. Reading it blindly
// reported "Hosted playback failed: source not supported or
// blocked." for a stream that was playing perfectly — a
// failure message attributed to the wrong load entirely.
const errorMessage =
activeVideoEl.error && activeVideoEl.error !== staleMediaError
? getHostedVideoErrorMessage(activeVideoEl)
: "";
if (errorMessage) { if (errorMessage) {
this.emit("status", { message: errorMessage }); this.emit("status", { message: errorMessage });
} }
@@ -1080,9 +1103,6 @@ class PlaybackSession extends SimpleEventEmitter {
}); });
}; };
// Use default stall timeout here; the initial start timeout is handled by the race wrapper
attachWatchdogs({ stallMs: 8000 });
const handleFatalPlaybackError = (err) => { const handleFatalPlaybackError = (err) => {
this.service.log( this.service.log(
"[playVideoWithFallback] Direct URL playback threw:", "[playVideoWithFallback] Direct URL playback threw:",
@@ -1091,6 +1111,14 @@ class PlaybackSession extends SimpleEventEmitter {
outcomeResolver({ status: "fallback", reason: "play-error" }); outcomeResolver({ status: "fallback", reason: "play-error" });
}; };
// Bind the source BEFORE arming the watchdogs. hls.js's attachMedia()
// points `src` at a MediaSource blob URL, which aborts any load still
// pending on the reused modal element and fires `abort`/`emptied`.
// With watchdogs already live those read as a playback failure and
// abandon a stream that is about to play fine. A plain `src =` is
// safe either way: media events are dispatched asynchronously, so
// listeners registered later in the same task still observe them.
let hlsAttached = true;
try { try {
// Guarantee the direct load is CORS-free regardless of how we got // Guarantee the direct load is CORS-free regardless of how we got
// here (see resetVideoElement) so any hosted URL plays reliably. // here (see resetVideoElement) so any hosted URL plays reliably.
@@ -1100,8 +1128,57 @@ class PlaybackSession extends SimpleEventEmitter {
// ignore // ignore
} }
activeVideoEl.crossOrigin = null; activeVideoEl.crossOrigin = null;
activeVideoEl.src = candidateUrl;
const playPromise = activeVideoEl.play(); // HLS cannot be assigned to `src` outside Safari — it needs hls.js
// + MSE. attachHlsSource() handles both paths and resolves once the
// manifest is parsed. A null result means HLS is unplayable here
// (or already failed fatally), and the outcome promise is settled,
// so skip play() and let the normal fallback plumbing take over.
if (isHlsUrl(candidateUrl)) {
const hlsHandle = await attachHlsSource(
activeVideoEl,
candidateUrl,
{
log: (...args) => this.service.log(...args),
onFatalError: (message) => {
this.emit("status", { message });
outcomeResolver({
status: "fallback",
reason: "hls-error",
errorMessage: message,
});
},
}
);
if (!hlsHandle) {
hlsAttached = false;
// A fatal HLS error already resolved the outcome with a
// specific message; this only covers "no HLS support at all".
const message =
"Hosted playback failed: this browser cannot play HLS streams.";
this.emit("status", { message });
outcomeResolver({
status: "fallback",
reason: "hls-unsupported",
errorMessage: message,
});
}
} else {
activeVideoEl.src = candidateUrl;
}
} catch (err) {
hlsAttached = false;
handleFatalPlaybackError(err);
}
try {
// Use default stall timeout here; the initial start timeout is
// handled by the race wrapper.
if (hlsAttached) {
attachWatchdogs({ stallMs: 8000 });
}
const playPromise = hlsAttached ? activeVideoEl.play() : null;
if (playPromise && typeof playPromise.catch === "function") { if (playPromise && typeof playPromise.catch === "function") {
playPromise.catch((err) => { playPromise.catch((err) => {
if (err?.name === "NotAllowedError") { if (err?.name === "NotAllowedError") {
@@ -1215,9 +1292,15 @@ class PlaybackSession extends SimpleEventEmitter {
let lastUrlResult = null; let lastUrlResult = null;
for (let i = 0; i < this.hostedSourceCandidates.length; i += 1) { for (let i = 0; i < this.hostedSourceCandidates.length; i += 1) {
const candidateUrl = this.hostedSourceCandidates[i]; const candidateUrl = this.hostedSourceCandidates[i];
// HLS starts slower by construction (see HLS_PLAYBACK_START_TIMEOUT).
// `forcedSource` still means "wait indefinitely", so keep 0 as 0.
const candidateTimeout =
effectiveTimeout && isHlsUrl(candidateUrl)
? Math.max(effectiveTimeout, HLS_PLAYBACK_START_TIMEOUT)
: effectiveTimeout;
const urlResult = await withTimeout( const urlResult = await withTimeout(
attemptHostedPlayback(candidateUrl), attemptHostedPlayback(candidateUrl),
effectiveTimeout, candidateTimeout,
"URL Playback" "URL Playback"
); );
+20
View File
@@ -1,4 +1,9 @@
import { SHORT_TIMEOUT_MS } from "../constants.js"; import { SHORT_TIMEOUT_MS } from "../constants.js";
import {
isHlsUrl,
prefersNativeHls,
probeHlsManifest,
} from "../services/hlsPlayback.js";
export default class UrlHealthController { export default class UrlHealthController {
constructor({ constructor({
@@ -275,6 +280,21 @@ export default class UrlHealthController {
return { outcome: "error" }; return { outcome: "error" };
} }
// A bare <video> cannot load an .m3u8 on the browsers bitvid actually runs
// hls.js on. Worse, Chrome neither plays it NOR errors — it emits no events
// at all, so this probe burned the full timeout on every healthy HLS card.
// Since playback will use hls.js (which fetches the playlist over CORS), a
// readable #EXTM3U response is the accurate signal — and far cheaper than
// pulling in the hls.js bundle just to paint a badge.
if (isHlsUrl(trimmed) && !prefersNativeHls()) {
const defaultTimeout =
this.constants.URL_PROBE_TIMEOUT_MS || SHORT_TIMEOUT_MS;
const effective =
Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : defaultTimeout;
const outcome = await probeHlsManifest(trimmed, effective);
return { outcome };
}
if (this.activeVideoProbes >= this.MAX_CONCURRENT_VIDEO_PROBES) { if (this.activeVideoProbes >= this.MAX_CONCURRENT_VIDEO_PROBES) {
await new Promise((resolve) => { await new Promise((resolve) => {
this.videoProbeQueue.push(resolve); this.videoProbeQueue.push(resolve);
+8
View File
@@ -25,6 +25,7 @@
"blossom-client-sdk": "5.0.0", "blossom-client-sdk": "5.0.0",
"cssnano": "^7.1.2", "cssnano": "^7.1.2",
"fake-indexeddb": "^6.2.5", "fake-indexeddb": "^6.2.5",
"hls.js": "^1.6.16",
"jsdom": "^28.1.0", "jsdom": "^28.1.0",
"nostr-wasm": "^0.1.0", "nostr-wasm": "^0.1.0",
"pixelmatch": "^5.3.0", "pixelmatch": "^5.3.0",
@@ -3252,6 +3253,13 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/hls.js": {
"version": "1.6.16",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/hookified": { "node_modules/hookified": {
"version": "1.15.1", "version": "1.15.1",
"resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz",
+2
View File
@@ -17,6 +17,7 @@
"build:beacon": "npm run build:beacon:bundle && npm run lint:inline-styles", "build:beacon": "npm run build:beacon:bundle && npm run lint:inline-styles",
"build:bitcoin-connect": "node scripts/build-bitcoin-connect.mjs", "build:bitcoin-connect": "node scripts/build-bitcoin-connect.mjs",
"build:blossom-sdk": "node scripts/build-blossom-sdk.mjs", "build:blossom-sdk": "node scripts/build-blossom-sdk.mjs",
"build:hls": "node scripts/build-hls.mjs",
"ci:beacon": "npm run build:beacon", "ci:beacon": "npm run build:beacon",
"format": "prettier --cache --write css/tailwind.source.css css/tokens.css css/docs.css \"docs/**/*.html\" tailwind.config.cjs README.md CONTRIBUTING.md", "format": "prettier --cache --write css/tailwind.source.css css/tokens.css css/docs.css \"docs/**/*.html\" tailwind.config.cjs README.md CONTRIBUTING.md",
"audit": "node scripts/daily-design-system-audit.mjs", "audit": "node scripts/daily-design-system-audit.mjs",
@@ -65,6 +66,7 @@
"blossom-client-sdk": "5.0.0", "blossom-client-sdk": "5.0.0",
"cssnano": "^7.1.2", "cssnano": "^7.1.2",
"fake-indexeddb": "^6.2.5", "fake-indexeddb": "^6.2.5",
"hls.js": "^1.6.16",
"jsdom": "^28.1.0", "jsdom": "^28.1.0",
"nostr-wasm": "^0.1.0", "nostr-wasm": "^0.1.0",
"pixelmatch": "^5.3.0", "pixelmatch": "^5.3.0",
+46
View File
@@ -0,0 +1,46 @@
// Bundles hls.js into a single self-contained ESM file that bitvid lazy-imports
// the first time a viewer opens an HLS (.m3u8) source. Mirrors the
// blossom-sdk / bitcoin-connect / floating-ui vendor steps. Pinned via the exact
// devDependency version in package.json.
//
// Lazy-import is the whole point: hls.js is ~530KB minified, and the vast
// majority of bitvid videos are progressive MP4 or WebTorrent. Nothing here may
// be imported from a module that loads on boot.
import { build } from "esbuild";
import { mkdir, copyFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const entryPoint = path.join(repoRoot, "scripts/vendor/hls-entry.mjs");
const vendorDir = path.join(repoRoot, "vendor");
const outFile = path.join(vendorDir, "hls.bundle.min.js");
const licenseSrc = path.join(repoRoot, "node_modules/hls.js/LICENSE");
const licenseOut = path.join(vendorDir, "hls.LICENSE");
await mkdir(vendorDir, { recursive: true });
await build({
bundle: true,
entryPoints: [entryPoint],
outfile: outFile,
format: "esm",
splitting: false,
platform: "browser",
target: ["es2020"],
minify: true,
sourcemap: false,
define: {
"process.env.NODE_ENV": '"production"',
global: "globalThis",
},
logLevel: "info",
});
try {
await copyFile(licenseSrc, licenseOut);
} catch (error) {
console.warn("[build-hls] could not copy LICENSE:", error.message);
}
console.log("[build-hls] wrote", path.relative(repoRoot, outFile));
+6
View File
@@ -0,0 +1,6 @@
// Vendor entry for hls.js. We deliberately bundle the FULL build rather than
// `hls.js/light`: the light build drops the alternate-audio stream controller,
// and real-world HLS ladders (e.g. nostube/slidestr publishes an
// `EXT-X-MEDIA:TYPE=AUDIO` rendition group) demux audio into its own playlist.
// With the light build those streams play video-only or not at all.
export { default } from "hls.js";
+632
View File
@@ -0,0 +1,632 @@
// HLS (.m3u8) support — see js/services/hlsPlayback.js.
//
// Scenario spec (SCN-hls-*): bitvid used to attach every hosted source with
// `videoEl.src = url`, which only plays HLS on Safari. Nostr clients publishing
// bitvid's v3 schema with HLS ladders (nostube/slidestr) were unplayable on
// Chrome/Firefox, and the URL-health probe reported every healthy HLS stream as
// offline. These tests pin the observable decisions at those boundaries:
// detection, engine selection, teardown, and the manifest probe.
//
// The hls.js engine itself is never loaded here — the tests exercise the
// native-HLS branch and the fetch-based manifest probe, which are the two paths
// that do not require a real MediaSource implementation.
import { test } from "node:test";
import assert from "node:assert/strict";
import {
HLS_MIME_TYPES,
attachHlsSource,
detachHls,
getAttachedHls,
isHlsMimeType,
isHlsSource,
isHlsUrl,
prefersNativeHls,
probeHlsManifest,
supportsMse,
supportsNativeHls,
} from "../../js/services/hlsPlayback.js";
// --- SCN-hls-detect -------------------------------------------------------
// Given a hosted URL, when its path ends in .m3u8, then it is treated as HLS —
// regardless of query strings (signed CDN URLs) or fragments.
test("isHlsUrl matches .m3u8 paths, including with query/fragment", () => {
assert.equal(
isHlsUrl(
"https://almond.apps2.slidestr.net/3751b84f27234fc8ce3227d132b422f7e00f4a50c6cdc322080fed89a302d7dd.m3u8"
),
true
);
assert.equal(isHlsUrl("https://cdn.example/v.M3U8"), true);
assert.equal(isHlsUrl("https://cdn.example/v.m3u8?token=abc&exp=1"), true);
assert.equal(isHlsUrl("https://cdn.example/v.m3u8#t=10"), true);
assert.equal(isHlsUrl(" https://cdn.example/v.m3u8 "), true);
});
test("isHlsUrl rejects non-playlist URLs and incidental .m3u8 substrings", () => {
assert.equal(isHlsUrl("https://cdn.example/v.mp4"), false);
assert.equal(isHlsUrl("https://cdn.example/v.webm"), false);
// .m3u8 must terminate the PATH, not merely appear somewhere in the URL.
assert.equal(isHlsUrl("https://cdn.example/v.m3u8.mp4"), false);
assert.equal(isHlsUrl("https://cdn.example/?next=/a.m3u8"), false);
assert.equal(isHlsUrl(""), false);
assert.equal(isHlsUrl(null), false);
assert.equal(isHlsUrl(undefined), false);
assert.equal(isHlsUrl(42), false);
});
test("isHlsMimeType covers the canonical and legacy playlist types", () => {
for (const type of HLS_MIME_TYPES) {
assert.equal(isHlsMimeType(type), true, `${type} should be HLS`);
assert.equal(isHlsMimeType(type.toUpperCase()), true);
}
// Parameters must not defeat the match (servers append charset).
assert.equal(isHlsMimeType("application/vnd.apple.mpegurl; charset=utf-8"), true);
assert.equal(isHlsMimeType("video/mp4"), false);
assert.equal(isHlsMimeType("text/plain"), false);
assert.equal(isHlsMimeType(""), false);
assert.equal(isHlsMimeType(null), false);
});
test("isHlsSource accepts either the URL or an imeta mime hint", () => {
assert.equal(isHlsSource({ url: "https://cdn.example/v.m3u8" }), true);
assert.equal(
isHlsSource({ url: "https://cdn.example/stream", mimeType: "application/vnd.apple.mpegurl" }),
true
);
assert.equal(isHlsSource({ url: "https://cdn.example/v.mp4" }), false);
assert.equal(isHlsSource({}), false);
assert.equal(isHlsSource(), false);
});
// --- SCN-hls-engine-choice ------------------------------------------------
// Given a browser that CLAIMS native HLS support via canPlayType, when MSE is
// also available, then hls.js must win. This is not a preference — it is a
// correctness requirement measured in real browsers:
//
// Firefox 146: canPlayType("application/vnd.apple.mpegurl") === ""
// bare <video> → MEDIA_ERR_SRC_NOT_SUPPORTED (code 4)
// Chrome 149: canPlayType(...) === "maybe" ← and it is genuinely true there
//
// canPlayType is only ever a hint, so the engine choice keys off MSE (what
// hls.js actually needs) and falls back to native only when MSE is absent.
const makeFakeVideo = (canPlayTypeResult = "") => ({
src: "",
canPlayType: () => canPlayTypeResult,
});
// Node has no MediaSource; install/remove a fake to drive supportsMse().
const withMediaSource = async (isTypeSupported, fn) => {
const had = "MediaSource" in globalThis;
const original = globalThis.MediaSource;
globalThis.MediaSource = { isTypeSupported };
try {
return await fn();
} finally {
if (had) {
globalThis.MediaSource = original;
} else {
delete globalThis.MediaSource;
}
}
};
test("supportsNativeHls reflects canPlayType for the playlist types", () => {
assert.equal(supportsNativeHls(makeFakeVideo("maybe")), true);
assert.equal(supportsNativeHls(makeFakeVideo("probably")), true);
assert.equal(supportsNativeHls(makeFakeVideo("")), false);
});
test("supportsNativeHls survives a throwing canPlayType", () => {
const hostile = {
canPlayType() {
throw new Error("nope");
},
};
assert.equal(supportsNativeHls(hostile), false);
});
test("supportsMse tracks MediaSource.isTypeSupported", async () => {
assert.equal(supportsMse(), false, "no MediaSource in this environment");
await withMediaSource(() => true, () => {
assert.equal(supportsMse(), true);
});
await withMediaSource(() => false, () => {
assert.equal(supportsMse(), false);
});
await withMediaSource(() => {
throw new Error("boom");
}, () => {
assert.equal(supportsMse(), false, "a throwing check must not propagate");
});
});
test("prefersNativeHls is false whenever MSE can back hls.js", async () => {
const chromeLike = makeFakeVideo("maybe");
await withMediaSource(() => true, () => {
// Chrome's canPlayType says "maybe" — hls.js must still win.
assert.equal(prefersNativeHls(chromeLike), false);
});
// Only with MSE genuinely gone does native become the choice.
assert.equal(prefersNativeHls(chromeLike), true);
assert.equal(prefersNativeHls(makeFakeVideo("")), false, "neither path works");
});
test("attachHlsSource prefers hls.js over a native claim when MSE exists", async () => {
const video = makeFakeVideo("maybe");
const { engine, instances } = makeFakeEngine();
const handle = await withMediaSource(() => true, () =>
attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => engine,
})
);
assert.equal(handle.mode, "mse");
assert.equal(video.src, "", "must not use the native src path");
assert.equal(instances.length, 1);
});
test("attachHlsSource uses native playback when MSE is unavailable", async () => {
const video = makeFakeVideo("maybe");
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8");
assert.ok(handle, "expected a handle");
assert.equal(handle.mode, "native");
assert.equal(video.src, "https://cdn.example/v.m3u8");
// Nothing to destroy on the native path, but destroy() must stay callable.
assert.doesNotThrow(() => handle.destroy());
assert.equal(getAttachedHls(video), null);
});
test("attachHlsSource falls back to native when the engine fails to load", async () => {
const video = makeFakeVideo("maybe");
const handle = await withMediaSource(() => true, () =>
attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => null,
})
);
assert.equal(handle.mode, "native", "an attempt beats a guaranteed failure");
assert.equal(video.src, "https://cdn.example/v.m3u8");
});
test("attachHlsSource trims the URL before assigning it", async () => {
const video = makeFakeVideo("maybe");
await attachHlsSource(video, " https://cdn.example/v.m3u8\n");
assert.equal(video.src, "https://cdn.example/v.m3u8");
});
test("attachHlsSource returns null for missing element or URL", async () => {
assert.equal(await attachHlsSource(null, "https://cdn.example/v.m3u8"), null);
assert.equal(await attachHlsSource(makeFakeVideo("maybe"), ""), null);
assert.equal(await attachHlsSource(makeFakeVideo("maybe"), null), null);
});
// --- MSE test double ------------------------------------------------------
// A minimal stand-in for the hls.js constructor: enough surface for the
// attach/teardown/error contract, with hooks to drive events deterministically.
// `autoManifest` mirrors real hls.js, which parses the manifest asynchronously
// after loadSource(); tests that drive errors turn it off.
const HLS_EVENTS = {
MANIFEST_PARSED: "hlsManifestParsed",
ERROR: "hlsError",
};
const HLS_ERROR_TYPES = {
NETWORK_ERROR: "networkError",
MEDIA_ERROR: "mediaError",
OTHER_ERROR: "otherError",
};
function makeFakeEngine({ autoManifest = true, levels = [{ height: 240 }] } = {}) {
const instances = [];
class FakeHls {
static isSupported() {
return true;
}
static get Events() {
return HLS_EVENTS;
}
static get ErrorTypes() {
return HLS_ERROR_TYPES;
}
constructor(config) {
this.config = config;
this.handlers = new Map();
this.destroyCalls = 0;
this.startLoadCalls = 0;
this.recoverMediaErrorCalls = 0;
this.swapAudioCodecCalls = 0;
this.attachedMedia = null;
this.loadedSource = null;
instances.push(this);
}
on(event, handler) {
if (!this.handlers.has(event)) {
this.handlers.set(event, []);
}
this.handlers.get(event).push(handler);
}
emit(event, data) {
for (const handler of this.handlers.get(event) || []) {
handler(event, data);
}
}
attachMedia(media) {
this.attachedMedia = media;
}
loadSource(url) {
this.loadedSource = url;
if (autoManifest) {
// Real hls.js parses asynchronously; keep that ordering.
queueMicrotask(() => this.emit(HLS_EVENTS.MANIFEST_PARSED, { levels }));
}
}
startLoad() {
this.startLoadCalls += 1;
}
recoverMediaError() {
this.recoverMediaErrorCalls += 1;
}
swapAudioCodec() {
this.swapAudioCodecCalls += 1;
}
destroy() {
this.destroyCalls += 1;
}
}
return { engine: FakeHls, instances };
}
// --- SCN-hls-mse ----------------------------------------------------------
// Given an MSE browser, when a playlist parses with at least one playable
// level, then attach resolves; when hls.js drops every level (e.g. an
// HEVC-only ladder on Firefox) or errors fatally, then attach fails with a
// specific, user-facing message instead of a bare "no playable source".
test("attachHlsSource resolves once the manifest parses, and configures hls.js", async () => {
const video = makeFakeVideo("");
const { engine, instances } = makeFakeEngine();
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => engine,
});
assert.equal(handle.mode, "mse");
// Native `src` must stay untouched — MSE owns the element now.
assert.equal(video.src, "");
// A bounded back-buffer matters for hour-long VOD on low-memory devices.
assert.equal(instances[0].config.backBufferLength, 90);
assert.equal(instances[0].config.enableWorker, true);
});
test("attachHlsSource fails when no rendition survives codec filtering", async () => {
const video = makeFakeVideo("");
const { engine, instances } = makeFakeEngine({ levels: [] });
const messages = [];
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => engine,
onFatalError: (message) => messages.push(message),
});
assert.equal(handle, null);
assert.equal(messages.length, 1);
assert.match(messages[0], /codec your browser supports/i);
assert.equal(instances[0].destroyCalls, 1, "must not leak the instance");
assert.equal(getAttachedHls(video), null);
});
test("attachHlsSource returns null when hls.js is unsupported here", async () => {
const video = makeFakeVideo("");
class Unsupported {
static isSupported() {
return false;
}
}
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => Unsupported,
});
assert.equal(handle, null);
assert.equal(video.src, "", "must not fall back to an unplayable src");
});
test("attachHlsSource returns null when the engine bundle fails to load", async () => {
const video = makeFakeVideo("");
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => null,
});
assert.equal(handle, null);
});
test("a fatal network error retries twice before giving up", async () => {
const video = makeFakeVideo("");
const { engine, instances } = makeFakeEngine({ autoManifest: false });
const messages = [];
const pending = attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => engine,
onFatalError: (message) => messages.push(message),
});
await Promise.resolve();
const instance = instances[0];
const networkError = {
fatal: true,
type: HLS_ERROR_TYPES.NETWORK_ERROR,
details: "manifestLoadError",
};
instance.emit(HLS_EVENTS.ERROR, networkError);
assert.equal(instance.startLoadCalls, 1);
instance.emit(HLS_EVENTS.ERROR, networkError);
assert.equal(instance.startLoadCalls, 2);
// Third fatal network error exhausts the retry budget.
instance.emit(HLS_EVENTS.ERROR, networkError);
assert.equal(instance.startLoadCalls, 2);
assert.equal(await pending, null);
assert.equal(messages.length, 1);
assert.match(messages[0], /network or CORS/i);
assert.equal(instance.destroyCalls, 1);
});
test("a fatal media error recovers, then swaps audio codec, then gives up", async () => {
const video = makeFakeVideo("");
const { engine, instances } = makeFakeEngine({ autoManifest: false });
const messages = [];
const pending = attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => engine,
onFatalError: (message) => messages.push(message),
});
await Promise.resolve();
const instance = instances[0];
const mediaError = {
fatal: true,
type: HLS_ERROR_TYPES.MEDIA_ERROR,
details: "bufferAppendError",
};
instance.emit(HLS_EVENTS.ERROR, mediaError);
assert.equal(instance.recoverMediaErrorCalls, 1);
assert.equal(instance.swapAudioCodecCalls, 0);
instance.emit(HLS_EVENTS.ERROR, mediaError);
assert.equal(instance.recoverMediaErrorCalls, 2);
assert.equal(instance.swapAudioCodecCalls, 1, "second attempt swaps codec");
instance.emit(HLS_EVENTS.ERROR, mediaError);
assert.equal(instance.recoverMediaErrorCalls, 2, "budget exhausted");
assert.equal(await pending, null);
assert.match(messages[0], /codec your browser cannot decode/i);
});
test("non-fatal errors never tear the stream down", async () => {
const video = makeFakeVideo("");
const { engine, instances } = makeFakeEngine({ autoManifest: false });
const messages = [];
const pending = attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => engine,
onFatalError: (message) => messages.push(message),
});
await Promise.resolve();
const instance = instances[0];
instance.emit(HLS_EVENTS.ERROR, {
fatal: false,
type: HLS_ERROR_TYPES.NETWORK_ERROR,
details: "fragLoadError",
});
assert.equal(instance.destroyCalls, 0);
assert.equal(messages.length, 0);
// A transient fragment error must still let the stream come up.
instance.emit(HLS_EVENTS.MANIFEST_PARSED, { levels: [{ height: 720 }] });
const handle = await pending;
assert.equal(handle.mode, "mse");
});
test("a stuck manifest fails via the attach timeout rather than hanging", async () => {
const video = makeFakeVideo("");
const { engine, instances } = makeFakeEngine({ autoManifest: false });
const messages = [];
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => engine,
manifestTimeoutMs: 20,
onFatalError: (message) => messages.push(message),
});
assert.equal(handle, null);
assert.match(messages[0], /did not load in time/i);
assert.equal(instances[0].destroyCalls, 1);
});
test("attachMedia throwing is reported, not propagated", async () => {
const video = makeFakeVideo("");
const { engine } = makeFakeEngine({ autoManifest: false });
const messages = [];
class Hostile extends engine {
attachMedia() {
throw new Error("MediaSource unavailable");
}
}
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => Hostile,
onFatalError: (message) => messages.push(message),
});
assert.equal(handle, null);
assert.match(messages[0], /could not be attached/i);
});
// --- SCN-hls-teardown -----------------------------------------------------
// Given an attached hls.js instance, when the modal tears the element down,
// then destroy() runs exactly once and the element is unbound — otherwise the
// transmuxing worker and segment fetches keep running after the modal closes.
test("detachHls destroys the bound hls.js instance exactly once", async () => {
const video = makeFakeVideo("");
const { engine, instances } = makeFakeEngine();
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => engine,
});
assert.ok(handle, "expected an MSE handle");
assert.equal(handle.mode, "mse");
const instance = instances[0];
assert.equal(getAttachedHls(video), instance);
assert.equal(instance.loadedSource, "https://cdn.example/v.m3u8");
assert.equal(instance.attachedMedia, video);
assert.equal(detachHls(video), true);
assert.equal(instance.destroyCalls, 1);
assert.equal(getAttachedHls(video), null);
// Second detach (e.g. modal teardown after the session already reset) must
// not double-destroy — hls.js throws on a destroyed instance.
assert.equal(detachHls(video), false);
assert.equal(instance.destroyCalls, 1);
});
test("handle.destroy() tears the instance down too", async () => {
const video = makeFakeVideo("");
const { engine, instances } = makeFakeEngine();
const handle = await attachHlsSource(video, "https://cdn.example/v.m3u8", {
createEngine: async () => engine,
});
handle.destroy();
assert.equal(instances[0].destroyCalls, 1);
assert.equal(getAttachedHls(video), null);
});
test("re-attaching to the same element destroys the previous instance", async () => {
const video = makeFakeVideo("");
const { engine, instances } = makeFakeEngine();
await attachHlsSource(video, "https://cdn.example/a.m3u8", {
createEngine: async () => engine,
});
await attachHlsSource(video, "https://cdn.example/b.m3u8", {
createEngine: async () => engine,
});
assert.equal(instances.length, 2);
assert.equal(instances[0].destroyCalls, 1, "first instance must be destroyed");
assert.equal(instances[1].destroyCalls, 0);
assert.equal(instances[1].loadedSource, "https://cdn.example/b.m3u8");
assert.equal(getAttachedHls(video), instances[1]);
});
test("detachHls is a safe no-op for non-HLS elements and null", () => {
assert.equal(detachHls(null), false);
assert.equal(detachHls(undefined), false);
assert.equal(detachHls(makeFakeVideo("")), false);
});
test("getAttachedHls returns null when nothing is bound", () => {
assert.equal(getAttachedHls(makeFakeVideo("")), null);
assert.equal(getAttachedHls(null), null);
});
// --- SCN-hls-probe --------------------------------------------------------
// Given the URL-health badge probing an HLS URL on an MSE browser, when the
// playlist is CORS-readable and starts with #EXTM3U, then the source is "ok".
// A bare <video> probe (the old path) reported every such URL as offline, which
// combined with the magnet-less "unhealthy" stream badge marks the card
// confirmed-dead and hides it from the grid.
const withFetch = async (impl, fn) => {
const original = globalThis.fetch;
globalThis.fetch = impl;
try {
return await fn();
} finally {
globalThis.fetch = original;
}
};
test("probeHlsManifest returns ok for a readable #EXTM3U playlist", async () => {
const result = await withFetch(
async () => ({
ok: true,
text: async () =>
'#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=584301,CODECS="avc1.640015"\nlow.m3u8\n',
}),
() => probeHlsManifest("https://cdn.example/v.m3u8")
);
assert.equal(result, "ok");
});
test("probeHlsManifest tolerates leading whitespace/BOM-ish padding", async () => {
const result = await withFetch(
async () => ({ ok: true, text: async () => "\n\n #EXTM3U\n" }),
() => probeHlsManifest("https://cdn.example/v.m3u8")
);
assert.equal(result, "ok");
});
test("probeHlsManifest reports error for a non-playlist body", async () => {
const result = await withFetch(
async () => ({ ok: true, text: async () => "<html>404</html>" }),
() => probeHlsManifest("https://cdn.example/v.m3u8")
);
assert.equal(result, "error");
});
test("probeHlsManifest reports error for a non-2xx response", async () => {
const result = await withFetch(
async () => ({ ok: false, status: 403, text: async () => "#EXTM3U" }),
() => probeHlsManifest("https://cdn.example/v.m3u8")
);
assert.equal(result, "error");
});
test("probeHlsManifest reports error when the fetch rejects (CORS/DNS)", async () => {
const result = await withFetch(
async () => {
throw new TypeError("Failed to fetch");
},
() => probeHlsManifest("https://cdn.example/v.m3u8")
);
assert.equal(result, "error");
});
test("probeHlsManifest distinguishes a timeout from a hard error", async () => {
const result = await withFetch(
(_url, options) =>
new Promise((_resolve, reject) => {
// Never settles on its own; the probe's own AbortController must fire.
options?.signal?.addEventListener("abort", () => {
reject(new DOMException("Aborted", "AbortError"));
});
}),
() => probeHlsManifest("https://cdn.example/v.m3u8", 30)
);
assert.equal(result, "timeout");
});
test("probeHlsManifest rejects empty input without touching the network", async () => {
let called = false;
const result = await withFetch(
async () => {
called = true;
return { ok: true, text: async () => "#EXTM3U" };
},
() => probeHlsManifest(" ")
);
assert.equal(result, "error");
assert.equal(called, false);
});
@@ -0,0 +1,185 @@
// SCN-stale-media-error
//
// Given the modal <video> is reused across videos, teardownVideoElement() /
// resetVideoElement() clear `src` and call load(). Browsers respond by leaving a
// MEDIA_ERR_SRC_NOT_SUPPORTED ("Empty src attribute") MediaError on the element.
//
// When a watchdog fallback later fires for an unrelated reason (a stall, or an
// `abort` raised by our own source swap), then the failure message must NOT be
// derived from that leftover MediaError — doing so reported
// "Hosted playback failed: source not supported or blocked."
// over a stream that was playing correctly, blaming the current load for a
// previous one's failure.
//
// Then: only a MediaError produced by THIS attempt may be surfaced.
import { describe, test, before, after } from "node:test";
import assert from "node:assert/strict";
import { PlaybackService } from "../../js/services/playbackService.js";
// Minimal <video> double: records listeners so the test can dispatch the exact
// media events the real watchdogs bind to.
function makeVideoStub({ initialError = null } = {}) {
const listeners = new Map();
return {
error: initialError,
src: "",
srcObject: null,
currentTime: 0,
readyState: 0,
networkState: 0,
muted: false,
dataset: {},
listeners,
addEventListener(type, handler) {
if (!listeners.has(type)) listeners.set(type, []);
listeners.get(type).push(handler);
},
removeEventListener(type, handler) {
const arr = listeners.get(type) || [];
const i = arr.indexOf(handler);
if (i >= 0) arr.splice(i, 1);
},
dispatch(type) {
for (const h of [...(listeners.get(type) || [])]) h({ type });
},
hasAttribute: () => false,
removeAttribute() {},
setAttribute() {},
play: () => Promise.resolve(),
pause() {},
load() {},
};
}
const EMPTY_SRC_ERROR = Object.freeze({
code: 4, // MEDIA_ERR_SRC_NOT_SUPPORTED
message: "MEDIA_ELEMENT_ERROR: Empty src attribute",
});
describe("PlaybackService stale MediaError handling", () => {
before(() => {
global.window = {
localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
};
global.document = { createElement: () => makeVideoStub() };
});
after(() => {
delete global.window;
delete global.document;
});
const runWatchdogFallback = async ({ initialError, errorAtFallback }) => {
const video = makeVideoStub({ initialError });
const service = new PlaybackService({
logger: () => {},
urlFirstEnabled: true,
isValidMagnetUri: () => false,
});
const statuses = [];
let fallbackReason = null;
// Drive the watchdog contract directly: this is the boundary where the
// message is chosen, independent of network or codec support.
const cleanup = service.registerUrlPlaybackWatchdogs(video, {
stallMs: 0,
onSuccess: () => {},
onFallback: (reason) => {
fallbackReason = reason;
},
});
video.error = errorAtFallback;
video.dispatch("stalled");
cleanup();
return { statuses, fallbackReason, video };
};
test("a fallback still reports its reason to the caller", async () => {
const { fallbackReason } = await runWatchdogFallback({
initialError: EMPTY_SRC_ERROR,
errorAtFallback: EMPTY_SRC_ERROR,
});
assert.equal(fallbackReason, "stalled");
});
test("the leftover empty-src MediaError is identity-distinguishable", () => {
// The fix hinges on identity: starting a new load clears `error` to null,
// and a genuine new failure allocates a DIFFERENT MediaError object. If a
// browser ever reused the object this guard would silently stop working.
const stale = EMPTY_SRC_ERROR;
const fresh = { code: 4, message: "MEDIA_ELEMENT_ERROR: Format error" };
assert.notEqual(stale, fresh);
assert.equal(stale === EMPTY_SRC_ERROR, true);
});
// Drive a real session to the watchdog-fallback branch with a stale error
// already on the element, and capture what the viewer would be shown.
const runSessionWithStall = async ({ initialError, errorAtFallback }) => {
const video = makeVideoStub({ initialError });
// Fire `stalled` as soon as the session arms its watchdogs — this is the
// spurious fallback the viewer hits while the stream is actually fine.
const originalAdd = video.addEventListener.bind(video);
video.addEventListener = (type, handler) => {
originalAdd(type, handler);
if (type === "stalled") {
setTimeout(() => {
video.error = errorAtFallback;
video.dispatch("stalled");
}, 0);
}
};
const service = new PlaybackService({
logger: () => {},
urlFirstEnabled: true,
isValidMagnetUri: () => false,
});
const statuses = [];
const session = service.createSession({
url: "https://cdn.example/video.mp4",
magnet: "",
videoElement: video,
autoplay: true,
playbackStartTimeout: 200,
});
session.on?.("status", (p) => statuses.push(p.message));
await session.execute();
return statuses;
};
test("a stale empty-src error is NOT blamed on the current load", async () => {
const statuses = await runSessionWithStall({
initialError: EMPTY_SRC_ERROR,
errorAtFallback: EMPTY_SRC_ERROR, // unchanged => stale
});
assert.equal(
statuses.some((m) => /source not supported or blocked/i.test(m)),
false,
`stale error must not be reported; got: ${JSON.stringify(statuses)}`
);
});
test("a MediaError raised by THIS load is still reported", async () => {
// The guard must not silence genuine failures — a new MediaError object
// appearing during the attempt is real and must reach the viewer.
const statuses = await runSessionWithStall({
initialError: null,
errorAtFallback: {
code: 4,
message: "MEDIA_ELEMENT_ERROR: Format error",
},
});
assert.equal(
statuses.some((m) => /source not supported or blocked/i.test(m)),
true,
`genuine error must be reported; got: ${JSON.stringify(statuses)}`
);
});
});
@@ -141,6 +141,68 @@ test(".ogg url WITHOUT an audio imeta is kept (ambiguous → treated as video)",
assert.equal(parsed.url, "https://cdn.example/clip.ogg"); assert.equal(parsed.url, "https://cdn.example/clip.ogg");
}); });
// HLS playlists are a CONTAINER format, not an assertion about content. The
// `audio/mpegurl` / `audio/x-mpegurl` spellings are legacy aliases for an HLS
// playlist of any kind, so they must not vote "audio-only" — otherwise an HLS
// video ladder published with the legacy type is misfiled as a podcast and
// silently dropped from every video feed.
test("HLS playlist events are kept regardless of the playlist mime spelling", () => {
const makeHlsEvent = (mime, id) => ({
id,
content: JSON.stringify({
version: 3,
title: "Why I'm (sort of) not worried about AI",
url: "https://almond.apps2.slidestr.net/3751b84f.m3u8",
videoRootId: `root-${id}`,
}),
tags: [
[
"imeta",
"dim 1280x720",
"url https://almond.apps2.slidestr.net/3751b84f.m3u8",
`m ${mime}`,
"duration 3512",
],
],
});
for (const mime of [
"application/vnd.apple.mpegurl",
"application/x-mpegurl",
"audio/mpegurl",
"audio/x-mpegurl",
"APPLICATION/VND.APPLE.MPEGURL",
]) {
const parsed = convertEventToVideo(makeHlsEvent(mime, `evt-hls-${mime}`));
assert.equal(
parsed.invalid,
false,
`HLS note with "m ${mime}" must not be filtered as audio-only`
);
assert.equal(parsed.url, "https://almond.apps2.slidestr.net/3751b84f.m3u8");
}
});
test("a genuine audio/* variant still filters an HLS-tagged note", () => {
// The playlist type abstains; it must not *rescue* a note that also carries a
// real audio-only variant and no video variant.
const audioHls = {
id: "evt-hls-audio",
content: JSON.stringify({
version: 3,
title: "Podcast, HLS packaged",
url: "https://cdn.example/episode.m3u8",
videoRootId: "root-hls-audio",
}),
tags: [
["imeta", "url https://cdn.example/episode.m3u8", "m application/x-mpegurl"],
["imeta", "url https://cdn.example/episode.mp3", "m audio/mpeg"],
],
};
assert.equal(convertEventToVideo(audioHls).invalid, true);
});
test("a video imeta variant wins even when an audio variant is also present", () => { test("a video imeta variant wins even when an audio variant is also present", () => {
const mixed = { const mixed = {
id: "evt-mixed", id: "evt-mixed",
+28
View File
@@ -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.
+40
View File
@@ -0,0 +1,40 @@
var O=Number.isFinite||function(n){return typeof n=="number"&&isFinite(n)},No=Number.isSafeInteger||function(n){return typeof n=="number"&&Math.abs(n)<=Bo},Bo=Number.MAX_SAFE_INTEGER||9007199254740991,K=(function(n){return n.NETWORK_ERROR="networkError",n.MEDIA_ERROR="mediaError",n.KEY_SYSTEM_ERROR="keySystemError",n.MUX_ERROR="muxError",n.OTHER_ERROR="otherError",n})({}),L=(function(n){return n.KEY_SYSTEM_NO_KEYS="keySystemNoKeys",n.KEY_SYSTEM_NO_ACCESS="keySystemNoAccess",n.KEY_SYSTEM_NO_SESSION="keySystemNoSession",n.KEY_SYSTEM_NO_CONFIGURED_LICENSE="keySystemNoConfiguredLicense",n.KEY_SYSTEM_LICENSE_REQUEST_FAILED="keySystemLicenseRequestFailed",n.KEY_SYSTEM_SERVER_CERTIFICATE_REQUEST_FAILED="keySystemServerCertificateRequestFailed",n.KEY_SYSTEM_SERVER_CERTIFICATE_UPDATE_FAILED="keySystemServerCertificateUpdateFailed",n.KEY_SYSTEM_SESSION_UPDATE_FAILED="keySystemSessionUpdateFailed",n.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED="keySystemStatusOutputRestricted",n.KEY_SYSTEM_STATUS_INTERNAL_ERROR="keySystemStatusInternalError",n.KEY_SYSTEM_DESTROY_MEDIA_KEYS_ERROR="keySystemDestroyMediaKeysError",n.KEY_SYSTEM_DESTROY_CLOSE_SESSION_ERROR="keySystemDestroyCloseSessionError",n.KEY_SYSTEM_DESTROY_REMOVE_SESSION_ERROR="keySystemDestroyRemoveSessionError",n.MANIFEST_LOAD_ERROR="manifestLoadError",n.MANIFEST_LOAD_TIMEOUT="manifestLoadTimeOut",n.MANIFEST_PARSING_ERROR="manifestParsingError",n.MANIFEST_INCOMPATIBLE_CODECS_ERROR="manifestIncompatibleCodecsError",n.LEVEL_EMPTY_ERROR="levelEmptyError",n.LEVEL_LOAD_ERROR="levelLoadError",n.LEVEL_LOAD_TIMEOUT="levelLoadTimeOut",n.LEVEL_PARSING_ERROR="levelParsingError",n.LEVEL_SWITCH_ERROR="levelSwitchError",n.AUDIO_TRACK_LOAD_ERROR="audioTrackLoadError",n.AUDIO_TRACK_LOAD_TIMEOUT="audioTrackLoadTimeOut",n.SUBTITLE_LOAD_ERROR="subtitleTrackLoadError",n.SUBTITLE_TRACK_LOAD_TIMEOUT="subtitleTrackLoadTimeOut",n.FRAG_LOAD_ERROR="fragLoadError",n.FRAG_LOAD_TIMEOUT="fragLoadTimeOut",n.FRAG_DECRYPT_ERROR="fragDecryptError",n.FRAG_PARSING_ERROR="fragParsingError",n.FRAG_GAP="fragGap",n.REMUX_ALLOC_ERROR="remuxAllocError",n.KEY_LOAD_ERROR="keyLoadError",n.KEY_LOAD_TIMEOUT="keyLoadTimeOut",n.BUFFER_ADD_CODEC_ERROR="bufferAddCodecError",n.BUFFER_INCOMPATIBLE_CODECS_ERROR="bufferIncompatibleCodecsError",n.BUFFER_APPEND_ERROR="bufferAppendError",n.BUFFER_APPENDING_ERROR="bufferAppendingError",n.BUFFER_STALLED_ERROR="bufferStalledError",n.BUFFER_FULL_ERROR="bufferFullError",n.BUFFER_SEEK_OVER_HOLE="bufferSeekOverHole",n.BUFFER_NUDGE_ON_STALL="bufferNudgeOnStall",n.ASSET_LIST_LOAD_ERROR="assetListLoadError",n.ASSET_LIST_LOAD_TIMEOUT="assetListLoadTimeout",n.ASSET_LIST_PARSING_ERROR="assetListParsingError",n.INTERSTITIAL_ASSET_ITEM_ERROR="interstitialAssetItemError",n.INTERNAL_EXCEPTION="internalException",n.INTERNAL_ABORTED="aborted",n.ATTACH_MEDIA_ERROR="attachMediaError",n.UNKNOWN="unknown",n})({}),m=(function(n){return n.MEDIA_ATTACHING="hlsMediaAttaching",n.MEDIA_ATTACHED="hlsMediaAttached",n.MEDIA_DETACHING="hlsMediaDetaching",n.MEDIA_DETACHED="hlsMediaDetached",n.MEDIA_ENDED="hlsMediaEnded",n.STALL_RESOLVED="hlsStallResolved",n.BUFFER_RESET="hlsBufferReset",n.BUFFER_CODECS="hlsBufferCodecs",n.BUFFER_CREATED="hlsBufferCreated",n.BUFFER_APPENDING="hlsBufferAppending",n.BUFFER_APPENDED="hlsBufferAppended",n.BUFFER_EOS="hlsBufferEos",n.BUFFERED_TO_END="hlsBufferedToEnd",n.BUFFER_FLUSHING="hlsBufferFlushing",n.BUFFER_FLUSHED="hlsBufferFlushed",n.MANIFEST_LOADING="hlsManifestLoading",n.MANIFEST_LOADED="hlsManifestLoaded",n.MANIFEST_PARSED="hlsManifestParsed",n.LEVEL_SWITCHING="hlsLevelSwitching",n.LEVEL_SWITCHED="hlsLevelSwitched",n.LEVEL_LOADING="hlsLevelLoading",n.LEVEL_LOADED="hlsLevelLoaded",n.LEVEL_UPDATED="hlsLevelUpdated",n.LEVEL_PTS_UPDATED="hlsLevelPtsUpdated",n.LEVELS_UPDATED="hlsLevelsUpdated",n.AUDIO_TRACKS_UPDATED="hlsAudioTracksUpdated",n.AUDIO_TRACK_SWITCHING="hlsAudioTrackSwitching",n.AUDIO_TRACK_SWITCHED="hlsAudioTrackSwitched",n.AUDIO_TRACK_LOADING="hlsAudioTrackLoading",n.AUDIO_TRACK_LOADED="hlsAudioTrackLoaded",n.AUDIO_TRACK_UPDATED="hlsAudioTrackUpdated",n.SUBTITLE_TRACKS_UPDATED="hlsSubtitleTracksUpdated",n.SUBTITLE_TRACKS_CLEARED="hlsSubtitleTracksCleared",n.SUBTITLE_TRACK_SWITCH="hlsSubtitleTrackSwitch",n.SUBTITLE_TRACK_LOADING="hlsSubtitleTrackLoading",n.SUBTITLE_TRACK_LOADED="hlsSubtitleTrackLoaded",n.SUBTITLE_TRACK_UPDATED="hlsSubtitleTrackUpdated",n.SUBTITLE_FRAG_PROCESSED="hlsSubtitleFragProcessed",n.CUES_PARSED="hlsCuesParsed",n.NON_NATIVE_TEXT_TRACKS_FOUND="hlsNonNativeTextTracksFound",n.INIT_PTS_FOUND="hlsInitPtsFound",n.FRAG_LOADING="hlsFragLoading",n.FRAG_LOAD_EMERGENCY_ABORTED="hlsFragLoadEmergencyAborted",n.FRAG_LOADED="hlsFragLoaded",n.FRAG_DECRYPTED="hlsFragDecrypted",n.FRAG_PARSING_INIT_SEGMENT="hlsFragParsingInitSegment",n.FRAG_PARSING_USERDATA="hlsFragParsingUserdata",n.FRAG_PARSING_METADATA="hlsFragParsingMetadata",n.FRAG_PARSED="hlsFragParsed",n.FRAG_BUFFERED="hlsFragBuffered",n.FRAG_CHANGED="hlsFragChanged",n.FPS_DROP="hlsFpsDrop",n.FPS_DROP_LEVELine truncated
Fragment duration: ${i.duration.toFixed(3)}
Time to underbuffer: ${x.toFixed(3)} s
Estimated load time for current fragment: ${U.toFixed(3)} s
Estimated load time for down switch fragment: ${G.toFixed(3)} s
TTFB estimate: ${_|0} ms
Current BW estimate: ${O(b)?b|0:"Unknown"} bps
New BW estimate: ${this.getBwEstimate()|0} bps
Switching to level ${H} @ ${k|0} bps`),a.nextLoadLevel=a.nextAutoLevel=H,this.clearTimer();let q=()=>{if(this.clearTimer(),this.fragCurrent===i&&this.hls.loadLevel===H&&H>0){let J=this.getStarvationDelay();if(this.warn(`Aborting inflight request ${H>0?"and switching down":""}
Fragment duration: ${i.duration.toFixed(3)} s
Time to underbuffer: ${J.toFixed(3)} s`),i.abortRequests(),this.fragCurrent=this.partCurrent=null,H>f){let j=this.findBestLevel(this.hls.levels[f].bitrate,f,H,0,J,1,1);j===-1&&(j=f),this.hls.nextLoadLevel=this.hls.nextAutoLevel=j,this.resetEstimator(this.hls.levels[j].bitrate)}}};p||U>G*2?q():this.timer=self.setInterval(q,G*1e3),a.trigger(m.FRAG_LOAD_EMERGENCY_ABORTED,{frag:i,part:r,stats:h})},this.hls=e,this.bwEstimator=this.initEstimator(),this.registerListeners()}resetEstimator(e){e&&(this.log(`setting initial bwe to ${e}`),this.hls.config.abrEwmaDefaultEstimate=e),this.firstSelection=-1,this.bwEstimator=this.initEstimator()}initEstimator(){let e=this.hls.config;return new di(e.abrEwmaSlowVoD,e.abrEwmaFastVoD,e.abrEwmaDefaultEstimate)}registerListeners(){let{hls:e}=this;e.on(m.MANIFEST_LOADING,this.onManifestLoading,this),e.on(m.FRAG_LOADING,this.onFragLoading,this),e.on(m.FRAG_LOADED,this.onFragLoaded,this),e.on(m.FRAG_BUFFERED,this.onFragBuffered,this),e.on(m.LEVEL_SWITCHING,this.onLevelSwitching,this),e.on(m.LEVEL_LOADED,this.onLevelLoaded,this),e.on(m.LEVELS_UPDATED,this.onLevelsUpdated,this),e.on(m.MAX_AUTO_LEVEL_UPDATED,this.onMaxAutoLevelUpdated,this),e.on(m.ERROR,this.onError,this)}unregisterListeners(){let{hls:e}=this;e&&(e.off(m.MANIFEST_LOADING,this.onManifestLoading,this),e.off(m.FRAG_LOADING,this.onFragLoading,this),e.off(m.FRAG_LOADED,this.onFragLoaded,this),e.off(m.FRAG_BUFFERED,this.onFragBuffered,this),e.off(m.LEVEL_SWITCHING,this.onLevelSwitching,this),e.off(m.LEVEL_LOADED,this.onLevelLoaded,this),e.off(m.LEVELS_UPDATED,this.onLevelsUpdated,this),e.off(m.MAX_AUTO_LEVEL_UPDATED,this.onMaxAutoLevelUpdated,this),e.off(m.ERROR,this.onError,this))}destroy(){this.unregisterListeners(),this.clearTimer(),this.hls=this._abandonRulesCheck=this.supportedCache=null,this.fragCurrent=this.partCurrent=null}onManifestLoading(e,t){this.lastLoadedFragLevel=-1,this.firstSelection=-1,this.lastLevelLoadSec=0,this.supportedCache={},this.fragCurrent=this.partCurrent=null,this.onLevelsUpdated(),this.clearTimer()}onLevelsUpdated(){this.lastLoadedFragLevel>-1&&this.fragCurrent&&(this.lastLoadedFragLevel=this.fragCurrent.level),this._nextAutoLevel=-1,this.onMaxAutoLevelUpdated(),this.codecTiers=null,this.audioTracksByGroup=null}onMaxAutoLevelUpdated(){this.firstSelection=-1,this.nextAutoLevelKey=""}onFragLoading(e,t){let s=t.frag;if(!this.ignoreFragment(s)){if(!s.bitrateTest){var i;this.fragCurrent=s,this.partCurrent=(i=t.part)!=null?i:null}this.clearTimer(),this.timer=self.setInterval(this._abandonRulesCheck,100)}}onLevelSwitching(e,t){this.clearTimer()}onError(e,t){if(!t.fatal)switch(t.details){case L.BUFFER_ADD_CODEC_ERROR:case L.BUFFER_APPEND_ERROR:this.lastLoadedFragLevel=-1,this.firstSelection=-1;break;case L.FRAG_LOAD_TIMEOUT:{let s=t.frag,{fragCurrent:i,partCurrent:r}=this;if(s&&i&&s.sn===i.sn&&s.level===i.level){let a=performance.now(),o=r?r.stats:s.stats,c=a-o.loading.start,l=o.loading.first?o.loading.first-o.loading.start:-1;if(o.loaded&&l>-1){let u=this.bwEstimator.getEstimateTTFB();this.bwEstimator.sample(c-Math.min(u,l),o.loaded)}else this.bwEstimator.sampleTTFB(c)}break}}}getTimeToLoadFrag(e,t,s,i){let r=e+s/t,a=i?e+this.lastLevelLoadSec:0;return r+a}onLevelLoaded(e,t){let s=this.hls.config,{loading:i}=t.stats,r=i.end-i.first;O(r)&&(this.lastLevelLoadSec=r/1e3),t.details.live?this.bwEstimator.update(s.abrEwmaSlowLive,s.abrEwmaFastLive):this.bwEstimator.update(s.abrEwmaSlowVoD,s.abrEwmaFastVoD),this.timer>-1&&this._abandonRulesCheck(t.levelInfo)}onFragLoaded(e,{frag:t,part:s}){let i=s?s.stats:t.stats;if(t.type===N.MAIN&&this.bwEstimator.sampleTTFB(i.loading.first-i.loading.start),!this.ignoreFragment(t)){if(this.clearTimer(),t.level===this._nextAutoLevel&&(this._nextAutoLevel=-1),this.firstSelection=-1,this.hls.config.abrMaxWithRealBitrate){let r=s?s.duration:t.duration,a=this.hls.levels[t.level],o=(a.loaded?a.loaded.bytes:0)+i.loaded,c=(a.loaded?a.loaded.duration:0)+r;a.loaded={bytes:o,duration:c},a.realBitrate=Math.round(8*o/c)}if(t.bitrateTest){let r={stats:i,frag:t,part:s,id:t.type};this.onFragBuffered(m.FRAG_BUFFERED,r),t.bitrateTest=!1}else this.lastLoadedFragLevel=t.level}}onFragBuffered(e,t){let{frag:s,part:i}=t,r=i!=null&&i.stats.loaded?i.stats:s.stats;if(r.aborted||this.ignoreFragment(s))return;let a=r.parsing.end-r.loading.start-Math.min(r.loading.first-r.loading.start,this.bwEstimator.getEstimateTTFB());this.bwEstimator.sample(a,r.loaded),r.bwEstimate=this.getBwEstimate(),s.bitrateTest?this.bitrateTestDelay=a/1e3:this.bitrateTestDelay=0}ignoreFragment(e){return e.type!==N.MAIN||e.sn==="initSegment"}clearTimer(){this.timer>-1&&(self.clearInterval(this.timer),this.timer=-1)}get firstAutoLevel(){let{maxAutoLevel:e,minAutoLevel:t}=this.hls,s=this.getBwEstimate(),i=this.hls.config.maxStarvationDelay,r=this.findBestLevel(s,t,e,0,i,1,1);if(r>-1)return r;let a=this.hls.firstLevel,o=Math.min(Math.max(a,t),e);return this.warn(`Could not find best starting auto level. Defaulting to first in Line truncated
Playlist starting @${e.startSN}
${e.m3u8}
Playlist starting @${t.startSN}
${t.m3u8}`)}function Ua(n,e,t=!0){let s=e.startSN+e.skippedSegments-n.startSN,i=n.fragments,r=s>=0,a=0;if(r&&s<i.length)a=i[s].start;else if(r&&e.startSN===n.endSN+1)a=n.fragmentEnd;else if(r&&t)a=n.fragmentStart+s*e.levelTargetDuration;else if(!e.skippedSegments&&e.fragmentStart===0)a=n.fragmentStart;else return;Pi(e,a)}function Pi(n,e){if(e){let t=n.fragments;for(let s=n.skippedSegments;s<t.length;s++)t[s].addStart(e);n.fragmentHint&&n.fragmentHint.addStart(e)}}function $a(n,e=1/0){let t=1e3*n.targetduration;if(n.updated){let s=n.fragments;if(s.length&&t*4>e){let r=s[s.length-1].duration*1e3;r<t&&(t=r)}}else t/=2;return Math.round(t)}function Ga(n,e,t){if(!n)return null;let s=n.fragments[e-n.startSN];return s||(s=n.fragmentHint,s&&s.sn===e)?s:e<n.startSN&&t&&t.sn===e?t:null}function Fn(n,e,t){return n?Ka(n.partList,e,t):null}function Ka(n,e,t){if(n)for(let s=n.length;s--;){let i=n[s];if(i.index===t&&i.fragment.sn===e)return i}return null}function Va(n){n.forEach((e,t)=>{var s;(s=e.details)==null||s.fragments.forEach(i=>{i.level=t,i.initSegment&&(i.initSegment.level=t)})})}function ac(n,e){return n!==e&&e?Nn(n)!==Nn(e):!1}function Nn(n){return n.replace(/\?[^?]*$/,"")}function Pt(n,e){for(let s=0,i=n.length;s<i;s++){var t;if(((t=n[s])==null?void 0:t.cc)===e)return n[s]}return null}function oc(n,e){return!!(n&&e.startCC<n.endCC&&e.endCC>n.startCC)}function Bn(n,e){let t=n.start+e;n.startPTS=t,n.setStart(t),n.endPTS=t+n.duration}function Ha(n,e){let t=e.fragments;for(let s=0,i=t.length;s<i;s++)Bn(t[s],n);e.fragmentHint&&Bn(e.fragmentHint,n),e.alignedSliding=!0}function lc(n,e){n&&(Ya(e,n),e.alignedSliding||Is(e,n),!e.alignedSliding&&!e.skippedSegments&&Ua(n,e,!1))}function Ya(n,e){if(!oc(e,n))return;let t=Math.min(e.endCC,n.endCC),s=Pt(e.fragments,t),i=Pt(n.fragments,t);if(!s||!i)return;te.log(`Aligning playlist at start of dicontinuity sequence ${t}`);let r=s.start-i.start;Ha(r,n)}function Is(n,e){if(!n.hasProgramDateTime||!e.hasProgramDateTime)return;let t=n.fragments,s=e.fragments;if(!t.length||!s.length)return;let i,r,a=Math.min(e.endCC,n.endCC);e.startCC<a&&n.startCC<a&&(i=Pt(s,a),r=Pt(t,a)),(!i||!r)&&(i=s[Math.floor(s.length/2)],r=Pt(t,i.cc)||t[Math.floor(t.length/2)]);let o=i.programDateTime,c=r.programDateTime;if(!o||!c)return;let l=(c-o)/1e3-(r.start-i.start);Ha(l,n)}function Se(n,e,t){xe(n,e,t),n.addEventListener(e,t)}function xe(n,e,t){n.removeEventListener(e,t)}var cc={toString:function(n){let e="",t=n.length;for(let s=0;s<t;s++)e+=`[${n.start(s).toFixed(3)}-${n.end(s).toFixed(3)}]`;return e}},D={STOPPED:"STOPPED",IDLE:"IDLE",KEY_LOADING:"KEY_LOADING",FRAG_LOADING:"FRAG_LOADING",FRAG_LOADING_WAITING_RETRY:"FRAG_LOADING_WAITING_RETRY",WAITING_TRACK:"WAITING_TRACK",PARSING:"PARSING",PARSED:"PARSED",ENDED:"ENDED",ERROR:"ERROR",WAITING_INIT_PTS:"WAITING_INIT_PTS",WAITING_LEVEL:"WAITING_LEVEL"},Bt=class extends Ss{constructor(e,t,s,i,r){super(i,e.logger),this.hls=void 0,this.fragPrevious=null,this.fragCurrent=null,this.fragmentTracker=void 0,this.transmuxer=null,this._state=D.STOPPED,this.playlistType=void 0,this.media=null,this.mediaBuffer=null,this.config=void 0,this.bitrateTest=!1,this.lastCurrentTime=0,this.nextLoadPosition=0,this.startPosition=0,this.startTimeOffset=null,this.retryDate=0,this.levels=null,this.fragmentLoader=void 0,this.keyLoader=void 0,this.levelLastLoaded=null,this.startFragRequested=!1,this.decrypter=void 0,this.initPTS=[],this.buffering=!0,this.loadingParts=!1,this.loopSn=void 0,this.onMediaSeeking=()=>{let{config:a,fragCurrent:o,media:c,mediaBuffer:l,state:h}=this,u=c?c.currentTime:0,d=W.bufferInfo(l||c,u,a.maxBufferHole),f=!d.len;if(this.log(`Media seeking to ${O(u)?u.toFixed(3):u}, state: ${h}, ${f?"out of":"in"} buffer`),this.state===D.ENDED)this.resetLoadingState();else if(o){let g=a.maxFragLookUpTolerance,y=o.start-g,p=o.start+o.duration+g;if(f||p<d.start||y>d.end){let E=u>p;(u<y||E)&&(E&&o.loader&&(this.log(`Cancelling fragment load for seek (sn: ${o.sn})`),o.abortRequests(),this.resetLoadingState()),this.fragPrevious=null)}}if(c){this.fragmentTracker.removeFragmentsInRange(u,1/0,this.playlistType,!0);let g=this.lastCurrentTime;if(u>g&&(this.lastCurrentTime=u),!this.loadingParts){let y=Math.max(d.end,u),p=this.shouldLoadParts(this.getLevelDetails(),y);p&&(this.log(`LL-Part loading ON after seeking to ${u.toFixed(2)} with buffer @${y.toFixed(2)}`),this.loadingParts=p)}}this.hls.hasEnoughToStart||(this.log(`Setting ${f?"startPosition":"nextLoadPosition"} to ${u} for seek without enough to start`),this.nextLoadPosition=u,f&&(this.startPosition=u)),f&&this.state===D.IDLE&&this.tickImmediate()},this.onMediaEnded=()=>{this.log("setting startPosition to 0 because media ended"),this.startPosition=this.lastCurrentTime=0},this.playlistType=r,this.hls=e,this.fragmentLoader=new Li(e.config),this.keyLoader=s,this.fragmentTracker=t,this.config=e.config,this.decrypter=new Ft(e.config)}registerListeners(){let{hls:e}=this;e.on(m.MEDIA_ATTACHED,this.onMediaAttached,this),e.on(m.MEDIA_DE Line truncated
discontinuity: ${T}
trackSwitch: ${S}
contiguous: ${A}
accurateTimeOffset: ${c}
timeOffset: ${y}
initSegmentChange: ${R}`);let P=new Vi(s,i,t,o,h);this.configureTransmuxer(P)}if(this.frag=r,this.part=a,this.workerContext)this.workerContext.worker.postMessage({instanceNo:f,cmd:"demux",data:e,decryptdata:p,chunkMeta:l,state:I},e instanceof ArrayBuffer?[e]:[]);else if(g){let P=g.push(e,p,l,I);Kt(P)?P.then(M=>{this.handleTransmuxComplete(M)}).catch(M=>{this.transmuxerError(M,l,"transmuxer-interface push error")}):this.handleTransmuxComplete(P)}}flush(e){e.transmuxing.start=self.performance.now();let{instanceNo:t,transmuxer:s}=this;if(this.workerContext)this.workerContext.worker.postMessage({instanceNo:t,cmd:"flush",chunkMeta:e});else if(s){let i=s.flush(e);Kt(i)?i.then(r=>{this.handleFlushResult(r,e)}).catch(r=>{this.transmuxerError(r,e,"transmuxer-interface flush error")}):this.handleFlushResult(i,e)}}transmuxerError(e,t,s){this.hls&&(this.error=e,this.hls.trigger(m.ERROR,{type:K.MEDIA_ERROR,details:L.FRAG_PARSING_ERROR,chunkMeta:t,frag:this.frag||void 0,part:this.part||void 0,fatal:!1,error:e,err:e,reason:s}))}handleFlushResult(e,t){e.forEach(s=>{this.handleTransmuxComplete(s)}),this.onFlush(t)}configureTransmuxer(e){let{instanceNo:t,transmuxer:s}=this;this.workerContext?this.workerContext.worker.postMessage({instanceNo:t,cmd:"configure",config:e}):s&&s.configure(e)}handleTransmuxComplete(e){e.chunkMeta.transmuxing.end=self.performance.now(),this.onTransmuxComplete(e)}},Wn=100,Yi=class extends Bt{constructor(e,t,s){super(e,t,s,"audio-stream-controller",N.AUDIO),this.mainAnchor=null,this.mainFragLoading=null,this.audioOnly=!1,this.bufferedTrack=null,this.switchingTrack=null,this.trackId=-1,this.waitingData=null,this.mainDetails=null,this.flushing=!1,this.bufferFlushed=!1,this.cachedTrackLoadedData=null,this.registerListeners()}onHandlerDestroying(){this.unregisterListeners(),super.onHandlerDestroying(),this.resetItem()}resetItem(){this.mainDetails=this.mainAnchor=this.mainFragLoading=this.bufferedTrack=this.switchingTrack=this.waitingData=this.cachedTrackLoadedData=null}registerListeners(){super.registerListeners();let{hls:e}=this;e.on(m.LEVEL_LOADED,this.onLevelLoaded,this),e.on(m.AUDIO_TRACKS_UPDATED,this.onAudioTracksUpdated,this),e.on(m.AUDIO_TRACK_SWITCHING,this.onAudioTrackSwitching,this),e.on(m.AUDIO_TRACK_LOADED,this.onAudioTrackLoaded,this),e.on(m.BUFFER_RESET,this.onBufferReset,this),e.on(m.BUFFER_CREATED,this.onBufferCreated,this),e.on(m.BUFFER_FLUSHING,this.onBufferFlushing,this),e.on(m.BUFFER_FLUSHED,this.onBufferFlushed,this),e.on(m.INIT_PTS_FOUND,this.onInitPtsFound,this),e.on(m.FRAG_LOADING,this.onFragLoading,this),e.on(m.FRAG_BUFFERED,this.onFragBuffered,this)}unregisterListeners(){let{hls:e}=this;e&&(super.unregisterListeners(),e.off(m.LEVEL_LOADED,this.onLevelLoaded,this),e.off(m.AUDIO_TRACKS_UPDATED,this.onAudioTracksUpdated,this),e.off(m.AUDIO_TRACK_SWITCHING,this.onAudioTrackSwitching,this),e.off(m.AUDIO_TRACK_LOADED,this.onAudioTrackLoaded,this),e.off(m.BUFFER_RESET,this.onBufferReset,this),e.off(m.BUFFER_CREATED,this.onBufferCreated,this),e.off(m.BUFFER_FLUSHING,this.onBufferFlushing,this),e.off(m.BUFFER_FLUSHED,this.onBufferFlushed,this),e.off(m.INIT_PTS_FOUND,this.onInitPtsFound,this),e.off(m.FRAG_LOADING,this.onFragLoading,this),e.off(m.FRAG_BUFFERED,this.onFragBuffered,this))}onInitPtsFound(e,{frag:t,id:s,initPTS:i,timescale:r,trackId:a}){if(s===N.MAIN){let o=t.cc,c=this.fragCurrent;if(this.initPTS[o]={baseTime:i,timescale:r,trackId:a},this.log(`InitPTS for cc: ${o} found from main: ${i/r} (${i}/${r}) trackId: ${a}`),this.mainAnchor=t,this.state===D.WAITING_INIT_PTS){let l=this.waitingData;(!l&&!this.loadingParts||l&&l.frag.cc!==o)&&this.syncWithAnchor(t,l?.frag)}else!this.hls.hasEnoughToStart&&c&&c.cc!==o?(c.abortRequests(),this.syncWithAnchor(t,c)):this.state===D.IDLE&&this.tick()}}getLoadPosition(){return!this.startFragRequested&&this.nextLoadPosition>=0?this.nextLoadPosition:super.getLoadPosition()}syncWithAnchor(e,t){var s;let i=((s=this.mainFragLoading)==null?void 0:s.frag)||null;if(t&&i?.cc===t.cc)return;let r=(i||e).cc,a=this.getLevelDetails(),o=this.getLoadPosition(),c=Da(a,r,o);c&&(this.log(`Syncing with main frag at ${c.start} cc ${c.cc}`),this.startFragRequested=!1,this.nextLoadPosition=c.start,this.resetLoadingState(),this.state===D.IDLE&&this.doTickIdle())}startLoad(e,t){if(!this.levels){this.startPosition=e,this.state=D.STOPPED;return}let s=this.lastCurrentTime;this.stopLoad(),this.setInterval(Wn),s>0&&e===-1?(this.log(`Override startPosition with lastCurrentTime @${s.toFixed(3)}`),e=s,this.state=D.IDLE):this.state=D.WAITING_TRACK,this.nextLoadPosition=this.lastCurrentTime=e+this.timelineOffset,this.startPosition=t?-1:e,this.tick()}doTick(){switch(this.state){case D.IDLE:this.doTickIdle();break;case D.WAITING_TRACK:{let{levels:e,trackId:t}=this,s=e?.[t],i=s?.details;if(i&&!this.waitForLive(s)){if(this.waitForCdnTuneIn(i))break;this.state=D.WAITING_INIT_PTS}break}case D.FRAG_LOADING_WAITING_RETRY:{this.checkRetryDate();break}case D.WAITING_INIT_PTS:{let e Line truncated
${this.list("video")}
${this.list("audio")}
${this.list("audiovideo")}}`}list(e){var t,s;return(t=this.queues)!=null&&t[e]||(s=this.tracks)!=null&&s[e]?`${e}: (${this.listSbInfo(e)}) ${this.listOps(e)}`:""}listSbInfo(e){var t;let s=(t=this.tracks)==null?void 0:t[e],i=s?.buffer;return i?`SourceBuffer${i.updating?" updating":""}${s.ended?" ended":""}${s.ending?" ending":""}`:"none"}listOps(e){var t;return((t=this.queues)==null?void 0:t[e].map(s=>s.label).join(", "))||""}},qn=/(avc[1234]|hvc1|hev1|dvh[1e]|vp09|av01)(?:\.[^.,]+)+/,ho="HlsJsTrackRemovedError",Xi=class extends Error{constructor(e){super(e),this.name=ho}},zi=class extends Ae{constructor(e,t){super("buffer-controller",e.logger),this.hls=void 0,this.fragmentTracker=void 0,this.details=null,this._objectUrl=null,this.operationQueue=null,this.bufferCodecEventsTotal=0,this.media=null,this.mediaSource=null,this.lastMpegAudioChunk=null,this.blockedAudioAppend=null,this.lastVideoAppendEnd=0,this.appendSource=void 0,this.transferData=void 0,this.overrides=void 0,this.appendErrors={audio:0,video:0,audiovideo:0},this.tracks={},this.sourceBuffers=[[null,null],[null,null]],this._onEndStreaming=s=>{var i;this.hls&&((i=this.mediaSource)==null?void 0:i.readyState)==="open"&&this.hls.pauseBuffering()},this._onStartStreaming=s=>{this.hls&&this.hls.resumeBuffering()},this._onMediaSourceOpen=s=>{let{media:i,mediaSource:r}=this;s&&this.log("Media source opened"),!(!i||!r)&&(r.removeEventListener("sourceopen",this._onMediaSourceOpen),i.removeEventListener("emptied",this._onMediaEmptied),this.updateDuration(),this.hls.trigger(m.MEDIA_ATTACHED,{media:i,mediaSource:r}),this.mediaSource!==null&&this.checkPendingTracks())},this._onMediaSourceClose=()=>{this.log("Media source closed")},this._onMediaSourceEnded=()=>{this.log("Media source ended")},this._onMediaEmptied=()=>{let{mediaSrc:s,_objectUrl:i}=this;s!==i&&this.error(`Media element src was set while attaching MediaSource (${i} > ${s})`)},this.hls=e,this.fragmentTracker=t,this.appendSource=Yo(ze(e.config.preferManagedMediaSource)),this.initTracks(),this.registerListeners()}hasSourceTypes(){return Object.keys(this.tracks).length>0}destroy(){this.unregisterListeners(),this.details=null,this.lastMpegAudioChunk=this.blockedAudioAppend=null,this.transferData=this.overrides=void 0,this.operationQueue&&(this.operationQueue.destroy(),this.operationQueue=null),this.hls=this.fragmentTracker=null,this._onMediaSourceOpen=this._onMediaSourceClose=null,this._onMediaSourceEnded=null,this._onStartStreaming=this._onEndStreaming=null}registerListeners(){let{hls:e}=this;e.on(m.MEDIA_ATTACHING,this.onMediaAttaching,this),e.on(m.MEDIA_DETACHING,this.onMediaDetaching,this),e.on(m.MANIFEST_LOADING,this.onManifestLoading,this),e.on(m.MANIFEST_PARSED,this.onManifestParsed,this),e.on(m.BUFFER_RESET,this.onBufferReset,this),e.on(m.BUFFER_APPENDING,this.onBufferAppending,this),e.on(m.BUFFER_CODECS,this.onBufferCodecs,this),e.on(m.BUFFER_EOS,this.onBufferEos,this),e.on(m.BUFFER_FLUSHING,this.onBufferFlushing,this),e.on(m.LEVEL_UPDATED,this.onLevelUpdated,this),e.on(m.FRAG_PARSED,this.onFragParsed,this),e.on(m.FRAG_CHANGED,this.onFragChanged,this),e.on(m.ERROR,this.onError,this)}unregisterListeners(){let{hls:e}=this;e.off(m.MEDIA_ATTACHING,this.onMediaAttaching,this),e.off(m.MEDIA_DETACHING,this.onMediaDetaching,this),e.off(m.MANIFEST_LOADING,this.onManifestLoading,this),e.off(m.MANIFEST_PARSED,this.onManifestParsed,this),e.off(m.BUFFER_RESET,this.onBufferReset,this),e.off(m.BUFFER_APPENDING,this.onBufferAppending,this),e.off(m.BUFFER_CODECS,this.onBufferCodecs,this),e.off(m.BUFFER_EOS,this.onBufferEos,this),e.off(m.BUFFER_FLUSHING,this.onBufferFlushing,this),e.off(m.LEVEL_UPDATED,this.onLevelUpdated,this),e.off(m.FRAG_PARSED,this.onFragParsed,this),e.off(m.FRAG_CHANGED,this.onFragChanged,this),e.off(m.ERROR,this.onError,this)}transferMedia(){let{media:e,mediaSource:t}=this;if(!e)return null;let s={};if(this.operationQueue){let r=this.isUpdating();r||this.operationQueue.removeBlockers();let a=this.isQueued();(r||a)&&this.warn(`Transfering MediaSource with${a?" operations in queue":""}${r?" updating SourceBuffer(s)":""} ${this.operationQueue}`),this.operationQueue.destroy()}let i=this.transferData;return!this.sourceBufferCount&&i&&i.mediaSource===t?se(s,i.tracks):this.sourceBuffers.forEach(r=>{let[a]=r;a&&(s[a]=se({},this.tracks[a]),this.removeBuffer(a)),r[0]=r[1]=null}),{media:e,mediaSource:t,tracks:s}}initTracks(){let e={};this.sourceBuffers=[[null,null],[null,null]],this.tracks=e,this.resetQueue(),this.resetAppendErrors(),this.lastMpegAudioChunk=this.blockedAudioAppend=null,this.lastVideoAppendEnd=0}onManifestLoading(){this.bufferCodecEventsTotal=0,this.details=null}onManifestParsed(e,t){var s;let i=2;(t.audio&&!t.video||!t.altAudio)&&(i=1),this.bufferCodecEventsTotal=i,this.log(`${i} bufferCodec event(s) expected.`),(s=this.transferData)!=null&&s.mediaSource&&this.sourceBufferCount&&i&&this.bufferCreated()}onMediaAttaching(e,t){let s=this.media=t.media;this.transferData=this.overrides=void Line truncated
required tracks: ${re(s,(c,l)=>c==="initSegment"?void 0:l)};
transfer tracks: ${re(i,(c,l)=>c==="initSegment"?void 0:l)}}`),!da(i,s)){t.mediaSource=null,t.tracks=void 0;let c=e.currentTime,l=this.details,h=Math.max(c,l?.fragments[0].start||0);if(h-c>1){this.log(`attachTransferred: waiting for playback to reach new tracks start time ${c} -> ${h}`);return}this.warn(`attachTransferred: resetting MediaSource for incompatible tracks ("${Object.keys(i)}"->"${Object.keys(s)}") start time: ${h} currentTime: ${c}`),this.onMediaDetaching(m.MEDIA_DETACHING,{}),this.onMediaAttaching(m.MEDIA_ATTACHING,t),e.currentTime=h;return}this.transferData=void 0,r.forEach(c=>{let l=c,h=i[l];if(h){let u=h.buffer;if(u){let d=this.fragmentTracker,f=h.id;if(d.hasFragments(f)||d.hasParts(f)){let p=W.getBuffered(u);d.detectEvictedFragments(l,p,f,null,!0)}let g=ti(l),y=[l,u];this.sourceBuffers[g]=y,u.updating&&this.operationQueue&&this.operationQueue.prependBlocker(l),this.trackSourceBuffer(l,h)}}}),o(),this.bufferCreated()}else this.log("attachTransferred: MediaSource w/o SourceBuffers"),o()}get mediaSourceOpenOrEnded(){var e;let t=(e=this.mediaSource)==null?void 0:e.readyState;return t==="open"||t==="ended"}onMediaDetaching(e,t){let s=!!t.transferMedia;this.transferData=this.overrides=void 0;let{media:i,mediaSource:r,_objectUrl:a}=this;if(r){if(this.log(`media source ${s?"transferring":"detaching"}`),s)this.sourceBuffers.forEach(([o])=>{o&&this.removeBuffer(o)}),this.resetQueue();else{if(this.mediaSourceOpenOrEnded){let o=r.readyState==="open";try{let c=r.sourceBuffers;for(let l=c.length;l--;)o&&c[l].abort(),r.removeSourceBuffer(c[l]);o&&r.endOfStream()}catch(c){this.warn(`onMediaDetaching: ${c.message} while calling endOfStream`)}}this.sourceBufferCount&&this.onBufferReset()}r.removeEventListener("sourceopen",this._onMediaSourceOpen),r.removeEventListener("sourceended",this._onMediaSourceEnded),r.removeEventListener("sourceclose",this._onMediaSourceClose),this.appendSource&&(r.removeEventListener("startstreaming",this._onStartStreaming),r.removeEventListener("endstreaming",this._onEndStreaming)),this.mediaSource=null,this._objectUrl=null}i&&(i.removeEventListener("emptied",this._onMediaEmptied),s||(a&&self.URL.revokeObjectURL(a),this.mediaSrc===a?(i.removeAttribute("src"),this.appendSource&&jn(i),i.load()):this.warn("media|source.src was changed by a third party - skip cleanup")),this.media=null),this.hls.trigger(m.MEDIA_DETACHED,t)}onBufferReset(){this.sourceBuffers.forEach(([e])=>{e&&this.resetBuffer(e)}),this.initTracks()}resetBuffer(e){var t;let s=(t=this.tracks[e])==null?void 0:t.buffer;if(this.removeBuffer(e),s)try{var i;(i=this.mediaSource)!=null&&i.sourceBuffers.length&&this.mediaSource.removeSourceBuffer(s)}catch(r){this.warn(`onBufferReset ${e}`,r)}delete this.tracks[e]}removeBuffer(e){this.removeBufferListeners(e),this.sourceBuffers[ti(e)]=[null,null];let t=this.tracks[e];t&&(t.buffer=void 0)}resetQueue(){this.operationQueue&&this.operationQueue.destroy(),this.operationQueue=new ji(this.tracks)}onBufferCodecs(e,t){var s;let i=this.tracks,r=Object.keys(t);this.log(`BUFFER_CODECS: "${r}" (current SB count ${this.sourceBufferCount})`);let a="audiovideo"in t&&(i.audio||i.video)||i.audiovideo&&("audio"in t||"video"in t),o=!a&&this.sourceBufferCount&&this.media&&r.some(c=>!i[c]);if(a||o){this.warn(`Unsupported transition between "${Object.keys(i)}" and "${r}" SourceBuffers`);return}r.forEach(c=>{var l,h;let u=t[c],{id:d,codec:f,levelCodec:g,container:y,metadata:p,supplemental:E}=u,T=i[c],S=(l=this.transferData)==null||(l=l.tracks)==null?void 0:l[c],v=S!=null&&S.buffer?S:T,x=v?.pendingCodec||v?.codec,_=v?.levelCodec;T||(T=i[c]={buffer:void 0,listeners:[],codec:f,supplemental:E,container:y,levelCodec:g,metadata:p,id:d});let A=ns(x,_),b=A?.replace(qn,"$1"),R=ns(f,g),I=(h=R)==null?void 0:h.replace(qn,"$1");R&&A&&b!==I&&(c.slice(0,5)==="audio"&&(R=gs(R,this.appendSource)),this.log(`switching codec ${x} to ${R}`),R!==(T.pendingCodec||T.codec)&&(T.pendingCodec=R),T.container=y,this.appendChangeType(c,y,R))}),(this.tracksReady||this.sourceBufferCount)&&(t.tracks=this.sourceBufferTracks),!this.sourceBufferCount&&(this.bufferCodecEventsTotal>1&&!this.tracks.video&&!t.video&&((s=t.audio)==null?void 0:s.id)==="main"&&(this.log("Main audio-only"),this.bufferCodecEventsTotal=1),this.mediaSourceOpenOrEnded&&this.checkPendingTracks())}get sourceBufferTracks(){return Object.keys(this.tracks).reduce((e,t)=>{let s=this.tracks[t];return e[t]={id:s.id,container:s.container,codec:s.codec,levelCodec:s.levelCodec},e},{})}appendChangeType(e,t,s){let i=`${t};codecs=${s}`,r={label:`change-type=${i}`,execute:()=>{let a=this.tracks[e];if(a){let o=a.buffer;o!=null&&o.changeType&&(this.log(`changing ${e} sourceBuffer type to ${i}`),o.changeType(i),a.codec=s,a.container=t)}this.shiftAndExecuteNext(e)},onStart:()=>{},onComplete:()=>{},onError:a=>{this.warn(`Failed to change ${e} SourceBuffer type`,a)}};this.append(r,e,this.isPending(this.tracks[e]))}blockAudio(e){var t;let s=e.start,i=s+e.duration*.05;if(((t=this.fragmen Line truncated
} (data length: ${t.byteLength})`),s.update(t)}getSelectedKeySystemFormats(){return Object.keys(this.keySystemAccessPromises).map(e=>({keySystem:e,hasMediaKeys:this.keySystemAccessPromises[e].hasMediaKeys})).filter(({hasMediaKeys:e})=>!!e).map(({keySystem:e})=>qs(e)).filter(e=>!!e)}getKeySystemAccess(e){return this.getKeySystemSelectionPromise(e).then(({keySystem:t,mediaKeys:s})=>this.attemptSetMediaKeys(t,s))}selectKeySystem(e){return new Promise((t,s)=>{this.getKeySystemSelectionPromise(e).then(({keySystem:i})=>{let r=qs(i);r?t(r):s(new Error(`Unable to find format for key-system "${i}"`))}).catch(s)})}selectKeySystemFormat(e){let t=Object.keys(e.levelkeys||{});return this.keyFormatPromise||(this.log(`Selecting key-system from fragment (sn: ${e.sn} ${e.type}: ${e.level}) key formats ${t.join(", ")}`),this.keyFormatPromise=this.getKeyFormatPromise(t)),this.keyFormatPromise}getKeyFormatPromise(e){let t=_t(this.config),s=e.map(os).filter(i=>!!i&&t.indexOf(i)!==-1);return this.selectKeySystem(s)}getKeyStatus(e){let{mediaKeySessions:t}=this;for(let s=0;s<t.length;s++){let i=zh(e,t[s]);if(i)return i}}loadKey(e){let t=e.keyInfo.decryptdata,s=es(t),i=this.bannedKeyIds[s];if(i||this.getKeyStatus(t)==="internal-error"){let o=Jn(i||"internal-error",t);return this.handleError(o,e.frag),Promise.reject(o)}let r=`(keyId: ${s} format: "${t.keyFormat}" method: ${t.method} uri: ${t.uri})`;this.log(`Starting session for key ${r}`);let a=this.keyIdToKeySessionPromise[s];if(!a){let o=this.getKeySystemForKeyPromise(t).then(({keySystem:c,mediaKeys:l})=>(this.throwIfDestroyed(),this.log(`Handle encrypted media sn: ${e.frag.sn} ${e.frag.type}: ${e.frag.level} using key ${r}`),this.attemptSetMediaKeys(c,l).then(()=>(this.throwIfDestroyed(),this.createMediaKeySessionContext({keySystem:c,mediaKeys:l,decryptdata:t}))))).then(c=>{let l="cenc",h=t.pssh?t.pssh.buffer:null;return this.generateRequestWithPreferredKeySession(c,l,h,"playlist-key")});return o.catch(c=>this.handleError(c,e.frag)),this.keyIdToKeySessionPromise[s]=o,o}return a.catch(o=>{if(o instanceof Te){let c=ee({},o.data);this.getKeyStatus(t)==="internal-error"&&(c.decryptdata=t);let l=new Te(c,o.message);this.handleError(l,e.frag)}}),a}throwIfDestroyed(e="Invalid state"){if(!this.hls)throw new Error("invalid state")}handleError(e,t){if(this.hls)if(e instanceof Te){t&&(e.data.frag=t);let s=e.data.decryptdata;this.error(`${e.message}${s?` (${me(s.keyId||[])})`:""}`),this.hls.trigger(m.ERROR,e.data)}else this.error(e.message),this.hls.trigger(m.ERROR,{type:K.KEY_SYSTEM_ERROR,details:L.KEY_SYSTEM_NO_KEYS,error:e,fatal:!0})}getKeySystemForKeyPromise(e){let t=es(e),s=this.keyIdToKeySessionPromise[t];if(!s){let i=os(e.keyFormat),r=i?[i]:_t(this.config);return this.attemptKeySystemAccess(r)}return s}getKeySystemSelectionPromise(e){if(e.length||(e=_t(this.config)),e.length===0)throw new Te({type:K.KEY_SYSTEM_ERROR,details:L.KEY_SYSTEM_NO_CONFIGURED_LICENSE,fatal:!0},`Missing key-system license configuration options ${re({drmSystems:this.config.drmSystems})}`);return this.attemptKeySystemAccess(e)}attemptSetMediaKeys(e,t){if(this.mediaResolved=void 0,this.mediaKeys===t)return Promise.resolve();let s=this.setMediaKeysQueue.slice();this.log(`Setting media-keys for "${e}"`);let i=Promise.all(s).then(()=>this.media?this.media.setMediaKeys(t):new Promise((r,a)=>{this.mediaResolved=()=>{if(this.mediaResolved=void 0,!this.media)return a(new Error("Attempted to set mediaKeys without media element attached"));this.mediaKeys=t,this.media.setMediaKeys(t).then(r).catch(a)}}));return this.mediaKeys=t,this.setMediaKeysQueue.push(i),i.then(()=>{this.log(`Media-keys set for "${e}"`),s.push(i),this.setMediaKeysQueue=this.setMediaKeysQueue.filter(r=>s.indexOf(r)===-1)})}generateRequestWithPreferredKeySession(e,t,s,i){var r;let a=(r=this.config.drmSystems)==null||(r=r[e.keySystem])==null?void 0:r.generateRequest;if(a)try{let g=a.call(this.hls,t,s,e);if(!g)throw new Error("Invalid response from configured generateRequest filter");t=g.initDataType,s=g.initData?g.initData:null,e.decryptdata.pssh=s?new Uint8Array(s):null}catch(g){if(this.warn(g.message),this.hls&&this.hls.config.debug)throw g}if(s===null)return this.log(`Skipping key-session request for "${i}" (no initData)`),Promise.resolve(e);let o=es(e.decryptdata),c=e.decryptdata.uri;this.log(`Generating key-session request for "${i}" keyId: ${o} URI: ${c} (init data type: ${t} length: ${s.byteLength})`);let l=new Br,h=e._onmessage=g=>{let y=e.mediaKeysSession;if(!y){l.emit("error",new Error("invalid state"));return}let{messageType:p,message:E}=g;this.log(`"${p}" message event for session "${y.sessionId}" message size: ${E.byteLength}`),p==="license-request"||p==="license-renewal"?this.renewLicense(e,E).catch(T=>{l.eventNames().length?l.emit("error",T):this.handleError(T)}):p==="license-release"?e.keySystem===ae.FAIRPLAY&&this.updateKeySession(e,_i("acknowledged")).then(()=>this.removeSession(e)).catch(T=>this.handleError(T)):this.warn(`unhandlLine truncated
Schedule: ${c.map(y=>ke(y))} pos: ${this.timelinePos}`),h.length&&this.log(`Removed events ${h}`);let d=null,f=null;a&&(d=this.updateItem(a,this.timelinePos),this.itemsMatch(a,d)?this.playingItem=d:this.waitingItem=this.endedItem=null),this.waitingItem=this.updateItem(this.waitingItem),this.endedItem=this.updateItem(this.endedItem);let g=this.bufferingItem;if(g&&(f=this.updateItem(g,this.bufferedPos),this.itemsMatch(g,f)?this.bufferingItem=f:g.event&&(this.bufferingItem=this.playingItem,this.clearInterstitial(g.event,null))),s.forEach(y=>{y.assetList.forEach(p=>{this.clearAssetPlayer(p.identifier,null)})}),this.playerQueue.forEach(y=>{if(y.interstitial.appendInPlace){let p=y.assetItem.timelineStart,E=y.timelineOffset-p;if(E)try{y.timelineOffset=p}catch(T){Math.abs(E)>vt&&this.warn(`${T} ("${y.assetId}" ${y.timelineOffset}->${p})`)}}}),u||i){if(this.hls.trigger(m.INTERSTITIALS_UPDATED,{events:o.slice(0),schedule:c.slice(0),durations:l,removedIds:h}),this.isInterstitial(a)&&h.includes(a.event.identifier)){this.warn(`Interstitial "${a.event.identifier}" removed while playing`),this.primaryFallback(a.event);return}a&&this.trimInPlace(d,a),g&&f!==d&&this.trimInPlace(f,g),this.checkBuffer()}},this.hls=e,this.HlsPlayerClass=t,this.assetListLoader=new cr(e),this.schedule=new lr(this.onScheduleUpdate,e.logger),this.registerListeners()}registerListeners(){let e=this.hls;e&&(e.on(m.MEDIA_ATTACHING,this.onMediaAttaching,this),e.on(m.MEDIA_ATTACHED,this.onMediaAttached,this),e.on(m.MEDIA_DETACHING,this.onMediaDetaching,this),e.on(m.MANIFEST_LOADING,this.onManifestLoading,this),e.on(m.LEVEL_UPDATED,this.onLevelUpdated,this),e.on(m.AUDIO_TRACK_SWITCHING,this.onAudioTrackSwitching,this),e.on(m.AUDIO_TRACK_UPDATED,this.onAudioTrackUpdated,this),e.on(m.SUBTITLE_TRACK_SWITCH,this.onSubtitleTrackSwitch,this),e.on(m.SUBTITLE_TRACK_UPDATED,this.onSubtitleTrackUpdated,this),e.on(m.EVENT_CUE_ENTER,this.onInterstitialCueEnter,this),e.on(m.ASSET_LIST_LOADED,this.onAssetListLoaded,this),e.on(m.BUFFER_APPENDED,this.onBufferAppended,this),e.on(m.BUFFER_FLUSHED,this.onBufferFlushed,this),e.on(m.BUFFERED_TO_END,this.onBufferedToEnd,this),e.on(m.MEDIA_ENDED,this.onMediaEnded,this),e.on(m.ERROR,this.onError,this),e.on(m.DESTROYING,this.onDestroying,this))}unregisterListeners(){let e=this.hls;e&&(e.off(m.MEDIA_ATTACHING,this.onMediaAttaching,this),e.off(m.MEDIA_ATTACHED,this.onMediaAttached,this),e.off(m.MEDIA_DETACHING,this.onMediaDetaching,this),e.off(m.MANIFEST_LOADING,this.onManifestLoading,this),e.off(m.LEVEL_UPDATED,this.onLevelUpdated,this),e.off(m.AUDIO_TRACK_SWITCHING,this.onAudioTrackSwitching,this),e.off(m.AUDIO_TRACK_UPDATED,this.onAudioTrackUpdated,this),e.off(m.SUBTITLE_TRACK_SWITCH,this.onSubtitleTrackSwitch,this),e.off(m.SUBTITLE_TRACK_UPDATED,this.onSubtitleTrackUpdated,this),e.off(m.EVENT_CUE_ENTER,this.onInterstitialCueEnter,this),e.off(m.ASSET_LIST_LOADED,this.onAssetListLoaded,this),e.off(m.BUFFER_CODECS,this.onBufferCodecs,this),e.off(m.BUFFER_APPENDED,this.onBufferAppended,this),e.off(m.BUFFER_FLUSHED,this.onBufferFlushed,this),e.off(m.BUFFERED_TO_END,this.onBufferedToEnd,this),e.off(m.MEDIA_ENDED,this.onMediaEnded,this),e.off(m.ERROR,this.onError,this),e.off(m.DESTROYING,this.onDestroying,this))}startLoad(){this.resumeBuffering()}stopLoad(){this.pauseBuffering()}resumeBuffering(){var e;(e=this.getBufferingPlayer())==null||e.resumeBuffering()}pauseBuffering(){var e;(e=this.getBufferingPlayer())==null||e.pauseBuffering()}destroy(){this.unregisterListeners(),this.stopLoad(),this.assetListLoader&&this.assetListLoader.destroy(),this.emptyPlayerQueue(),this.clearScheduleState(),this.schedule&&this.schedule.destroy(),this.media=this.detachedData=this.mediaSelection=this.requiredTracks=this.altSelection=this.schedule=this.manager=null,this.hls=this.HlsPlayerClass=this.log=null,this.assetListLoader=null,this.onPlay=this.onPause=this.onSeeking=this.onTimeupdate=null,this.onScheduleUpdate=null}onDestroying(){let e=this.primaryMedia||this.media;e&&this.removeMediaListeners(e)}removeMediaListeners(e){xe(e,"play",this.onPlay),xe(e,"pause",this.onPause),xe(e,"seeking",this.onSeeking),xe(e,"timeupdate",this.onTimeupdate)}onMediaAttaching(e,t){let s=this.media=t.media;Se(s,"seeking",this.onSeeking),Se(s,"timeupdate",this.onTimeupdate),Se(s,"play",this.onPlay),Se(s,"pause",this.onPause)}onMediaAttached(e,t){let s=this.effectivePlayingItem,i=this.detachedData;if(this.detachedData=null,s===null)this.checkStart();else if(!i){this.clearScheduleState();let r=this.findItemIndex(s);this.setSchedulePosition(r)}}clearScheduleState(){this.log("clear schedule state"),this.playingItem=this.bufferingItem=this.waitingItem=this.endedItem=this.playingAsset=this.endedAsset=this.bufferingAsset=null}onMediaDetaching(e,t){let s=!!t.transferMedia,i=this.media;if(this.media=null,!s&&(i&&this.removeMediaListeners(i),this.detachedData)){let r=this.getBufferingPlayer();r&&(this.log(`Removing schedule state for detachedData and ${r}`),this.playingAsset=this.e Line truncated
`)),s}getTextAndFormat(){return this.rows}},Ms=class{constructor(e,t,s){this.chNr=void 0,this.outputFilter=void 0,this.mode=void 0,this.verbose=void 0,this.displayedMemory=void 0,this.nonDisplayedMemory=void 0,this.lastOutputScreen=void 0,this.currRollUpRow=void 0,this.writeScreen=void 0,this.cueStartTime=void 0,this.logger=void 0,this.chNr=e,this.outputFilter=t,this.mode=null,this.verbose=0,this.displayedMemory=new kt(s),this.nonDisplayedMemory=new kt(s),this.lastOutputScreen=new kt(s),this.currRollUpRow=this.displayedMemory.rows[we-1],this.writeScreen=this.displayedMemory,this.mode=null,this.cueStartTime=null,this.logger=s}reset(){this.mode=null,this.displayedMemory.reset(),this.nonDisplayedMemory.reset(),this.lastOutputScreen.reset(),this.outputFilter.reset(),this.currRollUpRow=this.displayedMemory.rows[we-1],this.writeScreen=this.displayedMemory,this.mode=null,this.cueStartTime=null}getHandler(){return this.outputFilter}setHandler(e){this.outputFilter=e}setPAC(e){this.writeScreen.setPAC(e)}setBkgData(e){this.writeScreen.setBkgData(e)}setMode(e){e!==this.mode&&(this.mode=e,this.logger.log(2,()=>"MODE="+e),this.mode==="MODE_POP-ON"?this.writeScreen=this.nonDisplayedMemory:(this.writeScreen=this.displayedMemory,this.writeScreen.reset()),this.mode!=="MODE_ROLL-UP"&&(this.displayedMemory.nrRollUpRows=null,this.nonDisplayedMemory.nrRollUpRows=null),this.mode=e)}insertChars(e){for(let s=0;s<e.length;s++)this.writeScreen.insertChar(e[s]);let t=this.writeScreen===this.displayedMemory?"DISP":"NON_DISP";this.logger.log(2,()=>t+": "+this.writeScreen.getDisplayText(!0)),(this.mode==="MODE_PAINT-ON"||this.mode==="MODE_ROLL-UP")&&(this.logger.log(1,()=>"DISPLAYED: "+this.displayedMemory.getDisplayText(!0)),this.outputDataUpdate())}ccRCL(){this.logger.log(2,"RCL - Resume Caption Loading"),this.setMode("MODE_POP-ON")}ccBS(){this.logger.log(2,"BS - BackSpace"),this.mode!=="MODE_TEXT"&&(this.writeScreen.backSpace(),this.writeScreen===this.displayedMemory&&this.outputDataUpdate())}ccAOF(){}ccAON(){}ccDER(){this.logger.log(2,"DER- Delete to End of Row"),this.writeScreen.clearToEndOfRow(),this.outputDataUpdate()}ccRU(e){this.logger.log(2,"RU("+e+") - Roll Up"),this.writeScreen=this.displayedMemory,this.setMode("MODE_ROLL-UP"),this.writeScreen.setRollUpRows(e)}ccFON(){this.logger.log(2,"FON - Flash On"),this.writeScreen.setPen({flash:!0})}ccRDC(){this.logger.log(2,"RDC - Resume Direct Captioning"),this.setMode("MODE_PAINT-ON")}ccTR(){this.logger.log(2,"TR"),this.setMode("MODE_TEXT")}ccRTD(){this.logger.log(2,"RTD"),this.setMode("MODE_TEXT")}ccEDM(){this.logger.log(2,"EDM - Erase Displayed Memory"),this.displayedMemory.reset(),this.outputDataUpdate(!0)}ccCR(){this.logger.log(2,"CR - Carriage Return"),this.writeScreen.rollUp(),this.outputDataUpdate(!0)}ccENM(){this.logger.log(2,"ENM - Erase Non-displayed Memory"),this.nonDisplayedMemory.reset()}ccEOC(){if(this.logger.log(2,"EOC - End Of Caption"),this.mode==="MODE_POP-ON"){let e=this.displayedMemory;this.displayedMemory=this.nonDisplayedMemory,this.nonDisplayedMemory=e,this.writeScreen=this.nonDisplayedMemory,this.logger.log(1,()=>"DISP: "+this.displayedMemory.getDisplayText())}this.outputDataUpdate(!0)}ccTO(e){this.logger.log(2,"TO("+e+") - Tab Offset"),this.writeScreen.moveCursor(e)}ccMIDROW(e){let t={flash:!1};if(t.underline=e%2===1,t.italics=e>=46,t.italics)t.foreground="white";else{let s=Math.floor(e/2)-16,i=["white","green","blue","cyan","red","yellow","magenta"];t.foreground=i[s]}this.logger.log(2,"MIDROW: "+re(t)),this.writeScreen.setPen(t)}outputDataUpdate(e=!1){let t=this.logger.time;t!==null&&this.outputFilter&&(this.cueStartTime===null&&!this.displayedMemory.isEmpty()?this.cueStartTime=t:this.displayedMemory.equals(this.lastOutputScreen)||(this.outputFilter.newCue(this.cueStartTime,t,this.lastOutputScreen),e&&this.outputFilter.dispatchCue&&this.outputFilter.dispatchCue(),this.cueStartTime=this.displayedMemory.isEmpty()?null:t),this.lastOutputScreen.copy(this.displayedMemory))}cueSplitAtTime(e){this.outputFilter&&(this.displayedMemory.isEmpty()||(this.outputFilter.newCue&&this.outputFilter.newCue(this.cueStartTime,e,this.displayedMemory),this.cueStartTime=e))}},Os=class{constructor(e,t,s){this.channels=void 0,this.currentChannel=0,this.cmdHistory=cu(),this.logger=void 0;let i=this.logger=new fr;this.channels=[null,new Ms(e,t,i),new Ms(e+1,s,i)]}getHandler(e){return this.channels[e].getHandler()}setHandler(e,t){this.channels[e].setHandler(t)}addData(e,t){this.logger.time=e;for(let s=0;s<t.length;s+=2){let i=t[s]&127,r=t[s+1]&127,a=!1,o=null;if(i===0&&r===0)continue;this.logger.log(3,()=>"["+st([t[s],t[s+1]])+"] -> ("+st([i,r])+")");let c=this.cmdHistory;if(i>=16&&i<=31){if(lu(i,r,c)){ts(null,null,c),this.logger.log(3,()=>"Repeated command ("+st([i,r])+") is dropped");continue}ts(i,r,this.cmdHistory),a=this.parseCmd(i,r),a||(a=this.parseMidrow(i,r)),a||(a=this.parsePAC(i,r)),a||(a=this.parseBackgroundAttributes(i,r))}else ts(null,null,c);if(!a&&(o=this.parseChars(i,r) Line truncated
`)}var Tr=class{constructor(){this.state="INITIAL",this.buffer="",this.decoder=new pr,this.regionList=[],this.cue=null,this.oncue=void 0,this.onparsingerror=void 0,this.onflush=void 0}parse(e){let t=this;e&&(t.buffer+=t.decoder.decode(e,{stream:!0}));function s(){let r=t.buffer,a=0;for(r=_o(r);a<r.length&&r[a]!=="\r"&&r[a]!==`
`;)++a;let o=r.slice(0,a);return r[a]==="\r"&&++a,r[a]===`
`&&++a,t.buffer=r.slice(a),o}function i(r){bo(r,function(a,o){},/:/)}try{let r="";if(t.state==="INITIAL"){if(!/\r\n|\n/.test(t.buffer))return this;r=s();let o=r.match(/^()?WEBVTT([ \t].*)?$/);if(!(o!=null&&o[0]))throw new Error("Malformed WebVTT signature.");t.state="HEADER"}let a=!1;for(;t.buffer;){if(!/\r\n|\n/.test(t.buffer))return this;switch(a?a=!1:r=s(),t.state){case"HEADER":/:/.test(r)?i(r):r||(t.state="ID");continue;case"NOTE":r||(t.state="ID");continue;case"ID":if(/^NOTE($|[ \t])/.test(r)){t.state="NOTE";break}if(!r)continue;if(t.cue=new Wr(0,0,""),t.state="CUE",r.indexOf("-->")===-1){t.cue.id=r;continue}case"CUE":if(!t.cue){t.state="BADCUE";continue}try{hu(r,t.cue,t.regionList)}catch{t.cue=null,t.state="BADCUE";continue}t.state="CUETEXT";continue;case"CUETEXT":{let o=r.indexOf("-->")!==-1;if(!r||o&&(a=!0)){t.oncue&&t.cue&&t.oncue(t.cue),t.cue=null,t.state="ID";continue}if(t.cue===null)continue;t.cue.text&&(t.cue.text+=`
`),t.cue.text+=r}continue;case"BADCUE":r||(t.state="ID")}}}catch{t.state==="CUETEXT"&&t.cue&&t.oncue&&t.oncue(t.cue),t.cue=null,t.state=t.state==="INITIAL"?"BADWEBVTT":"BADCUE"}return this}flush(){let e=this;try{if((e.cue||e.state==="HEADER")&&(e.buffer+=`
`,e.parse()),e.state==="INITIAL"||e.state==="BADWEBVTT")throw new Error("Malformed WebVTT signature.")}catch(t){e.onparsingerror&&e.onparsingerror(t)}return e.onflush&&e.onflush(),this}},uu=/\r\n|\n\r|\n|\r/g,ri=function(e,t,s=0){return e.slice(s,s+t.length)===t},du=function(e){let t=parseInt(e.slice(-3)),s=parseInt(e.slice(-6,-4)),i=parseInt(e.slice(-9,-7)),r=e.length>9?parseInt(e.substring(0,e.indexOf(":"))):0;if(!O(t)||!O(s)||!O(i)||!O(r))throw Error(`Malformed X-TIMESTAMP-MAP: Local:${e}`);return t+=1e3*s,t+=60*1e3*i,t+=3600*1e3*r,t};function qr(n,e,t){return Ct(n.toString())+Ct(e.toString())+Ct(t)}var fu=function(e,t,s){let i=e[t],r=e[i.prevCC];if(!r||!r.new&&i.new){e.ccOffset=e.presentationOffset=i.start,i.new=!1;return}for(;(a=r)!=null&&a.new;){var a;e.ccOffset+=i.start-r.start,i.new=!1,i=r,r=e[i.prevCC]}e.presentationOffset=s};function gu(n,e,t,s,i,r,a){let o=new Tr,c=be(new Uint8Array(n)).trim().replace(uu,`
`).split(`
`),l=[],h=e?Yc(e.baseTime,e.timescale):0,u="00:00.000",d=0,f=0,g,y=!0;o.oncue=function(p){let E=t[s],T=t.ccOffset,S=(d-h)/9e4;if(E!=null&&E.new&&(f!==void 0?T=t.ccOffset=E.start:fu(t,s,S)),S){if(!e){g=new Error("Missing initPTS for VTT MPEGTS");return}T=S-t.presentationOffset}let v=p.endTime-p.startTime,x=Le((p.startTime+T-f)*9e4,i*9e4)/9e4;p.startTime=Math.max(x,0),p.endTime=Math.max(x+v,0);let _=p.text.trim();p.text=decodeURIComponent(encodeURIComponent(_)),p.id||(p.id=qr(p.startTime,p.endTime,_)),p.endTime>0&&l.push(p)},o.onparsingerror=function(p){g=p},o.onflush=function(){if(g){a(g);return}r(l)},c.forEach(p=>{if(y)if(ri(p,"X-TIMESTAMP-MAP=")){y=!1,p.slice(16).split(",").forEach(E=>{ri(E,"LOCAL:")?u=E.slice(6):ri(E,"MPEGTS:")&&(d=parseInt(E.slice(7)))});try{f=du(u)/1e3}catch(E){g=E}return}else p===""&&(y=!1);o.parse(p+`
`)}),o.flush()}var ni="stpp.ttml.im1t",Do=/^(\d{2,}):(\d{2}):(\d{2}):(\d{2})\.?(\d+)?$/,Po=/^(\d*(?:\.\d*)?)(h|m|s|ms|f|t)$/,mu={left:"start",center:"center",right:"end",start:"start",end:"end"};function ia(n,e,t,s){let i=X(new Uint8Array(n),["mdat"]);if(i.length===0){s(new Error("Could not parse IMSC1 mdat"));return}let r=i.map(o=>be(o)),a=Hc(e.baseTime,1,e.timescale);try{r.forEach(o=>t(pu(o,a)))}catch(o){s(o)}}function pu(n,e){let i=new DOMParser().parseFromString(n,"text/xml").getElementsByTagName("tt")[0];if(!i)throw new Error("Invalid ttml");let r={frameRate:30,subFrameRate:1,frameRateMultiplier:0,tickRate:0},a=Object.keys(r).reduce((u,d)=>(u[d]=i.getAttribute(`ttp:${d}`)||r[d],u),{}),o=i.getAttribute("xml:space")!=="preserve",c=ra(ai(i,"styling","style")),l=ra(ai(i,"layout","region")),h=ai(i,"body","[begin]");return[].map.call(h,u=>{let d=Co(u,o);if(!d||!u.hasAttribute("begin"))return null;let f=li(u.getAttribute("begin"),a),g=li(u.getAttribute("dur"),a),y=li(u.getAttribute("end"),a);if(f===null)throw na(u);if(y===null){if(g===null)throw na(u);y=f+g}let p=new Wr(f-e,y-e,d);p.id=qr(p.startTime,p.endTime,p.text);let E=l[u.getAttribute("region")],T=c[u.getAttribute("style")],S=yu(E,T,c),{textAlign:v}=S;if(v){let x=mu[v];x&&(p.lineAlign=x),p.align=v}return se(p,S),p}).filter(u=>u!==null)}function ai(n,e,t){let s=n.getElementsByTagName(e)[0];return s?[].slice.call(s.querySelectorAll(t)):[]}function ra(n){return n.reduce((e,t)=>{let s=t.getAttribute("xml:id");return s&&(e[s]=t),e},{})}function Co(n,e){return[].slice.call(n.childNodes).reduce((t,s,i)=>{var r;return s.nodeName==="br"&&i?t+`
`:(r=s.childNodes)!=null&&r.length?Co(s,e):e?t+s.textContent.trim().replace(/\s+/g," "):t+s.textContent},"")}function yu(n,e,t){let s="http://www.w3.org/ns/ttml#styling",i=null,r=["displayAlign","textAlign","color","backgroundColor","fontSize","fontFamily"],a=n!=null&&n.hasAttribute("style")?n.getAttribute("style"):null;return a&&t.hasOwnProperty(a)&&(i=t[a]),r.reduce((o,c)=>{let l=oi(e,s,c)||oi(n,s,c)||oi(i,s,c);return l&&(o[c]=l),o},{})}function oi(n,e,t){return n&&n.hasAttributeNS(e,t)?n.getAttributeNS(e,t):null}function na(n){return new Error(`Could not parse ttml timestamp ${n}`)}function li(n,e){if(!n)return null;let t=Ro(n);return t===null&&(Do.test(n)?t=Eu(n,e):Po.test(n)&&(t=Tu(n,e))),t}function Eu(n,e){let t=Do.exec(n),s=(t[4]|0)+(t[5]|0)/e.subFrameRate;return(t[1]|0)*3600+(t[2]|0)*60+(t[3]|0)+s/e.frameRate}function Tu(n,e){let t=Po.exec(n),s=Number(t[1]);switch(t[2]){case"h":return s*3600;case"m":return s*60;case"ms":return s*1e3;case"f":return s/e.frameRate;case"t":return s/e.tickRate}return s}var yt=class{constructor(e,t){this.timelineController=void 0,this.cueRanges=[],this.trackName=void 0,this.startTime=null,this.endTime=null,this.screen=null,this.timelineController=e,this.trackName=t}dispatchCue(){this.startTime!==null&&(this.timelineController.addCues(this.trackName,this.startTime,this.endTime,this.screen,this.cueRanges),this.startTime=null)}newCue(e,t,s){(this.startTime===null||this.startTime>e)&&(this.startTime=e),this.endTime=t,this.screen=s,this.timelineController.createCaptionsTrack(this.trackName)}reset(){this.cueRanges=[],this.startTime=null}},Sr=class{constructor(e){this.hls=void 0,this.media=null,this.config=void 0,this.enabled=!0,this.Cues=void 0,this.textTracks=[],this.tracks=[],this.initPTS=[],this.unparsedVttFrags=[],this.captionsTracks={},this.nonNativeCaptionsTracks={},this.cea608Parser1=void 0,this.cea608Parser2=void 0,this.lastCc=-1,this.lastSn=-1,this.lastPartIndex=-1,this.prevCC=-1,this.vttCCs=oa(),this.captionsProperties=void 0,this.hls=e,this.config=e.config,this.Cues=e.config.cueHandler,this.captionsProperties={textTrack1:{label:this.config.captionsTextTrack1Label,languageCode:this.config.captionsTextTrack1LanguageCode},textTrack2:{label:this.config.captionsTextTrack2Label,languageCode:this.config.captionsTextTrack2LanguageCode},textTrack3:{label:this.config.captionsTextTrack3Label,languageCode:this.config.captionsTextTrack3LanguageCode},textTrack4:{label:this.config.captionsTextTrack4Label,languageCode:this.config.captionsTextTrack4LanguageCode}},e.on(m.MEDIA_ATTACHING,this.onMediaAttaching,this),e.on(m.MEDIA_DETACHING,this.onMediaDetaching,this),e.on(m.MANIFEST_LOADING,this.onManifestLoading,this),e.on(m.MANIFEST_LOADED,this.onManifestLoaded,this),e.on(m.SUBTITLE_TRACKS_UPDATED,this.onSubtitleTracksUpdated,this),e.on(m.FRAG_LOADING,this.onFragLoading,this),e.on(m.FRAG_LOADED,this.onFragLoaded,this),e.on(m.FRAG_PARSING_USERDATA,this.onFragParsingUserdata,this),e.on(m.FRAG_DECRYPTED,this.onFragDecrypted,this),e.on(m.INIT_PTS_FOUND,this.onInitPtsFound,this),e.on(m.SUBTITLE_TRACKS_CLEARED,this.onSubtitleTracksCleared,this),e.on(m.BUFFER_FLUSHING,this.onBufferFlushing,this)}destroy(){let{hls:e}=this;e.off(m.MEDIA_ATTACHING,this.onMediaAttaching,this),e.off(m.MEDIA_DETACHING,this.onMediaDetaching,this),e.off(m.MANIFEST_LOADING,this.onManifestLoading,this),e.off(m.MANIFEST_LOADED,this.onManifestLoaded,this),e.off(m.SUBTITLE_TRACKS_UPDATED,this.onSubtitleTracksUpdated,this),e.off(m.FRAG_LOADING,this.onFragLoading,this),e.off(m.FRAG_LOADED,this.onFragLoaded,this),e.off(m.FRAG_PARSING_USERDATA,this.onFragParsingUserdata,this),e.off(m.FRAG_DECRYPTED,this.onFragDecrypted,this),e.off(m.INIT_PTS_FOUND,this.onInitPtsFound,this),e.off(m.SUBTITLE_TRACKS_CLEARED,this.onSubtitleTracksCleared,this),e.off(m.BUFFER_FLUSHING,this.onBufferFlushing,this),this.hls=this.config=this.media=null,this.cea608Parser1=this.cea608Parser2=void 0}initCea608Parsers(){let e=new yt(this,"textTrack1"),t=new yt(this,"textTrack2"),s=new yt(this,"textTrack3"),i=new yt(this,"textTrack4");this.cea608Parser1=new Os(1,e,t),this.cea608Parser2=new Os(3,s,i)}addCues(e,t,s,i,r){let a=!1;for(let o=r.length;o--;){let c=r[o],l=Su(c[0],c[1],t,s);if(l>=0&&(c[0]=Math.min(c[0],t),c[1]=Math.max(c[1],s),a=!0,l/(s-t)>.5))return}if(a||r.push([t,s]),this.config.renderTextTracksNatively){let o=this.captionsTracks[e];this.Cues.newCue(o,t,s,i)}else{let o=this.Cues.newCue(null,t,s,i);this.hls.trigger(m.CUES_PARSED,{type:"captions",cues:o,track:e})}}onInitPtsFound(e,{frag:t,id:s,initPTS:i,timescale:r,trackId:a}){let{unparsedVttFrags:o}=this;s===N.MAIN&&(this.initPTS[t.cc]={baseTime:i,timescale:r,trackId:a}),o.length&&(this.unparsedVttFrags=[],o.forEach(c=>{this.initPTS[c.frag.cc]?this.onFragLoaded(m.FRAG_LOADED,c):this.hls.trigger(m.SUBTITLE_FRAG_PROCESSED,{success:!1,frag:c.frag,error:new Error("Subtitle discontinuity domain does not match main")})}))}getExistingTrack(e,t){let{media:s}=this;if(s)for(let i=0;i<s.textTracks.length;i+ Line truncated