app
Adom Video Player
Public Made by Adomby adom
Show any video in a real player: seek, scrub, frame step, chapters. One command, no hand-rolled servers.
← Commit history
Publish 0.1.3
14 files changed
+1007
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"+ }+}
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]"+ }+}
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 "<name>" play <file></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)"