Mercurial
diff mrjunejune/src/tools/hls_player/hls-player.js @ 242:543df0fe7168
[tools] Add full HLS player support
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Mon, 03 Aug 2026 13:14:41 -0700 |
| parents | |
| children |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/src/tools/hls_player/hls-player.js Mon Aug 03 13:14:41 2026 -0700 @@ -0,0 +1,672 @@ +(function hlsPlayerModule(global) { + "use strict"; + + const DEFAULT_CODECS = "vp09.00.10.08,opus"; + const HLS_MIME_TYPES = [ + "application/vnd.apple.mpegurl", + "application/x-mpegURL", + ]; + + function meaningfulLines(text) { + return text + .split(/\r?\n/) + .map(line => line.trim()) + .filter(Boolean); + } + + function parseAttributeList(value) { + const attributes = {}; + const expression = /([A-Z0-9-]+)=("[^"]*"|[^,]*)/g; + let match; + while ((match = expression.exec(value)) !== null) { + const rawValue = match[2]; + attributes[match[1]] = rawValue.startsWith('"') + ? rawValue.slice(1, -1) + : rawValue; + } + return attributes; + } + + function resolveUri(value, baseUrl) { + return new URL(value, baseUrl).href; + } + + function normalizePlaylistUrl(value, baseUrl) { + const url = new URL(value, baseUrl); + if (url.protocol !== "http:" && + url.protocol !== "https:" && + url.protocol !== "blob:") { + throw new Error("HLS playlist URL must use HTTP, HTTPS, or a local file."); + } + return url.href; + } + + function normalizeLocalPath(path) { + const output = []; + for (const part of path.replace(/\\/g, "/").split("/")) { + if (!part || part === ".") continue; + if (part === "..") { + if (!output.length) throw new Error("Local playlist path escapes its selected folder."); + output.pop(); + } else { + output.push(part); + } + } + return output.join("/"); + } + + function localRelativePath(currentPath, reference) { + const cleanReference = reference.split(/[?#]/)[0]; + if (/^https?:\/\//i.test(cleanReference) || + cleanReference.startsWith("blob:")) { + return null; + } + const slash = currentPath.lastIndexOf("/"); + const rootRelative = cleanReference.startsWith("/"); + const directory = !rootRelative && slash >= 0 + ? currentPath.slice(0, slash + 1) + : ""; + return normalizeLocalPath( + `${directory}${decodeURIComponent(cleanReference).replace(/^\/+/, "")}`, + ); + } + + async function rewritePlaylistUris(text, resolveReference) { + const output = []; + for (const originalLine of text.split(/\r?\n/)) { + const line = originalLine.trim(); + if (line.startsWith("#")) { + let rewrittenLine = originalLine; + const references = [ + ...originalLine.matchAll(/URI="([^"]+)"/g), + ].map(match => match[1]); + for (const reference of references) { + rewrittenLine = rewrittenLine.replace( + `URI="${reference}"`, + `URI="${await resolveReference(reference)}"`, + ); + } + output.push(rewrittenLine); + } else if (line && !line.startsWith("#")) { + output.push(await resolveReference(line)); + } else { + output.push(originalLine); + } + } + return output.join("\n"); + } + + async function createLocalHlsUrl(fileList) { + const files = Array.from(fileList || []); + if (!files.length) throw new Error("Choose an HLS playlist and its media files."); + + const byPath = new Map(); + const byName = new Map(); + for (const file of files) { + const relativePath = normalizeLocalPath(file.webkitRelativePath || file.name); + byPath.set(relativePath, file); + const basename = relativePath.split("/").pop(); + if (!byName.has(basename)) byName.set(basename, file); + else byName.set(basename, null); + } + + const playlists = [...byPath.entries()].filter(([filePath]) => + filePath.toLowerCase().endsWith(".m3u8") + ); + if (!playlists.length) throw new Error("No .m3u8 playlist was selected."); + const master = + playlists.find(([filePath]) => /(^|\/)master\.m3u8$/i.test(filePath)) || + playlists.find(([filePath]) => /stream\.m3u8$/i.test(filePath)) || + playlists[0]; + + const objectUrls = []; + const assetUrls = new Map(); + const playlistUrls = new Map(); + const resolving = new Set(); + + const rememberUrl = blob => { + const url = URL.createObjectURL(blob); + objectUrls.push(url); + return url; + }; + + const findFile = (reference, currentPath) => { + const relativePath = localRelativePath(currentPath, reference); + if (relativePath === null) return null; + const direct = byPath.get(relativePath); + if (direct) return [relativePath, direct]; + const basename = relativePath.split("/").pop(); + const unique = byName.get(basename); + if (unique) { + const uniquePath = [...byPath.entries()].find(([, file]) => file === unique)[0]; + return [uniquePath, unique]; + } + throw new Error(`Local HLS file is missing: ${reference}`); + }; + + const materializePlaylist = async (filePath, file) => { + if (playlistUrls.has(filePath)) return playlistUrls.get(filePath); + if (resolving.has(filePath)) throw new Error("Local HLS playlists contain a cycle."); + resolving.add(filePath); + try { + const rewritten = await rewritePlaylistUris( + await file.text(), + async reference => { + if (/^https?:\/\//i.test(reference) || + reference.startsWith("blob:")) return reference; + const [resolvedPath, resolvedFile] = findFile(reference, filePath); + if (resolvedPath.toLowerCase().endsWith(".m3u8")) { + return materializePlaylist(resolvedPath, resolvedFile); + } + if (!assetUrls.has(resolvedPath)) { + assetUrls.set(resolvedPath, rememberUrl(resolvedFile)); + } + return assetUrls.get(resolvedPath); + }, + ); + const url = rememberUrl(new Blob( + [rewritten], + { type: "application/vnd.apple.mpegurl" }, + )); + playlistUrls.set(filePath, url); + return url; + } finally { + resolving.delete(filePath); + } + }; + + try { + const url = await materializePlaylist(master[0], master[1]); + return { + url, + playlistName: master[0], + revoke() { + for (const objectUrl of objectUrls) URL.revokeObjectURL(objectUrl); + objectUrls.length = 0; + }, + }; + } catch (error) { + for (const objectUrl of objectUrls) URL.revokeObjectURL(objectUrl); + throw error; + } + } + + function parseMasterPlaylist(text, baseUrl) { + const lines = meaningfulLines(text); + const variants = []; + for (let index = 0; index < lines.length; index++) { + if (!lines[index].startsWith("#EXT-X-STREAM-INF:")) continue; + const attributes = parseAttributeList( + lines[index].slice("#EXT-X-STREAM-INF:".length), + ); + const uri = lines[index + 1]; + if (!uri || uri.startsWith("#")) { + throw new Error("HLS variant is missing its playlist URL."); + } + variants.push({ + url: resolveUri(uri, baseUrl), + bandwidth: Number(attributes.BANDWIDTH || 0), + codecs: attributes.CODECS || "", + resolution: attributes.RESOLUTION || "", + }); + index++; + } + return variants; + } + + function parseMediaPlaylist(text, baseUrl) { + const lines = meaningfulLines(text); + const segments = []; + let initSegment = null; + let duration = null; + let totalDuration = 0; + let endList = false; + + for (const line of lines) { + if (line.startsWith("#EXT-X-KEY:")) { + const attributes = parseAttributeList(line.slice("#EXT-X-KEY:".length)); + if ((attributes.METHOD || "NONE") !== "NONE") { + throw new Error("Encrypted HLS playlists are not supported by the JavaScript fallback."); + } + } else if (line.startsWith("#EXT-X-BYTERANGE")) { + throw new Error("Byte-range HLS playlists are not supported by the JavaScript fallback."); + } else if (line.startsWith("#EXT-X-MAP:")) { + const attributes = parseAttributeList(line.slice("#EXT-X-MAP:".length)); + if (!attributes.URI) throw new Error("HLS initialization segment URL is missing."); + initSegment = resolveUri(attributes.URI, baseUrl); + } else if (line.startsWith("#EXTINF:")) { + duration = Number(line.slice("#EXTINF:".length).split(",")[0]); + if (!Number.isFinite(duration)) throw new Error("Invalid HLS segment duration."); + } else if (line === "#EXT-X-ENDLIST") { + endList = true; + } else if (!line.startsWith("#")) { + if (duration === null) continue; + segments.push({ + url: resolveUri(line, baseUrl), + duration, + }); + totalDuration += duration; + duration = null; + } + } + + return { initSegment, segments, totalDuration, endList }; + } + + function chooseVariant(variants, isSupported = () => true) { + const supported = variants.filter(isSupported); + if (!supported.length) return null; + return supported.reduce((best, current) => + current.bandwidth > best.bandwidth ? current : best + ); + } + + function once(target, eventName, errorName) { + return new Promise((resolve, reject) => { + const cleanup = () => { + target.removeEventListener(eventName, onEvent); + if (errorName) target.removeEventListener(errorName, onError); + }; + const onEvent = event => { + cleanup(); + resolve(event); + }; + const onError = () => { + cleanup(); + reject(new Error(`Media event failed: ${errorName}`)); + }; + target.addEventListener(eventName, onEvent, { once: true }); + if (errorName) target.addEventListener(errorName, onError, { once: true }); + }); + } + + async function appendBuffer(sourceBuffer, bytes) { + sourceBuffer.appendBuffer(bytes); + await once(sourceBuffer, "updateend", "error"); + } + + class HlsPlayer { + constructor(video, options = {}) { + if (!video) throw new Error("A video element is required."); + this.video = video; + this.statusElement = options.statusElement || null; + this.detailsElement = options.detailsElement || null; + this.abortController = null; + this.mediaSource = null; + this.objectUrl = null; + this.hls = null; + this.generation = 0; + } + + setStatus(message, state = "loading") { + if (!this.statusElement) return; + this.statusElement.textContent = message; + this.statusElement.dataset.state = state; + } + + setDetails(details) { + if (!this.detailsElement) return; + for (const [key, value] of Object.entries(details)) { + const target = this.detailsElement.querySelector(`[data-detail="${key}"]`); + if (target) target.textContent = String(value); + } + this.detailsElement.hidden = false; + } + + destroy() { + this.generation++; + if (this.abortController) this.abortController.abort(); + this.abortController = null; + if (this.hls) this.hls.destroy(); + this.hls = null; + this.video.pause(); + this.video.removeAttribute("src"); + this.video.load(); + if (this.objectUrl) URL.revokeObjectURL(this.objectUrl); + this.objectUrl = null; + this.mediaSource = null; + } + + async fetchText(url, signal) { + const response = await fetch(url, { signal, cache: "no-store" }); + if (!response.ok) { + throw new Error(`Playlist request failed (${response.status}).`); + } + return response.text(); + } + + async fetchBytes(url, signal) { + const response = await fetch(url, { signal, cache: "no-store" }); + if (!response.ok) { + throw new Error(`Media request failed (${response.status}): ${url}`); + } + return response.arrayBuffer(); + } + + nativeHlsSupported() { + return HLS_MIME_TYPES.some(type => this.video.canPlayType(type) !== ""); + } + + hlsJsSupported() { + return Boolean(global.Hls && global.Hls.isSupported()); + } + + async loadWithHlsJs(playlistUrl, generation, signal) { + const Hls = global.Hls; + const hls = new Hls({ enableWorker: false }); + this.hls = hls; + this.setStatus("Loading HLS manifest..."); + + const manifest = await new Promise((resolve, reject) => { + let manifestData = null; + let mediaReady = this.video.readyState >= 2; + let settled = false; + + const cleanup = () => { + hls.off(Hls.Events.MEDIA_ATTACHED, onMediaAttached); + hls.off(Hls.Events.MANIFEST_PARSED, onManifestParsed); + this.video.removeEventListener("loadeddata", onLoadedData); + this.video.removeEventListener("error", onMediaError); + signal.removeEventListener("abort", onAbort); + }; + const finish = (callback, value) => { + if (settled) return; + settled = true; + cleanup(); + callback(value); + }; + const maybeResolve = () => { + if (manifestData && mediaReady) finish(resolve, manifestData); + }; + const onMediaAttached = () => hls.loadSource(playlistUrl); + const onManifestParsed = (_event, data) => { + manifestData = data; + maybeResolve(); + }; + const onLoadedData = () => { + mediaReady = true; + maybeResolve(); + }; + const onMediaError = () => { + finish(reject, new Error("The browser could not decode this HLS stream.")); + }; + const onHlsError = (_event, data) => { + if (!data.fatal) return; + const reason = data.error?.message || + data.reason || + data.details || + data.type || + "unknown error"; + const error = new Error(`HLS playback failed: ${reason}`); + if (!settled) { + finish(reject, error); + return; + } + if (generation === this.generation && this.hls === hls) { + hls.destroy(); + this.hls = null; + this.setStatus(error.message, "error"); + } + }; + const onAbort = () => { + const error = new Error("HLS loading was cancelled."); + error.name = "AbortError"; + finish(reject, error); + }; + + hls.on(Hls.Events.MEDIA_ATTACHED, onMediaAttached); + hls.on(Hls.Events.MANIFEST_PARSED, onManifestParsed); + hls.on(Hls.Events.ERROR, onHlsError); + this.video.addEventListener("loadeddata", onLoadedData); + this.video.addEventListener("error", onMediaError); + signal.addEventListener("abort", onAbort, { once: true }); + hls.attachMedia(this.video); + }); + + if (generation !== this.generation || signal.aborted) return; + const selectedLevelIndex = hls.currentLevel >= 0 + ? hls.currentLevel + : hls.loadLevel; + const selectedLevel = hls.levels[selectedLevelIndex] || hls.levels[0] || {}; + const codecs = [ + selectedLevel.videoCodec, + selectedLevel.audioCodec, + ].filter(Boolean).join(", ") || "Detected from stream"; + const live = !Number.isFinite(this.video.duration); + + if (!live && this.video.duration > 0 && this.video.seekable.length) { + this.video.currentTime = Math.min(0.05, this.video.duration); + await once(this.video, "seeked", "error"); + } + if (generation !== this.generation || signal.aborted) return; + + this.setDetails({ + mode: "hls.js", + segments: manifest.levels?.length > 1 + ? `${manifest.levels.length} adaptive levels` + : "Managed by hls.js", + duration: live ? "Live" : `${this.video.duration.toFixed(1)} seconds`, + codecs, + }); + this.setStatus( + live ? "Live stream ready. Press play." : "Stream ready. Press play.", + "ready", + ); + } + + async load(inputUrl, options = {}) { + this.destroy(); + const generation = this.generation; + this.abortController = new AbortController(); + const signal = this.abortController.signal; + try { + const playlistUrl = normalizePlaylistUrl( + inputUrl, + global.location?.href || "http://localhost/", + ); + this.setStatus("Loading playlist..."); + if (this.nativeHlsSupported() && !options.forceMediaSource) { + this.video.src = playlistUrl; + await once(this.video, "loadedmetadata", "error"); + if (generation !== this.generation || signal.aborted) return; + this.setDetails({ + mode: "Native HLS", + segments: "Managed by browser", + duration: Number.isFinite(this.video.duration) + ? `${this.video.duration.toFixed(1)} seconds` + : "Live", + codecs: "Managed by browser", + }); + this.setStatus("Stream ready.", "ready"); + return; + } + + if (this.hlsJsSupported()) { + await this.loadWithHlsJs(playlistUrl, generation, signal); + return; + } + + if (!global.MediaSource) { + throw new Error("This browser does not support native HLS or MediaSource playback."); + } + + let mediaPlaylistUrl = playlistUrl; + let codecs = ""; + let playlistText = await this.fetchText(mediaPlaylistUrl, signal); + const variants = parseMasterPlaylist(playlistText, mediaPlaylistUrl); + if (variants.length) { + const variant = chooseVariant(variants, candidate => { + const candidateCodecs = candidate.codecs || DEFAULT_CODECS; + return global.MediaSource.isTypeSupported( + `video/mp4; codecs="${candidateCodecs}"`, + ); + }); + if (!variant) { + throw new Error("No HLS variant uses a codec supported by this browser."); + } + mediaPlaylistUrl = variant.url; + codecs = variant.codecs; + this.setStatus(`Loading ${variant.bandwidth || "selected"} bps variant...`); + playlistText = await this.fetchText(mediaPlaylistUrl, signal); + } + + const playlist = parseMediaPlaylist(playlistText, mediaPlaylistUrl); + if (!playlist.endList) { + throw new Error("The JavaScript fallback currently supports VOD playlists only."); + } + if (!playlist.initSegment || !playlist.segments.length) { + throw new Error("The JavaScript fallback requires an fMP4 playlist with EXT-X-MAP."); + } + + codecs = codecs || DEFAULT_CODECS; + const mimeType = `video/mp4; codecs="${codecs}"`; + if (!global.MediaSource.isTypeSupported(mimeType)) { + throw new Error(`Browser does not support ${mimeType}.`); + } + + this.mediaSource = new global.MediaSource(); + this.objectUrl = URL.createObjectURL(this.mediaSource); + this.video.src = this.objectUrl; + await once(this.mediaSource, "sourceopen"); + if (generation !== this.generation) return; + + const sourceBuffer = this.mediaSource.addSourceBuffer(mimeType); + sourceBuffer.mode = "segments"; + const initBytes = await this.fetchBytes(playlist.initSegment, signal); + if (generation !== this.generation || signal.aborted) return; + await appendBuffer(sourceBuffer, initBytes); + + for (let index = 0; index < playlist.segments.length; index++) { + this.setStatus(`Loading segment ${index + 1} of ${playlist.segments.length}...`); + const segmentBytes = await this.fetchBytes( + playlist.segments[index].url, + signal, + ); + if (generation !== this.generation || signal.aborted) return; + await appendBuffer(sourceBuffer, segmentBytes); + } + + if (generation !== this.generation) return; + this.mediaSource.endOfStream(); + this.video.currentTime = Math.min(0.05, playlist.totalDuration); + await once(this.video, "seeked", "error"); + if (generation !== this.generation || signal.aborted) return; + this.setDetails({ + mode: "JavaScript MediaSource", + segments: playlist.segments.length, + duration: `${playlist.totalDuration.toFixed(1)} seconds`, + codecs, + }); + this.setStatus("Stream ready. Press play.", "ready"); + } catch (error) { + if (error.name === "AbortError" || generation !== this.generation) return; + if (this.hls) { + this.hls.destroy(); + this.hls = null; + } + this.setStatus(error.message || "Unable to load HLS stream.", "error"); + throw error; + } + } + } + + const exportsObject = { + HlsPlayer, + chooseVariant, + parseAttributeList, + parseMasterPlaylist, + parseMediaPlaylist, + normalizePlaylistUrl, + createLocalHlsUrl, + localRelativePath, + rewritePlaylistUris, + }; + + if (typeof module !== "undefined" && module.exports) { + module.exports = exportsObject; + } + global.HlsPlayerModule = exportsObject; + + if (global.document) { + global.addEventListener("DOMContentLoaded", () => { + const form = document.querySelector("#hlsForm"); + const input = document.querySelector("#hlsUrl"); + const video = document.querySelector("#hlsVideo"); + const status = document.querySelector("#hlsStatus"); + const details = document.querySelector("#hlsDetails"); + const sampleButton = document.querySelector("#sampleButton"); + const fileInputs = [ + document.querySelector("#hlsFiles"), + document.querySelector("#hlsFolder"), + ].filter(Boolean); + if (!form || !input || !video) return; + + const player = new HlsPlayer(video, { + statusElement: status, + detailsElement: details, + }); + global.hlsPlayer = player; + let localSelection = null; + let localLoadGeneration = 0; + + const load = () => { + localLoadGeneration++; + if (localSelection) { + localSelection.revoke(); + localSelection = null; + } + const value = input.value.trim(); + if (!value) return; + const url = new URL(global.location.href); + url.searchParams.set("url", value); + global.history.replaceState({}, "", url); + player.load(value).catch(() => {}); + }; + + const loadLocal = async files => { + const generation = ++localLoadGeneration; + if (localSelection) { + localSelection.revoke(); + localSelection = null; + } + let selection = null; + try { + selection = await createLocalHlsUrl(files); + if (generation !== localLoadGeneration) { + selection.revoke(); + return; + } + localSelection = selection; + input.value = `Local: ${selection.playlistName}`; + player.setStatus(`Loading local playlist ${selection.playlistName}...`); + await player.load(selection.url, { forceMediaSource: true }); + if (generation !== localLoadGeneration) return; + } catch (error) { + if (selection) selection.revoke(); + if (localSelection === selection) localSelection = null; + if (generation !== localLoadGeneration) return; + player.setStatus(error.message || "Unable to load local HLS files.", "error"); + } + }; + + form.addEventListener("submit", event => { + event.preventDefault(); + load(); + }); + sampleButton.addEventListener("click", () => { + input.value = "/public/hls-sample/h264-ts-stream.m3u8"; + load(); + }); + for (const fileInput of fileInputs) { + fileInput.addEventListener("change", () => { + if (fileInput.files?.length) loadLocal(fileInput.files); + fileInput.value = ""; + }); + } + + const requestedUrl = new URL(global.location.href).searchParams.get("url"); + if (requestedUrl) input.value = requestedUrl; + load(); + }); + } +})(typeof window !== "undefined" ? window : globalThis);