changeset 242:543df0fe7168

[tools] Add full HLS player support
author MrJuneJune <me@mrjunejune.com>
date Mon, 03 Aug 2026 13:14:41 -0700
parents 9c2eec61a152
children 823f2a8b16c8
files MODULE.bazel mrjunejune/BUILD mrjunejune/README.md mrjunejune/assets/video/sample_hls.mp4 mrjunejune/generate_hls_sample.c mrjunejune/main.c mrjunejune/src/public/hls-sample/h264-init.mp4 mrjunejune/src/public/hls-sample/h264-segment000.m4s mrjunejune/src/public/hls-sample/h264-segment001.m4s mrjunejune/src/public/hls-sample/h264-segment002.m4s mrjunejune/src/public/hls-sample/h264-segment003.m4s mrjunejune/src/public/hls-sample/h264-segment004.m4s mrjunejune/src/public/hls-sample/h264-segment005.m4s mrjunejune/src/public/hls-sample/h264-segment006.m4s mrjunejune/src/public/hls-sample/h264-segment007.m4s mrjunejune/src/public/hls-sample/h264-stream.m3u8 mrjunejune/src/public/hls-sample/h264-ts-segment000.ts mrjunejune/src/public/hls-sample/h264-ts-segment001.ts mrjunejune/src/public/hls-sample/h264-ts-segment002.ts mrjunejune/src/public/hls-sample/h264-ts-segment003.ts mrjunejune/src/public/hls-sample/h264-ts-segment004.ts mrjunejune/src/public/hls-sample/h264-ts-segment005.ts mrjunejune/src/public/hls-sample/h264-ts-segment006.ts mrjunejune/src/public/hls-sample/h264-ts-segment007.ts mrjunejune/src/public/hls-sample/h264-ts-stream.m3u8 mrjunejune/src/public/hls-sample/master.m3u8 mrjunejune/src/public/hls-sample/vp9-init.mp4 mrjunejune/src/public/hls-sample/vp9-segment000.m4s mrjunejune/src/public/hls-sample/vp9-segment001.m4s mrjunejune/src/public/hls-sample/vp9-segment002.m4s mrjunejune/src/public/hls-sample/vp9-segment003.m4s mrjunejune/src/public/hls-sample/vp9-segment004.m4s mrjunejune/src/public/hls-sample/vp9-segment005.m4s mrjunejune/src/public/hls-sample/vp9-segment006.m4s mrjunejune/src/public/hls-sample/vp9-segment007.m4s mrjunejune/src/public/hls-sample/vp9-stream.m3u8 mrjunejune/src/public/sw.js mrjunejune/src/tools/hls_player/hls-player.js mrjunejune/src/tools/hls_player/index.css mrjunejune/src/tools/hls_player/index.html mrjunejune/src/tools/index.html mrjunejune/test/BUILD mrjunejune/test/auto_generated_test.c mrjunejune/test/hls_player_test.js mrjunejune/test/integration_test.c mrjunejune/test/snapshots/tools.snapshot mrjunejune/test/snapshots/tools_hls_player.snapshot mrjunejune/test/theme_and_webp_test.js seobeo/s_web.c third_party/ffmpeg/BUILD third_party/ffmpeg/README.md third_party/ffmpeg/ffmpeg_cli.c third_party/ffmpeg/ffmpeg_cli.h third_party/ffmpeg/ffmpeg_hls_test.c
diffstat 54 files changed, 2291 insertions(+), 6 deletions(-) [+]
line wrap: on
line diff
--- a/MODULE.bazel	Mon Aug 03 11:26:30 2026 -0700
+++ b/MODULE.bazel	Mon Aug 03 13:14:41 2026 -0700
@@ -43,6 +43,18 @@
 """,
 )
 
+http_archive(
+  name = "hlsjs",
+  urls = ["https://github.com/video-dev/hls.js/releases/download/v1.6.16/release.zip"],
+  sha256 = "09c827969e702e82be694da80dbf16dbdf5ff6819c33a374b1757e1bc0adb458",
+  build_file_content = """
+exports_files(
+    ["dist/hls.min.js"],
+    visibility = ["//visibility:public"],
+)
+""",
+)
+
 node = use_extension("@rules_nodejs//nodejs:extensions.bzl", "node")
 node.toolchain(node_version = "20.18.0")
 use_repo(node, "nodejs")
--- a/mrjunejune/BUILD	Mon Aug 03 11:26:30 2026 -0700
+++ b/mrjunejune/BUILD	Mon Aug 03 13:14:41 2026 -0700
@@ -1,5 +1,6 @@
 load("@rules_cc//cc:cc_binary.bzl", "cc_binary")
 load("@rules_cc//cc:cc_library.bzl", "cc_library")
+load("@aspect_rules_js//js:defs.bzl", "js_library")
 load("@rules_shell//shell:sh_binary.bzl", "sh_binary")
 load("//gui_ze:gui_ze.bzl", "move_files_into_dir", "bundle", "webp_image")
 
@@ -66,6 +67,26 @@
   ],
 )
 
+filegroup(
+  name = "sample_hls_mp4",
+  srcs = ["assets/video/sample_hls.mp4"],
+  visibility = [
+    "//mrjunejune:__pkg__",
+    "//third_party/ffmpeg:__pkg__",
+  ],
+)
+
+cc_binary(
+  name = "generate_hls_sample",
+  srcs = ["generate_hls_sample.c"],
+  args = [
+    "$(location :sample_hls_mp4)",
+    "mrjunejune/src/public/hls-sample",
+  ],
+  data = [":sample_hls_mp4"],
+  deps = ["//third_party/ffmpeg:ffmpeg_cli"],
+)
+
 move_files_into_dir(
   name = "generated_public_webp",
   srcs = [":generated_webp_assets"],
@@ -74,6 +95,12 @@
 
 # Files
 move_files_into_dir(
+    name = "hlsjs_runtime",
+    srcs = ["@hlsjs//:dist/hls.min.js"],
+    dest = "src/public",
+)
+
+move_files_into_dir(
   name = "react_pages",
   srcs = [
     "//react_games:games"
@@ -119,7 +146,7 @@
   srcs = glob(
     ["src/public/*"],
     exclude = ["src/public/*.png"],
-  ) + [":generated_public_webp"],
+  ) + [":generated_public_webp", ":hlsjs_runtime"],
   visibility = ["//visibility:public"],
 )
 
@@ -134,7 +161,13 @@
   srcs = glob(
     ["src/**"],
     exclude = ["src/**/*.png"],
-  ) + [":react_pages", ":shared_js_non_public", ":shared_js_file", ":rich_editor_js", ":icons", ":generated_public_webp"],
+  ) + [":react_pages", ":shared_js_non_public", ":shared_js_file", ":rich_editor_js", ":icons", ":generated_public_webp", ":hlsjs_runtime"],
+  visibility = ["//mrjunejune/test:__pkg__"],
+)
+
+js_library(
+  name = "hls_player_js",
+  srcs = ["src/tools/hls_player/hls-player.js"],
   visibility = ["//mrjunejune/test:__pkg__"],
 )
 
--- a/mrjunejune/README.md	Mon Aug 03 11:26:30 2026 -0700
+++ b/mrjunejune/README.md	Mon Aug 03 13:14:41 2026 -0700
@@ -17,6 +17,28 @@
 corresponding list in `mrjunejune/BUILD` or `assets/BUILD`, then reference the
 generated `.webp` URL from the site.
 
+## HLS player
+
+`/tools/hls_player` is a dependency-free JavaScript HLS player. It uses native
+HLS where available and a MediaSource fallback for unencrypted VOD playlists
+with fragmented MP4 segments. A VP9/Opus HLS sample is bundled at:
+
+```text
+/public/hls-sample/master.m3u8
+```
+
+The sample is generated from `mrjunejune/assets/video/sample_hls.mp4` through
+the restored FFmpeg CLI wrapper:
+
+```bash
+bazel run //mrjunejune:generate_hls_sample
+```
+
+```bash
+bazel test //mrjunejune/test:hls_player_test
+bazel test //mrjunejune/test:theme_and_webp_test
+```
+
 ## TODO 
 
 - Add caching layer
Binary file mrjunejune/assets/video/sample_hls.mp4 has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/generate_hls_sample.c	Mon Aug 03 13:14:41 2026 -0700
@@ -0,0 +1,174 @@
+#include "third_party/ffmpeg/ffmpeg_cli.h"
+
+#include <dirent.h>
+#include <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <unistd.h>
+
+static int remove_flat_directory(const char *directory)
+{
+  DIR *entries = opendir(directory);
+  if (!entries)
+    return errno == ENOENT ? 0 : -1;
+
+  struct dirent *entry;
+  while ((entry = readdir(entries)) != NULL)
+  {
+    if (strcmp(entry->d_name, ".") == 0 ||
+        strcmp(entry->d_name, "..") == 0)
+      continue;
+    char path[1024];
+    int length = snprintf(path, sizeof(path), "%s/%s", directory, entry->d_name);
+    if (length < 0 || (size_t)length >= sizeof(path) || unlink(path) != 0)
+    {
+      closedir(entries);
+      return -1;
+    }
+  }
+  closedir(entries);
+  return rmdir(directory);
+}
+
+static int write_master_playlist(const char *directory)
+{
+  char path[1024];
+  int length = snprintf(path, sizeof(path), "%s/master.m3u8", directory);
+  if (length < 0 || (size_t)length >= sizeof(path))
+    return -1;
+
+  FILE *playlist = fopen(path, "wb");
+  if (!playlist)
+    return -1;
+  const char *content =
+      "#EXTM3U\n"
+      "#EXT-X-VERSION:7\n"
+      "#EXT-X-STREAM-INF:BANDWIDTH=1200000,AVERAGE-BANDWIDTH=1000000,"
+      "CODECS=\"avc1.4d401f,mp4a.40.2\"\n"
+      "h264-stream.m3u8\n"
+      "#EXT-X-STREAM-INF:BANDWIDTH=900000,AVERAGE-BANDWIDTH=750000,"
+      "CODECS=\"vp09.00.10.08,opus\"\n"
+      "vp9-stream.m3u8\n";
+  size_t content_length = strlen(content);
+  int result = fwrite(content, 1, content_length, playlist) == content_length
+      ? 0
+      : -1;
+  fclose(playlist);
+  return result;
+}
+
+static const char *workspace_path(
+    const char *path,
+    char *buffer,
+    size_t buffer_size)
+{
+  const char *workspace = getenv("BUILD_WORKSPACE_DIRECTORY");
+  if (!workspace || path[0] == '/')
+    return path;
+  int length = snprintf(buffer, buffer_size, "%s/%s", workspace, path);
+  return length < 0 || (size_t)length >= buffer_size ? NULL : buffer;
+}
+
+int main(int argc, char **argv)
+{
+  if (argc != 3)
+  {
+    fprintf(stderr, "Usage: %s <input.mp4> <output-directory>\n", argv[0]);
+    return 2;
+  }
+
+  char final_buffer[1024];
+  const char *final_directory = workspace_path(
+      argv[2],
+      final_buffer,
+      sizeof(final_buffer));
+  if (!final_directory)
+  {
+    fprintf(stderr, "HLS output path is too long.\n");
+    return 1;
+  }
+
+  char stage_template[1100];
+  int stage_length = snprintf(
+      stage_template,
+      sizeof(stage_template),
+      "%s.stage-XXXXXX",
+      final_directory);
+  if (stage_length < 0 || (size_t)stage_length >= sizeof(stage_template) ||
+      !mkdtemp(stage_template))
+  {
+    fprintf(stderr, "Unable to create the HLS staging directory.\n");
+    return 1;
+  }
+
+  Fmp4HlsOpts options = ffmpeg_fmp4_hls_opts_default();
+  options.codec = FFMPEG_HLS_VP9_OPUS;
+  FfmpegResult result = ffmpeg_video_to_fmp4_hls(
+      argv[1],
+      stage_template,
+      "vp9",
+      &options);
+  if (result == FFMPEG_OK)
+  {
+    options.codec = FFMPEG_HLS_H264_AAC;
+    options.video_bitrate = 1200;
+    options.audio_bitrate = 128;
+    result = ffmpeg_video_to_fmp4_hls(
+        argv[1],
+        stage_template,
+        "h264",
+        &options);
+  }
+  if (result == FFMPEG_OK)
+  {
+    options.use_mpeg_ts = true;
+    result = ffmpeg_video_to_fmp4_hls(
+        argv[1],
+        stage_template,
+        "h264-ts",
+        &options);
+  }
+  if (result != FFMPEG_OK || write_master_playlist(stage_template) != 0)
+  {
+    fprintf(stderr, "%s\n", ffmpeg_last_error());
+    remove_flat_directory(stage_template);
+    return 1;
+  }
+
+  char backup_directory[1100];
+  int backup_length = snprintf(
+      backup_directory,
+      sizeof(backup_directory),
+      "%s.backup",
+      final_directory);
+  if (backup_length < 0 || (size_t)backup_length >= sizeof(backup_directory))
+  {
+    remove_flat_directory(stage_template);
+    return 1;
+  }
+  remove_flat_directory(backup_directory);
+
+  bool had_previous = rename(final_directory, backup_directory) == 0;
+  if (!had_previous && errno != ENOENT)
+  {
+    remove_flat_directory(stage_template);
+    return 1;
+  }
+  if (rename(stage_template, final_directory) != 0)
+  {
+    if (had_previous)
+      rename(backup_directory, final_directory);
+    remove_flat_directory(stage_template);
+    return 1;
+  }
+  if (had_previous && remove_flat_directory(backup_directory) != 0)
+  {
+    fprintf(stderr, "Generated HLS sample, but could not remove its backup.\n");
+    return 1;
+  }
+
+  printf("Generated fMP4 and MPEG-TS HLS samples from %s in %s\n", argv[1], final_directory);
+  return 0;
+}
--- a/mrjunejune/main.c	Mon Aug 03 11:26:30 2026 -0700
+++ b/mrjunejune/main.c	Mon Aug 03 13:14:41 2026 -0700
@@ -295,6 +295,15 @@
   return resp;
 }
 
+Seobeo_Request_Entry* GetHlsPlayer(Seobeo_Request_Entry *req, Dowa_Arena *arena)
+{
+  Seobeo_Request_Entry *resp = NULL;
+  char *final_body = Dowa_Arena_Allocate(arena, 50 * 1024);
+  Seobeo_Render_Html_FilePath(final_body, "/tools/hls_player/index.html", arena);
+  Dowa_HashMap_Push_Arena(resp, "body", final_body, arena);
+  return resp;
+}
+
 // Background thread function for media processing
 void *Simple_WebpConverter_Background(void *arg)
 {
@@ -736,6 +745,7 @@
 CREATE_REDIRECT_HANDLER(Tools, "/tools")
 CREATE_REDIRECT_HANDLER(MarkDownToHtml, "/tools/markdown_to_html")
 CREATE_REDIRECT_HANDLER(FileConverter, "/tools/file_converter")
+CREATE_REDIRECT_HANDLER(HlsPlayer, "/tools/hls_player")
 CREATE_REDIRECT_HANDLER(Talk, "/talk")
 CREATE_REDIRECT_HANDLER(Editor, "/editor")
 
@@ -1856,6 +1866,8 @@
 
   Seobeo_Router_Register("GET", "/tools/file_converter", GetFileConverter);
   Seobeo_Router_Register("GET", "/tools/file_converter/index.html", GetRedirectFileConverter);
+  Seobeo_Router_Register("GET", "/tools/hls_player", GetHlsPlayer);
+  Seobeo_Router_Register("GET", "/tools/hls_player/index.html", GetRedirectHlsPlayer);
 
   // -- File converter --/
   Seobeo_Router_Register("POST", "/api/convert/image-to-webp", ConvertImageToWebP);
Binary file mrjunejune/src/public/hls-sample/h264-init.mp4 has changed
Binary file mrjunejune/src/public/hls-sample/h264-segment000.m4s has changed
Binary file mrjunejune/src/public/hls-sample/h264-segment001.m4s has changed
Binary file mrjunejune/src/public/hls-sample/h264-segment002.m4s has changed
Binary file mrjunejune/src/public/hls-sample/h264-segment003.m4s has changed
Binary file mrjunejune/src/public/hls-sample/h264-segment004.m4s has changed
Binary file mrjunejune/src/public/hls-sample/h264-segment005.m4s has changed
Binary file mrjunejune/src/public/hls-sample/h264-segment006.m4s has changed
Binary file mrjunejune/src/public/hls-sample/h264-segment007.m4s has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/src/public/hls-sample/h264-stream.m3u8	Mon Aug 03 13:14:41 2026 -0700
@@ -0,0 +1,23 @@
+#EXTM3U
+#EXT-X-VERSION:7
+#EXT-X-TARGETDURATION:2
+#EXT-X-MEDIA-SEQUENCE:0
+#EXT-X-PLAYLIST-TYPE:VOD
+#EXT-X-MAP:URI="h264-init.mp4"
+#EXTINF:2.000000,
+h264-segment000.m4s
+#EXTINF:2.000000,
+h264-segment001.m4s
+#EXTINF:2.000000,
+h264-segment002.m4s
+#EXTINF:2.000000,
+h264-segment003.m4s
+#EXTINF:2.000000,
+h264-segment004.m4s
+#EXTINF:2.000000,
+h264-segment005.m4s
+#EXTINF:2.000000,
+h264-segment006.m4s
+#EXTINF:0.633333,
+h264-segment007.m4s
+#EXT-X-ENDLIST
Binary file mrjunejune/src/public/hls-sample/h264-ts-segment000.ts has changed
Binary file mrjunejune/src/public/hls-sample/h264-ts-segment001.ts has changed
Binary file mrjunejune/src/public/hls-sample/h264-ts-segment002.ts has changed
Binary file mrjunejune/src/public/hls-sample/h264-ts-segment003.ts has changed
Binary file mrjunejune/src/public/hls-sample/h264-ts-segment004.ts has changed
Binary file mrjunejune/src/public/hls-sample/h264-ts-segment005.ts has changed
Binary file mrjunejune/src/public/hls-sample/h264-ts-segment006.ts has changed
Binary file mrjunejune/src/public/hls-sample/h264-ts-segment007.ts has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/src/public/hls-sample/h264-ts-stream.m3u8	Mon Aug 03 13:14:41 2026 -0700
@@ -0,0 +1,22 @@
+#EXTM3U
+#EXT-X-VERSION:3
+#EXT-X-TARGETDURATION:2
+#EXT-X-MEDIA-SEQUENCE:0
+#EXT-X-PLAYLIST-TYPE:VOD
+#EXTINF:2.000000,
+h264-ts-segment000.ts
+#EXTINF:2.000000,
+h264-ts-segment001.ts
+#EXTINF:2.000000,
+h264-ts-segment002.ts
+#EXTINF:2.000000,
+h264-ts-segment003.ts
+#EXTINF:2.000000,
+h264-ts-segment004.ts
+#EXTINF:2.000000,
+h264-ts-segment005.ts
+#EXTINF:2.000000,
+h264-ts-segment006.ts
+#EXTINF:0.633333,
+h264-ts-segment007.ts
+#EXT-X-ENDLIST
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/src/public/hls-sample/master.m3u8	Mon Aug 03 13:14:41 2026 -0700
@@ -0,0 +1,6 @@
+#EXTM3U
+#EXT-X-VERSION:7
+#EXT-X-STREAM-INF:BANDWIDTH=1200000,AVERAGE-BANDWIDTH=1000000,CODECS="avc1.4d401f,mp4a.40.2"
+h264-stream.m3u8
+#EXT-X-STREAM-INF:BANDWIDTH=900000,AVERAGE-BANDWIDTH=750000,CODECS="vp09.00.10.08,opus"
+vp9-stream.m3u8
Binary file mrjunejune/src/public/hls-sample/vp9-init.mp4 has changed
Binary file mrjunejune/src/public/hls-sample/vp9-segment000.m4s has changed
Binary file mrjunejune/src/public/hls-sample/vp9-segment001.m4s has changed
Binary file mrjunejune/src/public/hls-sample/vp9-segment002.m4s has changed
Binary file mrjunejune/src/public/hls-sample/vp9-segment003.m4s has changed
Binary file mrjunejune/src/public/hls-sample/vp9-segment004.m4s has changed
Binary file mrjunejune/src/public/hls-sample/vp9-segment005.m4s has changed
Binary file mrjunejune/src/public/hls-sample/vp9-segment006.m4s has changed
Binary file mrjunejune/src/public/hls-sample/vp9-segment007.m4s has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/src/public/hls-sample/vp9-stream.m3u8	Mon Aug 03 13:14:41 2026 -0700
@@ -0,0 +1,23 @@
+#EXTM3U
+#EXT-X-VERSION:7
+#EXT-X-TARGETDURATION:2
+#EXT-X-MEDIA-SEQUENCE:0
+#EXT-X-PLAYLIST-TYPE:VOD
+#EXT-X-MAP:URI="vp9-init.mp4"
+#EXTINF:2.000000,
+vp9-segment000.m4s
+#EXTINF:2.000000,
+vp9-segment001.m4s
+#EXTINF:2.000000,
+vp9-segment002.m4s
+#EXTINF:2.000000,
+vp9-segment003.m4s
+#EXTINF:2.000000,
+vp9-segment004.m4s
+#EXTINF:2.000000,
+vp9-segment005.m4s
+#EXTINF:2.000000,
+vp9-segment006.m4s
+#EXTINF:0.633333,
+vp9-segment007.m4s
+#EXT-X-ENDLIST
--- a/mrjunejune/src/public/sw.js	Mon Aug 03 11:26:30 2026 -0700
+++ b/mrjunejune/src/public/sw.js	Mon Aug 03 13:14:41 2026 -0700
@@ -1,5 +1,5 @@
 // Service Worker for MrJuneJune PWA
-const CACHE_VERSION = 'v2-webp';
+const CACHE_VERSION = 'v4-hlsjs';
 const CACHE_NAME = `mrjunejune-${CACHE_VERSION}`;
 
 // Files to cache immediately on install
@@ -69,6 +69,12 @@
     return;
   }
 
+  // The HLS tool and media must always reflect the current player version.
+  if (url.pathname.startsWith('/tools/hls_player') ||
+      url.pathname.startsWith('/public/hls-sample/')) {
+    return;
+  }
+
   event.respondWith(
     caches.match(request).then((cachedResponse) => {
       if (cachedResponse) {
--- /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);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/src/tools/hls_player/index.css	Mon Aug 03 13:14:41 2026 -0700
@@ -0,0 +1,171 @@
+.hls-form {
+  margin: 1.5rem 0;
+}
+
+.hls-form label {
+  display: block;
+  margin-bottom: 0.4rem;
+  font-weight: 700;
+}
+
+.hls-url-row {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) auto auto;
+  gap: 0.6rem;
+}
+
+.hls-url-row input,
+.hls-url-row button {
+  min-height: 44px;
+  font: inherit;
+}
+
+.hls-url-row input {
+  width: 100%;
+  padding: 0.65rem 0.8rem;
+  color: rgb(var(--gray-dark));
+  background: var(--white);
+  border: 1px solid var(--darkgray);
+  border-radius: 6px;
+}
+
+.hls-url-row button {
+  padding: 0.65rem 1rem;
+  color: var(--white);
+  background: var(--darkgray);
+  border: 1px solid var(--darkgray);
+  border-radius: 6px;
+  cursor: pointer;
+}
+
+.hls-url-row button:hover,
+.hls-url-row button:focus-visible {
+  color: var(--white);
+  background: var(--awesome);
+  border-color: var(--awesome);
+}
+
+.hls-player-shell {
+  overflow: hidden;
+  border: 1px solid var(--darkgray);
+  border-radius: 8px;
+  background: #10131a;
+}
+
+.hls-local-picker {
+  margin: 1rem 0 1.5rem;
+  padding: 0.9rem;
+  border: 1px solid var(--gray-light);
+  border-radius: 8px;
+}
+
+.hls-local-picker legend {
+  padding: 0 0.4rem;
+  font-weight: 700;
+}
+
+.hls-local-picker p {
+  margin: 0 0 0.75rem;
+}
+
+.hls-local-actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.65rem;
+}
+
+.hls-file-button {
+  display: inline-flex;
+  align-items: center;
+  min-height: 44px;
+  padding: 0.65rem 1rem;
+  color: var(--white);
+  background: var(--darkgray);
+  border-radius: 6px;
+  cursor: pointer;
+}
+
+.hls-file-button:hover,
+.hls-file-button:focus-within {
+  background: var(--awesome);
+}
+
+.hls-file-button input {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  overflow: hidden;
+  clip: rect(0 0 0 0);
+  white-space: nowrap;
+}
+
+#hlsVideo {
+  display: block;
+  width: 100%;
+  aspect-ratio: 16 / 9;
+  background: #05070b;
+}
+
+.hls-status {
+  min-height: 2.75rem;
+  margin: 0;
+  padding: 0.65rem 0.9rem;
+  color: #f3f5f8;
+  background: #171b24;
+}
+
+.hls-status[data-state="error"] {
+  color: #ffd7df;
+  background: #6d1732;
+}
+
+.hls-status[data-state="ready"] {
+  color: #d8ffe9;
+  background: #185b3a;
+}
+
+.hls-details {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 0.75rem;
+  margin: 1rem 0;
+}
+
+.hls-details[hidden] {
+  display: none;
+}
+
+.hls-details div {
+  padding: 0.7rem;
+  border: 1px solid var(--gray-light);
+  border-radius: 6px;
+}
+
+.hls-details dt {
+  font-size: 0.8rem;
+  font-weight: 700;
+  text-transform: uppercase;
+}
+
+.hls-details dd {
+  margin: 0.25rem 0 0;
+  overflow-wrap: anywhere;
+}
+
+.hls-notes {
+  margin-top: 1.5rem;
+}
+
+@media (max-width: 720px) {
+  .hls-url-row {
+    grid-template-columns: 1fr 1fr;
+  }
+
+  .hls-url-row input {
+    grid-column: 1 / -1;
+  }
+
+  .hls-details {
+    grid-template-columns: 1fr 1fr;
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/src/tools/hls_player/index.html	Mon Aug 03 13:14:41 2026 -0700
@@ -0,0 +1,94 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    {{/parts/base_head.html}}
+    <title>HLS Player - MrJuneJune</title>
+    <link rel="stylesheet" href="/tools/hls_player/index.css" />
+    <script src="/public/hls.min.js" defer></script>
+    <script src="/tools/hls_player/hls-player.js" defer></script>
+  </head>
+  <body>
+    {{/parts/header.html}}
+    <main>
+      <h1>Online HLS Player</h1>
+      <p>Load an HLS playlist from a URL, or try the sample stream bundled with this site.</p>
+
+      <form id="hlsForm" class="hls-form">
+        <label for="hlsUrl">Playlist URL</label>
+        <div class="hls-url-row">
+          <input
+            id="hlsUrl"
+            name="url"
+            type="text"
+            inputmode="url"
+            value="/public/hls-sample/h264-ts-stream.m3u8"
+            placeholder="https://example.com/stream.m3u8"
+            required
+          />
+          <button type="submit">Load</button>
+          <button id="sampleButton" type="button">Sample</button>
+        </div>
+      </form>
+
+      <fieldset class="hls-local-picker">
+        <legend>Local HLS</legend>
+        <p>Select the playlist together with every init and segment file it references.</p>
+        <div class="hls-local-actions">
+          <label class="hls-file-button">
+            Choose HLS files
+            <input
+              id="hlsFiles"
+              type="file"
+              accept=".m3u8,.m4s,.mp4,.ts,.aac,.vtt,.key,application/vnd.apple.mpegurl"
+              multiple
+            />
+          </label>
+          <label class="hls-file-button">
+            Choose HLS folder
+            <input
+              id="hlsFolder"
+              type="file"
+              webkitdirectory
+              multiple
+            />
+          </label>
+        </div>
+      </fieldset>
+
+      <div class="hls-player-shell">
+        <video id="hlsVideo" controls playsinline preload="metadata"></video>
+        <p id="hlsStatus" class="hls-status" role="status" aria-live="polite">Ready to load a playlist.</p>
+      </div>
+
+      <dl id="hlsDetails" class="hls-details" hidden>
+        <div>
+          <dt>Playback</dt>
+          <dd data-detail="mode">-</dd>
+        </div>
+        <div>
+          <dt>Segments</dt>
+          <dd data-detail="segments">-</dd>
+        </div>
+        <div>
+          <dt>Duration</dt>
+          <dd data-detail="duration">-</dd>
+        </div>
+        <div>
+          <dt>Codec</dt>
+          <dd data-detail="codecs">-</dd>
+        </div>
+      </dl>
+
+      <section class="hls-notes">
+        <h2>Notes</h2>
+        <ul>
+          <li>Remote playlists must allow cross-origin requests.</li>
+          <li>Local loading stays in your browser; selected files are not uploaded.</li>
+          <li>Chrome, Edge, and Firefox support MPEG-TS, fragmented MP4, live, and adaptive HLS through hls.js.</li>
+          <li>Safari uses native HLS playback when available.</li>
+        </ul>
+      </section>
+    </main>
+    {{/parts/footer.html}}
+  </body>
+</html>
--- a/mrjunejune/src/tools/index.html	Mon Aug 03 11:26:30 2026 -0700
+++ b/mrjunejune/src/tools/index.html	Mon Aug 03 13:14:41 2026 -0700
@@ -11,12 +11,12 @@
        <ul class="nav-list">
          <li><a href="/tools/markdown_to_html">MarkDown to HTML</a></li>
          <li><a href="/tools/file_converter">Images to Webp / Video to Mp4</a></li>
+         <li><a href="/tools/hls_player">Online HLS Player</a></li>
          <li><a href="/notes">Personal Notes</a></li>
        </ul>
        <h3> TODOs </h3>
        <ul class="nav-list">
          <li>- Simple online LaTex editor.</li>
-         <li>- Online HLS player.</li>
        </ul>
      </main>
      {{/parts/footer.html}}
--- a/mrjunejune/test/BUILD	Mon Aug 03 11:26:30 2026 -0700
+++ b/mrjunejune/test/BUILD	Mon Aug 03 13:14:41 2026 -0700
@@ -49,6 +49,14 @@
 )
 
 js_test(
+  name = "hls_player_test",
+  entry_point = "hls_player_test.js",
+  data = ["//mrjunejune:hls_player_js"],
+  size = "small",
+  timeout = "short",
+)
+
+js_test(
   name = "theme_and_webp_test",
   entry_point = "theme_and_webp_test.js",
   data = [
--- a/mrjunejune/test/auto_generated_test.c	Mon Aug 03 11:26:30 2026 -0700
+++ b/mrjunejune/test/auto_generated_test.c	Mon Aug 03 13:14:41 2026 -0700
@@ -28,6 +28,8 @@
     {"/tools/markdown_to_html/index.html", 301, SNAPSHOT_DIR, TEST_HOST, TEST_PORT},
     {"/tools/file_converter", 200, SNAPSHOT_DIR, TEST_HOST, TEST_PORT},
     {"/tools/file_converter/index.html", 301, SNAPSHOT_DIR, TEST_HOST, TEST_PORT},
+    {"/tools/hls_player", 200, SNAPSHOT_DIR, TEST_HOST, TEST_PORT},
+    {"/tools/hls_player/index.html", 301, SNAPSHOT_DIR, TEST_HOST, TEST_PORT},
     // TODO: POST route - POST /api/convert/image-to-webp - requires request body
     // TODO: POST route - POST /api/convert/video-to-mp4 - requires request body
     // TODO: Dynamic route - GET /api/download/:filename - fill in actual path
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/test/hls_player_test.js	Mon Aug 03 13:14:41 2026 -0700
@@ -0,0 +1,86 @@
+const assert = require('node:assert/strict');
+const {
+  chooseVariant,
+  parseAttributeList,
+  parseMasterPlaylist,
+  parseMediaPlaylist,
+  normalizePlaylistUrl,
+  localRelativePath,
+  rewritePlaylistUris,
+} = require('../src/tools/hls_player/hls-player.js');
+
+const attributes = parseAttributeList(
+  'BANDWIDTH=500000,RESOLUTION=640x360,CODECS="avc1.42c01e,mp4a.40.2"',
+);
+assert.equal(attributes.BANDWIDTH, '500000');
+assert.equal(attributes.RESOLUTION, '640x360');
+assert.equal(attributes.CODECS, 'avc1.42c01e,mp4a.40.2');
+
+const variants = parseMasterPlaylist(
+  '#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=100\nlow.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=200,CODECS="avc1.42c01e"\nhigh.m3u8\n',
+  'https://example.com/master.m3u8',
+);
+assert.equal(variants.length, 2);
+assert.equal(chooseVariant(variants).url, 'https://example.com/high.m3u8');
+assert.equal(
+  chooseVariant(variants, variant => variant.bandwidth < 200).url,
+  'https://example.com/low.m3u8',
+);
+assert.equal(chooseVariant(variants, () => false), null);
+
+const media = parseMediaPlaylist(
+  '#EXTM3U\n#EXT-X-MAP:URI="init.mp4"\n#EXTINF:1.5,\nseg0.m4s\n#EXTINF:2.0,\nseg1.m4s\n#EXT-X-ENDLIST\n',
+  'https://example.com/path/stream.m3u8',
+);
+assert.equal(media.initSegment, 'https://example.com/path/init.mp4');
+assert.equal(media.segments.length, 2);
+assert.equal(media.segments[1].url, 'https://example.com/path/seg1.m4s');
+assert.equal(media.totalDuration, 3.5);
+assert.equal(media.endList, true);
+
+assert.equal(
+  normalizePlaylistUrl('/stream.m3u8', 'https://example.com/tool'),
+  'https://example.com/stream.m3u8',
+);
+assert.throws(
+  () => normalizePlaylistUrl('file:///tmp/stream.m3u8', 'https://example.com'),
+  /HTTP, HTTPS, or a local file/,
+);
+assert.equal(
+  localRelativePath('folder/master.m3u8', 'video/stream.m3u8'),
+  'folder/video/stream.m3u8',
+);
+assert.equal(
+  localRelativePath('folder/video/stream.m3u8', '../init.mp4'),
+  'folder/init.mp4',
+);
+assert.equal(
+  localRelativePath('folder/video/stream.m3u8', '/init.mp4'),
+  'init.mp4',
+);
+assert.throws(
+  () => localRelativePath('master.m3u8', '../outside.m4s'),
+  /escapes/,
+);
+
+assert.throws(
+  () => parseMediaPlaylist(
+    '#EXTM3U\n#EXT-X-KEY:METHOD=AES-128,URI="key"\n#EXTINF:1,\nseg.m4s\n',
+    'https://example.com/stream.m3u8',
+  ),
+  /Encrypted HLS/,
+);
+
+(async () => {
+  const rewritten = await rewritePlaylistUris(
+    '#EXTM3U\n#EXT-X-MEDIA:TYPE=AUDIO,URI="audio.m3u8"\n#EXT-X-MAP:URI="init.mp4"\n#EXT-X-KEY:METHOD=AES-128,URI="key.bin"\n#EXTINF:1,\nseg.m4s\n',
+    async reference => `blob:${reference}`,
+  );
+  assert.match(rewritten, /URI="blob:audio\.m3u8"/);
+  assert.match(rewritten, /URI="blob:init\.mp4"/);
+  assert.match(rewritten, /URI="blob:key\.bin"/);
+  assert.match(rewritten, /blob:seg\.m4s/);
+})().catch(error => {
+  console.error(error);
+  process.exitCode = 1;
+});
--- a/mrjunejune/test/integration_test.c	Mon Aug 03 11:26:30 2026 -0700
+++ b/mrjunejune/test/integration_test.c	Mon Aug 03 13:14:41 2026 -0700
@@ -391,6 +391,7 @@
     {"/tools", 200, NULL, NULL, NULL, 0},
     {"/tools/markdown_to_html", 200, NULL, NULL, NULL, 0},
     {"/tools/file_converter", 200, NULL, NULL, NULL, 0},
+    {"/tools/hls_player", 200, NULL, NULL, NULL, 0},
     {"/talk", 200, NULL, NULL, NULL, 0},
   };
   int num_success_tests = sizeof(success_tests) / sizeof(success_tests[0]);
@@ -401,6 +402,7 @@
     {"/tools/index.html", 301, NULL, NULL, NULL, 0},
     {"/tools/markdown_to_html/index.html", 301, NULL, NULL, NULL, 0},
     {"/tools/file_converter/index.html", 301, NULL, NULL, NULL, 0},
+    {"/tools/hls_player/index.html", 301, NULL, NULL, NULL, 0},
   };
   int num_redirect_tests = sizeof(redirect_tests) / sizeof(redirect_tests[0]);
 
--- a/mrjunejune/test/snapshots/tools.snapshot	Mon Aug 03 11:26:30 2026 -0700
+++ b/mrjunejune/test/snapshots/tools.snapshot	Mon Aug 03 13:14:41 2026 -0700
@@ -3,7 +3,10 @@
   <head>
     <meta charset="UTF-8">
 <meta name="viewport" content="width=device-width, initial-scale=1.0">
+<meta name="theme-color" content="#544e43">
 <link rel="icon" type="image/svg+xml" href="/public/epi_all_colors.svg">
+<link rel="manifest" href="/public/manifest.json">
+<link rel="apple-touch-icon" href="/public/epi_all_colors.svg">
 
 <link rel="preload" href="/public/fonts/Roboto-Regular.ttf" as="font"  crossorigin>
 <link rel="preload" href="/public/fonts/Roboto-Thin.ttf"as="font" crossorigin>
@@ -19,6 +22,8 @@
 <link rel="preload" href="/base.css" as="style" />
 <link rel="stylesheet" href="/base.css" />
 
+<script src="/public/pwa-register.js" defer></script>
+
 
     <link rel="stylesheet" href="/tools/index.css" />
   </head>
@@ -166,12 +171,12 @@
        <ul class="nav-list">
          <li><a href="/tools/markdown_to_html">MarkDown to HTML</a></li>
          <li><a href="/tools/file_converter">Images to Webp / Video to Mp4</a></li>
+         <li><a href="/tools/hls_player">Online HLS Player</a></li>
+         <li><a href="/notes">Personal Notes</a></li>
        </ul>
        <h3> TODOs </h3>
-       <p> Probably should add this... </p>
        <ul class="nav-list">
          <li>- Simple online LaTex editor.</li>
-         <li>- Online HLS player.</li>
        </ul>
      </main>
      <div style="display: flex; align-items: center; justify-content: center; margin: 30px 0px;">
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/test/snapshots/tools_hls_player.snapshot	Mon Aug 03 13:14:41 2026 -0700
@@ -0,0 +1,257 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<meta name="theme-color" content="#544e43">
+<link rel="icon" type="image/svg+xml" href="/public/epi_all_colors.svg">
+<link rel="manifest" href="/public/manifest.json">
+<link rel="apple-touch-icon" href="/public/epi_all_colors.svg">
+
+<link rel="preload" href="/public/fonts/Roboto-Regular.ttf" as="font"  crossorigin>
+<link rel="preload" href="/public/fonts/Roboto-Thin.ttf"as="font" crossorigin>
+
+<!-- <link rel="preload" href="/public/fonts/atkinson-regular.woff" as="font" type="font/woff" crossorigin> -->
+<!-- <link rel="preload" href="/public/fonts/atkinson-bold.woff" as="font" type="font/woff" crossorigin> -->
+
+<!-- <link rel="preload" href="/public/fonts/more-sugar.extras.otf" as="font" type="font/otf" crossorigin> -->
+<link rel="preload" href="/public/fonts/more-sugar.regular.otf" as="font" type="font/otf" crossorigin>
+<link rel="preload" href="/public/fonts/more-sugar.thin.otf" as="font" type="font/otf" crossorigin>
+<link rel="preload" href="/public/epi_all_colors.svg" as="image">
+
+<link rel="preload" href="/base.css" as="style" />
+<link rel="stylesheet" href="/base.css" />
+
+<script src="/public/pwa-register.js" defer></script>
+
+
+    <title>HLS Player - MrJuneJune</title>
+    <link rel="stylesheet" href="/tools/hls_player/index.css" />
+    <script src="/public/hls.min.js" defer></script>
+    <script src="/tools/hls_player/hls-player.js" defer></script>
+  </head>
+  <body>
+    <style>
+  :root {
+    --header-background: var(--white);
+    --header-color: rgb(var(--black));
+    --link-hover-accent: var(--awesome);
+  }
+
+  /* Fixed icon in top left corner */
+  #themeToggle {
+    position: fixed;
+    top: 20px;
+    left: 20px;
+    background: var(--header-background);
+    display: flex;
+    align-items: center;
+    border-radius: 50%;
+    cursor: pointer;
+    z-index: 1000;
+    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+    transition: transform 0.2s ease;
+  }
+
+  #themeToggle:hover {
+    transform: scale(1.05);
+  }
+
+  /* Professional header */
+  header {
+    margin: auto;
+    padding: 1.5em 1em;
+    font-family: "More", sans-serif;
+    box-shadow: 0 2px 8px rgba(var(--black), 5%);
+    width: 720px;
+    max-width: calc(100% - 2em);
+    text-align: center;
+  }
+
+  header h1 {
+    margin: 0;
+    font-size: 1.8em;
+    font-weight: 700;
+    letter-spacing: -0.5px;
+  }
+
+  header h1 a {
+    text-decoration: none;
+    color: var(--header-color);
+  }
+
+  header h1 a::before {
+    display: none;
+  }
+
+  /* Mobile responsiveness */
+  @media (max-width: 720px) {
+    #themeToggle {
+      top: 15px;
+      left: 15px;
+    }
+
+    header {
+      padding: 1em;
+    }
+
+    header h1 {
+      font-size: 1.5em;
+    }
+  }
+
+  @media (max-width: 480px) {
+    #themeToggle {
+      top: 10px;
+      left: 10px;
+    }
+
+    #themeToggle img {
+      height: 40px;
+      width: 40px;
+    }
+
+    header h1 {
+      font-size: 1.3em;
+    }
+  }
+
+  #logo {
+    width: 300px;
+  }
+
+  /* 1. DEFINE THE DEFAULTS (Light Mode) */
+  :root {
+    --logo-invert: invert(0);
+    --epi-grayscale: grayscale(0) brightness(1);
+  }
+  
+  /* 2. MANUAL DARK OVERRIDE */
+  html.dark {
+    --logo-invert: invert(1);
+    --epi-grayscale: grayscale(1);
+  }
+  
+  /* 3. MANUAL LIGHT OVERRIDE */
+  html.light-mode {
+    --logo-invert: invert(0);
+    --epi-grayscale: brightness(2.9) grayscale(1);
+  }
+  
+  /* 4. SYSTEM PREFERENCE */
+  @media (prefers-color-scheme: dark) {
+    :root:not(.light-mode) {
+      --logo-invert: invert(1);
+    }
+  }
+  
+  /* 5. APPLY TO ELEMENTS */
+  #logo {
+    -webkit-filter: var(--logo-invert);
+    filter: var(--logo-invert);
+    transition: filter 0.3s ease;
+  }
+  
+  .epi-logo {
+    -webkit-filter: var(--epi-grayscale);
+    filter: var(--epi-grayscale);
+    transition: filter 0.3s ease;
+  }
+</style>
+
+<div id="themeToggle">
+  <img id="epiChan" class="epi-logo" aria-label="Toggle dark mode" src="/public/epi_all_colors.svg" height="50" width="50">
+</div>
+
+<header>
+  <h1><a href="/">MrJuneJune</a></h1>
+</header>
+<script src="/index.js"></script>
+
+
+    <main>
+      <h1>Online HLS Player</h1>
+      <p>Load an HLS playlist from a URL, or try the sample stream bundled with this site.</p>
+
+      <form id="hlsForm" class="hls-form">
+        <label for="hlsUrl">Playlist URL</label>
+        <div class="hls-url-row">
+          <input
+            id="hlsUrl"
+            name="url"
+            type="text"
+            inputmode="url"
+            value="/public/hls-sample/h264-ts-stream.m3u8"
+            placeholder="https://example.com/stream.m3u8"
+            required
+          />
+          <button type="submit">Load</button>
+          <button id="sampleButton" type="button">Sample</button>
+        </div>
+      </form>
+
+      <fieldset class="hls-local-picker">
+        <legend>Local HLS</legend>
+        <p>Select the playlist together with every init and segment file it references.</p>
+        <div class="hls-local-actions">
+          <label class="hls-file-button">
+            Choose HLS files
+            <input
+              id="hlsFiles"
+              type="file"
+              accept=".m3u8,.m4s,.mp4,.ts,.aac,.vtt,.key,application/vnd.apple.mpegurl"
+              multiple
+            />
+          </label>
+          <label class="hls-file-button">
+            Choose HLS folder
+            <input
+              id="hlsFolder"
+              type="file"
+              webkitdirectory
+              multiple
+            />
+          </label>
+        </div>
+      </fieldset>
+
+      <div class="hls-player-shell">
+        <video id="hlsVideo" controls playsinline preload="metadata"></video>
+        <p id="hlsStatus" class="hls-status" role="status" aria-live="polite">Ready to load a playlist.</p>
+      </div>
+
+      <dl id="hlsDetails" class="hls-details" hidden>
+        <div>
+          <dt>Playback</dt>
+          <dd data-detail="mode">-</dd>
+        </div>
+        <div>
+          <dt>Segments</dt>
+          <dd data-detail="segments">-</dd>
+        </div>
+        <div>
+          <dt>Duration</dt>
+          <dd data-detail="duration">-</dd>
+        </div>
+        <div>
+          <dt>Codec</dt>
+          <dd data-detail="codecs">-</dd>
+        </div>
+      </dl>
+
+      <section class="hls-notes">
+        <h2>Notes</h2>
+        <ul>
+          <li>Remote playlists must allow cross-origin requests.</li>
+          <li>Local loading stays in your browser; selected files are not uploaded.</li>
+          <li>Chrome, Edge, and Firefox support MPEG-TS, fragmented MP4, live, and adaptive HLS through hls.js.</li>
+          <li>Safari uses native HLS playback when available.</li>
+        </ul>
+      </section>
+    </main>
+    <div style="display: flex; align-items: center; justify-content: center; margin: 30px 0px;">
+  <small>&copy; 2026 June Park</small>
+</div>
+
+  </body>
+</html>
--- a/mrjunejune/test/theme_and_webp_test.js	Mon Aug 03 11:26:30 2026 -0700
+++ b/mrjunejune/test/theme_and_webp_test.js	Mon Aug 03 13:14:41 2026 -0700
@@ -122,6 +122,228 @@
   return sample;
 }
 
+async function testHlsPlayer(browser, siteRoot) {
+  const page = await browser.newPage();
+  const errors = [];
+  const mediaRequests = [];
+  let testingExpectedFailure = false;
+  let testingExpectedReload = false;
+  page.on('pageerror', error => errors.push(`pageerror: ${error.message}`));
+  page.on('console', message => {
+    if (message.type() !== 'error') return;
+    if (testingExpectedFailure &&
+        message.text().startsWith('Failed to load resource:')) return;
+    errors.push(`console: ${message.text()}`);
+  });
+  page.on('requestfailed', request => {
+    if (testingExpectedReload &&
+        request.failure()?.errorText === 'net::ERR_ABORTED') return;
+    errors.push(`requestfailed: ${request.url()} ${request.failure()?.errorText || ''}`);
+  });
+  page.on('request', request => {
+    if (/\.(?:m3u8|m4s|mp4)(?:\?|$)/.test(request.url())) {
+      mediaRequests.push(request.url());
+    }
+  });
+
+  const playlistResponse = await fetch(
+    `${baseUrl}/public/hls-sample/master.m3u8`,
+  );
+  assert.equal(playlistResponse.status, 200);
+  assert.match(
+    playlistResponse.headers.get('content-type') || '',
+    /^application\/vnd\.apple\.mpegurl/,
+  );
+  assert.match(
+    await playlistResponse.text(),
+    /#EXT-X-STREAM-INF:.*CODECS="vp09\.00\.10\.08,opus"/,
+  );
+
+  const variantResponse = await fetch(
+    `${baseUrl}/public/hls-sample/vp9-stream.m3u8`,
+  );
+  assert.equal(variantResponse.status, 200);
+  assert.match(
+    await variantResponse.text(),
+    /#EXT-X-MAP:URI="vp9-init\.mp4"/,
+  );
+
+  const segmentResponse = await fetch(
+    `${baseUrl}/public/hls-sample/vp9-segment000.m4s`,
+  );
+  assert.equal(segmentResponse.status, 200);
+  assert.match(
+    segmentResponse.headers.get('content-type') || '',
+    /^video\/iso\.segment/,
+  );
+  const transportStreamResponse = await fetch(
+    `${baseUrl}/public/hls-sample/h264-ts-segment000.ts`,
+  );
+  assert.equal(transportStreamResponse.status, 200);
+  assert.match(
+    transportStreamResponse.headers.get('content-type') || '',
+    /^video\/mp2t/,
+  );
+  assert.ok((await transportStreamResponse.arrayBuffer()).byteLength > 0);
+
+  await page.goto(
+    `${baseUrl}/tools/hls_player?url=${encodeURIComponent('/public/hls-sample/master.m3u8')}`,
+    {
+      waitUntil: 'networkidle',
+    },
+  );
+  await page.getByRole('button', { name: 'Sample', exact: true }).waitFor();
+  assert.equal(
+    await page.locator('#hlsUrl').evaluate(input => input.defaultValue),
+    '/public/hls-sample/h264-ts-stream.m3u8',
+  );
+  await page.waitForFunction(() => {
+    const status = document.querySelector('#hlsStatus');
+    return status?.dataset.state === 'ready' ||
+      status?.dataset.state === 'error';
+  }, null, { timeout: 15000 });
+  const terminalState = await page.locator('#hlsStatus').getAttribute('data-state');
+  if (terminalState !== 'ready') {
+    throw new Error(
+      `HLS player failed: ${await page.locator('#hlsStatus').textContent()}\n${errors.join('\n')}`,
+    );
+  }
+  assert.match(await page.locator('#hlsStatus').textContent(), /Stream ready/);
+  assert.equal(
+    await page.locator('[data-detail="mode"]').textContent(),
+    'hls.js',
+  );
+  assert.match(
+    await page.locator('[data-detail="segments"]').textContent(),
+    /Managed by hls\.js|adaptive levels/,
+  );
+  await page.waitForFunction(() => {
+    const video = document.querySelector('#hlsVideo');
+    return video.readyState >= HTMLMediaElement.HAVE_METADATA &&
+      Number.isFinite(video.duration) &&
+      video.duration >= 14;
+  });
+  const firstFrame = await page.locator('#hlsVideo').evaluate(video => {
+    const canvas = document.createElement('canvas');
+    canvas.width = video.videoWidth;
+    canvas.height = video.videoHeight;
+    const context = canvas.getContext('2d');
+    context.drawImage(video, 0, 0);
+    const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
+    let total = 0;
+    for (let index = 0; index < pixels.length; index += 4) {
+      total += pixels[index] + pixels[index + 1] + pixels[index + 2];
+    }
+    return total / (canvas.width * canvas.height * 3);
+  });
+  assert.ok(firstFrame > 10, `Initial HLS frame is black: ${firstFrame}`);
+  await page.locator('#hlsVideo').evaluate(video => video.play());
+  await page.waitForFunction(() => document.querySelector('#hlsVideo').currentTime > 0.2);
+  await page.locator('#hlsVideo').evaluate(video => video.pause());
+  assert.ok(mediaRequests.some(url => url.endsWith('/hls-sample/master.m3u8')));
+  assert.ok(mediaRequests.some(url => url.endsWith('-stream.m3u8')));
+  assert.ok(mediaRequests.some(url => url.endsWith('-init.mp4')));
+  assert.ok(mediaRequests.some(url => /-segment\d+\.m4s$/.test(url)));
+
+  await page.evaluate(() => {
+    window.hlsPlayer.hls.trigger(window.Hls.Events.ERROR, {
+      fatal: true,
+      type: window.Hls.ErrorTypes.MEDIA_ERROR,
+      details: 'testRuntimeFailure',
+      error: new Error('runtime segment failed'),
+    });
+  });
+  await page.locator('#hlsStatus[data-state="error"]').waitFor();
+  assert.match(
+    await page.locator('#hlsStatus').textContent(),
+    /runtime segment failed/,
+  );
+  await page.locator('#hlsUrl').fill('/public/hls-sample/master.m3u8');
+  await page.getByRole('button', { name: 'Load', exact: true }).click();
+  await page.locator('#hlsStatus[data-state="ready"]').waitFor();
+
+  let delayedSegment = true;
+  await page.route('**/hls-sample/vp9-segment000.m4s', async route => {
+    if (delayedSegment) {
+      delayedSegment = false;
+      await new Promise(resolve => setTimeout(resolve, 250));
+    }
+    await route.continue();
+  });
+  testingExpectedReload = true;
+  await page.locator('#hlsUrl').fill('/public/hls-sample/master.m3u8');
+  await page.getByRole('button', { name: 'Load', exact: true }).click();
+  await page.waitForTimeout(25);
+  await page.getByRole('button', { name: 'Load', exact: true }).click();
+  await page.locator('#hlsStatus[data-state="ready"]').waitFor();
+  testingExpectedReload = false;
+  assert.match(await page.locator('#hlsStatus').textContent(), /Stream ready/);
+
+  await page.evaluate(() => {
+    window.__hlsObjectUrls = { created: [], revoked: [] };
+    const createObjectURL = URL.createObjectURL.bind(URL);
+    const revokeObjectURL = URL.revokeObjectURL.bind(URL);
+    URL.createObjectURL = value => {
+      const url = createObjectURL(value);
+      window.__hlsObjectUrls.created.push(url);
+      return url;
+    };
+    URL.revokeObjectURL = url => {
+      window.__hlsObjectUrls.revoked.push(url);
+      revokeObjectURL(url);
+    };
+  });
+  const localFiles = listFiles(path.join(siteRoot, 'public/hls-sample'));
+  await page.locator('#hlsFiles').setInputFiles(localFiles);
+  await page.waitForFunction(() =>
+    document.querySelector('#hlsUrl')?.value.startsWith('Local: ')
+  );
+  await page.locator('#hlsStatus[data-state="ready"]').waitFor();
+  assert.match(await page.locator('#hlsUrl').inputValue(), /^Local: /);
+  assert.equal(
+    await page.locator('[data-detail="mode"]').textContent(),
+    'hls.js',
+  );
+  await page.locator('#hlsVideo').evaluate(video => video.play());
+  await page.waitForFunction(() => document.querySelector('#hlsVideo').currentTime > 0.3);
+  await page.locator('#hlsVideo').evaluate(video => video.pause());
+  const incompleteLocalFiles = localFiles.filter(file =>
+    file.endsWith('master.m3u8') ||
+    file.endsWith('vp9-stream.m3u8')
+  );
+  await page.locator('#hlsFiles').setInputFiles(incompleteLocalFiles);
+  await page.locator('#hlsStatus[data-state="error"]').waitFor();
+  assert.match(
+    await page.locator('#hlsStatus').textContent(),
+    /Local HLS file is missing/,
+  );
+  const objectUrlCounts = await page.evaluate(() => ({
+    created: window.__hlsObjectUrls.created.length,
+    revoked: window.__hlsObjectUrls.revoked.length,
+  }));
+  assert.ok(objectUrlCounts.created > 10, JSON.stringify(objectUrlCounts));
+  assert.ok(
+    objectUrlCounts.revoked >= objectUrlCounts.created,
+    JSON.stringify(objectUrlCounts),
+  );
+
+  await page.route('**/invalid.m3u8', route => route.fulfill({
+    status: 404,
+    body: 'not found',
+  }));
+  testingExpectedFailure = true;
+  await page.locator('#hlsUrl').fill(`${baseUrl}/invalid.m3u8`);
+  await page.getByRole('button', { name: 'Load', exact: true }).click();
+  await page.locator('#hlsStatus[data-state="error"]').waitFor();
+  assert.match(
+    await page.locator('#hlsStatus').textContent(),
+    /404|manifestLoadError|Playlist request failed/,
+  );
+
+  if (errors.length) throw new Error(errors.join('\n'));
+  await page.close();
+}
+
 function findFreePort() {
   return new Promise((resolve, reject) => {
     const socket = net.createServer();
@@ -172,9 +394,14 @@
     const manifest = await (
       await fetch(`${baseUrl}/public/manifest.json`)
     ).text();
+    const serviceWorker = await (
+      await fetch(`${baseUrl}/public/sw.js`)
+    ).text();
     for (const source of [home, dogGame, manifest]) {
       assert.doesNotMatch(source, /\.png(?:["')]|$)/i);
     }
+    assert.match(serviceWorker, /v4-hlsjs/);
+    assert.ok(serviceWorker.includes("startsWith('/tools/hls_player')"));
 
     for (const asset of [
       'sprite_shiba0.webp',
@@ -199,6 +426,7 @@
     assert.ok(dark.count > 0);
     assert.ok(light.luminance < 175, JSON.stringify(light));
     assert.ok(dark.luminance > 200, JSON.stringify(dark));
+    await testHlsPlayer(browser, siteRoot);
   } finally {
     if (browser) await browser.close();
     await stopProcess(server);
--- a/seobeo/s_web.c	Mon Aug 03 11:26:30 2026 -0700
+++ b/seobeo/s_web.c	Mon Aug 03 13:14:41 2026 -0700
@@ -292,6 +292,9 @@
     else if (strstr(file_path, ".xml")) mime = "application/xml";
     else if (strstr(file_path, ".pdf")) mime = "application/pdf";
     else if (strstr(file_path, ".txt")) mime = "text/plain";
+    else if (strstr(file_path, ".m3u8")) mime = "application/vnd.apple.mpegurl";
+    else if (strstr(file_path, ".m4s")) mime = "video/iso.segment";
+    else if (strstr(file_path, ".ts")) mime = "video/mp2t";
     else if (strstr(file_path, ".mp4")) mime = "video/mp4";
     else if (strstr(file_path, ".webm")) mime = "video/webm";
     else if (strstr(file_path, ".mp3")) mime = "audio/mpeg";
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/third_party/ffmpeg/BUILD	Mon Aug 03 13:14:41 2026 -0700
@@ -0,0 +1,20 @@
+load("@rules_cc//cc:cc_binary.bzl", "cc_binary")
+load("@rules_cc//cc:cc_library.bzl", "cc_library")
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+
+cc_library(
+    name = "ffmpeg_cli",
+    srcs = ["ffmpeg_cli.c"],
+    hdrs = ["ffmpeg_cli.h"],
+    visibility = ["//visibility:public"],
+)
+
+cc_test(
+    name = "ffmpeg_hls_test",
+    srcs = ["ffmpeg_hls_test.c"],
+    args = ["$(location //mrjunejune:sample_hls_mp4)"],
+    data = ["//mrjunejune:sample_hls_mp4"],
+    deps = [":ffmpeg_cli"],
+    size = "large",
+    timeout = "long",
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/third_party/ffmpeg/README.md	Mon Aug 03 13:14:41 2026 -0700
@@ -0,0 +1,24 @@
+# FFmpeg CLI wrapper
+
+This package restores the existing process-based FFmpeg wrapper and adds
+fragmented-MP4 HLS VOD generation.
+
+The wrapper uses `fork`/`execvp` with explicit arguments instead of building a
+shell command. FFmpeg must be installed on the machine running the target.
+
+```c
+Fmp4HlsOpts options = ffmpeg_fmp4_hls_opts_default();
+options.codec = FFMPEG_HLS_VP9_OPUS;
+ffmpeg_video_to_fmp4_hls(
+    "input.mp4",
+    "output",
+    "sample",
+    &options);
+```
+
+The wrapper supports VP9/Opus and H.264/AAC fMP4 renditions so callers can
+build codec-compatible HLS master playlists.
+
+```bash
+bazel test //third_party/ffmpeg:ffmpeg_hls_test
+```
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/third_party/ffmpeg/ffmpeg_cli.c	Mon Aug 03 13:14:41 2026 -0700
@@ -0,0 +1,257 @@
+#include "third_party/ffmpeg/ffmpeg_cli.h"
+
+#include <errno.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+#define MAX_ERROR_LENGTH 512
+#define MAX_PATH_LENGTH 1024
+
+static char g_last_error[MAX_ERROR_LENGTH];
+
+static void set_error(const char *format, ...)
+{
+  va_list arguments;
+  va_start(arguments, format);
+  vsnprintf(g_last_error, sizeof(g_last_error), format, arguments);
+  va_end(arguments);
+}
+
+static bool file_exists(const char *path)
+{
+  struct stat status;
+  return path && stat(path, &status) == 0 && S_ISREG(status.st_mode);
+}
+
+static bool ensure_directory(const char *path)
+{
+  struct stat status;
+  if (stat(path, &status) == 0)
+    return S_ISDIR(status.st_mode);
+  return mkdir(path, 0755) == 0;
+}
+
+static int run_process(char *const arguments[])
+{
+  pid_t child = fork();
+  if (child < 0)
+  {
+    set_error("fork failed: %s", strerror(errno));
+    return -1;
+  }
+  if (child == 0)
+  {
+    execvp(arguments[0], arguments);
+    _exit(127);
+  }
+
+  int status = 0;
+  while (waitpid(child, &status, 0) < 0)
+  {
+    if (errno == EINTR)
+      continue;
+    set_error("waitpid failed: %s", strerror(errno));
+    return -1;
+  }
+  if (!WIFEXITED(status))
+    return -1;
+  return WEXITSTATUS(status);
+}
+
+Fmp4HlsOpts ffmpeg_fmp4_hls_opts_default(void)
+{
+  return (Fmp4HlsOpts){
+    .segment_duration = 2,
+    .video_bitrate = 900,
+    .audio_bitrate = 96,
+    .max_width = 960,
+    .max_height = 720,
+    .include_audio = true,
+    .use_mpeg_ts = false,
+    .codec = FFMPEG_HLS_VP9_OPUS,
+  };
+}
+
+bool ffmpeg_is_available(void)
+{
+  char *const arguments[] = {
+    "ffmpeg", "-version", NULL,
+  };
+  return run_process(arguments) == 0;
+}
+
+const char *ffmpeg_last_error(void)
+{
+  return g_last_error;
+}
+
+FfmpegResult ffmpeg_video_to_fmp4_hls(
+    const char *input_path,
+    const char *output_dir,
+    const char *name,
+    const Fmp4HlsOpts *options)
+{
+  if (!input_path || !output_dir || !name || name[0] == '\0')
+  {
+    set_error("input path, output directory, and name are required");
+    return FFMPEG_ERR_INVALID_ARGS;
+  }
+  if (!file_exists(input_path))
+  {
+    set_error("input file not found: %s", input_path);
+    return FFMPEG_ERR_INPUT_NOT_FOUND;
+  }
+  if (!ensure_directory(output_dir))
+  {
+    set_error("unable to create output directory: %s", output_dir);
+    return FFMPEG_ERR_OUTPUT_FAILED;
+  }
+
+  Fmp4HlsOpts defaults = ffmpeg_fmp4_hls_opts_default();
+  const Fmp4HlsOpts *opts = options ? options : &defaults;
+  if (opts->segment_duration <= 0 ||
+      opts->video_bitrate <= 0 ||
+      opts->max_width <= 0 ||
+      opts->max_height <= 0)
+  {
+    set_error("invalid fMP4 HLS options");
+    return FFMPEG_ERR_INVALID_ARGS;
+  }
+
+  char playlist_path[MAX_PATH_LENGTH];
+  char init_name[256];
+  char segment_pattern[MAX_PATH_LENGTH];
+  char segment_duration[16];
+  char video_bitrate[32];
+  char audio_bitrate[32];
+  char keyframe_expression[32];
+  char scale_filter[256];
+
+  snprintf(
+      playlist_path,
+      sizeof(playlist_path),
+      "%s/%s-stream.m3u8",
+      output_dir,
+      name);
+  snprintf(init_name, sizeof(init_name), "%s-init.mp4", name);
+  snprintf(
+      segment_pattern,
+      sizeof(segment_pattern),
+      "%s/%s-segment%%03d.%s",
+      output_dir,
+      name,
+      opts->use_mpeg_ts ? "ts" : "m4s");
+  snprintf(segment_duration, sizeof(segment_duration), "%d", opts->segment_duration);
+  snprintf(video_bitrate, sizeof(video_bitrate), "%dk", opts->video_bitrate);
+  snprintf(audio_bitrate, sizeof(audio_bitrate), "%dk", opts->audio_bitrate);
+  snprintf(
+      keyframe_expression,
+      sizeof(keyframe_expression),
+      "expr:gte(t,n_forced*%d)",
+      opts->segment_duration);
+  snprintf(
+      scale_filter,
+      sizeof(scale_filter),
+      "scale=w='min(iw,%d)':h='min(ih,%d)':force_original_aspect_ratio=decrease,"
+      "scale=trunc(iw/2)*2:trunc(ih/2)*2",
+      opts->max_width,
+      opts->max_height);
+
+  char *arguments[48];
+  size_t count = 0;
+#define ADD_ARGUMENT(value) arguments[count++] = (char *)(value)
+  ADD_ARGUMENT("ffmpeg");
+  ADD_ARGUMENT("-hide_banner");
+  ADD_ARGUMENT("-loglevel");
+  ADD_ARGUMENT("error");
+  ADD_ARGUMENT("-nostdin");
+  ADD_ARGUMENT("-y");
+  ADD_ARGUMENT("-i");
+  ADD_ARGUMENT(input_path);
+  ADD_ARGUMENT("-map");
+  ADD_ARGUMENT("0:v:0");
+  if (opts->include_audio)
+  {
+    ADD_ARGUMENT("-map");
+    ADD_ARGUMENT("0:a:0?");
+  }
+  ADD_ARGUMENT("-c:v");
+  if (opts->codec == FFMPEG_HLS_H264_AAC)
+  {
+    ADD_ARGUMENT("libx264");
+    ADD_ARGUMENT("-preset");
+    ADD_ARGUMENT("medium");
+    ADD_ARGUMENT("-profile:v");
+    ADD_ARGUMENT("main");
+    ADD_ARGUMENT("-level:v");
+    ADD_ARGUMENT("3.1");
+  }
+  else
+  {
+    ADD_ARGUMENT("libvpx-vp9");
+    ADD_ARGUMENT("-deadline");
+    ADD_ARGUMENT("good");
+    ADD_ARGUMENT("-cpu-used");
+    ADD_ARGUMENT("4");
+  }
+  ADD_ARGUMENT("-pix_fmt");
+  ADD_ARGUMENT("yuv420p");
+  ADD_ARGUMENT("-b:v");
+  ADD_ARGUMENT(video_bitrate);
+  ADD_ARGUMENT("-vf");
+  ADD_ARGUMENT(scale_filter);
+  ADD_ARGUMENT("-force_key_frames");
+  ADD_ARGUMENT(keyframe_expression);
+  if (opts->include_audio)
+  {
+    ADD_ARGUMENT("-c:a");
+    ADD_ARGUMENT(
+        opts->codec == FFMPEG_HLS_H264_AAC
+            ? "aac"
+            : "libopus");
+    ADD_ARGUMENT("-b:a");
+    ADD_ARGUMENT(audio_bitrate);
+  }
+  else
+  {
+    ADD_ARGUMENT("-an");
+  }
+  ADD_ARGUMENT("-f");
+  ADD_ARGUMENT("hls");
+  ADD_ARGUMENT("-hls_time");
+  ADD_ARGUMENT(segment_duration);
+  ADD_ARGUMENT("-hls_playlist_type");
+  ADD_ARGUMENT("vod");
+  if (!opts->use_mpeg_ts)
+  {
+    ADD_ARGUMENT("-hls_segment_type");
+    ADD_ARGUMENT("fmp4");
+    ADD_ARGUMENT("-hls_fmp4_init_filename");
+    ADD_ARGUMENT(init_name);
+  }
+  ADD_ARGUMENT("-hls_segment_filename");
+  ADD_ARGUMENT(segment_pattern);
+  ADD_ARGUMENT(playlist_path);
+  arguments[count] = NULL;
+#undef ADD_ARGUMENT
+
+  int result = run_process(arguments);
+  if (result != 0)
+  {
+    set_error("ffmpeg HLS conversion failed with exit code %d", result);
+    return FFMPEG_ERR_PROCESS_FAILED;
+  }
+  if (!file_exists(playlist_path))
+  {
+    set_error("playlist was not created: %s", playlist_path);
+    return FFMPEG_ERR_OUTPUT_FAILED;
+  }
+
+  return FFMPEG_OK;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/third_party/ffmpeg/ffmpeg_cli.h	Mon Aug 03 13:14:41 2026 -0700
@@ -0,0 +1,41 @@
+#ifndef FFMPEG_CLI_H
+#define FFMPEG_CLI_H
+
+#include <stdbool.h>
+
+typedef enum {
+  FFMPEG_OK = 0,
+  FFMPEG_ERR_INPUT_NOT_FOUND = 1,
+  FFMPEG_ERR_OUTPUT_FAILED = 2,
+  FFMPEG_ERR_INVALID_ARGS = 3,
+  FFMPEG_ERR_PROCESS_FAILED = 4,
+} FfmpegResult;
+
+typedef enum {
+  FFMPEG_HLS_VP9_OPUS = 0,
+  FFMPEG_HLS_H264_AAC = 1,
+} Fmp4HlsCodec;
+
+typedef struct {
+  int segment_duration;
+  int video_bitrate;
+  int audio_bitrate;
+  int max_width;
+  int max_height;
+  bool include_audio;
+  bool use_mpeg_ts;
+  Fmp4HlsCodec codec;
+} Fmp4HlsOpts;
+
+Fmp4HlsOpts ffmpeg_fmp4_hls_opts_default(void);
+
+FfmpegResult ffmpeg_video_to_fmp4_hls(
+    const char *input_path,
+    const char *output_dir,
+    const char *name,
+    const Fmp4HlsOpts *opts);
+
+bool ffmpeg_is_available(void);
+const char *ffmpeg_last_error(void);
+
+#endif
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/third_party/ffmpeg/ffmpeg_hls_test.c	Mon Aug 03 13:14:41 2026 -0700
@@ -0,0 +1,82 @@
+#include "third_party/ffmpeg/ffmpeg_cli.h"
+
+#include <assert.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+static char *read_file(const char *path)
+{
+  FILE *file = fopen(path, "rb");
+  if (!file)
+    return NULL;
+  fseek(file, 0, SEEK_END);
+  long length = ftell(file);
+  fseek(file, 0, SEEK_SET);
+  char *content = malloc((size_t)length + 1);
+  fread(content, 1, (size_t)length, file);
+  content[length] = '\0';
+  fclose(file);
+  return content;
+}
+
+int main(int argc, char **argv)
+{
+  assert(argc == 2);
+  char temporary[] = "/tmp/ffmpeg-hls-test-XXXXXX";
+  assert(mkdtemp(temporary));
+
+  Fmp4HlsOpts options = ffmpeg_fmp4_hls_opts_default();
+  options.max_width = 480;
+  options.max_height = 360;
+  options.video_bitrate = 350;
+  options.codec = FFMPEG_HLS_VP9_OPUS;
+  FfmpegResult result = ffmpeg_video_to_fmp4_hls(
+      argv[1],
+      temporary,
+      "test",
+      &options);
+  if (result != FFMPEG_OK)
+  {
+    fprintf(stderr, "%s\n", ffmpeg_last_error());
+    return 1;
+  }
+
+  char playlist_path[512];
+  snprintf(playlist_path, sizeof(playlist_path), "%s/test-stream.m3u8", temporary);
+  char *playlist = read_file(playlist_path);
+  assert(playlist);
+  assert(strstr(playlist, "#EXT-X-MAP:URI=\"test-init.mp4\""));
+  assert(strstr(playlist, "test-segment000.m4s"));
+  assert(strstr(playlist, "#EXT-X-ENDLIST"));
+  free(playlist);
+
+  options.codec = FFMPEG_HLS_H264_AAC;
+  options.use_mpeg_ts = true;
+  result = ffmpeg_video_to_fmp4_hls(
+      argv[1],
+      temporary,
+      "test-ts",
+      &options);
+  if (result != FFMPEG_OK)
+  {
+    fprintf(stderr, "%s\n", ffmpeg_last_error());
+    return 1;
+  }
+  snprintf(
+      playlist_path,
+      sizeof(playlist_path),
+      "%s/test-ts-stream.m3u8",
+      temporary);
+  playlist = read_file(playlist_path);
+  assert(playlist);
+  assert(!strstr(playlist, "#EXT-X-MAP"));
+  assert(strstr(playlist, "test-ts-segment000.ts"));
+  assert(strstr(playlist, "#EXT-X-ENDLIST"));
+  free(playlist);
+
+  char cleanup[640];
+  snprintf(cleanup, sizeof(cleanup), "rm -rf '%s'", temporary);
+  return system(cleanup) == 0 ? 0 : 1;
+}