view mrjunejune/src/tools/hls_player/hls-player.js @ 245:3843bb6253ac

[tools] Debounce live LaTeX preview Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Mon, 03 Aug 2026 18:23: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);