← Commit history
README.mdadded+53
@@ -0,0 +1,53 @@+# Adom Video Player++When an AI shows you a video, you should be able to jump around in it. `adom-video` is the standard way for any AI in an Adom workspace to show a video or audio file: one command opens it in a Hydrogen webview tab with a real player whose timeline seeks.++```bash+adom-video --ai-thread "<conversation name>" play render.mp4 --title "Draft 2"+```++<img src="docs/player.png" width="760" alt="The Adom video player in a Hydrogen webview tab: a seekable timeline with chapter marks, frame step and speed controls, and a chapter list">++## Why it exists++AIs used to show videos by starting a quick `python -m http.server`. That server ignores the HTTP `Range` header, so the browser can only play the file from the start: the timeline does nothing, and a copy cached from that server stays unseekable even after a fix. `adom-video` runs one small server per container that answers `206 Partial Content`, honors `If-Range` and `HEAD`, and sends an `ETag` with `no-cache`, so seeking always works and a re-rendered file never plays stale.++## The player++| | |+|---|---|+| **Timeline** | Click or drag anywhere; hover shows the time; chapter marks |+| **Keyboard** | Space or K play, J / L 10 s, arrows 5 s, `,` `.` one frame, `[` `]` speed, 0 to 9 jump, F fullscreen, M mute, ? help |+| **Speed** | 0.25x, 0.5x, 1x, 1.5x, 2x |+| **Chapters** | `--chapters ch.json` (`[{"t": 12.5, "title": "Grind"}]`) or a WebVTT file; the current chapter is highlighted |+| **Auto-reload** | Re-render the file at the same path and the player reloads it, keeping your place |+| **Resume** | Each file reopens where you left it |+| **Library** | Every file shown in this container, one click away |++## Commands++| Command | What it does |+|---|---|+| `adom-video --ai-thread X play <file> [--title] [--chapters] [--fps] [--name] [--surface wv\|pup] [--no-open]` | Register a file and show it |+| `adom-video --ai-thread X url <file>` | Register and print the player URL |+| `adom-video ls` | The running server, its port and version, and the library |+| `adom-video settings [key=value]` | `ttlHours`, `autoplay`, `defaultSpeed`, `surface`, `rememberPosition` |+| `adom-video --ai-thread X stop` | Stop the server |++## How it behaves++- **Tabs:** opens in a Hydrogen work pane, never over VS Code. It reuses its tab by name and removes duplicates.+- **Security:** binds `127.0.0.1` on an automatic port and serves only files that were registered.+- **Lifetime:**+  - One server per container, restarted automatically when the package updates.+  - Exits after 24 hours idle.+  - Hydrogen can restart it after a wake.+- **Dependencies:** none beyond Node.++## Install++```bash+adom-wiki pkg install adom/adom-video-player+```++It ships the `adom-video-player` skill, so an AI that is about to show a video finds it by itself.
bin/adom-videoadded+188
@@ -0,0 +1,188 @@+#!/usr/bin/env node+// adom-video: show any video or audio file in a real player (seek, scrub,+// frame step, chapters, speed) through one shared Range-capable server.+'use strict';+const fs = require('fs');+const path = require('path');+const os = require('os');+const http = require('http');+const { spawn, execFileSync } = require('child_process');++const ROOT = path.resolve(fs.realpathSync(__filename), '..', '..');+const PKG = require(path.join(ROOT, 'package.json'));+const SERVER_FILE = path.join(os.homedir(), '.adom', 'instances', 'adom-video-player', 'server.json');++const HELP = `adom-video ${PKG.version}: play video and audio with a real, seekable player++  adom-video --ai-thread "<name>" play <file> [--title T] [--chapters ch.json|ch.vtt] [--fps N]+                                       [--name "Tab name"] [--surface wv|pup] [--no-open]+  adom-video --ai-thread "<name>" url <file>      register and print the player URL (embed it anywhere)+  adom-video ls                                   the running server, its port, version and library+  adom-video --ai-thread "<name>" stop            stop the shared server+  adom-video settings [key=value ...]             read or change settings (ttlHours, autoplay, defaultSpeed, surface)+  adom-video --version++Why: a quick "python -m http.server" ignores HTTP Range requests, so the browser cannot+seek and the video only plays from the start. This server answers 206 Partial Content,+sends ETags (a re-rendered file never plays stale from cache) and the player page adds+keyboard control, frame stepping, speeds, chapters and auto-reload when the file changes.++Chapters: JSON [{"t": 12.5, "title": "Grind"}] or a WebVTT file.+Keys in the player: space/k play, j/l -/+10 s, arrows -/+5 s, , . frame step, [ ] speed,+0-9 jump to 0-90 %, f fullscreen, m mute, ? help.`;++const argv = process.argv.slice(2);+let thread = process.env.ADOM_AI_THREAD || '';+const rest = [];+for (let i = 0; i < argv.length; i++) {+  if (argv[i] === '--ai-thread') thread = argv[++i] || '';+  else rest.push(argv[i]);+}+const verb = rest.shift();+const opt = (k) => { const i = rest.indexOf(k); if (i < 0) return undefined; const v = rest[i + 1]; rest.splice(i, 2); return v; };+const flag = (k) => { const i = rest.indexOf(k); if (i < 0) return false; rest.splice(i, 1); return true; };++const die = (o) => { console.log(JSON.stringify(o, null, 2)); process.exit(1); };+const requireThread = () => {+  if (!thread.trim()) die({ ok: false, errorCode: 'caller_identity_required',+    error: 'adom-video requires every AI caller to give its ai-thread name.',+    hint: 'Re-run as: adom-video --ai-thread "<short name a human recognizes for this conversation>" ' + (verb || 'play') + ' ...' });+};++function req(port, method, p, body) {+  return new Promise((resolve, reject) => {+    const data = body ? JSON.stringify({ aiThread: thread, ...body }) : null;+    const r = http.request({ host: '127.0.0.1', port, method, path: p, timeout: 4000,+      headers: data ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {} }, (res) => {+      let d = ''; res.on('data', (c) => (d += c)); res.on('end', () => { try { resolve(JSON.parse(d)); } catch { resolve({ ok: false, raw: d }); } });+    });+    r.on('error', reject); r.on('timeout', () => r.destroy(new Error('timeout'))); if (data) r.write(data); r.end();+  });+}++async function live() {+  let s; try { s = JSON.parse(fs.readFileSync(SERVER_FILE, 'utf8')); } catch { return null; }+  try { const v = await req(s.port, 'GET', '/version'); if (v.ok) return { ...s, running: v.version }; } catch {}+  return null;+}++async function ensureServer() {+  const s = await live();+  if (s && s.running === PKG.version) return s;+  if (s) { try { await req(s.port, 'POST', '/api/shutdown', {}); } catch {} await new Promise((r) => setTimeout(r, 300)); }+  const child = spawn(process.execPath, [path.join(ROOT, 'server.js'), '--port', 'auto', '--ai-thread', thread || 'unknown'],+    { detached: true, stdio: 'ignore' });+  child.unref();+  for (let i = 0; i < 50; i++) {+    await new Promise((r) => setTimeout(r, 100));+    const n = await live(); if (n) return n;+  }+  die({ ok: false, error: 'the server did not start', hint: `run it in the foreground to see why: node ${path.join(ROOT, 'server.js')} --port auto` });+}++function hydrogen() {+  try { execFileSync('adom-cli', ['hydrogen', 'workspace', 'tabs'], { stdio: 'pipe', timeout: 8000 }); return true; } catch { return false; }+}++function tabsNamed(name) {+  try {+    const j = JSON.parse(execFileSync('adom-cli', ['hydrogen', 'workspace', 'tabs'], { stdio: 'pipe', timeout: 8000 }).toString());+    return (j.tabs || []).filter((t) => t.name === name);+  } catch { return []; }+}++// Keep ONE tab with this name: the one showing `url` if any, else the first. Remove the rest from the+// highest index down (indexes shift as tabs close). Refreshing to a new URL can leave the old tab behind.+function dedupe(name, url) {+  let tabs = tabsNamed(name);+  if (tabs.length < 2) return tabs;+  const keep = tabs.find((t) => t.panelState && t.panelState.url === url) || tabs[0];+  const drop = tabs.filter((t) => t !== keep).sort((x, y) => (x.panelId === y.panelId ? y.tabIndex - x.tabIndex : 0));+  for (const t of drop) {+    try { execFileSync('adom-cli', ['hydrogen', 'workspace', 'remove-tab', '--panel-id', t.panelId, '--tab-index', String(t.tabIndex)], { stdio: 'pipe', timeout: 8000 }); } catch {}+  }+  return tabsNamed(name);+}++function baseUrl(port) {+  // Under Hydrogen the webview shares the machine: localhost is right.+  // In a cloud container the page renders in the user's browser: use the proxy.+  if (process.env.ADOM_DESKTOP_MODE === 'local' || hydrogen()) return `http://localhost:${port}`;+  const proxy = process.env.VSCODE_PROXY_URI;+  if (proxy) return proxy.replace('{{port}}', port).replace(/\/$/, '');+  return `http://localhost:${port}`;+}++async function main() {+  if (!verb || verb === 'help' || verb === '--help' || verb === '-h') return console.log(HELP);+  if (verb === '--version' || verb === 'version') return console.log(`adom-video ${PKG.version}`);++  if (verb === 'ls') {+    const s = await live();+    if (!s) return console.log(JSON.stringify({ ok: true, running: false, hint: 'adom-video --ai-thread "<name>" play <file> starts it' }, null, 2));+    const lib = await req(s.port, 'GET', '/api/library');+    const out = { ok: true, running: true, port: s.port, pid: s.pid, version: s.running, startedBy: s.thread, url: baseUrl(s.port),+      library: (lib.items || []).map((x) => ({ id: x.id, title: x.title, path: x.path, exists: x.exists, watch: `${baseUrl(s.port)}/watch/${x.id}` })) };+    if (s.running !== PKG.version) out.warn = `server runs ${s.running}, disk has ${PKG.version}: the next play restarts it`;+    return console.log(JSON.stringify(out, null, 2));+  }++  if (verb === 'settings') {+    const s = await ensureServer();+    const kv = {}; for (const a of rest) { const m = /^(\w+)=(.*)$/.exec(a); if (m) kv[m[1]] = m[2] === 'true' ? true : m[2] === 'false' ? false : isNaN(+m[2]) ? m[2] : +m[2]; }+    return console.log(JSON.stringify(Object.keys(kv).length ? await req(s.port, 'POST', '/settings', kv) : await req(s.port, 'GET', '/settings'), null, 2));+  }++  if (verb === 'stop') {+    requireThread();+    const s = await live(); if (!s) return console.log(JSON.stringify({ ok: true, running: false }));+    return console.log(JSON.stringify(await req(s.port, 'POST', '/api/shutdown', {})));+  }++  if (verb === 'play' || verb === 'url' || verb === 'show') {+    requireThread();+    const title = opt('--title'), chapters = opt('--chapters'), fps = opt('--fps'), name = opt('--name');+    const surfaceArg = opt('--surface'); const noOpen = flag('--no-open') || verb === 'url';+    const file = rest[0];+    if (!file) die({ ok: false, error: 'give a file: adom-video play <file>' });+    const abs = path.resolve(file);+    if (!fs.existsSync(abs)) die({ ok: false, error: `no such file: ${abs}` });+    const s = await ensureServer();+    const r = await req(s.port, 'POST', '/api/add', { path: abs, title, chapters: chapters ? path.resolve(chapters) : undefined, fps });+    if (!r.ok) die(r);+    const url = `${baseUrl(s.port)}${r.watch}`;+    const out = { ok: true, url, media: `${baseUrl(s.port)}${r.media}`, id: r.item.id, title: r.item.title, port: s.port };+    if (!noOpen) {+      const settings = (await req(s.port, 'GET', '/settings')).settings || {};+      const surface = surfaceArg || settings.surface || 'wv';+      const tab = name || `Video: ${r.item.title}`.slice(0, 60);+      if (surface === 'pup') {+        try {+          execFileSync('adom-bridge', ['--ai-thread', thread, 'pup_open_window', JSON.stringify({ url, reason: 'show a video the user asked to watch' })], { stdio: 'pipe', timeout: 60000 });+          out.opened = 'pup window';+        } catch (e) { out.opened = false; out.openError = String(e.message).slice(0, 300); }+      } else if (hydrogen()) {+        // Same name already showing this URL: just bring it forward (a refresh of the same URL can fall+        // back to recreate and leave a duplicate). Different URL: navigate it. None: open one; with no+        // --panel-id Hydrogen picks a work pane and never covers the editor.+        let existing = dedupe(tab, url);+        const showing = existing[0] && existing[0].panelState && existing[0].panelState.url === url;+        if (showing) {+          try { execFileSync('adom-cli', ['hydrogen', 'workspace', 'active-tab', '--name', tab], { stdio: 'pipe', timeout: 8000 }); } catch {}+          out.opened = `Hydrogen webview tab "${tab}" (already showing it)`;+          return console.log(JSON.stringify(out, null, 2));+        }+        const args = ['hydrogen', 'webview', 'open-or-refresh', '--name', tab, '--url', url, '--ai-thread', thread,+          '--reason', `video: ${r.item.title}`.slice(0, 80),+          '--restart', `adom-video --ai-thread ${JSON.stringify(thread)} play ${JSON.stringify(abs)} --no-open`];+        try { execFileSync('adom-cli', args, { stdio: 'pipe', timeout: 15000 }); out.opened = `Hydrogen webview tab "${tab}"`; dedupe(tab, url); }+        catch (e) { out.opened = false; out.openError = String(e.stderr || e.message).slice(0, 300); }+      } else {+        out.opened = false; out.hint = 'no Hydrogen here: open the url in a browser, or pass --surface pup';+      }+    }+    return console.log(JSON.stringify(out, null, 2));+  }+  die({ ok: false, error: `unknown verb "${verb}"`, hint: 'adom-video help' });+}+main().catch((e) => die({ ok: false, error: String(e && e.message || e) }));
docs/hero.pngadded
⋯ 1 unchanged line ⋯
docs/player.pngadded
⋯ 1 unchanged line ⋯
install.shadded+16
@@ -0,0 +1,16 @@+#!/bin/bash+# Install adom-video: app files under ~/.local/share, CLI on PATH, skill for Claude and Codex.+set -e+HERE="$(cd "$(dirname "$0")" && pwd)"+DEST="$HOME/.local/share/adom-video-player"+mkdir -p "$DEST" "$HOME/.local/bin"+rm -rf "$DEST.new" && mkdir -p "$DEST.new"+cp -r "$HERE/bin" "$HERE/public" "$HERE/server.js" "$HERE/package.json" "$DEST.new/"+rm -rf "$DEST" && mv "$DEST.new" "$DEST"+chmod 755 "$DEST/bin/adom-video"+ln -sf "$DEST/bin/adom-video" "$HOME/.local/bin/adom-video"+for d in "$HOME/.claude/skills/adom-video-player" "$HOME/.codex/skills/adom-video-player"; do+  mkdir -p "$d" && cp "$HERE/skills/adom-video-player/SKILL.md" "$d/SKILL.md"+done+# a running server on old code is replaced on the next play (version check)+echo "OK: adom-video $(node -p "require('$DEST/package.json').version") installed. Try: adom-video help"
package.jsonadded+92
@@ -0,0 +1,92 @@+{+  "slug": "adom-video-player",+  "type": "app",+  "version": "0.1.3",+  "title": "Adom Video Player",+  "brief": "Show any video in a real player: seek, scrub, frame step, chapters. One command, no hand-rolled servers.",+  "summary": "When an AI shows you a video or audio file, it plays here: a shared server that answers HTTP Range requests, so the timeline seeks, plus a player page with keyboard control, frame stepping, speeds, chapters and auto-reload when the file is re-rendered.",+  "description": "adom-video is the standard way for an AI to show a user a video or audio file in Hydrogen (or pup). It runs one small zero-dependency Node server per container that serves only the files you register, with full HTTP Range support (206 Partial Content, If-Range, HEAD, ETag, no-cache), which is what a browser needs to seek. A quick python -m http.server does not do this, so videos shown that way cannot be scrubbed. The player page adds keyboard control (space, J/K/L, arrows, frame step, speed, 0-9 jumps, fullscreen), a seekable timeline with chapter marks and hover time, a chapter list, a library of everything shown in the container, per-file resume, and auto-reload with position kept when the file changes on disk. It follows the Adom app conventions: --ai-thread on every verb, one registry entry, reuse instead of respawn, a 24 hour idle reaper.",+  "category": "Developer Tools",+  "tags": [+    "video",+    "player",+    "mp4",+    "webm",+    "audio",+    "seek",+    "scrub",+    "range requests",+    "webview",+    "preview",+    "chapters",+    "frame step"+  ],+  "status": "live",+  "discovery_pitch": "Show a video to the user with a real, seekable player instead of a hand-rolled http.server that cannot scrub: adom-video play <file> opens it in a Hydrogen webview tab with keyboard control, frame step, speeds and chapters.",+  "discovery_triggers": [+    "show me the video",+    "play the video",+    "play this mp4",+    "show the mp4 in a webview",+    "preview the render",+    "watch the recording",+    "open the video in hydrogen",+    "video in webview",+    "video in pup",+    "serve a video",+    "temporary server for a video",+    "python http.server video",+    "can't scrub the video",+    "cannot seek video",+    "video timeline does not work",+    "jump around in the video",+    "range requests",+    "206 partial content",+    "video player",+    "audio player",+    "play an mp3",+    "show the narration",+    "show the draft video",+    "frame step a video",+    "video chapters",+    "adom-video",+    "adom-video-player"+  ],+  "sample_prompts": [+    {+      "label": "Show a render",+      "prompt": "show me the video you just rendered"+    },+    {+      "label": "Scrub a recording",+      "prompt": "open the screen recording so I can jump around in it"+    },+    {+      "label": "Chapters",+      "prompt": "play the demo with a chapter for each step"+    }+  ],+  "bundled": true,+  "scripts": {+    "install": "./install.sh",+    "uninstall": "./uninstall.sh"+  },+  "install": {+    "binary": "bin/adom-video"+  },+  "files": [+    "bin/**",+    "public/**",+    "server.js",+    "package.json",+    "install.sh",+    "uninstall.sh",+    "skills/**",+    "README.md",+    "docs/**"+  ],+  "dependencies": {},+  "hero": {+    "path": "docs/hero.png"+  }+}\ No newline at end of file
page.jsonadded+98
@@ -0,0 +1,98 @@+{+  "slug": "adom-video-player",+  "type": "app",+  "version": "0.1.3",+  "title": "Adom Video Player",+  "brief": "Show any video in a real player: seek, scrub, frame step, chapters. One command, no hand-rolled servers.",+  "summary": "When an AI shows you a video or audio file, it plays here: a shared server that answers HTTP Range requests, so the timeline seeks, plus a player page with keyboard control, frame stepping, speeds, chapters and auto-reload when the file is re-rendered.",+  "description": "adom-video is the standard way for an AI to show a user a video or audio file in Hydrogen (or pup). It runs one small zero-dependency Node server per container that serves only the files you register, with full HTTP Range support (206 Partial Content, If-Range, HEAD, ETag, no-cache), which is what a browser needs to seek. A quick python -m http.server does not do this, so videos shown that way cannot be scrubbed. The player page adds keyboard control (space, J/K/L, arrows, frame step, speed, 0-9 jumps, fullscreen), a seekable timeline with chapter marks and hover time, a chapter list, a library of everything shown in the container, per-file resume, and auto-reload with position kept when the file changes on disk. It follows the Adom app conventions: --ai-thread on every verb, one registry entry, reuse instead of respawn, a 24 hour idle reaper.",+  "category": "Developer Tools",+  "tags": [+    "video",+    "player",+    "mp4",+    "webm",+    "audio",+    "seek",+    "scrub",+    "range requests",+    "webview",+    "preview",+    "chapters",+    "frame step"+  ],+  "status": "live",+  "discovery_pitch": "Show a video to the user with a real, seekable player instead of a hand-rolled http.server that cannot scrub: adom-video play <file> opens it in a Hydrogen webview tab with keyboard control, frame step, speeds and chapters.",+  "discovery_triggers": [+    "show me the video",+    "play the video",+    "play this mp4",+    "show the mp4 in a webview",+    "preview the render",+    "watch the recording",+    "open the video in hydrogen",+    "video in webview",+    "video in pup",+    "serve a video",+    "temporary server for a video",+    "python http.server video",+    "can't scrub the video",+    "cannot seek video",+    "video timeline does not work",+    "jump around in the video",+    "range requests",+    "206 partial content",+    "video player",+    "audio player",+    "play an mp3",+    "show the narration",+    "show the draft video",+    "frame step a video",+    "video chapters",+    "adom-video",+    "adom-video-player"+  ],+  "sample_prompts": [+    {+      "label": "Show a render",+      "prompt": "show me the video you just rendered"+    },+    {+      "label": "Scrub a recording",+      "prompt": "open the screen recording so I can jump around in it"+    },+    {+      "label": "Chapters",+      "prompt": "play the demo with a chapter for each step"+    }+  ],+  "bundled": true,+  "scripts": {+    "install": "./install.sh",+    "uninstall": "./uninstall.sh"+  },+  "install": {+    "binary": "bin/adom-video"+  },+  "files": [+    "bin/**",+    "public/**",+    "server.js",+    "package.json",+    "install.sh",+    "uninstall.sh",+    "skills/**",+    "README.md",+    "docs/**"+  ],+  "dependencies": {},+  "hero": {+    "type": "image",+    "path": "docs/hero.png"+  },+  "org": "adom",+  "author": {+    "name": "John Lauer",+    "email": "[email protected]"+  }+}\ No newline at end of file
public/index.htmladded+26
@@ -0,0 +1,26 @@+<!doctype html>+<html lang="en">+<head>+<meta charset="utf-8">+<meta name="viewport" content="width=device-width, initial-scale=1">+<title>Adom Video Player</title>+<link rel="stylesheet" href="/static/player.css">+<style> main { display: block; padding: 16px; height: auto; } .list li { justify-content: space-between; } .p { color: var(--muted); font-size: 12px; } </style>+</head>+<body>+<header><div class="title"><span class="dot"></span><h1>Adom Video Player</h1></div><div class="meta">Every video an AI showed you in this container. Click one to watch.</div></header>+<main><ul id="lib" class="list"></ul></main>+<script>+fetch('/api/library', { cache: 'no-store' }).then((r) => r.json()).then((r) => {+  const ul = document.getElementById('lib');+  if (!r.items.length) ul.innerHTML = '<li class="empty">Nothing yet. An AI runs: adom-video --ai-thread "&lt;name&gt;" play &lt;file&gt;</li>';+  r.items.forEach((x) => {+    const li = document.createElement('li'); if (!x.exists) li.classList.add('gone');+    const a = document.createElement('span'); a.textContent = x.title;+    const p = document.createElement('span'); p.className = 'p'; p.textContent = x.path;+    li.append(a, p); li.onclick = () => (location.href = '/watch/' + x.id); ul.appendChild(li);+  });+});+</script>+</body>+</html>
public/player.cssadded+47
@@ -0,0 +1,47 @@+:root {+  --bg: #0d1117; --panel: #161b22; --line: #30363d; --text: #e6edf3; --muted: #8b949e;+  --accent: #00b8b1; --accent2: #00e6dc; --warn: #d29922;+}+* { box-sizing: border-box; }+html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text);+  font: 14px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif; }+header { display: flex; align-items: center; gap: 16px; padding: 10px 16px; border-bottom: 1px solid var(--line); flex-wrap: wrap; }+.title { display: flex; align-items: center; gap: 10px; min-width: 0; }+.dot { width: 10px; height: 10px; border-radius: 50%; background: var(--accent); flex: none; }+h1 { font-size: 16px; margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }+.meta { color: var(--muted); font-size: 12px; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }+.actions { display: flex; gap: 8px; }+main { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: 0; height: calc(100% - 52px); }+@media (max-width: 860px) { main { grid-template-columns: 1fr; height: auto; } }+.stage { display: flex; flex-direction: column; padding: 12px 16px; gap: 8px; min-height: 0; }+video { width: 100%; flex: 1; min-height: 0; background: #000; border-radius: 8px; outline: none; }+.timeline { position: relative; height: 18px; background: var(--panel); border: 1px solid var(--line); border-radius: 9px; cursor: pointer; overflow: hidden; }+.timeline .buf { position: absolute; inset: 0 auto 0 0; background: #21262d; width: 0; }+.timeline .prog { position: absolute; inset: 0 auto 0 0; background: linear-gradient(90deg, #0061ef, var(--accent)); width: 0; }+.timeline .marks i { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--text); opacity: .7; }+.timeline .hover { position: absolute; top: -1px; transform: translateX(-50%); font-size: 11px; background: var(--bg); padding: 0 4px; border-radius: 4px; display: none; pointer-events: none; }+.bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }+.spacer { flex: 1; }+.time { font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--muted); margin-left: 6px; }+.btn { background: var(--panel); color: var(--text); border: 1px solid var(--line); border-radius: 6px; padding: 5px 10px; cursor: pointer; font: inherit; text-decoration: none; }+.btn:hover { border-color: var(--accent); }+.btn.primary { background: var(--accent); color: #001614; border-color: var(--accent); font-weight: 600; min-width: 68px; }+.btn.on { border-color: var(--accent); color: var(--accent2); }+.speeds { display: flex; gap: 4px; }+.speeds .btn { padding: 4px 7px; font-size: 12px; }+.loop { color: var(--muted); font-size: 13px; display: flex; gap: 4px; align-items: center; }+aside { border-left: 1px solid var(--line); padding: 12px 14px; overflow: auto; }+aside h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: 6px 0 8px; }+.list { list-style: none; margin: 0 0 18px; padding: 0; }+.list li { padding: 6px 8px; border-radius: 6px; cursor: pointer; display: flex; gap: 8px; }+.list li:hover { background: var(--panel); }+.list li.active { background: var(--panel); outline: 1px solid var(--accent); }+.list li .t { font: 12px ui-monospace, monospace; color: var(--accent2); flex: none; }+.list li.empty { color: var(--muted); cursor: default; font-size: 12px; }+.list li.gone { opacity: .45; }+.toast { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); background: var(--panel); border: 1px solid var(--accent); padding: 8px 14px; border-radius: 8px; opacity: 0; transition: opacity .25s; pointer-events: none; }+.toast.show { opacity: 1; }+.help { position: fixed; inset: 0; background: #0009; display: flex; align-items: center; justify-content: center; }+.help[hidden] { display: none; }+.help .card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 16px 20px; min-width: 300px; }+.help td { padding: 3px 14px 3px 0; } .help td:first-child { font-family: ui-monospace, monospace; color: var(--accent2); }
public/player.htmladded+66
@@ -0,0 +1,66 @@+<!doctype html>+<html lang="en">+<head>+<meta charset="utf-8">+<meta name="viewport" content="width=device-width, initial-scale=1">+<title>Video</title>+<link rel="stylesheet" href="/static/player.css">+</head>+<body>+<header>+  <div class="title"><span class="dot"></span><h1 id="title">Loading</h1></div>+  <div class="meta" id="meta"></div>+  <div class="actions">+    <a id="download" class="btn" download>Download</a>+    <button class="btn" id="copy">Copy link</button>+    <button class="btn" id="helpBtn" title="Keyboard shortcuts (?)">?</button>+  </div>+</header>+<main>+  <section class="stage">+    <video id="v" controls preload="auto" playsinline></video>+    <div class="timeline" id="tl" title="Click to seek">+      <div class="buf" id="buf"></div><div class="prog" id="prog"></div>+      <div class="marks" id="marks"></div><div class="hover" id="hover"></div>+    </div>+    <div class="bar">+      <button class="btn" id="back10" title="Back 10 s (J)">−10 s</button>+      <button class="btn" id="prevF" title="Previous frame (,)">◂ frame</button>+      <button class="btn primary" id="play" title="Play / pause (space, K)">Play</button>+      <button class="btn" id="nextF" title="Next frame (.)">frame ▸</button>+      <button class="btn" id="fwd10" title="Forward 10 s (L)">+10 s</button>+      <span class="time" id="time">0:00.00 / 0:00.00</span>+      <span class="spacer"></span>+      <div class="speeds" id="speeds"></div>+      <label class="loop"><input type="checkbox" id="loop"> loop</label>+    </div>+  </section>+  <aside>+    <h2>Chapters</h2>+    <ol id="chapters" class="list"><li class="empty">No chapters. Pass --chapters to adom-video play.</li></ol>+    <h2>Library</h2>+    <ul id="library" class="list"></ul>+  </aside>+</main>+<div id="toast" class="toast"></div>+<div id="help" class="help" hidden>+  <div class="card">+    <h3>Keyboard</h3>+    <table>+      <tr><td>Space / K</td><td>play / pause</td></tr>+      <tr><td>J / L</td><td>back / forward 10 s</td></tr>+      <tr><td>← / →</td><td>back / forward 5 s</td></tr>+      <tr><td>, / .</td><td>previous / next frame</td></tr>+      <tr><td>[ / ]</td><td>slower / faster</td></tr>+      <tr><td>0 to 9</td><td>jump to 0 to 90 %</td></tr>+      <tr><td>Home / End</td><td>start / end</td></tr>+      <tr><td>F</td><td>fullscreen</td></tr>+      <tr><td>M</td><td>mute</td></tr>+      <tr><td>?</td><td>this help</td></tr>+    </table>+    <button class="btn" id="helpClose">Close</button>+  </div>+</div>+<script src="/static/player.js"></script>+</body>+</html>
public/player.jsadded+147
@@ -0,0 +1,147 @@+// adom-video-player page: seekable playback with keyboard control, frame step,+// speeds, chapters, a library, and auto-reload when the file changes on disk.+(() => {+  const $ = (id) => document.getElementById(id);+  const v = $('v');+  const id = location.pathname.split('/').pop();+  let item = null, settings = {}, fps = 30, etag = null;+  const store = {+    get(k) { try { return localStorage.getItem(k); } catch { return null; } },+    set(k, val) { try { localStorage.setItem(k, val); } catch {} },+  };++  const fmt = (t) => {+    if (!isFinite(t)) return '0:00.00';+    const h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), s = t % 60;+    const ss = s.toFixed(2).padStart(5, '0');+    return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`;+  };+  const size = (b) => b > 1e9 ? (b / 1e9).toFixed(2) + ' GB' : b > 1e6 ? (b / 1e6).toFixed(1) + ' MB' : (b / 1e3).toFixed(0) + ' KB';+  let toastT;+  const toast = (msg) => { const t = $('toast'); t.textContent = msg; t.classList.add('show'); clearTimeout(toastT); toastT = setTimeout(() => t.classList.remove('show'), 1800); };++  async function load(keep) {+    const r = await fetch(`/api/item/${id}`, { cache: 'no-store' }).then((x) => x.json());+    if (!r.ok) { $('title').textContent = 'Unknown video'; return; }+    item = r.item; settings = r.settings || {}; fps = item.fps || 30;+    document.title = item.title;+    $('title').textContent = item.title;+    $('meta').textContent = item.exists ? `${item.path} · ${size(item.size)}` : `${item.path} · file missing`;+    $('download').href = `/media/${id}`;+    $('download').setAttribute('download', item.path.split('/').pop());+    const t = keep ? v.currentTime : Number(settings.rememberPosition !== false && store.get('pos:' + id) || 0);+    const wasPaused = keep ? v.paused : !settings.autoplay;+    etag = item.etag;+    v.src = `/media/${id}?v=${encodeURIComponent(item.etag || '')}`;+    v.playbackRate = Number(store.get('speed') || settings.defaultSpeed || 1);+    v.addEventListener('loadedmetadata', () => {+      if (t > 0 && t < v.duration - 0.5) v.currentTime = t;+      if (!wasPaused) v.play().catch(() => {});+      marks(); speeds();+    }, { once: true });+    chapters();+  }++  function chapters() {+    const ol = $('chapters'); const ch = item.chapters || [];+    if (!ch.length) return;+    ol.innerHTML = '';+    ch.forEach((c, k) => {+      const li = document.createElement('li'); li.dataset.k = k;+      li.innerHTML = `<span class="t">${fmt(c.t).replace(/\.\d+$/, '')}</span><span></span>`;+      li.lastChild.textContent = c.title;+      li.onclick = () => { v.currentTime = c.t; v.play().catch(() => {}); };+      ol.appendChild(li);+    });+  }+  function activeChapter() {+    const ch = item && item.chapters || []; let a = -1;+    ch.forEach((c, k) => { if (v.currentTime + 0.05 >= c.t) a = k; });+    document.querySelectorAll('#chapters li').forEach((li) => li.classList.toggle('active', Number(li.dataset.k) === a));+  }+  function marks() {+    const m = $('marks'); m.innerHTML = '';+    (item.chapters || []).forEach((c) => { if (v.duration) { const i = document.createElement('i'); i.style.left = (100 * c.t / v.duration) + '%'; i.title = c.title; m.appendChild(i); } });+  }+  function speeds() {+    const box = $('speeds'); box.innerHTML = '';+    [0.25, 0.5, 1, 1.5, 2].forEach((s) => {+      const b = document.createElement('button'); b.className = 'btn' + (v.playbackRate === s ? ' on' : ''); b.textContent = s + '×';+      b.onclick = () => setSpeed(s); box.appendChild(b);+    });+  }+  function setSpeed(s) { v.playbackRate = s; store.set('speed', s); speeds(); toast(`Speed ${s}×`); }++  async function libraryList() {+    const r = await fetch('/api/library', { cache: 'no-store' }).then((x) => x.json()).catch(() => ({ items: [] }));+    const ul = $('library'); ul.innerHTML = '';+    (r.items || []).forEach((x) => {+      const li = document.createElement('li'); if (x.id === id) li.classList.add('active'); if (!x.exists) li.classList.add('gone');+      li.textContent = x.title; li.title = x.path;+      li.onclick = () => { location.href = `/watch/${x.id}`; };+      ul.appendChild(li);+    });+  }++  // timeline+  const tl = $('tl');+  const pos = (e) => { const r = tl.getBoundingClientRect(); return Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)); };+  tl.addEventListener('click', (e) => { if (v.duration) v.currentTime = pos(e) * v.duration; });+  tl.addEventListener('mousemove', (e) => { const h = $('hover'); if (!v.duration) return; h.style.display = 'block'; h.style.left = (pos(e) * 100) + '%'; h.textContent = fmt(pos(e) * v.duration); });+  tl.addEventListener('mouseleave', () => { $('hover').style.display = 'none'; });++  const tick = () => {+    $('time').textContent = `${fmt(v.currentTime)} / ${fmt(v.duration)}`;+    if (v.duration) {+      $('prog').style.width = (100 * v.currentTime / v.duration) + '%';+      if (v.buffered.length) $('buf').style.width = (100 * v.buffered.end(v.buffered.length - 1) / v.duration) + '%';+    }+    $('play').textContent = v.paused ? 'Play' : 'Pause';+    activeChapter();+  };+  ['timeupdate', 'play', 'pause', 'seeked', 'progress', 'durationchange'].forEach((e) => v.addEventListener(e, tick));+  setInterval(() => { if (!v.paused) tick(); if (item && settings.rememberPosition !== false) store.set('pos:' + id, v.currentTime); }, 250);++  const step = (n) => { v.pause(); v.currentTime = Math.max(0, Math.min(v.duration || 0, v.currentTime + n / fps)); };+  const jump = (s) => { v.currentTime = Math.max(0, Math.min(v.duration || 0, v.currentTime + s)); };+  const toggle = () => (v.paused ? v.play().catch(() => {}) : v.pause());+  $('play').onclick = toggle; $('back10').onclick = () => jump(-10); $('fwd10').onclick = () => jump(10);+  $('prevF').onclick = () => step(-1); $('nextF').onclick = () => step(1);+  $('loop').onchange = (e) => { v.loop = e.target.checked; };+  $('copy').onclick = () => { navigator.clipboard?.writeText(location.href).then(() => toast('Link copied'), () => toast(location.href)); };+  const help = (on) => { $('help').hidden = !on; };+  $('helpBtn').onclick = () => help(true); $('helpClose').onclick = () => help(false); $('help').onclick = (e) => { if (e.target.id === 'help') help(false); };++  document.addEventListener('keydown', (e) => {+    if (e.target.tagName === 'INPUT' || e.metaKey || e.ctrlKey || e.altKey) return;+    const k = e.key; let hit = true;+    if (k === ' ' || k === 'k' || k === 'K') toggle();+    else if (k === 'j' || k === 'J') jump(-10);+    else if (k === 'l' || k === 'L') jump(10);+    else if (k === 'ArrowLeft') jump(-5);+    else if (k === 'ArrowRight') jump(5);+    else if (k === ',') step(-1);+    else if (k === '.') step(1);+    else if (k === '[') setSpeed(Math.max(0.25, +(v.playbackRate - 0.25).toFixed(2)));+    else if (k === ']') setSpeed(Math.min(4, +(v.playbackRate + 0.25).toFixed(2)));+    else if (k === 'Home') v.currentTime = 0;+    else if (k === 'End') v.currentTime = v.duration || 0;+    else if (/^[0-9]$/.test(k)) { if (v.duration) v.currentTime = v.duration * Number(k) / 10; }+    else if (k === 'f' || k === 'F') { document.fullscreenElement ? document.exitFullscreen() : v.requestFullscreen?.(); }+    else if (k === 'm' || k === 'M') { v.muted = !v.muted; toast(v.muted ? 'Muted' : 'Sound on'); }+    else if (k === '?') help($('help').hidden);+    else if (k === 'Escape') help(false);+    else hit = false;+    if (hit) e.preventDefault();+  });++  // auto-reload when the file is re-rendered, keeping the position+  setInterval(async () => {+    try {+      const r = await fetch(`/api/item/${id}`, { cache: 'no-store' }).then((x) => x.json());+      if (r.ok && r.item.etag && etag && r.item.etag !== etag) { const at = fmt(v.currentTime); await load(true); toast(`File updated, reloaded at ${at}`); }+    } catch {}+  }, 2000);++  load(false); libraryList();+})();
server.jsadded+198
@@ -0,0 +1,198 @@+#!/usr/bin/env node+// adom-video-player server: one per container, serves registered media with+// full HTTP Range support (206, If-Range, HEAD, ETag) so every player can seek.+'use strict';+const http = require('http');+const fs = require('fs');+const path = require('path');+const crypto = require('crypto');+const os = require('os');++const PKG = require('./package.json');+const HOME = os.homedir();+const INST_DIR = path.join(HOME, '.adom', 'instances', 'adom-video-player');+const DATA_DIR = path.join(HOME, '.adom', 'adom-video-player');+const LIB_FILE = path.join(DATA_DIR, 'library.json');+const SET_FILE = path.join(DATA_DIR, 'settings.json');+const SERVER_FILE = path.join(INST_DIR, 'server.json');+const PUBLIC = path.join(__dirname, 'public');++const argv = process.argv.slice(2);+const argVal = (k) => { const i = argv.indexOf(k); return i >= 0 ? argv[i + 1] : undefined; };+const THREAD = argVal('--ai-thread') || process.env.ADOM_AI_THREAD || 'unknown';+const KEEP_ALIVE = argv.includes('--keep-alive');++fs.mkdirSync(INST_DIR, { recursive: true });+fs.mkdirSync(DATA_DIR, { recursive: true });++const readJSON = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };+const writeJSON = (f, v) => { const t = f + '.tmp'; fs.writeFileSync(t, JSON.stringify(v, null, 2)); fs.renameSync(t, f); };++const DEFAULT_SETTINGS = { ttlHours: 24, autoplay: true, defaultSpeed: 1, surface: 'wv', rememberPosition: true };+let settings = { ...DEFAULT_SETTINGS, ...readJSON(SET_FILE, {}) };+let library = readJSON(LIB_FILE, { items: [] });++const MIME = {+  '.mp4': 'video/mp4', '.m4v': 'video/mp4', '.mov': 'video/quicktime', '.webm': 'video/webm', '.mkv': 'video/x-matroska',+  '.ogv': 'video/ogg', '.avi': 'video/x-msvideo', '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.wav': 'audio/wav',+  '.ogg': 'audio/ogg', '.flac': 'audio/flac', '.gif': 'image/gif', '.png': 'image/png', '.jpg': 'image/jpeg',+  '.jpeg': 'image/jpeg', '.vtt': 'text/vtt', '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8',+  '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.json': 'application/json',+};+const mimeOf = (p) => MIME[path.extname(p).toLowerCase()] || 'application/octet-stream';+const idOf = (p) => crypto.createHash('sha1').update(p).digest('hex').slice(0, 10);++function statItem(it) {+  try {+    const st = fs.statSync(it.path);+    return { ...it, size: st.size, mtime: st.mtimeMs, etag: `"${st.size.toString(16)}-${Math.floor(st.mtimeMs).toString(16)}"`, exists: true };+  } catch { return { ...it, exists: false }; }+}++function parseChapters(src) {+  if (!src) return [];+  if (Array.isArray(src)) return src.map((c) => ({ t: Number(c.t ?? c.time ?? c.start ?? 0), title: String(c.title ?? c.name ?? '') }));+  try {+    const txt = fs.readFileSync(src, 'utf8');+    if (src.endsWith('.json')) return parseChapters(JSON.parse(txt));+    const out = []; const re = /(\d+:)?(\d{1,2}):(\d{2})[.,](\d{3})\s*-->.*\n(.+)/g; let m;+    while ((m = re.exec(txt))) out.push({ t: (m[1] ? parseInt(m[1]) * 3600 : 0) + parseInt(m[2]) * 60 + parseInt(m[3]) + parseInt(m[4]) / 1000, title: m[5].trim() });+    return out;+  } catch { return []; }+}++let lastActivity = Date.now();+let port = 0;++function register() {+  writeJSON(SERVER_FILE, { pid: process.pid, port, thread: THREAD, version: PKG.version, startedAt: startedAt, lastActivity: new Date(lastActivity).toISOString() });+}+const startedAt = new Date().toISOString();++function send(res, code, body, headers = {}) {+  const b = typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body, null, 2);+  res.writeHead(code, { 'Content-Type': typeof body === 'object' && !Buffer.isBuffer(body) ? 'application/json' : 'text/plain; charset=utf-8', ...headers });+  res.end(b);+}++function readBody(req) {+  return new Promise((resolve) => { let d = ''; req.on('data', (c) => (d += c)); req.on('end', () => { try { resolve(d ? JSON.parse(d) : {}); } catch { resolve({ __bad: true }); } }); });+}++const needThread = (b) => (b && typeof b.aiThread === 'string' && b.aiThread.trim()) ? null : {+  ok: false, errorCode: 'caller_identity_required',+  error: 'adom-video-player requires every AI caller to give its ai-thread name.',+  hint: 'Re-run with the global flag: adom-video --ai-thread "<name>" <verb>, or add a top-level "aiThread" field if you POST directly. Use a short name a HUMAN would recognize for THIS conversation.',+};++function serveStatic(res, file) {+  fs.readFile(file, (err, data) => {+    if (err) return send(res, 404, 'not found');+    res.writeHead(200, { 'Content-Type': mimeOf(file), 'Cache-Control': 'no-cache' }); res.end(data);+  });+}++// The heart of it: byte-range serving so <video> can seek anywhere.+function serveMedia(req, res, it) {+  const s = statItem(it);+  if (!s.exists) return send(res, 404, { ok: false, error: `file is gone: ${it.path}` });+  const total = s.size;+  const base = {+    'Content-Type': mimeOf(it.path), 'Accept-Ranges': 'bytes', ETag: s.etag,+    'Last-Modified': new Date(s.mtime).toUTCString(), 'Cache-Control': 'no-cache',+    'Content-Disposition': `inline; filename="${path.basename(it.path).replace(/"/g, '')}"`,+  };+  if (req.headers['if-none-match'] === s.etag && !req.headers.range) { res.writeHead(304, base); return res.end(); }+  let range = req.headers.range;+  const ifRange = req.headers['if-range'];+  if (range && ifRange && ifRange !== s.etag && ifRange !== base['Last-Modified']) range = null; // file changed: send it whole+  if (!range) {+    res.writeHead(200, { ...base, 'Content-Length': total });+    if (req.method === 'HEAD') return res.end();+    return fs.createReadStream(it.path).pipe(res);+  }+  const m = /^bytes=(\d*)-(\d*)$/.exec(range.trim());+  let start, end;+  if (m && (m[1] || m[2])) {+    if (m[1]) { start = parseInt(m[1], 10); end = m[2] ? parseInt(m[2], 10) : total - 1; }+    else { start = Math.max(0, total - parseInt(m[2], 10)); end = total - 1; }+  }+  if (start === undefined || isNaN(start) || start >= total || end < start) {+    res.writeHead(416, { ...base, 'Content-Range': `bytes */${total}` }); return res.end();+  }+  end = Math.min(end, total - 1);+  res.writeHead(206, { ...base, 'Content-Range': `bytes ${start}-${end}/${total}`, 'Content-Length': end - start + 1 });+  if (req.method === 'HEAD') return res.end();+  fs.createReadStream(it.path, { start, end }).pipe(res);+}++const server = http.createServer(async (req, res) => {+  lastActivity = Date.now();+  const url = new URL(req.url, 'http://x');+  const p = url.pathname;+  try {+    if (p === '/' || p === '/index.html') return serveStatic(res, path.join(PUBLIC, 'index.html'));+    if (p.startsWith('/watch/')) return serveStatic(res, path.join(PUBLIC, 'player.html'));+    if (p.startsWith('/static/')) {+      const f = path.join(PUBLIC, path.normalize(p.slice(8)).replace(/^(\.\.[/\\])+/, ''));+      if (!f.startsWith(PUBLIC)) return send(res, 403, 'no');+      return serveStatic(res, f);+    }+    if (p === '/version') return send(res, 200, { ok: true, app: 'adom-video-player', version: PKG.version, pid: process.pid, port, thread: THREAD });+    if (p === '/settings' && req.method === 'GET') return send(res, 200, { ok: true, settings });+    if (p === '/settings' && req.method === 'POST') {+      const b = await readBody(req);+      for (const k of Object.keys(DEFAULT_SETTINGS)) if (k in b) settings[k] = b[k];+      writeJSON(SET_FILE, settings); return send(res, 200, { ok: true, settings });+    }+    if (p === '/api/library') return send(res, 200, { ok: true, items: library.items.map(statItem) });+    if (p.startsWith('/api/item/')) {+      const it = library.items.find((x) => x.id === p.slice(10));+      return it ? send(res, 200, { ok: true, item: statItem(it), settings }) : send(res, 404, { ok: false, error: 'unknown id' });+    }+    if (p.startsWith('/media/')) {+      const id = p.slice(7).split('/')[0];+      const it = library.items.find((x) => x.id === id);+      if (!it) return send(res, 404, { ok: false, error: 'unknown id; register the file with adom-video play <file>' });+      return serveMedia(req, res, it);+    }+    if (p === '/api/add' && req.method === 'POST') {+      const b = await readBody(req); const no = needThread(b); if (no) return send(res, 400, no);+      const abs = path.resolve(String(b.path || ''));+      if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return send(res, 400, { ok: false, error: `not a file: ${abs}` });+      const id = idOf(abs);+      const prev = library.items.find((x) => x.id === id);+      const item = { id, path: abs, title: b.title || prev?.title || path.basename(abs), chapters: parseChapters(b.chapters) .length ? parseChapters(b.chapters) : (prev?.chapters || []),+        fps: Number(b.fps) || prev?.fps || 0, addedBy: b.aiThread, addedAt: new Date().toISOString() };+      library.items = [item, ...library.items.filter((x) => x.id !== id)].slice(0, 200);+      writeJSON(LIB_FILE, library);+      return send(res, 200, { ok: true, item: statItem(item), watch: `/watch/${id}`, media: `/media/${id}` });+    }+    if (p === '/api/remove' && req.method === 'POST') {+      const b = await readBody(req); const no = needThread(b); if (no) return send(res, 400, no);+      library.items = library.items.filter((x) => x.id !== b.id); writeJSON(LIB_FILE, library); return send(res, 200, { ok: true });+    }+    if (p === '/api/shutdown' && req.method === 'POST') {+      const b = await readBody(req); const no = needThread(b); if (no) return send(res, 400, no);+      send(res, 200, { ok: true, bye: true }); return setTimeout(shutdown, 100);+    }+    return send(res, 404, { ok: false, error: `no route ${p}` });+  } catch (e) { return send(res, 500, { ok: false, error: String(e && e.stack || e) }); }+});++function shutdown() {+  try { if (readJSON(SERVER_FILE, {}).pid === process.pid) fs.unlinkSync(SERVER_FILE); } catch {}+  process.exit(0);+}+process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown);++const want = argVal('--port');+server.listen(want && want !== 'auto' ? parseInt(want, 10) : 0, '127.0.0.1', () => {+  port = server.address().port; register();+  console.log(JSON.stringify({ ok: true, port, pid: process.pid, version: PKG.version, thread: THREAD }));+});+setInterval(() => {+  register();+  const ttl = Number(argVal('--ttl') || settings.ttlHours || 24);+  if (!KEEP_ALIVE && Date.now() - lastActivity > ttl * 3600e3) shutdown();+}, 60e3).unref();
skills/adom-video-player/SKILL.mdadded+72
@@ -0,0 +1,72 @@+---+name: adom-video-player+user-invocable: true+description: >-+  The standard way to SHOW a user any video or audio file (mp4, webm, mov, mkv, mp3, wav): adom-video play <file>+  opens it in a Hydrogen webview tab (or pup) with a real player whose timeline seeks, plus keyboard control,+  frame stepping, speeds, chapters, a library, and auto-reload when the file is re-rendered. Use it INSTEAD of+  writing a quick `python -m http.server`, `npx serve`, or any one-off server: those ignore HTTP Range requests,+  so the user cannot jump around in the video (the most common complaint). Trigger words: show me the video,+  play the video, play this mp4, open the recording, preview the render, watch the draft, video in webview, video+  in pup, serve a video file, temporary server for a video, can't scrub, cannot seek, timeline does not work,+  jump around in the video, range requests, 206 partial content, audio player, play the narration, frame step,+  video chapters, adom-video.+---++# adom-video: show a video the user can actually scrub++**Rule: never hand-roll a server to show a video.** `python -m http.server` and most quick servers ignore the+`Range` header, so the browser cannot seek: the timeline does nothing and the user cannot jump around. A cached+copy from such a server stays unseekable even after you fix it. Use `adom-video`; it does it right.++## The one command++```bash+adom-video --ai-thread "<this conversation's name>" play path/to/video.mp4 --title "Draft 2, silent"+```++It starts (or reuses) the one shared server, registers the file, and opens a Hydrogen webview tab named+`Video: <title>` beside VS Code, never over it. It prints JSON with the `url` and the `media` URL.++| Option | What it does |+|---|---|+| `--title T` | Title in the tab and the library |+| `--chapters ch.json` or `ch.vtt` | Chapter list and timeline marks. JSON: `[{"t": 12.5, "title": "Grind"}]` |+| `--fps N` | Frame rate for frame stepping (default 30) |+| `--name "Tab"` | Webview tab name; reusing a name navigates that tab instead of opening another |+| `--surface pup` | Open in a pup window instead of a webview |+| `--no-open` / `adom-video url <file>` | Only register and print the URL, for embedding |++Other verbs: `adom-video ls` (server, port, version, library), `adom-video settings [key=value]`+(`ttlHours`, `autoplay`, `defaultSpeed`, `surface`, `rememberPosition`), `adom-video --ai-thread X stop`.++## What the user gets++- A timeline that seeks anywhere (206 Partial Content, `If-Range`, `HEAD`, `ETag`).+- Keys: space or K play, J / L 10 s, arrows 5 s, `,` `.` one frame, `[` `]` speed, 0 to 9 jump, F fullscreen, M mute, ? help.+- Speed buttons 0.25x to 2x, loop, chapter list with the current chapter highlighted, hover time on the timeline.+- Re-render the file at the same path and the open player reloads it, keeping the position. No stale cache.+- Resumes where the user left off; a library of every file shown in this container (`/` on the server).++## Iterating on a render++Render to the same path and do nothing else: the player notices the new file within two seconds and reloads at+the same time. Run `play` again only to change the title or chapters.++## Displaying this app (REQUIRED, read before showing anything)++To SHOW a video, use `adom-video play` (it opens the proper Hydrogen webview tab itself). For manual display,+FIRST load the `webview` skill (adom-workspace-control) and follow it.+- **NEVER open this app in VS Code's "Simple Browser"**: not a webview in the Adom ecosystem, no live channel,+  no console read-back, no AI drivability. Using it is a bug.+- Surfaces: **wv**, the Hydrogen webview panel (`webview` skill; the default), **pup** (`pup` skill),+  the user's **real signed-in browser** (Adom browser extension), and **hdbw**, Hydrogen's standalone browser+  window. Simple Browser is NEVER one of them.+- Never cover the VS Code pane: the player opens in another panel.++## Notes++- The server binds 127.0.0.1 on an automatic port, serves ONLY registered files, and exits after 24 h idle.+- One server per container; `play` restarts it automatically when the package version changed.+- Publishing a video to a wiki page is a different job: see `demo-video-publishing`. The wiki serves files with+  Range support already.
uninstall.shadded+4
@@ -0,0 +1,4 @@+#!/bin/bash+adom-video --ai-thread "uninstall" stop >/dev/null 2>&1 || true+rm -f "$HOME/.local/bin/adom-video"; rm -rf "$HOME/.local/share/adom-video-player" "$HOME/.claude/skills/adom-video-player" "$HOME/.codex/skills/adom-video-player"+echo "OK: adom-video removed (library kept in ~/.adom/adom-video-player)"