Mercurial
view mrjunejune/src/tools/hls_player/hls-player.js @ 272:41a49c29a28f
polish JRPG conversation experience
Integrate desktop conversations into the utility panel, simplify the mobile frame, add modal destinations and a reusable Zenbu composer lab, and preserve explicit conversation resume behavior.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Fri, 07 Aug 2026 16:05:29 -0700 |
| parents | 543df0fe7168 |
| children |
line wrap: on
line source
(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);