mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
[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:
1 parent
726c0d26ed
commit
4404728410
17 files changed
+1720
-11
No files matched your search
@@ -94,8 +94,133 @@ sequenceDiagram
|
||||
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
|
||||
|
||||
- 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.
|
||||
|
||||
|
||||
## 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.
|
||||
@@ -3,11 +3,13 @@
|
||||
/**
|
||||
* Modal open/close lifecycle and controller routing.
|
||||
*
|
||||
* All module-level dependencies are injected from the Application
|
||||
* composition root rather than imported at module scope.
|
||||
* Stateful collaborators (clients, services, app state) are injected from the
|
||||
* 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.
|
||||
*/
|
||||
import { detachHls } from "../services/hlsPlayback.js";
|
||||
|
||||
/**
|
||||
* @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()}`
|
||||
);
|
||||
|
||||
// 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(() => {
|
||||
|
||||
@@ -3,11 +3,17 @@
|
||||
/**
|
||||
* URL-first + magnet fallback playback pipeline.
|
||||
*
|
||||
* All module-level dependencies are injected from the Application
|
||||
* composition root rather than imported at module scope.
|
||||
* Stateful collaborators (clients, services, app state) are injected from the
|
||||
* 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.
|
||||
*/
|
||||
import {
|
||||
attachHlsSource,
|
||||
detachHls,
|
||||
isHlsUrl,
|
||||
} from "../services/hlsPlayback.js";
|
||||
|
||||
/**
|
||||
* Normalize a deep-link / caller "force this playback source" value to the
|
||||
@@ -355,7 +361,23 @@ export function createPlaybackCoordinator(deps) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
await target.play();
|
||||
|
||||
@@ -189,6 +189,17 @@ export const DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD =
|
||||
export const DEFAULT_TRUST_SEED_NPUBS = SANITIZED_DEFAULT_TRUST_SEED_NPUBS;
|
||||
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({
|
||||
URL_FIRST_ENABLED: DEFAULT_PLAYBACK_SOURCE !== "torrent", // try URL before magnet in the player
|
||||
FEATURE_WATCH_HISTORY_V2: true,
|
||||
|
||||
@@ -1465,6 +1465,17 @@ function inferMimeTypeFromUrl(url) {
|
||||
// (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
|
||||
// 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 = []) {
|
||||
let hasVideo = false;
|
||||
let hasAudio = false;
|
||||
@@ -1482,6 +1493,9 @@ function detectImetaMediaKinds(tags = []) {
|
||||
continue;
|
||||
}
|
||||
const mime = trimmed.replace(/^m\s+/i, "").trim().toLowerCase();
|
||||
if (HLS_PLAYLIST_MIME_PATTERN.test(mime.split(";")[0].trim())) {
|
||||
continue;
|
||||
}
|
||||
if (mime.startsWith("video/")) {
|
||||
hasVideo = true;
|
||||
} else if (mime.startsWith("audio/")) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
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 {
|
||||
consumeAutoplayFallbackMute,
|
||||
@@ -9,6 +12,7 @@ import {
|
||||
SimpleEventEmitter,
|
||||
extractWebSeedsFromMagnet,
|
||||
} from "./playbackHelpers.js";
|
||||
import { attachHlsSource, detachHls, isHlsUrl } from "./hlsPlayback.js";
|
||||
// js/services/playbackService.js
|
||||
|
||||
/**
|
||||
@@ -855,6 +859,10 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
|
||||
const resetVideoElement = () => {
|
||||
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 {
|
||||
activeVideoEl.pause();
|
||||
} 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 } = {}) => {
|
||||
this.registerWatchdogs(activeVideoEl, {
|
||||
stallMs,
|
||||
@@ -1071,7 +1084,17 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
attachWatchdogs({ stallMs: 0 });
|
||||
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) {
|
||||
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) => {
|
||||
this.service.log(
|
||||
"[playVideoWithFallback] Direct URL playback threw:",
|
||||
@@ -1091,6 +1111,14 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
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 {
|
||||
// Guarantee the direct load is CORS-free regardless of how we got
|
||||
// here (see resetVideoElement) so any hosted URL plays reliably.
|
||||
@@ -1100,8 +1128,57 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
// ignore
|
||||
}
|
||||
activeVideoEl.crossOrigin = null;
|
||||
|
||||
// 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;
|
||||
const playPromise = activeVideoEl.play();
|
||||
}
|
||||
} 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") {
|
||||
playPromise.catch((err) => {
|
||||
if (err?.name === "NotAllowedError") {
|
||||
@@ -1215,9 +1292,15 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
let lastUrlResult = null;
|
||||
for (let i = 0; i < this.hostedSourceCandidates.length; i += 1) {
|
||||
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(
|
||||
attemptHostedPlayback(candidateUrl),
|
||||
effectiveTimeout,
|
||||
candidateTimeout,
|
||||
"URL Playback"
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { SHORT_TIMEOUT_MS } from "../constants.js";
|
||||
import {
|
||||
isHlsUrl,
|
||||
prefersNativeHls,
|
||||
probeHlsManifest,
|
||||
} from "../services/hlsPlayback.js";
|
||||
|
||||
export default class UrlHealthController {
|
||||
constructor({
|
||||
@@ -275,6 +280,21 @@ export default class UrlHealthController {
|
||||
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) {
|
||||
await new Promise((resolve) => {
|
||||
this.videoProbeQueue.push(resolve);
|
||||
|
||||
Generated
+8
@@ -25,6 +25,7 @@
|
||||
"blossom-client-sdk": "5.0.0",
|
||||
"cssnano": "^7.1.2",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"hls.js": "^1.6.16",
|
||||
"jsdom": "^28.1.0",
|
||||
"nostr-wasm": "^0.1.0",
|
||||
"pixelmatch": "^5.3.0",
|
||||
@@ -3252,6 +3253,13 @@
|
||||
"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": {
|
||||
"version": "1.15.1",
|
||||
"resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"build:beacon": "npm run build:beacon:bundle && npm run lint:inline-styles",
|
||||
"build:bitcoin-connect": "node scripts/build-bitcoin-connect.mjs",
|
||||
"build:blossom-sdk": "node scripts/build-blossom-sdk.mjs",
|
||||
"build:hls": "node scripts/build-hls.mjs",
|
||||
"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",
|
||||
"audit": "node scripts/daily-design-system-audit.mjs",
|
||||
@@ -65,6 +66,7 @@
|
||||
"blossom-client-sdk": "5.0.0",
|
||||
"cssnano": "^7.1.2",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"hls.js": "^1.6.16",
|
||||
"jsdom": "^28.1.0",
|
||||
"nostr-wasm": "^0.1.0",
|
||||
"pixelmatch": "^5.3.0",
|
||||
|
||||
@@ -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));
|
||||
Vendored
+6
@@ -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";
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
// 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", () => {
|
||||
const mixed = {
|
||||
id: "evt-mixed",
|
||||
|
||||
Vendored
+28
@@ -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.
|
||||
Vendored
+40
@@ -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
|
||||
Reference in new issue
Block a user