Mercurial
view mrjunejune/src/tools/hls_player/hls-player.js @ 279:b3b547563ec7
Add Google connector service and agent wiki
Implement the C/Seobeo Google Drive and Gmail connector with encrypted OAuth storage, Zenbu authentication, browser testing, AI tool discovery, chunked HTTP decoding, and Bazel coverage. Consolidate repository guidance into progressive wiki documentation and enforce arena-first allocation for new first-party C code.
Co-authored-by: Copilot <[email protected]>
Copilot-Session: 84c338fd-0939-4bb3-b7f3-1062eb213e5d
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Mon, 17 Aug 2026 22:22:36 -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);