// 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 //