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);