← Commit history
README.md+24−5
@@ -17,27 +17,46 @@ AIs used to show videos by starting a quick `python -m http.server`. That server | | | |---|---| | **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 |+| **Keyboard** | YouTube's shortcuts: Space or K, J / L, arrows, ↑ ↓ volume, M, `,` `.` frame, `<` `>` speed, 0 to 9, Home / End, Ctrl+arrows chapters, F, T theater, I picture-in-picture, C captions, + / - caption size, Shift+N / Shift+P, ? | | **Speed** | 0.25x, 0.5x, 1x, 1.5x, 2x |+| **Captions** | `--captions subs.vtt` (or .srt); C toggles, + / - resize | | **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 | +Hover **Shortcuts** for the full list; every button names its key in a tooltip.++<img src="docs/shortcuts.png" width="760" alt="The Shortcuts hover list over the player: every YouTube keyboard shortcut and what it does, with captions showing on the video">+ ## Commands +Every reply starts with `OK:` or `ERROR:`, and every error carries a `Hint:` with the exact next command. Add `--json` for the raw payload.+ | 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 play <file> [--title] [--chapters] [--captions] [--fps] [--name] [--surface wv\|hbw\|pup\|nb] [--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 |+| `adom-video --ai-thread X show` | Open the library page |+| `adom-video --ai-thread X ui toast\|seek\|play\|pause\|speed\|theater\|captions\|click\|reveal\|cursor\|reload ...` | Drive the open player over its live channel |+| `adom-video ls` / `health` | The server, its version, open pages and the library |+| `adom-video settings [key=value]` | The same settings as the Settings window, validated |+| `adom-video --ai-thread X stop --reason "..." [--confirm]` | Stop the shared server (reason-gated; asks for confirmation while others use it) |+| `adom-video install` | Update to the latest version from the wiki |++## Built to the Adom app standard++- **One header:** the 44px Adom header. Hover the name for the version, server and wiki link.+- **Explained controls:** every control has a tooltip that names its key. Tooltips stay on screen and never cover what you hover.+- **Settings:** a Settings window (the cog) with autoplay, resume, default speed, where videos open, background pup windows, idle shutdown and developer mode. Settings live on the server and the AI changes them with `adom-video settings`.+- **Server-side state:** the library, positions and settings live on the server, never in browser storage.+- **AI-drivable:** an SSE live channel keeps the page, the CLI and the AI in step. The AI can raise toasts, seek, and click with a visible cursor. It reads the page's console at `GET /console` and the full state at `GET /state`.  ## 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.+- **Surfaces:** a Hydrogen webview tab by default; also a Hydrogen browser window, a pup window (in the background) or your own browser. - **Lifetime:**   - One server per container, restarted automatically when the package updates.   - Exits after 24 hours idle.
bin/adom-video+161−99
@@ -1,6 +1,6 @@ #!/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.+// adom-video: show any video or audio file in a real player (seek, scrub, frame+// step, chapters, YouTube's keyboard shortcuts) through one shared server. 'use strict'; const fs = require('fs'); const path = require('path');@@ -12,47 +12,60 @@ 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 HELP = `adom-video ${PKG.version}: play video and audio in a real, seekable player++  adom-video --ai-thread "<name>" play <file> [--title T] [--chapters ch.json|ch.vtt] [--captions subs.vtt|.srt]+                                       [--fps N] [--name "Tab name"] [--surface wv|hbw|pup|nb] [--no-open]+  adom-video --ai-thread "<name>" url <file>         register and print the player URL+  adom-video --ai-thread "<name>" show [--surface S]  open the library page+  adom-video --ai-thread "<name>" ui <directive> ...  drive the open player page:+        toast "text" [--kind info|success|error]   seek <seconds>   play   pause   speed <x>+        click <selector>   reveal <selector>   cursor <selector>   theater   captions   reload+        eval "<js>"   (needs Developer mode: adom-video settings devMode=true)+  adom-video ls                                      the server, its port and version, and the library+  adom-video settings [key=value ...]                read or change settings (see the Settings window)+  adom-video --ai-thread "<name>" stop --reason "<what the user asked>" [--confirm]+  adom-video health | install | --version | help+  Add --json to any verb for the raw JSON.++Why: a quick "python -m http.server" ignores HTTP Range requests, so the browser cannot seek.+This server answers 206 Partial Content with ETags (no stale cache), and the player has+YouTube's keyboard shortcuts (hover Shortcuts in the player), chapters, captions, frame step+and auto-reload when the file is re-rendered.`;  const argv = process.argv.slice(2); let thread = process.env.ADOM_AI_THREAD || '';+let asJson = false; const rest = []; for (let i = 0; i < argv.length; i++) {   if (argv[i] === '--ai-thread') thread = argv[++i] || '';+  else if (argv[i] === '--json') asJson = true;   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); };+// OK:/ERROR: + Hint: contract; --json prints the payload instead.+function ok(line, data = {}, extra = []) {+  if (asJson) console.log(JSON.stringify({ ok: true, ...data }, null, 2));+  else { console.log('OK: ' + line); for (const x of extra) console.log(x); }+  process.exit(0);+}+function err(line, hints = [], data = {}) {+  if (asJson) console.log(JSON.stringify({ ok: false, error: line, hints, ...data }, null, 2));+  else { console.log('ERROR: ' + line); for (const h of hints) console.log('Hint: ' + h); }+  process.exit(1);+}+const fromServer = (r) => err(r.error || 'the server refused', [r.hint].filter(Boolean), r); 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') + ' ...' });+  if (!thread.trim()) err('adom-video needs your ai-thread name on this verb.', [`adom-video --ai-thread "<a short name the user recognizes for this conversation>" ${verb || 'play'} ...`], { errorCode: 'caller_identity_required' }); };  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,+    const r = http.request({ host: '127.0.0.1', port, method, path: p, timeout: 5000,       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 }); } });     });@@ -69,120 +82,169 @@ async function live() { 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' });+  if (s) {   // old code still running after a package update: replace it (our own restart, not a user disruption)+    try { process.kill(s.pid, 'SIGTERM'); } catch {}+    await new Promise((r) => setTimeout(r, 600));+  }+  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++) {+  for (let i = 0; i < 60; i++) {     await new Promise((r) => setTimeout(r, 100));-    const n = await live(); if (n) return n;+    const n = await live(); if (n && n.running === PKG.version) 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; }+  err('the player server did not start', [`run it in the foreground to see why: node ${path.join(ROOT, 'server.js')} --port auto`]); } +const sh = (cmd, args, timeout = 10000) => execFileSync(cmd, args, { stdio: 'pipe', timeout }).toString();+function hydrogen() { try { sh('adom-cli', ['hydrogen', 'workspace', 'tabs'], 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 []; }+  try { return (JSON.parse(sh('adom-cli', ['hydrogen', 'workspace', 'tabs'], 8000)).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.+// Keep ONE tab with this name: the one showing `url` if any. Remove the rest from the highest index+// down (indexes shift as tabs close). A refresh to a new URL can leave the old tab behind. function dedupe(name, url) {-  let tabs = tabsNamed(name);+  const 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 {}+  for (const t of tabs.filter((x) => x !== keep).sort((a, b) => (a.panelId === b.panelId ? b.tabIndex - a.tabIndex : 0))) {+    try { sh('adom-cli', ['hydrogen', 'workspace', 'remove-tab', '--panel-id', t.panelId, '--tab-index', String(t.tabIndex)], 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.+  // Under Hydrogen the surface shares the machine: localhost is right.+  // In a cloud container the page renders in the user's browser: use the proxy (the page uses relative URLs).   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 openSurface(s, url, tab, reason, restartCmd) {+  const settings = ((await req(s.port, 'GET', '/settings')).settings) || {};+  const surface = opt('--surface') || settings.surface || 'wv';+  if (surface === 'pup') {+    try {+      sh('adom-bridge', ['--ai-thread', thread, 'pup_open_window', JSON.stringify({ url, reason })], 60000);+      return 'a pup window (in the background)';+    } catch (e) { return { failed: String(e.stderr || e.message).slice(0, 300) }; }+  }+  if (surface === 'nb') {+    try { sh('adom-bridge', ['--ai-thread', thread, 'desktop_open_url', JSON.stringify({ url, reason })], 30000); return 'your browser'; }+    catch (e) { return { failed: String(e.stderr || e.message).slice(0, 300) }; }+  }+  if (surface === 'hbw') {+    try {+      const base = fs.readFileSync(path.join(os.homedir(), '.adom', 'hydrogen-control-url'), 'utf8').trim();+      sh('curl', ['-sf', '-X', 'POST', `${base}/hdbw/open`, '-H', 'content-type: application/json', '-d', JSON.stringify({ url })], 15000);+      return 'a Hydrogen browser window';+    } catch (e) { return { failed: 'the Hydrogen browser window is not available here: ' + String(e.message).slice(0, 200) }; }+  }+  if (!hydrogen()) return { failed: 'no Hydrogen here; open the URL in a browser or pass --surface pup' };+  // Same name already showing this URL: just bring it forward (refreshing 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.+  const existing = dedupe(tab, url);+  if (existing[0] && existing[0].panelState && existing[0].panelState.url === url) {+    try { sh('adom-cli', ['hydrogen', 'workspace', 'active-tab', '--name', tab], 8000); } catch {}+  } else {+    try {+      sh('adom-cli', ['hydrogen', 'webview', 'open-or-refresh', '--name', tab, '--url', url, '--ai-thread', thread,+        '--reason', reason.slice(0, 80), '--restart', restartCmd], 15000);+    } catch (e) { return { failed: String(e.stderr || e.message).slice(0, 300) }; }+    dedupe(tab, url);+  }+  await req(s.port, 'POST', '/api/tabs', { name: tab }).catch(() => {});+  return `the Hydrogen tab "${tab}"`;+}+ 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 === '--version' || verb === 'version') return ok(`adom-video ${PKG.version}`, { version: PKG.version });++  if (verb === 'install') {+    try { const out = sh('adom-wiki', ['pkg', 'install', 'adom/adom-video-player'], 180000); return ok('installed the latest adom-video-player from the wiki', { out }); }+    catch (e) { return err('install failed: ' + String(e.message).slice(0, 200), ['adom-wiki pkg install adom/adom-video-player']); }+  }++  if (verb === 'health') {+    const s = await live();+    if (!s) return ok(`installed ${PKG.version} (node ${process.versions.node}); the server is not running, the next play starts it`, { version: PKG.version, running: false });+    const h = await req(s.port, 'GET', '/health');+    const drift = s.running !== PKG.version ? ` WARN: it runs ${s.running}, disk has ${PKG.version}; the next play restarts it` : '';+    return ok(`server ${s.running} on port ${s.port}, ${h.pages} open page(s), ${h.library} video(s) in the library${drift}`, { ...h, disk: 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 (!s) return ok('the player server is not running', { running: false }, ['Hint: adom-video --ai-thread "<name>" play <file> starts it']);+    const st = await req(s.port, 'GET', '/state');+    const lines = (st.library || []).map((x) => `  ${x.id}  ${x.title}${x.exists ? '' : '  (file missing)'}\n         ${baseUrl(s.port)}/watch/${x.id}`);+    if (s.running !== PKG.version) lines.unshift(`WARN: the server runs ${s.running}, disk has ${PKG.version}; the next play restarts it`);+    return ok(`server ${s.running} on port ${s.port}, started by "${s.thread}", ${st.pages} open page(s)`, { ...st, url: baseUrl(s.port) }, lines);   }    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 (!Object.keys(kv).length) {+      const r = await req(s.port, 'GET', '/settings');+      return ok('settings', r, Object.entries(r.schema).map(([k, d]) => `  ${k} = ${JSON.stringify(r.settings[k])}   (${d.label}; ${d.type === 'enum' ? 'one of ' + d.values.join(', ') : d.type}; default ${JSON.stringify(d.default)})`));+    }+    requireThread();+    const r = await req(s.port, 'POST', '/settings', kv);+    return r.ok ? ok(`settings updated: ${Object.keys(kv).join(', ')}`, r) : fromServer(r);   }    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', {})));+    const s = await live(); if (!s) return ok('the player server was not running', { running: false });+    const r = await req(s.port, 'POST', '/api/shutdown', { reason: opt('--reason'), confirm: flag('--confirm') });+    return r.ok ? ok('the player server stopped', r) : fromServer(r);   } -  if (verb === 'play' || verb === 'url' || verb === 'show') {+  if (verb === 'ui') {     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 type = rest.shift();+    const s = await live(); if (!s) return err('the player server is not running', ['adom-video --ai-thread "<name>" play <file>']);+    const kind = opt('--kind');+    const a = rest[0];+    const body = {+      toast: { text: a, kind: kind || 'info' }, seek: { t: Number(a) }, speed: { rate: Number(a) },+      click: { selector: a }, reveal: { selector: a }, cursor: { selector: a, hover: true }, eval: { js: a },+    }[type] || {};+    const r = await req(s.port, 'POST', `/ui/${type}`, body);+    return r.ok ? ok(`${type} sent to ${r.delivered} open page(s)`, r) : fromServer(r);+  }++  if (verb === 'show') {+    requireThread();+    const s = await ensureServer();+    const url = `${baseUrl(s.port)}/`;+    const where = await openSurface(s, url, opt('--name') || 'Video Player', 'video library', `adom-video --ai-thread ${JSON.stringify(thread)} health`);+    if (typeof where === 'object') return err('could not open the library: ' + where.failed, [`open ${url} yourself`]);+    return ok(`the video library is open in ${where}`, { url });+  }++  if (verb === 'play' || verb === 'url') {+    requireThread();+    const title = opt('--title'), chapters = opt('--chapters'), fps = opt('--fps'), name = opt('--name'), caps = opt('--captions');+    const noOpen = flag('--no-open') || verb === 'url';     const file = rest[0];-    if (!file) die({ ok: false, error: 'give a file: adom-video play <file>' });+    if (!file) return err('give a file to play', ['adom-video --ai-thread "<name>" play path/to/video.mp4']);     const abs = path.resolve(file);-    if (!fs.existsSync(abs)) die({ ok: false, error: `no such file: ${abs}` });+    if (!fs.existsSync(abs)) return err(`no such file: ${abs}`, ['check the path; adom-video ls lists what is already registered']);     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 r = await req(s.port, 'POST', '/api/add', { path: abs, title, chapters: chapters ? path.resolve(chapters) : undefined, captions: caps ? path.resolve(caps) : undefined, fps });+    if (!r.ok) return fromServer(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));+    const data = { url, media: `${baseUrl(s.port)}${r.media}`, id: r.item.id, title: r.item.title, port: s.port };+    if (noOpen) return ok(`registered "${r.item.title}"`, data, [`url: ${url}`]);+    const where = await openSurface(s, url, name || `Video: ${r.item.title}`.slice(0, 60), `video: ${r.item.title}`,+      `adom-video --ai-thread ${JSON.stringify(thread)} play ${JSON.stringify(abs)} --no-open`);+    if (typeof where === 'object') return err('registered, but could not open it: ' + where.failed, [`open ${url} yourself, or pass --surface pup`], data);+    return ok(`playing "${r.item.title}" in ${where}`, { ...data, opened: where }, [`url: ${url}`, 'Hint: adom-video --ai-thread "<name>" ui seek <seconds> | ui toast "..." drives the open player']);   }-  die({ ok: false, error: `unknown verb "${verb}"`, hint: 'adom-video help' });+  return err(`unknown verb "${verb}"`, ['adom-video help']); }-main().catch((e) => die({ ok: false, error: String(e && e.message || e) }));+main().catch((e) => err(String(e && e.message || e), ['adom-video health']));
docs/player.png
⋯ 1 unchanged line ⋯
docs/shortcuts.pngadded
⋯ 1 unchanged line ⋯
install.sh

  
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-video-player",   "type": "app",-  "version": "0.1.3",+  "version": "0.3.0",   "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.",
page.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-video-player",   "type": "app",-  "version": "0.1.3",+  "version": "0.3.0",   "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.",
public/fonts/familjen-grotesk-700.woff2added
⋯ 1 unchanged line ⋯
public/fonts/jetbrains-mono-400.woff2added
⋯ 1 unchanged line ⋯
public/fonts/jetbrains-mono-500.woff2added
⋯ 1 unchanged line ⋯
public/fonts/satoshi-400.woff2added
⋯ 1 unchanged line ⋯
public/fonts/satoshi-500.woff2added
⋯ 1 unchanged line ⋯
public/icons.jsadded+24
@@ -0,0 +1,24 @@+// Line icons (stroke, currentColor), 24 viewBox, 1.8 weight: the adom-ui-design icon rules.+window.ICONS = {+  mark: '<rect x="2.5" y="4.5" width="19" height="15" rx="2.5"/><path d="M10 9.2v5.6l4.6-2.8z"/><path d="M2.5 16.5h19"/>',+  download: '<path d="M12 4v11"/><path d="M7.5 10.5 12 15l4.5-4.5"/><path d="M5 19.5h14"/>',+  link: '<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/>',+  keyboard: '<rect x="2.5" y="6" width="19" height="12" rx="2"/><path d="M6 10h.01M9.5 10h.01M13 10h.01M16.5 10h.01M6 13.5h.01M18 13.5h.01M8.5 14.5h7"/>',+  close: '<path d="M6 6l12 12M18 6 6 18"/>',+  check: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',+  info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v5.5M12 7.8h.01"/>',+  alert: '<circle cx="12" cy="12" r="9"/><path d="M12 7.5v5.5M12 16.3h.01"/>',+  restore: '<path d="M4 12a8 8 0 1 0 2.4-5.7"/><path d="M4 4.5v4h4"/>',+  back10: '<path d="M4 12a8 8 0 1 0 2.4-5.7"/><path d="M4 4.5v4h4"/><path d="M10 10v5M13 10h2v5h-2z"/>',+  fwd10: '<path d="M20 12a8 8 0 1 1-2.4-5.7"/><path d="M20 4.5v4h-4"/><path d="M10 10v5M13 10h2v5h-2z"/>',+  prevF: '<path d="M7 5v14"/><path d="M18 6.5v11l-8-5.5z"/>',+  nextF: '<path d="M17 5v14"/><path d="M6 6.5v11l8-5.5z"/>',+  play: '<path d="M8 5.5v13l10.5-6.5z"/>',+  pause: '<path d="M8.5 5.5v13M15.5 5.5v13"/>',+  cc: '<rect x="3" y="5.5" width="18" height="13" rx="2"/><path d="M10.5 10.2a2.2 2.2 0 1 0 0 3.6M17 10.2a2.2 2.2 0 1 0 0 3.6"/>',+  theater: '<rect x="2.5" y="6.5" width="19" height="11" rx="1.5"/>',+  pip: '<rect x="2.5" y="4.5" width="19" height="15" rx="2"/><rect x="12" y="11.5" width="7" height="5.5" rx="1"/>',+  full: '<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>',+  loop: '<path d="M17 3.5 20 6.5 17 9.5"/><path d="M4 11.5v-1a4 4 0 0 1 4-4h12"/><path d="M7 20.5 4 17.5 7 14.5"/><path d="M20 12.5v1a4 4 0 0 1-4 4H4"/>',+};+window.icon = (n, s = 18) => `<svg viewBox="0 0 24 24" width="${s}" height="${s}" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${window.ICONS[n] || ''}</svg>`;
public/icons/settings-cog-outline.svgadded+10
@@ -0,0 +1,10 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"+     fill="none" stroke="#e6edf3" stroke-width="1.8"+     stroke-linecap="round" stroke-linejoin="round">+  <!-- THE Adom settings cog: a true line drawing (strokes, no filled silhouette).+       Hardcoded #e6edf3 because this file is loaded as its own document via+       <img src> / favicon, where currentColor would resolve to black. Inline+       copies pasted into markup may swap the stroke to currentColor. -->+  <circle cx="12" cy="12" r="3"/>+  <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>+</svg>
public/icons/video-player.svgadded+1
@@ -0,0 +1 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#e6edf3" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="4.5" width="19" height="15" rx="2.5"/><path d="M10 9.2v5.6l4.6-2.8z"/><path d="M2.5 16.5h19"/></svg>
public/index.html+46−15
@@ -3,24 +3,55 @@ <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>+<title>Video Player</title>+<link rel="icon" type="image/svg+xml" href="favicon.svg">+<link rel="stylesheet" href="static/player.css"> </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>+<header class="app-header">+  <div class="brand" id="brand" tabindex="0" aria-label="About Video Player">+    <span class="brand-icon" id="brand-icon"></span><span class="app-name">Video Player</span><span class="app-sub">seekable playback</span>+  </div>+  <div class="vsep"></div>+  <div class="subject"><span class="subject-name" id="title">Library</span><span class="subject-meta mono" id="meta"></span></div>+  <div class="actions">++    <button class="icon-btn" id="settings-btn" aria-label="Settings" data-tooltip="Settings: autoplay, resume, default speed, where videos open, idle shutdown. Stored on this machine."><img src="static/icons/settings-cog-outline.svg" width="18" height="18" alt=""></button>+  </div>+</header>+<div class="popover" id="brand-pop"></div>+<main class="lib-main">+  <p class="lib-intro">Every video an AI showed you in this container. Click one to watch it, from where you left off.</p>+  <ul id="lib" class="list lib-list"></ul>+</main>+<dialog id="settings" aria-label="Settings">+  <div class="dlg-head"><h3>Settings</h3><button class="icon-btn" id="settings-close" aria-label="Close settings" data-tooltip="Close settings (Esc)" data-icon="close"></button></div>+  <div class="dlg-body" id="settings-body"></div>+  <footer class="settings-footer">+    <p class="footer-note">Settings are stored on this machine and apply to every video. Restoring returns every option above to its default.</p>+    <button class="restore-btn" id="restore" data-tooltip="Return every setting to its default. Your library and saved positions are kept." data-icon="restore" data-icon-size="16"> Restore defaults</button>+  </footer>+</dialog>++<script src="static/icons.js"></script>+<script src="static/ui.js"></script> <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);-  });-});+(async () => {+  const st = await UI.api('state'); UI.header(st); UI.initSettings();+  const draw = (items) => {+    const ul = document.getElementById('lib'); ul.innerHTML = '';+    if (!items.length) { ul.innerHTML = '<li class="empty">Nothing yet. An AI runs: adom-video play &lt;file&gt;</li>'; return; }+    items.forEach((x) => {+      const li = document.createElement('li'); if (!x.exists) li.classList.add('gone');+      const a = document.createElement('span'); a.className = 'lib-title'; a.textContent = x.title;+      const p = document.createElement('span'); p.className = 'lib-path mono'; p.textContent = x.path;+      li.append(a, p); li.setAttribute('data-tooltip', x.exists ? 'Watch ' + x.title : 'The file is gone: ' + x.path);+      li.onclick = () => (location.href = 'watch/' + x.id); ul.appendChild(li);+    });+  };+  draw(st.library);+  UI.connect({ library: (d) => draw(d.items), ui: (d) => UI.uiCommon(d) });+})(); </script> </body> </html>
public/player.css+142−39
@@ -1,47 +1,150 @@+@font-face { font-family: 'Familjen Grotesk'; font-weight: 600 700; src: url('fonts/familjen-grotesk-700.woff2') format('woff2'); font-display: swap; }+@font-face { font-family: 'Satoshi'; font-weight: 400; src: url('fonts/satoshi-400.woff2') format('woff2'); font-display: swap; }+@font-face { font-family: 'Satoshi'; font-weight: 500 700; src: url('fonts/satoshi-500.woff2') format('woff2'); font-display: swap; }+@font-face { font-family: 'JetBrains Mono'; font-weight: 400; src: url('fonts/jetbrains-mono-400.woff2') format('woff2'); font-display: swap; }+@font-face { font-family: 'JetBrains Mono'; font-weight: 500; src: url('fonts/jetbrains-mono-500.woff2') format('woff2'); font-display: swap; }+ :root {-  --bg: #0d1117; --panel: #161b22; --line: #30363d; --text: #e6edf3; --muted: #8b949e;-  --accent: #00b8b1; --accent2: #00e6dc; --warn: #d29922;+  --bg: #0d1117; --surface: #161b22; --elevated: #1c2128; --overlay: #21262d; --border: #30363d; --line: #30363d;+  --text: #e6edf3; --text-body: #c9d1d9; --text-2: #8b949e; --muted: #8b949e; --text-3: #484f58;+  --accent: #00b8b1; --accent-hover: #00d4cb; --accent-bright: #00e6dc; --on-accent: #05221f; --teal: #00b8b1;+  --blue: #64abff; --blue-fill: #0061ef; --purple: #8c6bf7; --green: #3fb950; --red: #f85149; --yellow: #d29922;+  --font-head: 'Familjen Grotesk', sans-serif; --font-body: 'Satoshi', sans-serif; --font-mono: 'JetBrains Mono', monospace; } * { 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; }+html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text-body); font: 400 14px/1.45 var(--font-body); }+.mono { font-family: var(--font-mono); }+a { color: var(--blue); }++/* ---- the 44px three-zone header, edge to edge ---- */+.app-header { height: 44px; display: flex; align-items: center; gap: 12px; padding: 0 16px; background: var(--surface); border-bottom: 1px solid var(--line); }+.brand { display: flex; align-items: center; gap: 8px; flex: none; cursor: default; outline: none; }+.brand-icon { display: inline-flex; color: var(--text); }+.app-name { font: 600 15px var(--font-head); color: var(--text); letter-spacing: -.01em; }+.app-sub { font-size: 13px; color: var(--muted); font-weight: 400; }+.vsep { width: 1px; height: 20px; background: var(--line); flex: none; }+.subject { display: flex; align-items: baseline; gap: 10px; min-width: 0; flex: 1; }+.subject-name { font-weight: 700; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }+.subject-meta { font-size: 12px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }+.actions { display: flex; gap: 6px; margin-left: auto; align-items: center; }+.icon-btn { width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; background: none; border: 1px solid transparent;+  border-radius: 8px; color: var(--muted); cursor: pointer; padding: 0; transition: color .15s ease, border-color .15s ease, background .15s ease; text-decoration: none; }+.icon-btn:hover { color: var(--teal); background: var(--elevated); }+.icon-btn:active { transform: translateY(1px); }+.icon-btn:focus-visible, .tgl:focus-visible, .play-btn:focus-visible, .speed:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }+.icon-btn.lg { width: 34px; height: 34px; border-color: var(--border); background: var(--surface); }+.icon-btn img { display: block; }+.popover { position: fixed; z-index: 99990; display: none; background: rgba(22,27,34,.98); border: 1px solid var(--border); border-radius: 12px;+  padding: 12px 14px; box-shadow: 0 12px 40px rgba(0,0,0,.55); max-width: min(460px, calc(100vw - 16px)); }+.popover.open { display: block; }+.pop-title { font: 600 15px var(--font-head); color: var(--text); margin-bottom: 8px; }+.pop-row { display: flex; gap: 14px; justify-content: space-between; font-size: 13px; padding: 2px 0; }+.pop-row span:first-child { color: var(--muted); }++/* ---- layout ---- */+main { display: grid; grid-template-columns: minmax(0, 1fr) 280px; height: calc(100% - 44px); }+.stage { display: flex; flex-direction: column; padding: 12px 16px; gap: 10px; 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; }+video::cue { font-size: var(--cap-size, 100%); }+.timeline { position: relative; height: 16px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; cursor: pointer; overflow: hidden; flex: none; }+.timeline .buf { position: absolute; inset: 0 auto 0 0; background: var(--overlay); width: 0; }+.timeline .prog { position: absolute; inset: 0 auto 0 0; background: linear-gradient(90deg, var(--blue-fill), var(--accent)); width: 0; }+.timeline .marks i { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--text); opacity: .65; }+.timeline .hover { position: absolute; top: -1px; transform: translateX(-50%); font: 500 11px var(--font-mono); background: var(--bg); color: var(--text); padding: 0 4px; border-radius: 4px; display: none; pointer-events: none; }+.bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; flex: none; } .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); }+.time { font-size: 13px; color: var(--muted); margin-left: 6px; }+.play-btn { height: 34px; min-width: 76px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; background: var(--accent); color: var(--on-accent);+  border: none; border-radius: 8px; font: 600 14px var(--font-body); cursor: pointer; transition: background .15s ease; }+.play-btn:hover { background: var(--accent-hover); } .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; }+.speed { height: 28px; padding: 0 8px; background: var(--surface); color: var(--text-body); border: 1px solid var(--border); border-radius: 8px; font: 500 12px var(--font-mono); cursor: pointer; }+.speed:hover { border-color: var(--accent); }+.speed[aria-pressed="true"], .tgl.is-on { background: rgba(0,184,177,.16); border-color: var(--accent); color: var(--accent-bright); box-shadow: inset 0 0 0 1px rgba(0,184,177,.35); }+.tgl { width: 34px; height: 34px; display: inline-flex; align-items: center; justify-content: center; background: var(--surface); color: var(--muted);+  border: 1px solid var(--border); border-radius: 8px; cursor: pointer; transition: color .15s ease, border-color .15s ease, background .15s ease; }+.tgl:hover { color: var(--teal); } 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; }+aside h2 { font: 600 12px var(--font-head); letter-spacing: .06em; text-transform: uppercase; 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); }+.list li { padding: 6px 8px; border-radius: 8px; cursor: pointer; display: flex; gap: 8px; border: 1px solid transparent; }+.list li:hover { background: var(--elevated); }+.list li.active { background: var(--elevated); border-color: var(--accent); color: var(--text); }+.list li .t { font: 500 12px var(--font-mono); color: var(--accent-bright); flex: none; }+.list li.empty { color: var(--muted); cursor: default; font-size: 13px; }+.list li.gone { opacity: .5; }+body.theater main { grid-template-columns: 1fr; }+body.theater aside { display: none; }+.revealed { outline: 2px solid var(--accent); outline-offset: 2px; transition: outline .2s ease; }+.lib-main { display: block; padding: 16px; height: auto; max-width: 980px; }+.lib-intro { color: var(--muted); margin: 0 0 12px; }+.lib-list li { justify-content: space-between; align-items: baseline; }+.lib-title { color: var(--text); font-weight: 500; }+.lib-path { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++/* ---- shortcuts list ---- */+.sc-pop table, .sc-table { border-collapse: collapse; }+.sc-pop td, .sc-table td { padding: 3px 16px 3px 0; font-size: 13px; vertical-align: top; }+.sc-pop td:first-child, .sc-table td:first-child { font: 500 12px var(--font-mono); color: var(--accent-bright); white-space: nowrap; }+.sc-foot { color: var(--muted); font-size: 12px; margin-top: 8px; }++/* ---- tooltip: body-level, fixed, 99999 ---- */+#global-tooltip { position: fixed; z-index: 99999; left: 0; top: 0; display: none; opacity: 0; pointer-events: none; max-width: min(320px, calc(100vw - 40px));+  background: rgba(22,27,34,.96); color: var(--text); border: 1px solid rgba(0,184,177,.28); border-radius: 10px; padding: 9px 13px;+  font: 400 13px/1.4 var(--font-body); box-shadow: 0 12px 40px rgba(0,0,0,.55); text-transform: none; letter-spacing: 0; transition: opacity .18s ease; }+#global-tooltip.visible { display: block; }+#global-tooltip.shown { opacity: 1; }++/* ---- toasts: bottom center, three types, max three ---- */+.toast-stack { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); z-index: 400; display: flex; flex-direction: column-reverse; gap: 10px; align-items: center; pointer-events: none; }+.toast { pointer-events: auto; display: flex; align-items: center; gap: 12px; min-width: 280px; max-width: min(520px, calc(100vw - 32px)); background: var(--surface);+  border: 1px solid var(--border); border-left: 3px solid var(--toast-accent, var(--accent)); border-radius: 12px; padding: 12px 14px; box-shadow: 0 18px 50px rgba(0,0,0,.5);+  color: var(--text-body); animation: toast-in 180ms cubic-bezier(.2,.8,.2,1) both; }+.toast.success { --toast-accent: var(--green); } .toast.error { --toast-accent: var(--red); } .toast.info { --toast-accent: var(--accent); }+.toast .ticon { color: var(--toast-accent); display: inline-flex; }+.toast .tmsg { flex: 1; }+.toast .taction { margin-left: auto; color: var(--blue); font-weight: 600; background: none; border: none; cursor: pointer; }+.toast .tclose { background: none; border: none; color: var(--muted); cursor: pointer; display: inline-flex; padding: 2px; }+.toast.leaving { animation: toast-out 140ms ease both; }+@keyframes toast-in { from { opacity: 0; transform: translateY(8px) scale(.98); } to { opacity: 1; transform: none; } }+@keyframes toast-out { from { opacity: 1; } to { opacity: 0; transform: translateY(4px); } }+@media (prefers-reduced-motion: reduce) { .toast, .toast.leaving { animation: none; } }++/* ---- dialogs: dim, never blur ---- */+dialog { background: var(--surface); color: var(--text-body); border: 1px solid var(--border); border-radius: 12px; padding: 0; width: min(560px, calc(100vw - 32px));+  max-height: calc(100vh - 64px); box-shadow: 0 24px 64px rgba(0,0,0,.6); }+dialog::backdrop { background: rgba(0,0,0,.25); }+.dlg-head { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--border); }+.dlg-head h3 { margin: 0; font: 600 16px var(--font-head); color: var(--text); }+.dlg-body { padding: 8px 16px 12px; overflow: auto; max-height: calc(100vh - 220px); scrollbar-width: thin; scrollbar-color: var(--border) transparent; }+.set-row { padding: 10px 0; display: flex; flex-direction: column; gap: 4px; border-bottom: 1px solid var(--overlay); }+.set-name { margin: 0; font: 600 15px var(--font-head); color: #8fd3d4; }+.set-help { margin: 0; font-size: 13px; color: var(--muted); }+.default-hint { font-size: 12px; color: var(--text-2); }+.select { align-self: flex-start; background: var(--overlay); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 5px 8px; font: 400 13px var(--font-body); }+.toggle { background: none; border: none; padding: 2px; cursor: pointer; flex-shrink: 0; align-self: flex-start; transition: transform .06s ease; }+.toggle-track { display: block; width: 32px; height: 18px; background: var(--overlay); border: 1px solid var(--border); border-radius: 9px; position: relative; transition: background .15s ease, border-color .15s ease; }+.toggle:hover .toggle-track { border-color: var(--accent); }+.toggle:active { transform: translateY(1px) scale(.97); }+.toggle.active .toggle-track { background: var(--accent); border-color: var(--accent); }+.toggle-thumb { position: absolute; top: 2px; left: 2px; width: 12px; height: 12px; background: var(--text); border-radius: 50%; transition: transform .18s ease, width .12s ease; }+.toggle.active .toggle-thumb { transform: translateX(14px); }+.toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 10px; }+.settings-footer { padding: 12px 16px 14px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 8px; }+.footer-note { margin: 0; font-size: 12px; color: var(--muted); }+.restore-btn { align-self: flex-start; display: inline-flex; align-items: center; gap: 6px; background: var(--overlay); color: var(--text-body); border: 1px solid var(--border); border-radius: 8px; padding: 6px 10px; cursor: pointer; font: 500 13px var(--font-body); }+.restore-btn.armed { border-color: var(--red); color: var(--red); }++/* ---- fake AI cursor ---- */+.ai-cursor { position: fixed; left: 0; top: 0; z-index: 99998; pointer-events: none; opacity: 0; transition: transform .32s cubic-bezier(.2,.8,.2,1), opacity .2s ease; display: flex; align-items: flex-start; gap: 4px; }+.ai-cursor.on { opacity: 1; }+.ai-label { font: 500 12px var(--font-body); background: var(--accent); color: var(--on-accent); padding: 2px 7px; border-radius: 999px; margin-top: 14px; white-space: nowrap; }+.ai-label:empty { display: none; }++/* ---- responsive and touch ---- */+@media (max-width: 860px) { main { grid-template-columns: 1fr; height: auto; } aside { border-left: none; border-top: 1px solid var(--line); } video { min-height: 220px; } }+@media (max-width: 720px) { .app-sub, .subject-meta, .vsep { display: none; } .time { width: 100%; margin: 0; } }+@media (hover: none), (pointer: coarse) {+  .icon-btn, .tgl, .icon-btn.lg, .speed, .play-btn { min-width: 44px; min-height: 44px; }+  .list li { padding: 12px 10px; }+}
public/player.html+86−40
@@ -3,64 +3,110 @@ <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">+<title>Video Player</title>+<link rel="icon" type="image/svg+xml" href="../favicon.svg">+<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>+<header class="app-header">+  <div class="brand" id="brand" tabindex="0" aria-label="About Video Player">+    <span class="brand-icon" id="brand-icon"></span><span class="app-name">Video Player</span><span class="app-sub">seekable playback</span>+  </div>+  <div class="vsep"></div>+  <div class="subject"><span class="subject-name" id="title">Loading</span><span class="subject-meta mono" id="meta"></span></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>+    <a class="icon-btn" id="download" download aria-label="Download" data-tooltip="Download this file to your computer" data-icon="download"></a>+    <button class="icon-btn" id="copy" aria-label="Copy link" data-tooltip="Copy a link to this player page" data-icon="link"></button>+    <button class="icon-btn" id="sc-btn" aria-label="Keyboard shortcuts" data-icon="keyboard"></button>+    <button class="icon-btn" id="settings-btn" aria-label="Settings" data-tooltip="Settings: autoplay, resume, default speed, where videos open, idle shutdown. Stored on this machine."><img src="../static/icons/settings-cog-outline.svg" width="18" height="18" alt=""></button>   </div> </header>+<div class="popover" id="brand-pop"></div> <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 class="timeline" id="tl" data-tooltip="Click anywhere to seek. Keys: arrows 5 s, J and L 10 s, 0 to 9 jump, Ctrl+arrows chapters">+      <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>+      <button class="icon-btn lg" id="back10" aria-label="Back 10 seconds" data-tooltip="Back 10 seconds (J)" data-icon="back10"></button>+      <button class="icon-btn lg" id="prevF" aria-label="Previous frame" data-tooltip="Previous frame (comma, while paused)" data-icon="prevF"></button>+      <button class="play-btn" id="play" aria-label="Play" data-tooltip="Play or pause (Space or K)"></button>+      <button class="icon-btn lg" id="nextF" aria-label="Next frame" data-tooltip="Next frame (period, while paused)" data-icon="nextF"></button>+      <button class="icon-btn lg" id="fwd10" aria-label="Forward 10 seconds" data-tooltip="Forward 10 seconds (L)" data-icon="fwd10"></button>+      <span class="time mono" 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 class="speeds" id="speeds" role="group" aria-label="Speed"></div>+      <button class="tgl" id="capBtn" aria-pressed="false" aria-label="Captions" data-tooltip="Captions on or off (C). Pass --captions file.vtt to add them." data-icon="cc"></button>+      <button class="tgl" id="loopBtn" aria-pressed="false" aria-label="Loop" data-tooltip="Repeat the video when it ends" data-icon="loop"></button>+      <button class="tgl" id="theaterBtn" aria-pressed="false" aria-label="Theater mode" data-tooltip="Theater mode: hide the side panel (T)" data-icon="theater"></button>+      <button class="icon-btn lg" id="pipBtn" aria-label="Picture-in-picture" data-tooltip="Picture-in-picture: keep watching in a small floating window (I)" data-icon="pip"></button>+      <button class="icon-btn lg" id="fsBtn" aria-label="Fullscreen" data-tooltip="Fullscreen (F)" data-icon="full"></button>     </div>   </section>   <aside>     <h2>Chapters</h2>-    <ol id="chapters" class="list"><li class="empty">No chapters. Pass --chapters to adom-video play.</li></ol>+    <ol id="chapters" class="list"><li class="empty">No chapters. An AI adds them with adom-video play --chapters.</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>+<div class="popover sc-pop" id="sc-pop"><table>+        <tr><td>Space or K</td><td>Play or pause</td></tr>+        <tr><td>J</td><td>Back 10 seconds</td></tr>+        <tr><td>L</td><td>Forward 10 seconds</td></tr>+        <tr><td>&#8592; / &#8594;</td><td>Back or forward 5 seconds</td></tr>+        <tr><td>&#8593; / &#8595;</td><td>Volume up or down 5%</td></tr>+        <tr><td>M</td><td>Mute or unmute</td></tr>+        <tr><td>, / .</td><td>Previous or next frame (while paused)</td></tr>+        <tr><td>&lt; / &gt;</td><td>Slower or faster (also [ and ])</td></tr>+        <tr><td>0 to 9</td><td>Jump to 0% to 90%</td></tr>+        <tr><td>Home / End</td><td>Start or end</td></tr>+        <tr><td>Ctrl+&#8592; / Ctrl+&#8594;</td><td>Previous or next chapter</td></tr>+        <tr><td>F</td><td>Fullscreen</td></tr>+        <tr><td>T</td><td>Theater mode</td></tr>+        <tr><td>I</td><td>Picture-in-picture</td></tr>+        <tr><td>C</td><td>Captions on or off</td></tr>+        <tr><td>+ / -</td><td>Caption size</td></tr>+        <tr><td>Shift+N / Shift+P</td><td>Next or previous video in the library</td></tr>+        <tr><td>Shift+/ (?)</td><td>Pin this list</td></tr>+        <tr><td>Esc</td><td>Close, or leave theater mode</td></tr>+  </table><div class="sc-foot">The same keys as YouTube. Press ? to pin this list.</div></div>+<dialog id="help" aria-label="Keyboard shortcuts">+  <div class="dlg-head"><h3>Keyboard shortcuts</h3><button class="icon-btn" id="help-close" aria-label="Close" data-tooltip="Close (Esc)" data-icon="close"></button></div>+  <div class="dlg-body"><table class="sc-table">+        <tr><td>Space or K</td><td>Play or pause</td></tr>+        <tr><td>J</td><td>Back 10 seconds</td></tr>+        <tr><td>L</td><td>Forward 10 seconds</td></tr>+        <tr><td>&#8592; / &#8594;</td><td>Back or forward 5 seconds</td></tr>+        <tr><td>&#8593; / &#8595;</td><td>Volume up or down 5%</td></tr>+        <tr><td>M</td><td>Mute or unmute</td></tr>+        <tr><td>, / .</td><td>Previous or next frame (while paused)</td></tr>+        <tr><td>&lt; / &gt;</td><td>Slower or faster (also [ and ])</td></tr>+        <tr><td>0 to 9</td><td>Jump to 0% to 90%</td></tr>+        <tr><td>Home / End</td><td>Start or end</td></tr>+        <tr><td>Ctrl+&#8592; / Ctrl+&#8594;</td><td>Previous or next chapter</td></tr>+        <tr><td>F</td><td>Fullscreen</td></tr>+        <tr><td>T</td><td>Theater mode</td></tr>+        <tr><td>I</td><td>Picture-in-picture</td></tr>+        <tr><td>C</td><td>Captions on or off</td></tr>+        <tr><td>+ / -</td><td>Caption size</td></tr>+        <tr><td>Shift+N / Shift+P</td><td>Next or previous video in the library</td></tr>+        <tr><td>Shift+/ (?)</td><td>Pin this list</td></tr>+        <tr><td>Esc</td><td>Close, or leave theater mode</td></tr>+  </table></div>+</dialog>+<dialog id="settings" aria-label="Settings">+  <div class="dlg-head"><h3>Settings</h3><button class="icon-btn" id="settings-close" aria-label="Close settings" data-tooltip="Close settings (Esc)" data-icon="close"></button></div>+  <div class="dlg-body" id="settings-body"></div>+  <footer class="settings-footer">+    <p class="footer-note">Settings are stored on this machine and apply to every video. Restoring returns every option above to its default.</p>+    <button class="restore-btn" id="restore" data-tooltip="Return every setting to its default. Your library and saved positions are kept." data-icon="restore" data-icon-size="16"> Restore defaults</button>+  </footer>+</dialog>++<script src="../static/icons.js"></script>+<script src="../static/ui.js"></script>+<script src="../static/player.js"></script> </body> </html>
public/player.js+123−58
@@ -1,89 +1,93 @@-// adom-video-player page: seekable playback with keyboard control, frame step,-// speeds, chapters, a library, and auto-reload when the file changes on disk.+// adom-video-player page: seekable playback, YouTube's keyboard shortcuts, chapters,+// captions, a library. All state lives on the server; the SSE channel keeps this+// page, the CLI and the AI in lockstep. (() => {+  const { api, toast, hoverPopover, header, initSettings, connect, uiCommon } = window.UI;   const $ = (id) => document.getElementById(id);   const v = $('v');-  const id = location.pathname.split('/').pop();+  const id = location.pathname.split('/').filter(Boolean).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 h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), s = (t % 60).toFixed(2).padStart(5, '0');+    return h ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`;   };   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;+    const r = await api(`api/item/${id}`);+    if (!r.ok) { $('title').textContent = 'Unknown video'; toast('This video is not in the library. An AI adds it with adom-video play <file>.', 'error'); return; }+    item = r.item; settings = r.settings || settings; fps = item.fps || 30;+    document.title = `${item.title} · Video Player`;     $('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);+    $('download').href = `../media/${id}`; $('download').setAttribute('download', item.path.split('/').pop());+    const pos = item.position || {};+    const t = keep ? v.currentTime : (settings.rememberPosition !== false ? Number(pos.t || 0) : 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.src = `../media/${id}?v=${encodeURIComponent(item.etag || '')}`;+    [...v.querySelectorAll('track')].forEach((x) => x.remove());+    if (item.captions) { const tr = document.createElement('track'); tr.kind = 'captions'; tr.label = 'Captions'; tr.srclang = 'en'; tr.src = `../captions/${id}`; v.appendChild(tr); }+    const rate = keep ? v.playbackRate : Number(pos.speed || settings.defaultSpeed || 1);     v.addEventListener('loadedmetadata', () => {+      v.playbackRate = rate;       if (t > 0 && t < v.duration - 0.5) v.currentTime = t;       if (!wasPaused) v.play().catch(() => {});-      marks(); speeds();+      marks(); speeds(); syncToggles();     }, { once: true });     chapters();   } +  // ---------- chapters, timeline, speeds ----------   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.innerHTML = '<span class="t"></span><span></span>';+      li.firstChild.textContent = fmt(c.t).replace(/\.\d+$/, ''); li.lastChild.textContent = c.title;+      li.setAttribute('data-tooltip', `Jump to "${c.title}" (Ctrl+arrows step chapters)`);       li.onclick = () => { v.currentTime = c.t; v.play().catch(() => {}); };       ol.appendChild(li);     });   }   function activeChapter() {-    const ch = item && item.chapters || []; let a = -1;+    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); } });+    (item.chapters || []).forEach((c) => { if (v.duration) { const i = document.createElement('i'); i.style.left = (100 * c.t / v.duration) + '%'; m.appendChild(i); } });   }+  const SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];   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 + '×';+      const b = document.createElement('button'); b.className = 'speed'; b.textContent = s + 'x';+      b.setAttribute('aria-pressed', String(v.playbackRate === s)); b.setAttribute('aria-label', `Speed ${s}x`);+      b.setAttribute('data-tooltip', `Play at ${s}x speed. The less-than and greater-than keys step the speed`);       b.onclick = () => setSpeed(s); box.appendChild(b);     });   }-  function setSpeed(s) { v.playbackRate = s; store.set('speed', s); speeds(); toast(`Speed ${s}×`); }+  function setSpeed(s) { v.playbackRate = s; speeds(); savePos(); toast(`Speed ${s}x`); }+  const speedStep = (d) => { let i = SPEEDS.indexOf(v.playbackRate); if (i < 0) i = SPEEDS.indexOf(1); setSpeed(SPEEDS[Math.max(0, Math.min(SPEEDS.length - 1, i + d))]); }; -  async function libraryList() {-    const r = await fetch('/api/library', { cache: 'no-store' }).then((x) => x.json()).catch(() => ({ items: [] }));+  async function libraryList(items) {+    if (!items) items = (await api('api/library')).items || [];     const ul = $('library'); ul.innerHTML = '';-    (r.items || []).forEach((x) => {+    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}`; };+      li.textContent = x.title; li.setAttribute('data-tooltip', x.exists ? `Watch ${x.title} (Shift+N and Shift+P step through the library)` : `The file is gone: ${x.path}`);+      li.onclick = () => { location.href = `../watch/${x.id}`; };       ul.appendChild(li);     });   } -  // timeline+  // ---------- 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; });@@ -96,52 +100,113 @@       $('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';+    const p = $('play'); const want = v.paused ? 'play' : 'pause';+    if (p.dataset.state !== want) { p.dataset.state = want; p.innerHTML = icon(want, 16) + (v.paused ? 'Play' : 'Pause'); p.setAttribute('aria-label', 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);+  ['timeupdate', 'play', 'pause', 'seeked', 'progress', 'durationchange', 'ratechange'].forEach((e) => v.addEventListener(e, tick));++  // positions live on the server (never localStorage)+  let lastSaved = -1;+  function savePos() { if (!item) return; lastSaved = v.currentTime; api('api/position', { id, t: v.currentTime, speed: v.playbackRate }).catch(() => {}); }+  setInterval(() => { if (!v.paused && Math.abs(v.currentTime - lastSaved) > 2) savePos(); }, 3000);+  v.addEventListener('pause', savePos); v.addEventListener('seeked', () => { if (v.paused) savePos(); });+  addEventListener('pagehide', savePos); +  // ---------- controls ----------   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());+  const vol = (d) => { v.muted = false; v.volume = Math.max(0, Math.min(1, +(v.volume + d).toFixed(2))); toast(`Volume ${Math.round(v.volume * 100)}%`); };+  const fs = () => { document.fullscreenElement ? document.exitFullscreen() : document.querySelector('.stage').requestFullscreen?.(); };+  const theater = (on) => { document.body.classList.toggle('theater', on ?? !document.body.classList.contains('theater')); syncToggles(); toast(document.body.classList.contains('theater') ? 'Theater mode' : 'Default view'); };+  const pip = async () => { try { document.pictureInPictureElement ? await document.exitPictureInPicture() : await v.requestPictureInPicture(); } catch { toast('Picture-in-picture is not available in this surface', 'error'); } };+  let capSize = 100;+  const capTrack = () => v.textTracks && v.textTracks[0];+  const captions = () => { const t = capTrack(); if (!t) return toast('No captions for this video. An AI adds them with adom-video play --captions file.vtt'); t.mode = t.mode === 'showing' ? 'hidden' : 'showing'; syncToggles(); toast(t.mode === 'showing' ? 'Captions on' : 'Captions off'); };+  const capFont = (d) => { if (!capTrack()) return toast('No captions for this video'); capSize = Math.max(50, Math.min(300, capSize + d)); document.documentElement.style.setProperty('--cap-size', capSize + '%'); toast(`Caption size ${capSize}%`); };+  const loop = () => { v.loop = !v.loop; syncToggles(); toast(v.loop ? 'Loop on' : 'Loop off'); };+  const chapterJump = (dir) => {+    const ch = (item && item.chapters) || []; if (!ch.length) return toast('This video has no chapters');+    const t = v.currentTime;+    const next = dir > 0 ? ch.find((c) => c.t > t + 0.5) : [...ch].reverse().find((c) => c.t < t - 1.5) || ch[0];+    if (next) { v.currentTime = next.t; toast(next.title); }+  };+  const sibling = async (dir) => {+    const items = ((await api('api/library')).items || []).filter((x) => x.exists); const k = items.findIndex((x) => x.id === id);+    const n = items[k + dir]; n ? (location.href = `../watch/${n.id}`) : toast(dir > 0 ? 'This is the last video in the library' : 'This is the first video in the library');+  };+  // toggles look like their state and stay in sync with every path that flips them+  function syncToggles() {+    const set = (el, on) => { el.classList.toggle('is-on', on); el.setAttribute('aria-pressed', String(on)); };+    set($('loopBtn'), v.loop); set($('theaterBtn'), document.body.classList.contains('theater'));+    set($('capBtn'), !!(capTrack() && capTrack().mode === 'showing'));+  }   $('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); };+  $('capBtn').onclick = captions; $('loopBtn').onclick = loop; $('theaterBtn').onclick = () => theater(); $('pipBtn').onclick = pip; $('fsBtn').onclick = fs;+  $('copy').onclick = () => { navigator.clipboard?.writeText(location.href).then(() => toast('Link copied', 'success'), () => toast(location.href)); };++  // shortcuts: hover the keyboard button for the list, click or ? to pin it+  hoverPopover($('sc-btn'), $('sc-pop'));+  const help = $('help');+  const pin = (on) => { if (on) { $('sc-pop').classList.remove('open'); help.showModal(); } else help.close(); };+  $('sc-btn').onclick = () => pin(!help.open); $('help-close').onclick = () => pin(false);+  help.addEventListener('click', (e) => { if (e.target !== help) return; const r = help.getBoundingClientRect(); if (!(e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom)) pin(false); }); +  // YouTube's keyboard shortcuts (plus [ ] for speed), in the capture phase so the native controls never double-act   document.addEventListener('keydown', (e) => {-    if (e.target.tagName === 'INPUT' || e.metaKey || e.ctrlKey || e.altKey) return;+    if (/INPUT|TEXTAREA|SELECT/.test(e.target.tagName) || e.metaKey || e.altKey) return;+    if (document.querySelector('dialog#settings[open]')) return;     const k = e.key; let hit = true;-    if (k === ' ' || k === 'k' || k === 'K') toggle();+    if (e.ctrlKey) { if (k === 'ArrowLeft') chapterJump(-1); else if (k === 'ArrowRight') chapterJump(1); else return; }+    else 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 === 'ArrowUp') vol(0.05);+    else if (k === 'ArrowDown') vol(-0.05);     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 === '<' || k === '[') speedStep(-1);+    else if (k === '>' || k === ']') speedStep(1);     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 === 'f' || k === 'F') fs();+    else if (k === 't' || k === 'T') theater();+    else if (k === 'i' || k === 'I') pip();     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 if (k === 'c' || k === 'C') captions();+    else if (k === '+' || k === '=') capFont(25);+    else if (k === '-' || k === '_') capFont(-25);+    else if (k === 'N' && e.shiftKey) sibling(1);+    else if (k === 'P' && e.shiftKey) sibling(-1);+    else if (k === '?') pin(!help.open);+    else if (k === 'Escape') { if (help.open) pin(false); else if (document.body.classList.contains('theater')) theater(false); else hit = false; }     else hit = false;-    if (hit) e.preventDefault();-  });+    if (hit) { e.preventDefault(); e.stopPropagation(); }+  }, true); -  // 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);+  // ---------- live channel: the AI drives this page from the CLI ----------+  connect({+    settings: (d) => { settings = d.settings; },+    library: (d) => libraryList(d.items),+    changed: async (d) => { if (d.id === id && d.etag !== etag) { const at = fmt(v.currentTime); await load(true); toast(`The file was updated; reloaded at ${at}`, 'success'); } },+    ui: async (d) => {+      if (await uiCommon(d)) return;+      if (d.type === 'seek') { v.currentTime = Number(d.t) || 0; toast(`AI jumped to ${fmt(v.currentTime)}`); }+      else if (d.type === 'play') v.play().catch(() => toast('The browser blocked autoplay; press Play', 'error'));+      else if (d.type === 'pause') v.pause();+      else if (d.type === 'speed') setSpeed(Number(d.rate) || 1);+      else if (d.type === 'theater') theater();+      else if (d.type === 'captions') captions();+    },+  }); -  load(false); libraryList();+  (async () => {+    const st = await api('state'); header(st); initSettings(); settings = st.settings;+    tick(); await load(false); libraryList(st.library);+  })(); })();
public/ui.jsadded+187
@@ -0,0 +1,187 @@+// Shared UI layer for every adom-video-player page, following adom-ui-design:+// body-appended tooltips with a 600ms delay and viewport clamp, bottom-center+// toasts, the header info popover, the Settings dialog (server-side settings),+// a console relay to the server, the SSE live channel, and the fake AI cursor.+(() => {+  const BASE = location.pathname.replace(/watch\/[^/]*$/, '').replace(/[^/]*$/, '');   // relative root, proxy-safe+  const HUMAN = 'human (player page)';+  const $ = (s, r = document) => r.querySelector(s);+  const api = (p, body) => fetch(BASE + p, body === undefined ? { cache: 'no-store' } :+    { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ aiThread: HUMAN, ...body }) }).then((r) => r.json());++  // ---------- console relay: the AI reads /console instead of DevTools ----------+  const queue = []; let flushT;+  const flush = () => { const lines = queue.splice(0); if (lines.length) navigator.sendBeacon?.(BASE + 'console', new Blob([JSON.stringify({ lines })], { type: 'application/json' })) || fetch(BASE + 'console', { method: 'POST', body: JSON.stringify({ lines }) }); };+  for (const level of ['log', 'info', 'warn', 'error']) {+    const orig = console[level].bind(console);+    console[level] = (...a) => { orig(...a); queue.push({ level, page: location.pathname, msg: a.map((x) => typeof x === 'string' ? x : (() => { try { return JSON.stringify(x); } catch { return String(x); } })()).join(' ') }); clearTimeout(flushT); flushT = setTimeout(flush, 300); };+  }+  addEventListener('error', (e) => console.error('window.onerror:', e.message, e.filename + ':' + e.lineno));+  addEventListener('unhandledrejection', (e) => console.error('unhandledrejection:', String(e.reason)));++  // ---------- tooltips: one body-level fixed div, 600ms, clamped on both axes ----------+  const tip = document.createElement('div'); tip.id = 'global-tooltip'; tip.setAttribute('role', 'tooltip'); document.body.appendChild(tip);+  let cx = 0, cy = 0, tipT, tipFor = null;+  addEventListener('mousemove', (e) => { cx = e.clientX; cy = e.clientY; }, true);+  function place(el, w, h, anchorRect) {+    const O = 14, vw = innerWidth, vh = innerHeight;+    const a = anchorRect || { left: cx, right: cx, top: cy, bottom: cy };+    // cursor quadrants first, then just below / above the trigger so the tip never covers it+    const cands = [{ x: cx + O, y: cy + O }, { x: cx - w - O, y: cy + O }, { x: cx + O, y: cy - h - O }, { x: cx - w - O, y: cy - h - O },+      { x: cx - w / 2, y: a.bottom + 8 }, { x: cx - w / 2, y: a.top - h - 8 }, { x: a.right + 8, y: cy - h / 2 }, { x: a.left - w - 8, y: cy - h / 2 }];+    const score = (c) => {+      let p = 0;+      if (c.x < 8 || c.x + w > vw - 8) p += 1000; if (c.y < 8 || c.y + h > vh - 8) p += 1000;+      if (anchorRect) { const ox = Math.max(0, Math.min(c.x + w, anchorRect.right) - Math.max(c.x, anchorRect.left)); const oy = Math.max(0, Math.min(c.y + h, anchorRect.bottom) - Math.max(c.y, anchorRect.top)); p += ox * oy; }+      return p;+    };+    const clamp = (c) => ({ x: Math.max(8, Math.min(c.x, vw - w - 8)), y: Math.max(8, Math.min(c.y, vh - h - 8)) });+    const b = cands.map((c) => ({ c, s: score(c) + score(clamp(c)) })).sort((p, z) => p.s - z.s)[0].c;+    return { x: Math.max(8, Math.min(b.x, vw - w - 8)), y: Math.max(8, Math.min(b.y, vh - h - 8)) };+  }+  function hideTip() { clearTimeout(tipT); tip.classList.remove('visible', 'shown'); tipFor = null; }+  document.addEventListener('mouseover', (e) => {+    const t = e.target.closest?.('[data-tooltip]');+    if (t === tipFor) return;+    hideTip(); if (!t || matchMedia('(hover: none)').matches) return;+    tipFor = t;+    tipT = setTimeout(() => {+      tip.textContent = t.getAttribute('data-tooltip'); tip.classList.add('visible');+      const { x, y } = place(tip, tip.offsetWidth, tip.offsetHeight, t.getBoundingClientRect());+      tip.style.left = x + 'px'; tip.style.top = y + 'px';+      requestAnimationFrame(() => tip.classList.add('shown'));+    }, 600);+  });+  addEventListener('scroll', hideTip, true); addEventListener('mousedown', hideTip, true);++  // a hover popover (the brand info card, the shortcuts list): 600ms show, 220ms hide grace, clamped+  function hoverPopover(trigger, pop) {+    let showT, hideT;+    const show = () => {+      clearTimeout(hideT); hideTip();+      showT = setTimeout(() => {+        pop.classList.add('open'); const r = trigger.getBoundingClientRect();+        const w = pop.offsetWidth, h = pop.offsetHeight;+        let x = Math.min(r.left, innerWidth - w - 8), y = r.bottom + 6;+        if (y + h > innerHeight - 8) y = Math.max(8, r.top - h - 6);+        pop.style.left = Math.max(8, x) + 'px'; pop.style.top = Math.max(8, Math.min(y, innerHeight - h - 8)) + 'px';+      }, 600);+    };+    const hide = () => { clearTimeout(showT); hideT = setTimeout(() => pop.classList.remove('open'), 220); };+    for (const el of [trigger, pop]) { el.addEventListener('mouseenter', show); el.addEventListener('mouseleave', hide); }+  }++  // ---------- toasts ----------+  const stack = document.createElement('div'); stack.className = 'toast-stack'; document.body.appendChild(stack);+  function toast(text, kind = 'info', action) {+    const el = document.createElement('div'); el.className = `toast ${kind}`;+    el.setAttribute('role', kind === 'error' ? 'alert' : 'status'); el.setAttribute('aria-live', kind === 'error' ? 'assertive' : 'polite');+    el.innerHTML = `<span class="ticon">${icon(kind === 'success' ? 'check' : kind === 'error' ? 'alert' : 'info', 16)}</span><span class="tmsg"></span>`;+    el.querySelector('.tmsg').textContent = text;+    if (action) { const a = document.createElement('button'); a.className = 'taction'; a.textContent = action.label; a.onclick = () => { action.run(); close(); }; el.appendChild(a); }+    const x = document.createElement('button'); x.className = 'tclose'; x.setAttribute('aria-label', 'Dismiss'); x.innerHTML = icon('close', 14); x.onclick = () => close(); el.appendChild(x);+    stack.prepend(el);+    while (stack.children.length > 3) stack.lastChild.remove();+    let left = kind === 'error' ? Infinity : 4000, t0 = Date.now(), timer;+    const arm = () => { if (isFinite(left)) timer = setTimeout(close, left); t0 = Date.now(); };+    el.addEventListener('mouseenter', () => { clearTimeout(timer); left -= Date.now() - t0; });+    el.addEventListener('mouseleave', arm);+    function close() { clearTimeout(timer); el.classList.add('leaving'); setTimeout(() => el.remove(), 150); }+    arm(); return el;+  }++  // ---------- header: brand info popover + icons ----------+  function header(state) {+    $('#brand-icon').innerHTML = icon('mark', 18);+    document.querySelectorAll('[data-icon]').forEach((b) => { b.insertAdjacentHTML('afterbegin', icon(b.dataset.icon, Number(b.dataset.iconSize || 18))); });+    const pop = $('#brand-pop');+    pop.innerHTML = `<div class="pop-title">Video Player</div>+      <div class="pop-row"><span>Version</span><span class="mono">${state.version}</span></div>+      <div class="pop-row"><span>Server</span><span class="mono">port ${state.port}, started by ${state.startedBy}</span></div>+      <div class="pop-row"><span>Engine</span><span>Range-capable Node server, SSE live channel</span></div>+      <div class="pop-row"><span>Wiki</span><a href="https://wiki.adom.inc/adom/adom-video-player" target="_blank" rel="noopener">adom/adom-video-player</a></div>`;+    hoverPopover($('#brand'), pop);+  }++  // ---------- Settings dialog: built from the server's schema ----------+  let settingsState = { settings: {}, schema: {} };+  function renderSettings() {+    const body = $('#settings-body'); body.innerHTML = '';+    for (const [k, s] of Object.entries(settingsState.schema)) {+      const v = settingsState.settings[k];+      const row = document.createElement('div'); row.className = 'set-row';+      const label = document.createElement('h4'); label.className = 'set-name'; label.textContent = s.label;+      const help = document.createElement('p'); help.className = 'set-help'; help.textContent = s.help;+      let ctl;+      if (s.type === 'boolean') {+        ctl = document.createElement('button'); ctl.className = 'toggle' + (v ? ' active' : ''); ctl.setAttribute('role', 'switch'); ctl.setAttribute('aria-checked', String(!!v));+        ctl.setAttribute('aria-label', s.label); ctl.innerHTML = '<span class="toggle-track"><span class="toggle-thumb"></span></span>';+        ctl.onclick = () => save(k, !settingsState.settings[k]);+      } else if (s.type === 'enum') {+        ctl = document.createElement('select'); ctl.className = 'select'; ctl.setAttribute('aria-label', s.label);+        for (const o of s.values) { const op = document.createElement('option'); op.value = o; op.textContent = s.labels?.[o] || (typeof o === 'number' ? o + 'x' : o); if (String(o) === String(v)) op.selected = true; ctl.appendChild(op); }+        ctl.onchange = () => save(k, ctl.value);+      } else {+        ctl = document.createElement('input'); ctl.type = 'number'; ctl.className = 'select'; ctl.min = s.min; ctl.max = s.max; ctl.value = v; ctl.setAttribute('aria-label', s.label);+        ctl.onchange = () => save(k, Number(ctl.value));+      }+      const def = document.createElement('span'); def.className = 'default-hint';+      def.textContent = 'Default: ' + (s.type === 'boolean' ? (s.default ? 'On' : 'Off') : (s.labels?.[s.default] || (s.type === 'enum' && typeof s.default === 'number' ? s.default + 'x' : s.default)));+      row.append(label, help, ctl, def); body.appendChild(row);+    }+  }+  async function save(k, v) {+    const r = await api('settings', { [k]: v });+    if (!r.ok) return toast(r.error, 'error');+    settingsState.settings = r.settings; renderSettings();+    toast(`${settingsState.schema[k].label}: ${typeof v === 'boolean' ? (v ? 'on' : 'off') : (settingsState.schema[k].labels?.[v] || v)}`, 'success');+  }+  function initSettings() {+    const dlg = $('#settings');+    $('#settings-btn').onclick = async () => { const r = await api('settings'); settingsState = r; renderSettings(); dlg.showModal(); };+    $('#settings-close').onclick = () => dlg.close();+    dlg.addEventListener('click', (e) => {+      if (e.target !== dlg) return; const r = dlg.getBoundingClientRect();+      if (!(e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom)) dlg.close();+    });+    const rb = $('#restore'); let armed = false, armT;+    rb.onclick = async () => {+      if (!armed) { armed = true; rb.classList.add('armed'); rb.lastChild.textContent = ' Click again to confirm'; armT = setTimeout(() => { armed = false; rb.classList.remove('armed'); rb.lastChild.textContent = ' Restore defaults'; }, 4000); return; }+      clearTimeout(armT); armed = false; rb.classList.remove('armed'); rb.lastChild.textContent = ' Restore defaults';+      const r = await api('settings', { restoreDefaults: true }); settingsState.settings = r.settings; renderSettings(); toast('Settings restored to defaults', 'success');+    };+  }++  // ---------- fake AI cursor ----------+  const cursor = document.createElement('div'); cursor.className = 'ai-cursor';+  cursor.innerHTML = '<svg viewBox="0 0 24 24" width="22" height="22"><path d="M5 3l14 8-6 1.5L10 19z" fill="#00b8b1" stroke="#05221f" stroke-width="1.2" stroke-linejoin="round"/></svg><span class="ai-label"></span>';+  document.body.appendChild(cursor);+  function glide(selector, label) {+    const el = document.querySelector(selector); if (!el) { toast(`AI could not find ${selector}`, 'error'); return Promise.resolve(null); }+    el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });+    const r = el.getBoundingClientRect();+    cursor.querySelector('.ai-label').textContent = label || '';+    cursor.classList.add('on'); cursor.style.transform = `translate(${r.left + r.width / 2}px, ${r.top + r.height / 2}px)`;+    return new Promise((res) => setTimeout(() => res(el), 380));+  }++  // ---------- SSE live channel ----------+  function connect(handlers) {+    const es = new EventSource(BASE + 'events');+    for (const [ev, fn] of Object.entries(handlers)) es.addEventListener(ev, (m) => { try { fn(JSON.parse(m.data)); } catch (e) { console.error('event', ev, e); } });+    es.onerror = () => console.warn('live channel reconnecting');+    return es;+  }+  async function uiCommon(d) {+    if (d.type === 'toast') toast(d.text || '', d.kind || 'info');+    else if (d.type === 'reload') location.reload();+    else if (d.type === 'cursor') await glide(d.selector, d.label || 'AI');+    else if (d.type === 'click') { const el = await glide(d.selector, d.label || 'AI clicks'); el?.click(); setTimeout(() => cursor.classList.remove('on'), 900); }+    else if (d.type === 'reveal') { const el = document.querySelector(d.selector); el?.closest('details')?.setAttribute('open', ''); el?.scrollIntoView({ block: 'center', behavior: 'smooth' }); el?.classList.add('revealed'); setTimeout(() => el?.classList.remove('revealed'), 1500); }+    else if (d.type === 'eval') { try { const v = await (0, eval)(d.js); console.log('eval result:', v); } catch (e) { console.error('eval error:', String(e)); } }+    else return false;+    return true;+  }++  window.UI = { BASE, HUMAN, api, toast, tip, hoverPopover, header, initSettings, connect, uiCommon, glide, setSettings: (s) => { settingsState = s; } };+})();
server.js+192−46
@@ -1,20 +1,23 @@ #!/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.+// adom-video-player server: one per container. Serves registered media with full+// HTTP Range support so every player can seek, owns all state (library, positions,+// settings), and drives the open player pages over an SSE live channel. 'use strict'; const http = require('http'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const os = require('os');+const { execFile } = require('child_process');  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 LIB_FILE = path.join(INST_DIR, 'library.json');+const POS_FILE = path.join(INST_DIR, 'positions.json');+const SET_FILE = path.join(INST_DIR, 'settings.json'); const SERVER_FILE = path.join(INST_DIR, 'server.json');+const OLD_DIR = path.join(HOME, '.adom', 'adom-video-player');   // 0.1/0.2 location, migrated once const PUBLIC = path.join(__dirname, 'public');  const argv = process.argv.slice(2);@@ -23,21 +26,51 @@ 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 writeJSON = (f, v) => { const t = f + '.' + process.pid + '.tmp'; fs.writeFileSync(t, JSON.stringify(v, null, 2)); fs.renameSync(t, f); };+for (const [o, n] of [['library.json', LIB_FILE], ['settings.json', SET_FILE]]) {+  if (!fs.existsSync(n) && fs.existsSync(path.join(OLD_DIR, o))) fs.copyFileSync(path.join(OLD_DIR, o), n);+}++// ---------- settings: schema-validated, server-side, broadcast on change ----------+const SCHEMA = {+  autoplay: { type: 'boolean', default: true, label: 'Play when a video opens', help: 'Start playback as soon as a video loads. Off: it opens paused at your last position.' },+  rememberPosition: { type: 'boolean', default: true, label: 'Resume where you left off', help: 'Reopen each video at the position you last watched it, stored on this machine.' },+  defaultSpeed: { type: 'enum', values: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2], default: 1, label: 'Default speed', help: 'Playback speed for a video you have not set a speed on yet.' },+  surface: { type: 'enum', values: ['wv', 'hbw', 'pup', 'nb'], labels: { wv: 'Hydrogen webview tab', hbw: 'Hydrogen browser window', pup: 'pup window', nb: 'Your own browser' }, default: 'wv', label: 'Where videos open', help: 'Where adom-video play shows a video when the AI does not choose.' },+  openInBackground: { type: 'boolean', default: true, label: 'Open pup windows in the background', help: 'A pup window opens without stealing focus; its taskbar button flashes instead.' },+  ttlHours: { type: 'number', min: 1, max: 720, default: 24, label: 'Stop the server after idle hours', help: 'The player server exits after this many hours with no requests. Your library and positions are kept.' },+  devMode: { type: 'boolean', default: false, label: 'Developer mode', help: 'Lets an AI run JavaScript in the open player page (adom-video ui eval) for debugging.' },+};+const defaults = () => Object.fromEntries(Object.entries(SCHEMA).map(([k, s]) => [k, s.default]));+let settings = { ...defaults(), ...readJSON(SET_FILE, {}) };+function validateSettings(patch) {+  const out = {}; const errors = [];+  for (const [k, v] of Object.entries(patch)) {+    if (k === 'aiThread' || k === 'reason') continue;+    const s = SCHEMA[k];+    if (!s) { errors.push(`unknown setting "${k}" (valid: ${Object.keys(SCHEMA).join(', ')})`); continue; }+    if (s.type === 'boolean') { if (typeof v !== 'boolean') errors.push(`${k} must be true or false`); else out[k] = v; }+    if (s.type === 'number') { const n = Number(v); if (!isFinite(n) || n < s.min || n > s.max) errors.push(`${k} must be a number from ${s.min} to ${s.max}`); else out[k] = n; }+    if (s.type === 'enum') { const m = s.values.find((x) => String(x) === String(v)); if (m === undefined) errors.push(`${k} must be one of: ${s.values.join(', ')}`); else out[k] = m; }+  }+  return { out, errors };+} -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: [] });+let positions = readJSON(POS_FILE, {});+let lastDisruption = null;+const threadsSeen = new Map();   // aiThread -> last ms+const tabs = new Set();          // webview tabs opened for this server (closed on reap)+const consoleRing = [];+const MAX_CONSOLE = 500;  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',+  '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.json': 'application/json', '.woff2': 'font/woff2', }; const mimeOf = (p) => MIME[path.extname(p).toLowerCase()] || 'application/octet-stream'; const idOf = (p) => crypto.createHash('sha1').update(p).digest('hex').slice(0, 10);@@ -45,8 +78,8 @@ const idOf = (p) => crypto.createHash('sha1').update(p).digest('hex').slice(0, 1 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 }; }+    return { ...it, size: st.size, mtime: st.mtimeMs, etag: `"${st.size.toString(16)}-${Math.floor(st.mtimeMs).toString(16)}"`, exists: true, position: positions[it.id] || null };+  } catch { return { ...it, exists: false, position: positions[it.id] || null }; } }  function parseChapters(src) {@@ -61,34 +94,61 @@ function parseChapters(src) {   } catch { return []; } } +// ---------- live channel (SSE) ----------+const clients = new Set();+function broadcast(event, data) {+  const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;+  for (const c of clients) { try { c.write(msg); } catch {} }+}+const stateSnapshot = () => ({+  ok: true, app: 'adom-video-player', version: PKG.version, port, pid: process.pid, startedBy: THREAD,+  settings, schema: SCHEMA, library: library.items.map(statItem), lastDisruption,+  pages: clients.size, threads: [...threadsSeen].map(([t, ms]) => ({ thread: t, idleSec: Math.round((Date.now() - ms) / 1000) })),+});++// file watcher: a re-rendered file reloads every open player within ~2 s+const etags = new Map();+setInterval(() => {+  for (const it of library.items) {+    const s = statItem(it); const prev = etags.get(it.id);+    if (prev !== undefined && s.etag !== prev) broadcast('changed', { id: it.id, etag: s.etag, exists: s.exists });+    etags.set(it.id, s.etag);+  }+}, 2000).unref();+ let lastActivity = Date.now(); let port = 0;-+const startedAt = new Date().toISOString(); function register() {-  writeJSON(SERVER_FILE, { pid: process.pid, port, thread: THREAD, version: PKG.version, startedAt: startedAt, lastActivity: new Date(lastActivity).toISOString() });+  writeJSON(SERVER_FILE, { pid: process.pid, port, thread: THREAD, version: PKG.version, startedAt, lastActivity: new Date(lastActivity).toISOString(), tabs: [...tabs] }); }-const startedAt = new Date().toISOString();+let posTimer = null;+const savePositions = () => { clearTimeout(posTimer); posTimer = setTimeout(() => writeJSON(POS_FILE, positions), 1000); };  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);+  const isObj = typeof body === 'object' && !Buffer.isBuffer(body);+  res.writeHead(code, { 'Content-Type': isObj ? 'application/json' : 'text/plain; charset=utf-8', ...headers });+  res.end(isObj ? JSON.stringify(body, null, 2) : body); }- 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 needThread(b) {+  if (b && typeof b.aiThread === 'string' && b.aiThread.trim()) { threadsSeen.set(b.aiThread.trim(), Date.now()); return null; }+  return { ok: false, errorCode: 'caller_identity_required', error: 'adom-video-player requires every 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 needReason(b, what, alternative) {+  const r = b && typeof b.reason === 'string' ? b.reason.trim() : '';+  if (r.length >= 10) return null;+  return { ok: false, errorCode: 'reason_required', error: `${what} requires a reason.`,+    hint: `Re-call with a "reason" (at least 10 characters) quoting or paraphrasing what the USER asked for. ${alternative}` };+}  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);+    res.writeHead(200, { 'Content-Type': mimeOf(file), 'Cache-Control': file.endsWith('.woff2') ? 'max-age=86400' : 'no-cache' }); res.end(data);   }); } @@ -126,6 +186,8 @@ function serveMedia(req, res, it) {   fs.createReadStream(it.path, { start, end }).pipe(res); } +const UI_DIRECTIVES = new Set(['toast', 'click', 'reveal', 'cursor', 'reload', 'seek', 'play', 'pause', 'speed', 'eval', 'theater', 'captions']);+ const server = http.createServer(async (req, res) => {   lastActivity = Date.now();   const url = new URL(req.url, 'http://x');@@ -134,57 +196,141 @@ const server = http.createServer(async (req, res) => {     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(/^(\.\.[/\\])+/, ''));+      const f = path.normalize(path.join(PUBLIC, p.slice(8)));       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') {+    if (p === '/favicon.ico' || p === '/favicon.svg') return serveStatic(res, path.join(PUBLIC, 'icons', 'video-player.svg'));+    if (p === '/version' || p === '/health') return send(res, 200, { ok: true, app: 'adom-video-player', version: PKG.version, pid: process.pid, port, thread: THREAD, pages: clients.size, library: library.items.length });+    if (p === '/state') return send(res, 200, stateSnapshot());++    if (p === '/events') {+      res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });+      res.write(':' + ' '.repeat(2048) + '\n\n');+      res.write(`event: state\ndata: ${JSON.stringify(stateSnapshot())}\n\n`);+      clients.add(res);+      const hb = setInterval(() => { try { res.write(': hb\n\n'); } catch {} }, 15000);+      req.on('close', () => { clearInterval(hb); clients.delete(res); });+      return;+    }++    if (p === '/console' && req.method === 'GET') {+      const n = Number(url.searchParams.get('n') || 100);+      return send(res, 200, { ok: true, lines: consoleRing.slice(-n) });+    }+    if (p === '/console' && 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 });+      for (const l of (Array.isArray(b.lines) ? b.lines : [b])) {+        consoleRing.push({ ts: new Date().toISOString(), level: String(l.level || 'log'), page: String(l.page || ''), msg: String(l.msg || '').slice(0, 2000) });+      }+      while (consoleRing.length > MAX_CONSOLE) consoleRing.shift();+      return send(res, 200, { ok: true });+    }++    if (p === '/settings' && req.method === 'GET') {+      return send(res, 200, { ok: true, settings, schema: SCHEMA, enums: Object.fromEntries(Object.entries(SCHEMA).filter(([, s]) => s.type === 'enum').map(([k, s]) => [k, s.values])) });     }+    if (p === '/settings' && req.method === 'POST') {+      const b = await readBody(req); const no = needThread(b); if (no) return send(res, 400, no);+      if (b.restoreDefaults === true) settings = defaults();+      else {+        const { out, errors } = validateSettings(b);+        if (errors.length) return send(res, 400, { ok: false, errorCode: 'invalid_setting', error: errors.join('; '), hint: 'GET /settings lists every setting with its type and allowed values.' });+        settings = { ...settings, ...out };+      }+      writeJSON(SET_FILE, settings); broadcast('settings', { settings, by: b.aiThread });+      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' });+      return it ? send(res, 200, { ok: true, item: statItem(it), settings }) : send(res, 404, { ok: false, error: 'unknown id', hint: 'adom-video ls lists the library.' });+    }+    if (p.startsWith('/captions/')) {+      const it = library.items.find((x) => x.id === p.slice(10));+      if (!it || !it.captions) return send(res, 404, 'no captions');+      return fs.readFile(it.captions, 'utf8', (err, txt) => {+        if (err) return send(res, 404, 'captions file is gone');+        if (!/^WEBVTT/.test(txt)) txt = 'WEBVTT\n\n' + txt.replace(/(\d\d:\d\d:\d\d),(\d\d\d)/g, '$1.$2');  // .srt -> .vtt+        res.writeHead(200, { 'Content-Type': 'text/vtt; charset=utf-8', 'Cache-Control': 'no-cache' }); res.end(txt);+      });     }     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>' });+      if (!it) return send(res, 404, { ok: false, error: 'unknown id', hint: '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}` });+      if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return send(res, 400, { ok: false, error: `not a file: ${abs}`, hint: 'Pass the path of a video or audio file.' });       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() };+      const ch = parseChapters(b.chapters);+      const item = { id, path: abs, title: b.title || prev?.title || path.basename(abs), chapters: ch.length ? ch : (prev?.chapters || []),+        fps: Number(b.fps) || prev?.fps || 0, captions: b.captions ? path.resolve(String(b.captions)) : (prev?.captions || null),+        addedBy: b.aiThread, addedAt: new Date().toISOString() };       library.items = [item, ...library.items.filter((x) => x.id !== id)].slice(0, 200);-      writeJSON(LIB_FILE, library);+      writeJSON(LIB_FILE, library); broadcast('library', { items: library.items.map(statItem) });       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 });+      const it = library.items.find((x) => x.id === b.id);+      library.items = library.items.filter((x) => x.id !== b.id); writeJSON(LIB_FILE, library);+      broadcast('library', { items: library.items.map(statItem) });+      return send(res, 200, { ok: true, removed: it ? it.title : null, kept: 'the file on disk is untouched; play it again to re-add it' });+    }+    if (p === '/api/position' && req.method === 'POST') {+      const b = await readBody(req); const no = needThread(b); if (no) return send(res, 400, no);+      if (!library.items.find((x) => x.id === b.id)) return send(res, 404, { ok: false, error: 'unknown id' });+      positions[b.id] = { t: Math.max(0, Number(b.t) || 0), speed: Number(b.speed) || undefined, at: new Date().toISOString() };+      savePositions(); return send(res, 200, { ok: true });+    }+    if (p === '/api/tabs' && req.method === 'POST') {+      const b = await readBody(req); const no = needThread(b); if (no) return send(res, 400, no);+      if (b.name) tabs.add(String(b.name)); register(); return send(res, 200, { ok: true, tabs: [...tabs] });     }++    if (p.startsWith('/ui/') && req.method === 'POST') {+      const type = p.slice(4);+      const b = await readBody(req); const no = needThread(b); if (no) return send(res, 400, no);+      if (!UI_DIRECTIVES.has(type)) return send(res, 400, { ok: false, errorCode: 'unknown_directive', error: `no ui directive "${type}"`, hint: `Valid: ${[...UI_DIRECTIVES].join(', ')}` });+      if (type === 'eval' && !settings.devMode) return send(res, 400, { ok: false, errorCode: 'dev_mode_off', error: 'ui eval runs JavaScript in the user\'s page, so it needs Developer mode.', hint: 'Ask the user, then: adom-video settings devMode=true' });+      if (!clients.size) return send(res, 409, { ok: false, errorCode: 'no_page_open', error: 'no player page is open to receive this', hint: 'Open one first: adom-video --ai-thread "<name>" play <file>' });+      const { aiThread, ...payload } = b;+      broadcast('ui', { type, ...payload, by: aiThread });+      return send(res, 200, { ok: true, delivered: clients.size });+    }+     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);+      const why = needReason(b, 'Stopping the shared player server', 'If you only want your video gone, close its tab instead; other threads may be using this server.');+      if (why) return send(res, 400, why);+      const others = [...threadsSeen].filter(([t, ms]) => t !== b.aiThread && !t.startsWith('human') && Date.now() - ms < 5 * 60e3).map(([t]) => t);+      if ((others.length || clients.size) && b.confirm !== true) {+        return send(res, 409, { ok: false, errorCode: 'confirm_required', error: `Other users of this server: ${others.length ? 'threads ' + others.join(', ') : 'none'}; open player pages: ${clients.size}.`,+          hint: 'Re-call with "confirm": true only if stopping it is really what the user wants; their open players will stop.' });+      }+      lastDisruption = { ts: new Date().toISOString(), verb: 'shutdown', thread: b.aiThread, reason: b.reason };+      console.log('DISRUPTION granted: shutdown by', b.aiThread, ':', b.reason);+      broadcast('ui', { type: 'toast', text: `The player server is stopping (${b.aiThread}: ${b.reason})`, kind: 'error' });+      send(res, 200, { ok: true, bye: true }); return setTimeout(() => shutdown('requested'), 200);     }-    return send(res, 404, { ok: false, error: `no route ${p}` });+    return send(res, 404, { ok: false, error: `no route ${p}`, hint: 'GET /state shows everything this server knows.' });   } catch (e) { return send(res, 500, { ok: false, error: String(e && e.stack || e) }); } }); -function shutdown() {+function shutdown(why) {+  try { writeJSON(POS_FILE, positions); } catch {}+  for (const name of tabs) { try { execFile('adom-cli', ['hydrogen', 'workspace', 'remove-tab', '--name', name], { timeout: 5000 }, () => {}); } catch {} }   try { if (readJSON(SERVER_FILE, {}).pid === process.pid) fs.unlinkSync(SERVER_FILE); } catch {}-  process.exit(0);+  setTimeout(() => process.exit(0), tabs.size ? 1500 : 0); }-process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown);+process.on('SIGTERM', () => shutdown('sigterm')); process.on('SIGINT', () => shutdown('sigint'));  const want = argVal('--port'); server.listen(want && want !== 'auto' ? parseInt(want, 10) : 0, '127.0.0.1', () => {@@ -194,5 +340,5 @@ server.listen(want && want !== 'auto' ? parseInt(want, 10) : 0, '127.0.0.1', () setInterval(() => {   register();   const ttl = Number(argVal('--ttl') || settings.ttlHours || 24);-  if (!KEEP_ALIVE && Date.now() - lastActivity > ttl * 3600e3) shutdown();+  if (!KEEP_ALIVE && Date.now() - lastActivity > ttl * 3600e3) shutdown('idle'); }, 60e3).unref();
skills/adom-video-player/SKILL.md+10−3
@@ -33,17 +33,24 @@ It starts (or reuses) the one shared server, registers the file, and opens a Hyd | `--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) |+| `--captions subs.vtt` | Captions track (.vtt or .srt); C toggles them | | `--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`.+Other verbs (every reply starts `OK:` or `ERROR:` with a `Hint:`; add `--json` for JSON):+- `adom-video --ai-thread X ui toast "..." | seek <s> | play | pause | speed <x> | theater | captions | click <sel> | cursor <sel> | reload`+  drives the OPEN player over its SSE channel (use toasts to narrate to the user while you drive it).+- `adom-video ls` / `health`: server, version, open pages, library. `GET /state` and `GET /console` on the server for the full picture.+- `adom-video settings [key=value]`: autoplay, rememberPosition, defaultSpeed, surface (wv, hbw, pup, nb), openInBackground, ttlHours, devMode.+  Invalid values are refused with the valid set. The user has the same settings behind the cog in the player.+- `adom-video --ai-thread X stop --reason "<what the user asked>"`: reason-gated; while a page is open or other threads use it,+  it also needs `--confirm`. Usually you do NOT need this: the server stops itself after 24 h idle and closes its tabs.  ## 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.+- YouTube's keyboard shortcuts, all of them: Space/K, J/L, arrows (seek and volume), M, `,` `.` frame, `<` `>` speed, 0 to 9, Home/End, Ctrl+arrows chapters, F, T theater, I picture-in-picture, C captions, + - caption size, Shift+N/P next/previous video, ? list. Hovering the Shortcuts button shows them; every button's tooltip names its key. - 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).
uninstall.sh