← Commit history
README.md+10−9
@@ -14,20 +14,21 @@ AIs used to show videos by starting a quick `python -m http.server`. That server  ## The player +The same player as the wiki's `::adom-video` widget (adom/widgets): YouTube's look, YouTube's shortcuts, and one code path, so a video behaves the same in Hydrogen as on a wiki page.+ | | | |---|---|-| **Timeline** | Click or drag anywhere; hover shows the time; chapter marks |-| **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 |+| **Timeline** | A wide grab zone around a thin bar: click or drag to scrub, hover for a frame preview, the time and the chapter name |+| **Chapters** | `--chapters ch.json` (`[{"t": 12.5, "title": "Grind"}]`) or a WebVTT file: bar segments, the current chapter beside the time (click it for the next), and a chapter list beside the player |+| **Captions** | `--captions subs.vtt` (or .srt), drawn by the player: C toggles, + / - resize, O / W text and background opacity |+| **Speed** | 0.25x to 4x in the settings menu or with `<` `>`; press and hold the video for 2x |+| **Frame step** | `,` `.` at the measured frame rate, with a pill showing frame N of total and the exact time |+| **Keyboard** | YouTube's shortcuts: Space or K, J / L, arrows, ↑ ↓ volume, M, 0 to 9, Home / End, Ctrl+arrows chapters, F or double-click fullscreen, T theater, I picture-in-picture, R loop, Shift+N / Shift+P through the library, media keys, ? for the list |+| **Auto-reload** | Re-render the file at the same path and the player reloads it, keeping your place and speed | | **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">+Hover the keyboard button in the header for the full list, or press ? for it on the video; every button names its key in a tooltip.  ## Commands 
bin/adom-video

  
install.sh

  
package.json+8−5
@@ -1,11 +1,11 @@ {   "slug": "adom-video-player",   "type": "app",-  "version": "0.3.0",+  "version": "0.4.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.",-  "description": "adom-video is the standard way for an AI to show a user a video or audio file in Hydrogen (or pup). It runs one small zero-dependency Node server per container that serves only the files you register, with full HTTP Range support (206 Partial Content, If-Range, HEAD, ETag, no-cache), which is what a browser needs to seek. A quick python -m http.server does not do this, so videos shown that way cannot be scrubbed. The player page adds keyboard control (space, J/K/L, arrows, frame step, speed, 0-9 jumps, fullscreen), a seekable timeline with chapter marks and hover time, a chapter list, a library of everything shown in the container, per-file resume, and auto-reload with position kept when the file changes on disk. It follows the Adom app conventions: --ai-thread on every verb, one registry entry, reuse instead of respawn, a 24 hour idle reaper.",+  "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, in the same YouTube-style player as the wiki's ::adom-video widget, with YouTube's shortcuts, chapters, captions, frame stepping and auto-reload when the file is re-rendered.",+  "description": "adom-video is the standard way for an AI to show a user a video or audio file in Hydrogen (or pup). It runs one small zero-dependency Node server per container that serves only the files you register, with full HTTP Range support (206 Partial Content, If-Range, HEAD, ETag, no-cache), which is what a browser needs to seek. A quick python -m http.server does not do this, so videos shown that way cannot be scrubbed. The player page is the wiki's ::adom-video widget (YouTube's look and every YouTube shortcut, a scrub timeline with frame preview and chapter segments, captions it draws itself, hold for 2x, frame stepping at the measured rate, speeds to 4x), plus theater mode, a chapter list, a library of everything shown in the container, per-file resume, and auto-reload with position kept when the file changes on disk. It follows the Adom app conventions: --ai-thread on every verb, one registry entry, reuse instead of respawn, a 24 hour idle reaper.",   "category": "Developer Tools",   "tags": [     "video",@@ -19,7 +19,10 @@     "webview",     "preview",     "chapters",-    "frame step"+    "frame step",+    "youtube",+    "captions",+    "subtitles"   ],   "status": "live",   "discovery_pitch": "Show a video to the user with a real, seekable player instead of a hand-rolled http.server that cannot scrub: adom-video play <file> opens it in a Hydrogen webview tab with keyboard control, frame step, speeds and chapters.",@@ -89,4 +92,4 @@   "hero": {     "path": "docs/hero.png"   }-}\ No newline at end of file+}
page.json+7−4
@@ -1,11 +1,11 @@ {   "slug": "adom-video-player",   "type": "app",-  "version": "0.3.0",+  "version": "0.4.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.",-  "description": "adom-video is the standard way for an AI to show a user a video or audio file in Hydrogen (or pup). It runs one small zero-dependency Node server per container that serves only the files you register, with full HTTP Range support (206 Partial Content, If-Range, HEAD, ETag, no-cache), which is what a browser needs to seek. A quick python -m http.server does not do this, so videos shown that way cannot be scrubbed. The player page adds keyboard control (space, J/K/L, arrows, frame step, speed, 0-9 jumps, fullscreen), a seekable timeline with chapter marks and hover time, a chapter list, a library of everything shown in the container, per-file resume, and auto-reload with position kept when the file changes on disk. It follows the Adom app conventions: --ai-thread on every verb, one registry entry, reuse instead of respawn, a 24 hour idle reaper.",+  "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, in the same YouTube-style player as the wiki's ::adom-video widget, with YouTube's shortcuts, chapters, captions, frame stepping and auto-reload when the file is re-rendered.",+  "description": "adom-video is the standard way for an AI to show a user a video or audio file in Hydrogen (or pup). It runs one small zero-dependency Node server per container that serves only the files you register, with full HTTP Range support (206 Partial Content, If-Range, HEAD, ETag, no-cache), which is what a browser needs to seek. A quick python -m http.server does not do this, so videos shown that way cannot be scrubbed. The player page is the wiki's ::adom-video widget (YouTube's look and every YouTube shortcut, a scrub timeline with frame preview and chapter segments, captions it draws itself, hold for 2x, frame stepping at the measured rate, speeds to 4x), plus theater mode, a chapter list, a library of everything shown in the container, per-file resume, and auto-reload with position kept when the file changes on disk. It follows the Adom app conventions: --ai-thread on every verb, one registry entry, reuse instead of respawn, a 24 hour idle reaper.",   "category": "Developer Tools",   "tags": [     "video",@@ -19,7 +19,10 @@     "webview",     "preview",     "chapters",-    "frame step"+    "frame step",+    "youtube",+    "captions",+    "subtitles"   ],   "status": "live",   "discovery_pitch": "Show a video to the user with a real, seekable player instead of a hand-rolled http.server that cannot scrub: adom-video play <file> opens it in a Hydrogen webview tab with keyboard control, frame step, speeds and chapters.",
public/adom-video.jsadded+842
@@ -0,0 +1,842 @@+// <adom-video>: a YouTube-style video player for wiki pages.+//+//   ::adom-video{video="/OWNER/SLUG/render/demo.mp4" title="Demo" chapters="/OWNER/SLUG/render/demo.chapters.json"}+//+// Every YouTube keyboard shortcut, a timeline you can actually hit (a 24px grab zone around a+// 4px bar, drag to scrub, hover for a frame preview and the time), chapters as bar segments,+// captions (WebVTT or SRT, drawn by the player so + / - can resize them), speed and loop in a+// settings menu, picture-in-picture, fullscreen. Self-contained: Shadow DOM, no dependencies.+//+// Attributes: video (required; never pass a video as `src`: the wiki inlines a registered tag's `src`+// file into the frame), src (optional JSON manifest {video, title, poster, chapters, captions}, which+// the wiki inlines, so chapters and captions work on private pages too), title, poster, chapters (JSON [{t,title}] or a WebVTT chapter+// file), captions (.vtt or .srt), start (seconds or 1m30s), loop, muted, autoplay.+//+// Inside a wiki widget frame the browser blocks fullscreen (the frame carries no allow=+// "fullscreen"), so F opens the same player full-window in a new tab at the current time,+// where F is real fullscreen. Keys reach the player once it is clicked, as on YouTube.+(() => {+  if (customElements.get('adom-video')) return;++  const ICON = {+    play: '<path d="M8 5.14v13.72a1 1 0 0 0 1.5.86l11-6.86a1 1 0 0 0 0-1.72l-11-6.86A1 1 0 0 0 8 5.14z"/>',+    pause: '<path d="M7 5h3.5v14H7zM13.5 5H17v14h-3.5z"/>',+    replay: '<path d="M12 5V2L7 6l5 4V7a6 6 0 1 1-6 6H4a8 8 0 1 0 8-8z"/>',+    vol: '<path d="M4 9v6h4l5 4V5L8 9H4zm12.5 3a4.5 4.5 0 0 0-2.5-4v8a4.5 4.5 0 0 0 2.5-4zM14 3.2v2.1a7 7 0 0 1 0 13.4v2.1a9 9 0 0 0 0-17.6z"/>',+    vollow: '<path d="M4 9v6h4l5 4V5L8 9H4zm12.5 3a4.5 4.5 0 0 0-2.5-4v8a4.5 4.5 0 0 0 2.5-4z"/>',+    mute: '<path d="M4 9v6h4l5 4V5L8 9H4zm12.6 3 2.7-2.7-1.4-1.4-2.7 2.7-2.7-2.7-1.4 1.4 2.7 2.7-2.7 2.7 1.4 1.4 2.7-2.7 2.7 2.7 1.4-1.4z"/>',+    cc: '<path d="M19 4H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm-8 7H9.5v-.5h-2v3h2V13H11v1a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1zm7 0h-1.5v-.5h-2v3h2V13H18v1a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1z"/>',+    gear: '<path d="M19.4 13a7.7 7.7 0 0 0 0-2l2.1-1.6a.5.5 0 0 0 .1-.6l-2-3.5a.5.5 0 0 0-.6-.2l-2.5 1a7.4 7.4 0 0 0-1.7-1l-.4-2.6A.5.5 0 0 0 14 2h-4a.5.5 0 0 0-.5.4l-.4 2.7a7.6 7.6 0 0 0-1.7 1l-2.5-1a.5.5 0 0 0-.6.2l-2 3.4a.5.5 0 0 0 .1.7L4.6 11a7.7 7.7 0 0 0 0 2l-2.1 1.6a.5.5 0 0 0-.1.6l2 3.5c.1.2.4.3.6.2l2.5-1c.5.4 1.1.7 1.7 1l.4 2.6c0 .3.2.5.5.5h4c.3 0 .5-.2.5-.4l.4-2.7c.6-.3 1.2-.6 1.7-1l2.5 1c.2.1.5 0 .6-.2l2-3.5a.5.5 0 0 0-.1-.6L19.4 13zM12 15.5a3.5 3.5 0 1 1 0-7 3.5 3.5 0 0 1 0 7z"/>',+    pip: '<path d="M19 11h-8v6h8v-6zm4 8V5a2 2 0 0 0-2-2H3a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h18a2 2 0 0 0 2-2zm-2 0H3V5h18v14z"/>',+    full: '<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/>',+    exitfull: '<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/>',+    popout: '<path d="M19 19H5V5h7V3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7h-2v7zM14 3v2h3.6l-9.8 9.8 1.4 1.4L19 6.4V10h2V3h-7z"/>',+    back: '<path d="M20 18V6l-8.5 6 8.5 6zm-8.5 0V6L3 12l8.5 6z"/>',+    fwd: '<path d="M4 6v12l8.5-6L4 6zm8.5 0v12l8.5-6-8.5-6z"/>',+    fstepf: '<path d="M6 5.5v13l9-6.5-9-6.5zM16 5h2.5v14H16z"/>',+    fstepb: '<path d="M18 5.5v13L9 12l9-6.5zM5.5 5H8v14H5.5z"/>',+    chev: '<path d="M9.3 6.7 10.7 5.3 17.4 12l-6.7 6.7-1.4-1.4 5.3-5.3z"/>',+    chevl: '<path d="M14.7 6.7 13.3 5.3 6.6 12l6.7 6.7 1.4-1.4L9.4 12z"/>',+    check: '<path d="M9 16.2 4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4z"/>',+    keys: '<path d="M20 5H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2zm-9 3h2v2h-2V8zm0 3h2v2h-2v-2zM8 8h2v2H8V8zm0 3h2v2H8v-2zm-1 2H5v-2h2v2zm0-3H5V8h2v2zm9 7H8v-2h8v2zm0-4h-2v-2h2v2zm0-3h-2V8h2v2zm3 3h-2v-2h2v2zm0-3h-2V8h2v2z"/>',+    loop: '<path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/>',+    speed: '<path d="M20.4 8.6 19.2 10.4a8 8 0 0 1-.2 7.6H5a8 8 0 0 1 10.6-11l1.8-1.2A10 10 0 0 0 3.3 19a2 2 0 0 0 1.7 1h14a2 2 0 0 0 1.7-1 10 10 0 0 0-.3-10.4zm-9.8 6.8a2 2 0 0 0 2.8 0l5.7-8.5-8.5 5.7a2 2 0 0 0 0 2.8z"/>',+    dl: '<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>',+  };+  const svg = (n, s = 24) => `<svg viewBox="0 0 24 24" width="${s}" height="${s}" aria-hidden="true">${ICON[n]}</svg>`;+  const SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3, 3.5, 4];+  const CAP_TEXT = [100, 75, 50, 25], CAP_WIN = [75, 50, 25, 0, 100];+  const clamp = (x, a, b) => Math.min(b, Math.max(a, x));+  const fmt = (t) => {+    if (!isFinite(t) || t < 0) t = 0;+    t = Math.floor(t);+    const h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), s = String(t % 60).padStart(2, '0');+    return h ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`;+  };+  const fmtMs = (t) => { if (!isFinite(t) || t < 0) t = 0; const s = (t % 60).toFixed(3).padStart(6, '0'), m = Math.floor(t / 60) % 60, h = Math.floor(t / 3600); return h ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`; };+  // "90", "1:30", "1m30s", "1h2m3s" -> seconds+  const parseT = (v) => {+    if (v == null || v === '') return 0;+    v = String(v).trim();+    if (/^\d+(\.\d+)?$/.test(v)) return Number(v);+    if (v.includes(':')) return v.split(':').reduce((a, x) => a * 60 + Number(x || 0), 0);+    const m = v.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s)?$/);+    return m ? (Number(m[1] || 0) * 3600 + Number(m[2] || 0) * 60 + Number(m[3] || 0)) : 0;+  };+  const vttTime = (s) => { const p = s.trim().replace(',', '.').split(':').map(Number); return p.length === 3 ? p[0] * 3600 + p[1] * 60 + p[2] : p[0] * 60 + p[1]; };+  // WebVTT or SRT -> [{start, end, text}]+  function parseCues(txt) {+    const out = [];+    txt.replace(/\r/g, '').split(/\n\n+/).forEach((block) => {+      const lines = block.split('\n').filter((l) => l.trim() !== '');+      const k = lines.findIndex((l) => l.includes('-->'));+      if (k < 0) return;+      const [a, b] = lines[k].split('-->');+      out.push({ start: vttTime(a), end: vttTime(b.trim().split(/\s+/)[0]), text: lines.slice(k + 1).join('\n') });+    });+    return out;+  }+  const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));+  // caption markup: keep <b> <i> <u>, drop the rest (voice tags, classes, timestamps)+  const capHTML = (s) => esc(s).replace(/&lt;(\/?)(b|i|u)&gt;/g, '<$1$2>').replace(/&lt;[^&]*?&gt;/g, '').replace(/\n/g, '<br>');++  const SHORTCUTS = [+    ['Play / pause', 'Space or K'], ['Back 10 s / forward 10 s', 'J / L'], ['Back 5 s / forward 5 s', '← / →'],+    ['Volume up / down', '↑ / ↓'], ['Mute', 'M'], ['Jump to 0% to 90%', '0 to 9'], ['Start / end', 'Home / End'],+    ['Previous / next frame (pauses)', ', / .'], ['Slower / faster (0.25x to 4x)', '< / >'], ['2x while held', 'Hold the mouse on the video'],+    ['Previous / next chapter', 'Ctrl + ← / →'], ['Previous / next video on the page', 'Shift + P / Shift + N'],+    ['Captions on / off', 'C'], ['Caption size', '+ / −'], ['Caption text opacity', 'O'], ['Caption background opacity', 'W'],+    ['Fullscreen', 'F or double-click'], ['Open large in a new tab', 'T'], ['Picture-in-picture', 'I'], ['Close menu / exit', 'Esc'],+    ['Loop', 'R'], ['Media keys', 'Play/Pause, Stop, Next'], ['This list', '?'],+  ];++  const CSS = `+  :host{display:block;max-width:100%;--accent:#00b8b1;--accent-hi:#00e6dc;color-scheme:dark;font:400 13px/1.3 "Inter","Roboto","Segoe UI",system-ui,sans-serif}+  *{box-sizing:border-box}+  button{font:inherit;color:inherit;background:none;border:0;padding:0;margin:0;cursor:pointer}+  .p{position:relative;width:100%;aspect-ratio:16/9;background:#000;border-radius:12px;overflow:hidden;color:#fff;outline:none;user-select:none;-webkit-user-select:none;container-type:inline-size}+  .p:focus-visible{box-shadow:0 0 0 2px var(--accent)}+  .p.fs,.p.page{border-radius:0;aspect-ratio:auto;width:100%;height:100%}+  :host(.page){position:fixed;inset:0;max-width:none}+  .p.hide-ui{cursor:none}+  video.main{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;background:#000;display:block}+  .click{position:absolute;inset:0}+  /* bottom chrome */+  .grad{position:absolute;left:0;right:0;bottom:0;height:120px;background:linear-gradient(transparent,rgba(0,0,0,.72));pointer-events:none;transition:opacity .25s}+  .top{position:absolute;left:0;right:0;top:0;padding:14px 16px 40px;background:linear-gradient(rgba(0,0,0,.6),transparent);font-size:17px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:none;transition:opacity .25s}+  .top:empty{display:none}+  .chrome{position:absolute;left:0;right:0;bottom:0;padding:0 12px 4px;transition:opacity .25s;z-index:3}+  .hide-ui .chrome,.hide-ui .grad,.hide-ui .top{opacity:0;pointer-events:none}+  .hide-ui .chrome *{pointer-events:none}+  /* the timeline: a 24px grab zone around a 4px bar */+  .bar{position:relative;height:24px;margin:0 0 2px;cursor:pointer;touch-action:none;outline:none}+  .segs{position:absolute;left:0;right:0;bottom:8px;height:4px;display:flex;gap:2px;transition:height .1s,bottom .1s}+  .bar:hover .segs,.bar.drag .segs,.bar:focus-visible .segs{height:6px;bottom:7px}+  .seg{position:relative;height:100%;background:rgba(255,255,255,.22);overflow:hidden;transition:transform .1s}+  .seg.hov{transform:scaleY(1.6)}+  .seg i{position:absolute;left:0;top:0;bottom:0;width:0}+  .seg .b{background:rgba(255,255,255,.42)}+  .seg .h{background:rgba(255,255,255,.5)}+  .seg .f{background:var(--accent)}+  .bar:not(:hover):not(.drag) .seg .h{width:0!important}+  .knob{position:absolute;bottom:11px;width:14px;height:14px;margin-left:-7px;border-radius:50%;background:var(--accent);transform:translateY(50%) scale(0);transition:transform .1s;pointer-events:none}+  .bar:hover .knob,.bar.drag .knob,.bar:focus-visible .knob{transform:translateY(50%) scale(1)}+  .tip{position:absolute;bottom:30px;transform:translateX(-50%);display:none;flex-direction:column;align-items:center;gap:4px;pointer-events:none;text-shadow:0 0 3px rgba(0,0,0,.9)}+  .bar:hover .tip,.bar.drag .tip{display:flex}+  .tip .pv{display:none;width:170px;border:2px solid #fff;border-radius:8px;background:#000;overflow:hidden}+  .tip .pv.on{display:block}+  .tip .pv video{display:block;width:100%;height:100%;object-fit:cover}+  .tip .ch{font-size:12px;max-width:220px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}+  .tip .ch:empty{display:none}+  .tip .t{font-size:13px;font-weight:600;background:rgba(0,0,0,.6);padding:2px 6px;border-radius:4px}+  /* control row */+  .row{display:flex;align-items:center;height:44px;gap:2px}+  .row .sp{flex:1;min-width:4px}+  .b48{width:44px;height:40px;display:inline-flex;align-items:center;justify-content:center;border-radius:20px;opacity:.95;flex:none;position:relative}+  .b48:hover{opacity:1;background:rgba(255,255,255,.1)}+  .b48 svg{fill:#fff;filter:drop-shadow(0 0 2px rgba(0,0,0,.5))}+  .b48.on::after{content:"";position:absolute;left:12px;right:12px;bottom:6px;height:3px;border-radius:2px;background:var(--accent)}+  .b48[hidden]{display:none}+  .vol{display:flex;align-items:center}+  .vslide{width:0;height:40px;position:relative;overflow:hidden;transition:width .2s;cursor:pointer;touch-action:none;outline:none}+  .vol:hover .vslide,.vol:focus-within .vslide,.vslide.drag{width:64px;margin-right:6px}+  .vslide .rail{position:absolute;left:6px;right:6px;top:50%;height:3px;margin-top:-1.5px;background:rgba(255,255,255,.3);border-radius:2px}+  .vslide .lvl{position:absolute;left:0;top:0;bottom:0;background:#fff;border-radius:2px}+  .vslide .dot{position:absolute;top:50%;width:12px;height:12px;margin:-6px 0 0 -6px;border-radius:50%;background:#fff}+  .time{padding:0 8px;white-space:nowrap;font-variant-numeric:tabular-nums;text-shadow:0 0 2px rgba(0,0,0,.5);flex:none}+  .chap{display:flex;align-items:center;min-width:0;padding:0 4px 0 2px;opacity:.92;white-space:nowrap;overflow:hidden;cursor:pointer;border-radius:16px;height:32px}+  .chap:hover{opacity:1;background:rgba(255,255,255,.1)}+  .chap span{overflow:hidden;text-overflow:ellipsis}+  .chap svg{fill:#fff;flex:none}+  .chap:empty,.chap[hidden]{display:none}+  .dotsep{opacity:.7;padding:0 4px}+  /* button tooltips */+  .tt{position:absolute;bottom:62px;padding:5px 9px;background:rgba(28,28,28,.92);border-radius:6px;font-size:12px;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .12s;z-index:5}+  .tt.on{opacity:1}+  /* center overlays */+  .bezel{position:absolute;left:50%;top:50%;width:64px;height:64px;margin:-32px 0 0 -32px;border-radius:50%;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none}+  .bezel svg{fill:#fff}+  .bezel.go{animation:bz .5s linear}+  @keyframes bz{0%{opacity:1;transform:scale(.9)}100%{opacity:0;transform:scale(1.6)}}+  .hud{position:absolute;left:50%;top:12%;transform:translateX(-50%);padding:8px 14px;background:rgba(0,0,0,.6);border-radius:6px;font-size:15px;font-weight:500;opacity:0;pointer-events:none;transition:opacity .25s;white-space:nowrap}+  .hud.on{opacity:1;transition:none}+  .hold{position:absolute;left:50%;top:14px;transform:translateX(-50%);display:none;align-items:center;gap:6px;padding:6px 14px;background:rgba(0,0,0,.6);border-radius:16px;font-size:14px;font-weight:600;pointer-events:none;z-index:4}+  .hold.on{display:flex}+  .fstep{position:absolute;left:50%;top:14px;transform:translateX(-50%);display:flex;align-items:center;gap:10px;padding:6px 14px 6px 10px;background:rgba(0,0,0,.66);border-radius:16px;font-size:14px;font-weight:500;font-variant-numeric:tabular-nums;white-space:nowrap;pointer-events:none;z-index:4;opacity:0;transition:opacity .3s}+  .fstep.on{opacity:1;transition:none}+  .fstep svg{fill:#fff;flex:none}+  .fstep b{font-weight:700}+  .fstep .dim{opacity:.7}+  .fstep.go svg{animation:fs .18s ease-out}+  @keyframes fs{0%{transform:scale(1.35)}100%{transform:scale(1)}}+  .hold svg{fill:#fff}+  .seekfx{position:absolute;top:0;bottom:0;width:30%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;background:rgba(255,255,255,.08);opacity:0;pointer-events:none;font-weight:500;transition:opacity .4s}+  .seekfx.l{left:0;border-radius:0 50% 50% 0/0 50% 50% 0}+  .seekfx.r{right:0;border-radius:50% 0 0 50%/50% 0 0 50%}+  .seekfx.on{opacity:1;transition:none}+  .seekfx svg{fill:#fff}+  .big{position:absolute;left:50%;top:50%;width:72px;height:50px;margin:-25px 0 0 -36px;border-radius:14px;background:rgba(15,15,15,.78);display:flex;align-items:center;justify-content:center;transition:background .15s}+  .big svg{fill:#fff}+  .big:hover,.p:hover .big{background:var(--accent)}+  .big[hidden]{display:none}+  .spin{position:absolute;left:50%;top:50%;width:52px;height:52px;margin:-26px 0 0 -26px;border:4px solid rgba(255,255,255,.25);border-top-color:#fff;border-radius:50%;animation:sp 0.9s linear infinite;display:none;pointer-events:none}+  .p.wait .spin{display:block}+  @keyframes sp{to{transform:rotate(360deg)}}+  .err{position:absolute;inset:0;display:none;align-items:center;justify-content:center;text-align:center;padding:24px;background:#111;font-size:14px;line-height:1.5}+  .err.on{display:flex}+  .err code{background:rgba(255,255,255,.1);padding:1px 5px;border-radius:4px;word-break:break-all}+  /* captions */+  .caps{position:absolute;left:5%;right:5%;bottom:5%;display:flex;flex-wrap:wrap;justify-content:center;pointer-events:none;transition:bottom .25s;z-index:2}+  .p:not(.hide-ui) .caps{bottom:76px}+  .caps span{background:rgba(8,8,8,var(--capbg,.75));opacity:var(--captxt,1);padding:2px 8px;border-radius:3px;text-align:center;font-size:calc(var(--cap,100) * clamp(13px, 3.4cqw, 30px) / 100);line-height:1.35;box-decoration-break:clone;-webkit-box-decoration-break:clone}+  .caps:empty{display:none}+  /* settings menu */+  .menu{position:absolute;right:12px;bottom:60px;min-width:250px;max-height:calc(100% - 72px);overflow:auto;background:rgba(28,28,28,.95);border-radius:12px;padding:8px 0;display:none;z-index:6;text-shadow:none}+  .menu.on{display:block}+  .mi{display:flex;align-items:center;width:100%;height:40px;padding:0 12px 0 14px;gap:14px;text-align:left}+  .mi:hover,.mi:focus-visible{background:rgba(255,255,255,.1);outline:none}+  .mi svg{fill:#fff;flex:none}+  .mi .lab{flex:1}+  .mi .val{opacity:.8}+  .mi .ck{width:24px;display:inline-flex}+  .mhead{display:flex;align-items:center;gap:10px;height:44px;padding:0 12px;border-bottom:1px solid rgba(255,255,255,.15);margin-bottom:6px;font-weight:500;width:100%;text-align:left}+  .mhead svg{fill:#fff}+  .tog{width:36px;height:14px;border-radius:7px;background:rgba(255,255,255,.3);position:relative;flex:none}+  .tog::after{content:"";position:absolute;top:-3px;left:-2px;width:20px;height:20px;border-radius:50%;background:#bbb;transition:left .15s,background .15s}+  .tog.on{background:color-mix(in srgb,var(--accent) 60%,transparent)}+  .tog.on::after{left:18px;background:#fff}+  /* shortcuts sheet */+  .help{position:absolute;inset:0;background:rgba(0,0,0,.82);display:none;z-index:7;overflow:auto;padding:18px 22px}+  .help.on{display:block}+  .help h3{margin:0 0 10px;font-size:16px;font-weight:600;display:flex;justify-content:space-between;align-items:center}+  .help table{border-collapse:collapse;width:100%;font-size:13px}+  .help td{padding:4px 0;border-bottom:1px solid rgba(255,255,255,.08)}+  .help td:last-child{text-align:right;white-space:nowrap;padding-left:12px}+  .help kbd{font:inherit;background:rgba(255,255,255,.14);padding:1px 6px;border-radius:4px}+  .help .x{font-size:20px;width:32px;height:32px;border-radius:16px}+  .help .x:hover{background:rgba(255,255,255,.12)}+  .help .cols{columns:2 260px;column-gap:28px}+  @container (max-width: 480px){ .chap,.dotsep,.b48.opt{display:none} .time{padding:0 4px} }+  @container (max-width: 340px){ .vol .vslide{display:none} }+  `;++  // one player owns the keyboard at a time: the one last clicked or focused (or the first on the page)+  let active = null;++  class AdomVideo extends HTMLElement {+    static get observedAttributes() { return ['video', 'src', 'title', 'poster', 'chapters', 'captions', 'start', 'loop', 'muted']; }++    constructor() {+      super();+      this.chapters = []; this.cues = []; this.capOn = false; this.capSize = 100;+      this.started = false; this.drag = null; this._t = {};+      const r = this.attachShadow({ mode: 'open' });+      r.innerHTML = `<style>${CSS}</style>+      <div class="p" tabindex="0" role="region" aria-label="Video player">+        <video class="main" playsinline preload="metadata"></video>+        <div class="click"></div>+        <div class="caps" aria-live="polite"></div>+        <div class="top"></div>+        <div class="seekfx l">${svg('back', 32)}<span></span></div>+        <div class="seekfx r">${svg('fwd', 32)}<span></span></div>+        <div class="bezel"></div>+        <div class="hud"></div>+        <div class="fstep" aria-live="polite"></div>+        <div class="hold">2x ${svg('fwd', 18)}</div>+        <div class="spin"></div>+        <button class="big" aria-label="Play (k)">${svg('play', 34)}</button>+        <div class="grad"></div>+        <div class="chrome">+          <div class="bar" role="slider" tabindex="0" aria-label="Seek" aria-valuemin="0" aria-valuemax="0" aria-valuenow="0">+            <div class="segs"></div><div class="knob"></div>+            <div class="tip"><div class="pv"></div><div class="ch"></div><div class="t"></div></div>+          </div>+          <div class="row">+            <button class="b48" data-k="play" data-tip="Play (k)">${svg('play')}</button>+            <div class="vol">+              <button class="b48" data-k="mute" data-tip="Mute (m)">${svg('vol')}</button>+              <div class="vslide" role="slider" tabindex="0" aria-label="Volume" aria-valuemin="0" aria-valuemax="100"><div class="rail"><div class="lvl"></div><div class="dot"></div></div></div>+            </div>+            <div class="time">0:00 / 0:00</div>+            <span class="dotsep" hidden>•</span>+            <button class="chap" hidden data-tip="Next chapter (Ctrl + →)"><span></span>${svg('chev', 18)}</button>+            <div class="sp"></div>+            <button class="b48" data-k="cc" data-tip="Subtitles/closed captions (c)" hidden>${svg('cc')}</button>+            <button class="b48" data-k="gear" data-tip="Settings">${svg('gear')}</button>+            <button class="b48 opt" data-k="pip" data-tip="Picture-in-picture (i)">${svg('pip')}</button>+            <button class="b48 opt" data-k="pop" data-tip="Open large in a new tab (t)">${svg('popout')}</button>+            <button class="b48" data-k="fs" data-tip="Full screen (f)">${svg('full')}</button>+          </div>+        </div>+        <div class="tt"></div>+        <div class="menu" role="menu"></div>+        <div class="help" role="dialog" aria-label="Keyboard shortcuts"></div>+        <div class="err"></div>+      </div>`;+      const $ = (s) => r.querySelector(s);+      this.$ = $;+      this.p = $('.p'); this.v = $('video.main'); this.bar = $('.bar'); this.segs = $('.segs');+      this._wire();+    }++    connectedCallback() {+      if (!active) active = this;+      if (location.pathname.includes('/widget/')) {+        if (window.top === window) { this.classList.add('page'); this.p.classList.add('page'); }+        // the frame grows to fit the player; a scrollbar would narrow it, shrink the player and stick there+        else document.documentElement.style.overflow = 'hidden';+      }+      // the wiki inlines the manifest as a child script, which is not parsed yet when a head script connects us+      if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => this._load(), { once: true });+      else this._load();+    }+    disconnectedCallback() { if (active === this) active = null; clearTimeout(this._t.hide); this.v.pause(); }+    attributeChangedCallback(n, a, b) { if (a !== b && this.isConnected && this._loaded) { if (n === 'video' || n === 'src') this._load(); else if (n === 'chapters') this._chapters(); else if (n === 'captions') this._captions(); else if (n === 'title') this.$('.top').textContent = b || ''; } }++    // absolute URL for a wiki path (the frame is opaque-origin, so resolve against the real location)+    _abs(u) { try { return new URL(u, location.href).href; } catch { return u; } }+    // the page render route answers a video's Range requests with the whole file (200), which makes it+    // unseekable; the files API answers 206, so stream /OWNER/SLUG/render/PATH from there+    _media(u) {+      const a = this._abs(u);+      try {+        const url = new URL(a);+        if (url.hostname !== 'wiki.adom.inc' && url.origin !== location.origin) return a;+        const m = url.pathname.match(/^\/([^/]+)\/([^/]+)\/render\/(.+)$/);+        if (m) url.pathname = `/api/pages/${m[1]}/${m[2]}/files/${m[3]}`;+        return url.href;+      } catch { return a; }+    }++    // a JSON manifest (the wiki inlines a registered tag's `src` file as a child script):+    // { "video": "...", "title", "poster", "chapters": [{t,title}] or a path, "captions": a path or WebVTT text }+    _manifest() {+      if (this._man !== undefined) return this._man;+      this._man = null;+      const el = this.querySelector('script[type="application/json"]');+      if (el) { try { const j = JSON.parse(el.textContent); if (j && typeof j === 'object' && !Array.isArray(j)) this._man = j; } catch {} }+      return this._man;+    }+    _opt(n) { const a = this.getAttribute(n); if (a != null && a !== '') return a; const m = this._manifest(); return m && m[n] != null ? m[n] : null; }++    _load() {+      this._loaded = true; this._man = undefined;+      const m = this._manifest();+      const src = this.getAttribute('video') || (m && m.video) || (m ? null : this.getAttribute('src'));+      const v = this.v;+      this.$('.top').textContent = this._opt('title') || '';+      if (!src) return this._error('No video: add <code>video="/OWNER/SLUG/render/video.mp4"</code> to the tag.');+      this.$('.err').classList.remove('on');+      if (this._opt('poster')) v.poster = this._abs(this._opt('poster'));+      v.loop = this.hasAttribute('loop');+      v.muted = this.hasAttribute('muted');+      this._startAt = parseT(this.getAttribute('start') || (location.hash.match(/[#&]t=([^&]+)/) || [])[1]);+      v.src = this._media(src);+      v.addEventListener('loadedmetadata', () => {+        if (v.videoWidth && !this.p.classList.contains('page')) this.p.style.aspectRatio = `${v.videoWidth} / ${v.videoHeight}`;+        if (this._startAt > 0 && this._startAt < v.duration) { v.currentTime = this._startAt; this._started(); }+        this._segments(); this._tick();+      }, { once: true });+      if (this.hasAttribute('autoplay')) { v.muted = true; v.play().then(() => this._started(), () => {}); }+      this._chapters(); this._captions(); this._volUI();+    }++    _error(html) { const e = this.$('.err'); e.innerHTML = `<div>${html}</div>`; e.classList.add('on'); this.p.classList.remove('wait'); }++    async _fetchText(u) {+      const r = await fetch(this._abs(u)).catch(() => null);+      return r && r.ok ? r.text() : null;+    }+    async _chapters() {+      const u = this._opt('chapters'); this.chapters = [];+      if (u) {+        const txt = typeof u === 'string' ? await this._fetchText(u) : JSON.stringify(u);+        if (txt) {+          try { const j = JSON.parse(txt); this.chapters = (Array.isArray(j) ? j : j.chapters || []).map((c) => ({ t: parseT(c.t ?? c.time ?? c.start), title: String(c.title ?? c.name ?? '') })); }+          catch { this.chapters = parseCues(txt).map((c) => ({ t: c.start, title: c.text.replace(/\n/g, ' ') })); }+          this.chapters.sort((a, b) => a.t - b.t);+          if (this.chapters.length && this.chapters[0].t > 0) this.chapters.unshift({ t: 0, title: '' });+        }+      }+      this._segments(); this._tick();+    }+    async _captions() {+      const u = this._opt('captions'); this.cues = [];+      if (u) { const txt = /-->/.test(u) ? u : await this._fetchText(u); if (txt) this.cues = parseCues(txt); }+      this.$('[data-k=cc]').hidden = !this.cues.length;+      this.capOn = this.cues.length > 0 && this.capOn;+      this._caps(); this._sync();+    }++    // ---------- timeline ----------+    _segments() {+      const d = this.v.duration;+      const ch = d && this.chapters.length > 1 ? this.chapters : [{ t: 0, title: '' }];+      this.segs.innerHTML = '';+      this._seg = ch.map((c, k) => {+        const end = k + 1 < ch.length ? ch[k + 1].t : (d || 1);+        const el = document.createElement('div'); el.className = 'seg';+        el.style.flex = `${Math.max(0.0001, end - c.t)} 1 0`;+        el.innerHTML = '<i class="b"></i><i class="h"></i><i class="f"></i>';+        this.segs.appendChild(el);+        return { el, t0: c.t, t1: end, title: c.title, b: el.children[0], h: el.children[1], f: el.children[2] };+      });+    }+    _fill(key, t) {+      (this._seg || []).forEach((s) => { s[key].style.width = (100 * clamp((t - s.t0) / Math.max(1e-6, s.t1 - s.t0), 0, 1)) + '%'; });+    }+    // fraction across the bar for a pointer x, mapped through the segment gaps so a click lands where it looks+    _frac(x) {+      const r = this.segs.getBoundingClientRect();+      const d = this.v.duration || 0;+      if (!this._seg || this._seg.length < 2 || !d) return clamp((x - r.left) / r.width, 0, 1);+      for (const s of this._seg) {+        const b = s.el.getBoundingClientRect();+        if (x <= b.right + 1) return clamp((s.t0 + clamp((x - b.left) / b.width, 0, 1) * (s.t1 - s.t0)) / d, 0, 1);+      }+      return 1;+    }+    _xOf(t) {+      const r = this.segs.getBoundingClientRect(), br = this.bar.getBoundingClientRect();+      const d = this.v.duration || 0;+      if (!d) return 0;+      const s = (this._seg || []).find((s) => t < s.t1) || (this._seg || [])[this._seg.length - 1];+      if (!s || this._seg.length < 2) return (t / d) * r.width + r.left - br.left;+      const b = s.el.getBoundingClientRect();+      return b.left - br.left + clamp((t - s.t0) / (s.t1 - s.t0), 0, 1) * b.width;+    }+    _chapterAt(t) { let c = null; for (const x of this.chapters) if (t + 0.05 >= x.t) c = x; return c; }++    _hover(x) {+      const d = this.v.duration; if (!d) return;+      const t = this._frac(x) * d;+      this._fill('h', t);+      (this._seg || []).forEach((s) => s.el.classList.toggle('hov', this._seg.length > 1 && t >= s.t0 && t < s.t1));+      const tip = this.$('.tip'), br = this.bar.getBoundingClientRect();+      tip.querySelector('.t').textContent = fmt(t);+      const c = this.chapters.length > 1 ? this._chapterAt(t) : null;+      tip.querySelector('.ch').textContent = c ? c.title : '';+      this._preview(t);+      const w = tip.offsetWidth || 60;+      tip.style.left = clamp(x - br.left, w / 2, br.width - w / 2) + 'px';+    }+    // a second, muted video seeks to the hovered time and shows that frame (lazy: made on first hover)+    _preview(t) {+      const box = this.$('.pv');+      if (!this._pv) {+        if (this._pvFailed) return;+        const pv = document.createElement('video');+        pv.muted = true; pv.preload = 'auto'; pv.playsInline = true; pv.src = this.v.currentSrc || this.v.src;+        pv.addEventListener('seeked', () => { box.classList.add('on'); if (this._pvWant != null && Math.abs(pv.currentTime - this._pvWant) > 0.3) pv.currentTime = this._pvWant; else this._pvWant = null; });+        pv.addEventListener('error', () => { this._pvFailed = true; box.classList.remove('on'); });+        box.appendChild(pv); this._pv = pv;+        const ar = this.v.videoWidth ? this.v.videoHeight / this.v.videoWidth : 9 / 16;+        box.style.height = Math.round(170 * ar) + 'px';+      }+      if (this._pv.seeking) { this._pvWant = t; return; }+      this._pv.currentTime = t;+    }++    _scrubTo(x, final) {+      const v = this.v, d = v.duration; if (!d) return;+      const t = this._frac(x) * d;+      this._hover(x);+      this._fill('f', t);+      this.$('.knob').style.left = this._xOf(t) + 'px';+      this.$('.time').textContent = `${fmt(t)} / ${fmt(d)}`;+      if (final || !this._seekBusy) {+        this._seekBusy = true;+        if (!final && v.fastSeek) v.fastSeek(t); else v.currentTime = t;+      } else this._seekPending = t;+    }++    _tick() {+      const v = this.v, d = v.duration || 0, t = this.drag ? null : v.currentTime;+      if (t != null) {+        this.$('.time').textContent = `${fmt(t)} / ${fmt(d)}`;+        this._fill('f', t);+        this.$('.knob').style.left = this._xOf(t) + 'px';+        this.bar.setAttribute('aria-valuemax', String(Math.floor(d)));+        this.bar.setAttribute('aria-valuenow', String(Math.floor(t)));+        this.bar.setAttribute('aria-valuetext', `${fmt(t)} of ${fmt(d)}`);+      }+      if (v.buffered.length) { let e = 0; for (let i = 0; i < v.buffered.length; i++) if (v.buffered.start(i) <= v.currentTime + 1) e = Math.max(e, v.buffered.end(i)); this._fill('b', e); }+      const c = this.chapters.length > 1 ? this._chapterAt(v.currentTime) : null;+      const chap = this.$('.chap');+      chap.hidden = !(c && c.title); this.$('.dotsep').hidden = chap.hidden;+      if (c) chap.firstChild.textContent = c.title;+      const pb = this.$('[data-k=play]');+      const ended = v.ended && !v.loop, want = ended ? 'replay' : v.paused ? 'play' : 'pause';+      if (pb.dataset.s !== want) { pb.dataset.s = want; pb.innerHTML = svg(want); pb.dataset.tip = want === 'pause' ? 'Pause (k)' : want === 'replay' ? 'Replay' : 'Play (k)'; }+      this._caps();+    }+    _caps() {+      const box = this.$('.caps');+      if (!this.capOn) { if (box.innerHTML) box.innerHTML = ''; return; }+      const t = this.v.currentTime;+      const html = this.cues.filter((c) => t >= c.start && t < c.end).map((c) => `<span>${capHTML(c.text)}</span>`).join('<br>');+      if (box.innerHTML !== html) box.innerHTML = html;+    }++    // ---------- actions ----------+    _started() { if (!this.started) { this.started = true; this.$('.big').hidden = true; } }+    toggle() {+      const v = this.v;+      if (v.paused || v.ended) { v.play().then(() => this._started(), (e) => { if (e && e.name === 'NotAllowedError') this._hud('Press play to start'); }); this._bezel('play'); }+      else { v.pause(); this._bezel('pause'); }+    }+    seekBy(s, fx) {+      const v = this.v; if (!v.duration) return;+      v.currentTime = clamp(v.currentTime + s, 0, v.duration); this._started();+      if (fx) this._seekfx(s);+      this._show();+    }+    seekTo(t) { const v = this.v; if (!v.duration) return; v.currentTime = clamp(t, 0, v.duration); this._started(); this._show(); }+    // one frame at the video's measured frame rate (default 30 fps), landing mid-frame so rounding never repeats one+    frame(n) {+      const v = this.v, f = this._fdt || 1 / 30;+      if (!v.paused) v.pause();+      const k = Math.floor(v.currentTime / f + 0.02) + n;+      v.currentTime = clamp((k + 0.5) * f, 0, v.duration || 0);+      this._started(); this._show();+      this._frameHud(n, f);+    }+    // the frame-step pill: direction, frame N of total, the exact time, the frame rate+    _frameHud(n, f) {+      const el = this.$('.fstep'), v = this.v;+      const idx = Math.max(0, Math.floor(v.currentTime / f + 0.02)), total = Math.max(1, Math.round((v.duration || 0) / f));+      const fps = 1 / f, fpsTxt = Math.abs(fps - Math.round(fps)) < 0.05 ? Math.round(fps) : fps.toFixed(2);+      el.innerHTML = `${svg(n > 0 ? 'fstepf' : 'fstepb', 18)}<span>Frame <b>${(idx + 1).toLocaleString()}</b> <span class="dim">of ${total.toLocaleString()}</span></span><span class="dim">·</span><span>${fmtMs(v.currentTime)}</span><span class="dim">· ${fpsTxt} fps${this._fdt ? '' : ' (assumed)'}</span>`;+      el.classList.remove('go'); void el.offsetWidth; el.classList.add('go', 'on');+      this.$('.top').style.opacity = '0';+      clearTimeout(this._t.fstep);+      this._t.fstep = setTimeout(() => this._frameHudOff(), 2000);+    }+    _frameHudOff() { clearTimeout(this._t.fstep); this.$('.fstep').classList.remove('on'); this.$('.top').style.opacity = ''; }+    volBy(d) { const v = this.v; v.muted = false; v.volume = clamp(Math.round((v.volume + d) * 100) / 100, 0, 1); this._hud(`${Math.round(v.volume * 100)}%`); this._bezel(v.volume === 0 ? 'mute' : d > 0 ? 'vol' : 'vollow'); }+    mute() { const v = this.v; v.muted = !v.muted; if (!v.muted && v.volume === 0) v.volume = 0.5; this._bezel(v.muted ? 'mute' : 'vol'); }+    speed(s) { this.v.playbackRate = s; this._hud(s === 1 ? 'Normal speed' : `${s}x`); this._menuRefresh(); }+    speedStep(d) { const r = this.v.playbackRate; this.speed(clamp(Math.round((r + 0.25 * d) * 4) / 4, 0.25, 4)); }+    capOpacity(which) {+      if (!this.cues.length) return this._hud('No captions for this video');+      const list = which === 'text' ? CAP_TEXT : CAP_WIN, key = which === 'text' ? '_capTxt' : '_capWin';+      const i = (list.indexOf(this[key] ?? list[0]) + 1) % list.length; this[key] = list[i];+      this.p.style.setProperty(which === 'text' ? '--captxt' : '--capbg', String(list[i] / 100));+      if (!this.capOn) { this.capOn = true; this._caps(); this._sync(); }+      this._hud(`Caption ${which === 'text' ? 'text' : 'background'} opacity ${list[i]}%`);+    }+    // Shift+N / Shift+P: the next or previous player in this document (each wiki widget frame holds one)+    sibling(dir) {+      const all = [...document.querySelectorAll('adom-video')], k = all.indexOf(this), n = all[k + dir];+      if (!n) return this._hud(dir > 0 ? 'No next video on this page' : 'No previous video on this page');+      this.v.pause(); active = n; n.scrollIntoView({ block: 'center', behavior: 'smooth' }); n.p.focus({ preventScroll: true }); n.toggle();+    }+    // press and hold on the picture: 2x while held, like YouTube+    _holdStart() { const v = this.v; if (v.paused || this._hold) return; this._hold = { rate: v.playbackRate }; v.playbackRate = Math.max(2, v.playbackRate); this.$('.hold').classList.add('on'); }+    _holdEnd() { if (!this._hold) return false; this.v.playbackRate = this._hold.rate; this._hold = null; this.$('.hold').classList.remove('on'); return true; }+    loop() { this.v.loop = !this.v.loop; this._hud(this.v.loop ? 'Loop on' : 'Loop off'); this._menuRefresh(); }+    captions() { if (!this.cues.length) return this._hud('No captions for this video'); this.capOn = !this.capOn; this._hud(this.capOn ? 'Captions on' : 'Captions off'); this._caps(); this._sync(); this._menuRefresh(); }+    capFont(d) { if (!this.cues.length) return this._hud('No captions for this video'); this.capSize = clamp(this.capSize + d, 50, 300); this.p.style.setProperty('--cap', this.capSize); this._hud(`Caption size ${this.capSize}%`); }+    chapter(dir) {+      const ch = this.chapters; if (ch.length < 2) return this._hud('This video has no chapters');+      const t = this.v.currentTime;+      const n = dir > 0 ? ch.find((c) => c.t > t + 0.5) : [...ch].reverse().find((c) => c.t < t - 1.5) || ch[0];+      if (n) { this.seekTo(n.t); if (n.title) this._hud(n.title); }+    }+    async pip() {+      try {+        if (document.pictureInPictureElement) await document.exitPictureInPicture();+        else { await this.v.requestPictureInPicture(); this._started(); }+      } catch { this._hud('Picture-in-picture is not available here'); }+    }+    // the player alone, full window, in a new tab at the current time+    popout() {+      const t = Math.floor(this.v.currentTime);+      let url;+      if (location.pathname.includes('/widget/')) { const u = new URL(location.href); u.searchParams.set('start', String(t)); url = u.href; }+      else url = (this.v.currentSrc || this.v.src).split('#')[0] + `#t=${t}`;+      this.v.pause();+      this._openTab(url);+      this._hud('Opened in a new tab');+    }+    // window.open with noopener always returns null, so open through a link (the frame allows popups)+    _openTab(url) { const a = document.createElement('a'); a.href = url; a.target = '_blank'; a.rel = 'noopener'; this.shadowRoot.appendChild(a); a.click(); a.remove(); }+    async fullscreen() {+      if (document.fullscreenElement) { await document.exitFullscreen().catch(() => {}); return; }+      if (document.fullscreenEnabled && this.p.requestFullscreen) {+        try { await this.p.requestFullscreen(); return; } catch {}+      }+      // a sandboxed wiki frame cannot go fullscreen: open the player full-window instead+      this.popout();+    }++    // ---------- feedback ----------+    _bezel(icon) { const b = this.$('.bezel'); b.innerHTML = svg(icon, 36); b.classList.remove('go'); void b.offsetWidth; b.classList.add('go'); }+    _hud(txt) { const h = this.$('.hud'); h.textContent = txt; h.classList.add('on'); clearTimeout(this._t.hud); this._t.hud = setTimeout(() => h.classList.remove('on'), 800); }+    _seekfx(s) {+      const el = this.$(s < 0 ? '.seekfx.l' : '.seekfx.r');+      this._acc = (this._accSide === Math.sign(s) && el.classList.contains('on')) ? this._acc + Math.abs(s) : Math.abs(s);+      this._accSide = Math.sign(s);+      el.querySelector('span').textContent = `${this._acc} seconds`;+      el.classList.add('on'); clearTimeout(this._t.fx); this._t.fx = setTimeout(() => el.classList.remove('on'), 650);+    }+    _show() {+      this.p.classList.remove('hide-ui');+      clearTimeout(this._t.hide);+      this._t.hide = setTimeout(() => {+        if (this.v.paused || this.drag || this._overChrome || this.$('.menu').classList.contains('on') || this.$('.help').classList.contains('on')) return;+        this.p.classList.add('hide-ui'); this._tipOff();+      }, 2500);+    }+    _volUI() {+      const v = this.v, lv = v.muted ? 0 : v.volume;+      const s = this.$('.vslide'), w = 52;+      s.querySelector('.lvl').style.width = (lv * w) + 'px';+      s.querySelector('.dot').style.left = (lv * w) + 'px';+      s.setAttribute('aria-valuenow', String(Math.round(lv * 100)));+      const mb = this.$('[data-k=mute]');+      const ic = lv === 0 ? 'mute' : lv < 0.5 ? 'vollow' : 'vol';+      if (mb.dataset.s !== ic) { mb.dataset.s = ic; mb.innerHTML = svg(ic); mb.dataset.tip = lv === 0 ? 'Unmute (m)' : 'Mute (m)'; }+    }+    _sync() {+      const on = (k, x) => { const b = this.$(`[data-k=${k}]`); b.classList.toggle('on', x); b.setAttribute('aria-pressed', String(x)); };+      on('cc', this.capOn);+      const f = this.$('[data-k=fs]'), isFs = document.fullscreenElement === this.p;+      this.p.classList.toggle('fs', isFs);+      f.innerHTML = svg(isFs ? 'exitfull' : 'full'); f.dataset.tip = isFs ? 'Exit full screen (f)' : 'Full screen (f)';+    }+    _tipOn(btn) {+      const tt = this.$('.tt'), t = btn.dataset.tip; if (!t || this.$('.menu').classList.contains('on')) return;+      tt.textContent = t; tt.classList.add('on');+      const pr = this.p.getBoundingClientRect(), br = btn.getBoundingClientRect();+      tt.style.left = clamp(br.left - pr.left + br.width / 2 - tt.offsetWidth / 2, 6, pr.width - tt.offsetWidth - 6) + 'px';+    }+    _tipOff() { this.$('.tt').classList.remove('on'); }++    // ---------- settings menu ----------+    _menu(view) {+      const m = this.$('.menu'); this._menuView = view;+      const v = this.v;+      if (view === 'speed') {+        m.innerHTML = `<button class="mhead" data-m="main">${svg('chevl')}Playback speed</button>` ++          (SPEEDS.includes(v.playbackRate) ? '' : `<button class="mi" data-speed="${v.playbackRate}"><span class="ck">${svg('check')}</span><span class="lab">${v.playbackRate} (custom)</span></button>`) ++          SPEEDS.map((s) => `<button class="mi" role="menuitemradio" data-speed="${s}"><span class="ck">${v.playbackRate === s ? svg('check') : ''}</span><span class="lab">${s === 1 ? 'Normal' : s}</span></button>`).join('');+      } else {+        const sp = v.playbackRate === 1 ? 'Normal' : v.playbackRate + 'x';+        m.innerHTML =+          `<button class="mi" data-m="speed">${svg('speed')}<span class="lab">Playback speed</span><span class="val">${sp}</span>${svg('chev', 18)}</button>` ++          (this.cues.length ? `<button class="mi" data-act="cc">${svg('cc')}<span class="lab">Captions</span><span class="tog ${this.capOn ? 'on' : ''}"></span></button>` : '') ++          `<button class="mi" data-act="loop">${svg('loop')}<span class="lab">Loop</span><span class="tog ${v.loop ? 'on' : ''}"></span></button>` ++          `<button class="mi" data-act="keys">${svg('keys')}<span class="lab">Keyboard shortcuts</span><span class="val">?</span></button>` ++          `<button class="mi" data-act="dl">${svg('dl')}<span class="lab">Open the video file</span></button>`;+      }+      m.classList.add('on'); this._tipOff();+      this.$('[data-k=gear]').classList.add('on');+    }+    _menuRefresh() { if (this.$('.menu').classList.contains('on')) this._menu(this._menuView); }+    _menuClose() { this.$('.menu').classList.remove('on'); this.$('[data-k=gear]').classList.remove('on'); this._show(); }+    _help(on) {+      const h = this.$('.help');+      if (on == null) on = !h.classList.contains('on');+      if (on) {+        h.innerHTML = `<h3>Keyboard shortcuts <button class="x" aria-label="Close">×</button></h3><div class="cols"><table>${SHORTCUTS.map(([a, k]) => `<tr><td>${a}</td><td>${k.split(' or ').map((x) => `<kbd>${esc(x)}</kbd>`).join(' or ')}</td></tr>`).join('')}</table></div>`;+        h.querySelector('.x').onclick = () => this._help(false);+        this._menuClose(); if (!this.v.paused) { this._wasPlaying = true; this.v.pause(); }+      } else if (this._wasPlaying) { this._wasPlaying = false; this.v.play().catch(() => {}); }+      h.classList.toggle('on', on);+    }++    // ---------- wiring ----------+    _wire() {+      const v = this.v, p = this.p, bar = this.bar, $ = this.$;+      ['timeupdate', 'play', 'pause', 'seeked', 'progress', 'durationchange', 'ratechange', 'ended'].forEach((e) => v.addEventListener(e, () => this._tick()));+      v.addEventListener('durationchange', () => this._segments());+      v.addEventListener('volumechange', () => this._volUI());+      v.addEventListener('play', () => { this._started(); this._show(); this._frameHudOff(); });+      v.addEventListener('pause', () => this._show());+      v.addEventListener('waiting', () => p.classList.add('wait'));+      ['playing', 'canplay', 'seeked', 'pause'].forEach((e) => v.addEventListener(e, () => p.classList.remove('wait')));+      v.addEventListener('seeked', () => { this._seekBusy = false; if (this._seekPending != null) { const t = this._seekPending; this._seekPending = null; this._seekBusy = true; v.fastSeek ? v.fastSeek(t) : (v.currentTime = t); } });+      v.addEventListener('error', () => {+        const s = this.getAttribute('video') || this.getAttribute('src') || '';+        this._error(`This video could not be loaded.<br><code>${esc(s)}</code><br><br>Check the path is <code>/OWNER/SLUG/render/FILE.mp4</code> and that the file is on the page. On a private page the widget frame may not be signed in.`);+      });+      document.addEventListener('fullscreenchange', () => this._sync());+      if (v.requestVideoFrameCallback) {+        let last = null;+        const onFrame = (now, md) => {+          // consecutive presented frames at 1x give the frame duration; keep the median of the last 31+          if (last && md.presentedFrames === last.pf + 1 && md.mediaTime > last.mt && v.playbackRate === 1) {+            const dt = md.mediaTime - last.mt;+            if (dt > 0.004 && dt < 0.2) {+              (this._dts = this._dts || []).push(dt); if (this._dts.length > 31) this._dts.shift();+              this._fdt = [...this._dts].sort((x, y) => x - y)[this._dts.length >> 1];+            }+          }+          last = { pf: md.presentedFrames, mt: md.mediaTime };+          v.requestVideoFrameCallback(onFrame);+        };+        v.requestVideoFrameCallback(onFrame);+      }+      v.addEventListener('play', () => {+        try {+          const ms = navigator.mediaSession; if (!ms) return;+          ms.metadata = new MediaMetadata({ title: this._opt('title') || 'Video' });+          ms.setActionHandler('play', () => v.play()); ms.setActionHandler('pause', () => v.pause());+          ms.setActionHandler('stop', () => { v.pause(); this.seekTo(0); });+          ms.setActionHandler('seekbackward', () => this.seekBy(-10, true)); ms.setActionHandler('seekforward', () => this.seekBy(10, true));+          ms.setActionHandler('nexttrack', () => this.sibling(1)); ms.setActionHandler('previoustrack', () => this.sibling(-1));+        } catch {}+      });++      // clicks on the picture: play/pause; double-click: fullscreen; touch: tap shows controls, double-tap a side seeks+      const click = $('.click');+      let lastTap = 0, tapTimer = 0, down = null, holdTimer = 0;+      click.addEventListener('pointerdown', (e) => {+        if (e.button !== 0) return;+        down = e.pointerId; clearTimeout(holdTimer);+        holdTimer = setTimeout(() => this._holdStart(), 450);+      });+      const cancelHold = () => { clearTimeout(holdTimer); if (this._holdEnd()) down = null; };+      click.addEventListener('pointerleave', cancelHold);+      click.addEventListener('pointercancel', () => { cancelHold(); down = null; });+      click.addEventListener('contextmenu', (e) => { if (this._hold) e.preventDefault(); });+      click.addEventListener('pointerup', (e) => {+        if (e.button !== 0) return;+        clearTimeout(holdTimer);+        const mine = down === e.pointerId; down = null;+        if (this._holdEnd()) return;       // a hold was 2x, not a click+        if (!mine) return;                 // the press began elsewhere (a focus click, a drag from outside)+        if ($('.menu').classList.contains('on')) { this._menuClose(); return; }+        if (e.pointerType === 'touch') {+          const now = Date.now(), r = p.getBoundingClientRect(), fx = (e.clientX - r.left) / r.width;+          if (now - lastTap < 300 && (fx < 0.35 || fx > 0.65)) { clearTimeout(tapTimer); this.seekBy(fx < 0.5 ? -10 : 10, true); lastTap = now; return; }+          lastTap = now;+          tapTimer = setTimeout(() => { if (p.classList.contains('hide-ui')) this._show(); else this.toggle(); }, 280);+          return;+        }+        this.toggle();+      });+      click.addEventListener('dblclick', (e) => { e.preventDefault(); this.fullscreen(); });+      $('.big').addEventListener('click', () => this.toggle());++      p.addEventListener('pointermove', (e) => { if (e.pointerType !== 'touch') this._show(); });+      p.addEventListener('pointerdown', (e) => { active = this; if (!this.shadowRoot.activeElement || e.composedPath()[0] === v || e.target === this) setTimeout(() => { if (!this.shadowRoot.activeElement) p.focus({ preventScroll: true }); }); });+      p.addEventListener('focusin', () => { active = this; });+      p.addEventListener('pointerleave', () => { if (!v.paused && !this.drag) { clearTimeout(this._t.hide); this._t.hide = setTimeout(() => { if (!v.paused && !this.drag && !$('.menu').classList.contains('on')) p.classList.add('hide-ui'); }, 600); } });+      const chrome = $('.chrome');+      chrome.addEventListener('pointerenter', () => { this._overChrome = true; });+      chrome.addEventListener('pointerleave', () => { this._overChrome = false; this._show(); });++      // the timeline+      bar.addEventListener('pointermove', (e) => { if (!this.drag) this._hover(e.clientX); });+      bar.addEventListener('pointerleave', () => { if (!this.drag) (this._seg || []).forEach((s) => s.el.classList.remove('hov')); });+      bar.addEventListener('pointerdown', (e) => {+        if (e.button !== 0 || !v.duration) return;+        e.preventDefault(); bar.setPointerCapture(e.pointerId); bar.classList.add('drag');+        this.drag = { resume: !v.paused }; v.pause(); this._started();+        this._scrubTo(e.clientX);+      });+      bar.addEventListener('pointermove', (e) => { if (this.drag) this._scrubTo(e.clientX); });+      const end = (e) => {+        if (!this.drag) return;+        const resume = this.drag.resume; this.drag = null; bar.classList.remove('drag');+        this._seekPending = null; this._scrubTo(e.clientX, true);+        if (resume) v.play().catch(() => {});+        this._show();+      };+      bar.addEventListener('pointerup', end); bar.addEventListener('pointercancel', end);+      bar.addEventListener('keydown', (e) => {+        if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { e.preventDefault(); e.stopPropagation(); this.seekBy(e.key === 'ArrowLeft' ? -5 : 5, true); }+      });++      // volume slider+      const vs = $('.vslide');+      const setVol = (x) => { const r = vs.getBoundingClientRect(); v.muted = false; v.volume = clamp((x - r.left - 6) / 52, 0, 1); };+      vs.addEventListener('pointerdown', (e) => { e.preventDefault(); vs.setPointerCapture(e.pointerId); vs.classList.add('drag'); setVol(e.clientX); });+      vs.addEventListener('pointermove', (e) => { if (vs.classList.contains('drag')) setVol(e.clientX); });+      const vend = () => vs.classList.remove('drag');+      vs.addEventListener('pointerup', vend); vs.addEventListener('pointercancel', vend);+      vs.addEventListener('wheel', (e) => { e.preventDefault(); this.volBy(e.deltaY < 0 ? 0.05 : -0.05); }, { passive: false });++      // buttons+      const act = {+        play: () => this.toggle(), mute: () => this.mute(), cc: () => this.captions(), pip: () => this.pip(),+        pop: () => this.popout(), fs: () => this.fullscreen(),+        gear: () => ($('.menu').classList.contains('on') ? this._menuClose() : this._menu('main')),+      };+      $('.row').addEventListener('click', (e) => { const b = e.target.closest('[data-k]'); if (b && act[b.dataset.k]) { act[b.dataset.k](); this._tipOff(); } });+      $('.chap').addEventListener('click', () => this.chapter(1));+      $('.row').addEventListener('pointerover', (e) => { const b = e.target.closest('[data-tip]'); if (b) this._tipOn(b); });+      $('.row').addEventListener('pointerout', (e) => { if (e.target.closest('[data-tip]')) this._tipOff(); });++      $('.menu').addEventListener('click', (e) => {+        const b = e.target.closest('button'); if (!b) return;+        if (b.dataset.m) this._menu(b.dataset.m);+        else if (b.dataset.speed) { this.speed(Number(b.dataset.speed)); this._menu('main'); }+        else if (b.dataset.act === 'cc') this.captions();+        else if (b.dataset.act === 'loop') this.loop();+        else if (b.dataset.act === 'keys') this._help(true);+        else if (b.dataset.act === 'dl') { this._openTab((v.currentSrc || v.src).split('#')[0]); this._menuClose(); }+      });++      // YouTube's keyboard shortcuts, for whichever player owns the keyboard+      document.addEventListener('keydown', (e) => {+        if (active !== this) return;+        const tgt = e.composedPath()[0];+        if (tgt && /^(INPUT|TEXTAREA|SELECT)$/.test(tgt.tagName || '') || (tgt && tgt.isContentEditable)) return;+        if (e.metaKey || e.altKey) return;+        // outside our own frame, only take keys while the player has focus+        const inFrame = location.pathname.includes('/widget/');+        if (!inFrame && !this.shadowRoot.activeElement && document.activeElement !== this) return;+        const k = e.key; let hit = true;+        if ($('.help').classList.contains('on') && k !== '?' && k !== 'Escape') return;+        if (e.ctrlKey) { if (k === 'ArrowLeft') this.chapter(-1); else if (k === 'ArrowRight') this.chapter(1); else return; }+        else if (k === ' ' || k === 'k' || k === 'K') this.toggle();+        else if (k === 'j' || k === 'J') this.seekBy(-10, true);+        else if (k === 'l' || k === 'L') this.seekBy(10, true);+        else if (k === 'ArrowLeft') this.seekBy(-5, true);+        else if (k === 'ArrowRight') this.seekBy(5, true);+        else if (k === 'ArrowUp') this.volBy(0.05);+        else if (k === 'ArrowDown') this.volBy(-0.05);+        else if (k === ',') this.frame(-1);+        else if (k === '.') this.frame(1);+        else if (k === '<') this.speedStep(-1);+        else if (k === '>') this.speedStep(1);+        else if (k === 'Home') this.seekTo(0);+        else if (k === 'End') this.seekTo(v.duration || 0);+        else if (/^[0-9]$/.test(k)) this.seekTo((v.duration || 0) * Number(k) / 10);+        else if (k === 'f' || k === 'F') this.fullscreen();+        else if (k === 't' || k === 'T') this.popout();+        else if (k === 'i' || k === 'I') this.pip();+        else if (k === 'm' || k === 'M') this.mute();+        else if (k === 'c' || k === 'C') this.captions();+        else if (k === 'r' || k === 'R') this.loop();+        else if (k === 'o' || k === 'O') this.capOpacity('text');+        else if (k === 'w' || k === 'W') this.capOpacity('win');+        else if (k === 'N' && e.shiftKey) this.sibling(1);+        else if (k === 'P' && e.shiftKey) this.sibling(-1);+        else if (k === 'MediaPlayPause') this.toggle();+        else if (k === 'MediaStop') { v.pause(); this.seekTo(0); }+        else if (k === 'MediaTrackNext') this.sibling(1);+        else if (k === 'MediaTrackPrevious') this.sibling(-1);+        else if (k === '+' || k === '=') this.capFont(25);+        else if (k === '-' || k === '_') this.capFont(-25);+        else if (k === '?') this._help();+        else if (k === 'Escape') { if ($('.help').classList.contains('on')) this._help(false); else if ($('.menu').classList.contains('on')) this._menuClose(); else hit = false; }+        else hit = false;+        if (hit) { e.preventDefault(); e.stopPropagation(); }+      }, true);+    }++    // for the wiki's widget driver and AI checks+    get adomDrive() {+      return {+        ready: () => new Promise((ok) => (this.v.readyState >= 1 ? ok() : this.v.addEventListener('loadedmetadata', () => ok(), { once: true }))),+        state: () => ({ state: this.v.paused ? 'paused' : 'playing', t: this.v.currentTime, duration: this.v.duration, speed: this.v.playbackRate, chapters: this.chapters.length, captions: this.cues.length }),+        set: (x) => { if (x === 'play' || x === 'playing') return this.v.play(); if (x === 'pause' || x === 'paused') return this.v.pause(); if (!isNaN(parseT(x))) this.seekTo(parseT(x)); },+      };+    }+  }+  customElements.define('adom-video', AdomVideo);+})();
public/player.css+10−28
@@ -31,7 +31,7 @@ a { color: var(--blue); }   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: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;@@ -43,27 +43,9 @@ a { color: var(--blue); }  /* ---- 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; }-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-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; }-.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); }+.stage { display: flex; align-items: center; justify-content: center; padding: 12px 16px; min-height: 0; min-width: 0; }+/* the wiki's <adom-video>: as wide as the stage allows while the whole picture stays on screen */+adom-video { width: min(100%, calc((100dvh - 44px - 24px) * var(--ar, 1.7778))); } aside { border-left: 1px solid var(--line); padding: 12px 14px; overflow: auto; } 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; }@@ -83,9 +65,9 @@ body.theater aside { display: none; } .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-pop table { border-collapse: collapse; }+.sc-pop td { padding: 3px 16px 3px 0; font-size: 13px; vertical-align: top; }+.sc-pop 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 ---- */@@ -142,9 +124,9 @@ dialog::backdrop { background: rgba(0,0,0,.25); } .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 (max-width: 860px) { main { grid-template-columns: 1fr; height: auto; } aside { border-left: none; border-top: 1px solid var(--line); } adom-video { width: 100%; } }+@media (max-width: 720px) { .app-sub, .subject-meta, .vsep { display: none; } } @media (hover: none), (pointer: coarse) {-  .icon-btn, .tgl, .icon-btn.lg, .speed, .play-btn { min-width: 44px; min-height: 44px; }+  .icon-btn { min-width: 44px; min-height: 44px; }   .list li { padding: 12px 10px; } }
public/player.html+13−54
@@ -23,27 +23,7 @@ </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" 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="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" 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>+  <section class="stage" id="stage"></section>   <aside>     <h2>Chapters</h2>     <ol id="chapters" class="list"><li class="empty">No chapters. An AI adds them with adom-video play --chapters.</li></ol>@@ -53,49 +33,27 @@ </main> <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>J / L</td><td>Back or 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>, / .</td><td>Previous or next frame (pauses)</td></tr>+        <tr><td>&lt; / &gt;</td><td>Slower or faster (0.25x to 4x)</td></tr>+        <tr><td>Hold the mouse</td><td>2x while held on the video</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>Shift+N / Shift+P</td><td>Next or previous video in the library</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>O / W</td><td>Caption text / background opacity</td></tr>+        <tr><td>F or double-click</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>+        <tr><td>R</td><td>Loop</td></tr>+        <tr><td>?</td><td>Shortcuts sheet on the video</td></tr>+        <tr><td>Esc</td><td>Close a menu, or leave theater mode</td></tr>+  </table><div class="sc-foot">The same keys as YouTube. Press ? for this list on the video.</div></div> <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>@@ -105,6 +63,7 @@   </footer> </dialog> +<script src="../static/adom-video.js"></script> <script src="../static/icons.js"></script> <script src="../static/ui.js"></script> <script src="../static/player.js"></script>
public/player.js+129−140
@@ -1,81 +1,126 @@-// 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.+// adom-video-player page: the wiki's <adom-video> player (adom-video.js, vendored unchanged from+// adom/widgets) in the app frame. The player owns playback, the timeline, captions, the+// settings menu and YouTube's shortcuts; this page adds what only the app has: the library+// (Shift+N/P), theater mode (T), resume and settings on the server, reload on re-render,+// and the SSE channel that lets the AI drive it. All state lives on the server. (() => {   const { api, toast, hoverPopover, header, initSettings, connect, uiCommon } = window.UI;   const $ = (id) => document.getElementById(id);-  const v = $('v');   const id = location.pathname.split('/').filter(Boolean).pop();-  let item = null, settings = {}, fps = 30, etag = null;+  let item = null, settings = {}, etag = null, player = null, v = null;    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).toFixed(2).padStart(5, '0');+    if (!isFinite(t)) t = 0;+    const h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), s = String(Math.floor(t % 60)).padStart(2, '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';+  const mediaUrl = () => `../media/${id}?v=${encodeURIComponent(item.etag || '')}`;++  // the player reads its chapters and captions from an inlined JSON manifest, as on the wiki+  function manifest() {+    let s = player.querySelector('script[type="application/json"]');+    if (!s) { s = document.createElement('script'); s.type = 'application/json'; player.appendChild(s); }+    s.textContent = JSON.stringify({ title: item.title, chapters: item.chapters || [], captions: item.captions ? `../captions/${id}` : null });+  }++  // after (re)load: speed, place, play state; once per load+  function onMeta({ t, rate, play }) {+    v.addEventListener('loadedmetadata', () => {+      v.playbackRate = rate;+      if (v.videoWidth) player.style.setProperty('--ar', String(v.videoWidth / v.videoHeight));+      if (t > 0 && t < v.duration - 0.5 && Math.abs(v.currentTime - t) > 0.05) v.currentTime = t;+      if (play) v.play().catch(() => {});  // a blocked autoplay leaves the big play button up+      chapters();+    }, { once: true });+  }    async function load(keep) {     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;+    item = r.item; settings = r.settings || settings;     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 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.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(); syncToggles();-    }, { once: true });-    chapters();+    if (!keep) {+      const pos = item.position || {};+      const t = settings.rememberPosition !== false ? Number(pos.t || 0) : 0;+      mount();+      if (item.fps) player._fdt = 1 / item.fps;  // --fps given: frame step at that rate until the player measures one+      player.setAttribute('title', item.title);+      if (t > 0) player.setAttribute('start', String(t));+      manifest();+      onMeta({ t, rate: Number(pos.speed || settings.defaultSpeed || 1), play: !!settings.autoplay });+      player.setAttribute('video', mediaUrl());+      $('stage').appendChild(player);+    } else {+      // re-rendered on disk: same place, same speed, same play state+      const t = v.currentTime, rate = player._hold ? player._hold.rate : v.playbackRate, play = !v.paused;+      player.toggleAttribute('loop', v.loop);+      player.setAttribute('start', String(t));+      player.setAttribute('title', item.title);+      manifest();+      onMeta({ t, rate, play });+      player.setAttribute('video', mediaUrl());+      if (player._pv) player._pv.src = v.src;  // the hover preview follows the new file+    }+  }++  // ---------- the player and the app's additions to it ----------+  function mount() {+    player = document.createElement('adom-video');+    v = player.v;+    const sr = player.shadowRoot;+    // T is theater mode here (in a wiki frame it opens the player large in a new tab), and the+    // pop-out button becomes YouTube's theater button+    player.popout = () => theater();+    const pop = sr.querySelector('[data-k=pop]');+    pop.dataset.k = 'theater'; pop.dataset.tip = 'Theater mode (t)';+    pop.innerHTML = '<svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"><path d="M19 6H5a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2zm0 10H5V8h14v8z"/></svg>';+    pop.addEventListener('click', () => theater());+    // Shift+N / Shift+P and the media keys step through the library+    player.sibling = (dir) => sibling(dir);+    // ? shows the app's list (the player's own names the wiki's keys)+    player._help = function (on) {+      const h = this.$('.help');+      if (on == null) on = !h.classList.contains('on');+      if (on) {+        const rows = [...$('sc-pop').querySelectorAll('tr')].map((tr) => [tr.cells[1].textContent, tr.cells[0].textContent]);+        h.innerHTML = `<h3>Keyboard shortcuts <button class="x" aria-label="Close">×</button></h3><div class="cols"><table>${rows.map(([a, k]) => `<tr><td>${a}</td><td>${k.split(/ or | \/ /).map((x) => `<kbd>${x.replace(/&/g, '&amp;').replace(/</g, '&lt;')}</kbd>`).join(' ')}</td></tr>`).join('')}</table></div>`;+        h.querySelector('.x').onclick = () => this._help(false);+        this._menuClose(); if (!v.paused) { this._wasPlaying = true; v.pause(); }+      } else if (this._wasPlaying) { this._wasPlaying = false; v.play().catch(() => {}); }+      h.classList.toggle('on', on);+    };+    ['timeupdate', 'seeked'].forEach((e) => v.addEventListener(e, activeChapter));+    v.addEventListener('pause', savePos);+    v.addEventListener('seeked', () => { if (v.paused) savePos(); });+    v.addEventListener('ratechange', () => { if (!player._hold) savePos(); });+    syncTheater();   } -  // ---------- chapters, timeline, speeds ----------+  // ---------- chapters, library ----------   function chapters() {     const ol = $('chapters'); const ch = item.chapters || [];-    if (!ch.length) return;+    if (!ch.length) { ol.innerHTML = '<li class="empty">No chapters. An AI adds them with adom-video play --chapters.</li>'; return; }     ol.innerHTML = '';     ch.forEach((c, k) => {       const li = document.createElement('li'); li.dataset.k = k;       li.innerHTML = '<span class="t"></span><span></span>';-      li.firstChild.textContent = fmt(c.t).replace(/\.\d+$/, ''); li.lastChild.textContent = c.title;+      li.firstChild.textContent = fmt(c.t); 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(() => {}); };+      li.onclick = () => { player.seekTo(c.t); v.play().catch(() => {}); };       ol.appendChild(li);     });+    activeChapter();   }   function activeChapter() {     const ch = (item && item.chapters) || []; let a = -1;     ch.forEach((c, k) => { if (v.currentTime + 0.05 >= c.t) a = k; });     document.querySelectorAll('#chapters li').forEach((li) => li.classList.toggle('active', Number(li.dataset.k) === a));   }-  function marks() {-    const m = $('marks'); m.innerHTML = '';-    (item.chapters || []).forEach((c) => { if (v.duration) { const i = document.createElement('i'); i.style.left = (100 * c.t / v.duration) + '%'; 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 = '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; 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(items) {     if (!items) items = (await api('api/library')).items || [];     const ul = $('library'); ul.innerHTML = '';@@ -86,127 +131,71 @@       ul.appendChild(li);     });   }--  // ---------- timeline -----------  const tl = $('tl');-  const pos = (e) => { const r = tl.getBoundingClientRect(); return Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)); };-  tl.addEventListener('click', (e) => { if (v.duration) v.currentTime = pos(e) * v.duration; });-  tl.addEventListener('mousemove', (e) => { const h = $('hover'); if (!v.duration) return; h.style.display = 'block'; h.style.left = (pos(e) * 100) + '%'; h.textContent = fmt(pos(e) * v.duration); });-  tl.addEventListener('mouseleave', () => { $('hover').style.display = 'none'; });--  const tick = () => {-    $('time').textContent = `${fmt(v.currentTime)} / ${fmt(v.duration)}`;-    if (v.duration) {-      $('prog').style.width = (100 * v.currentTime / v.duration) + '%';-      if (v.buffered.length) $('buf').style.width = (100 * v.buffered.end(v.buffered.length - 1) / v.duration) + '%';-    }-    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', 'ratechange'].forEach((e) => v.addEventListener(e, tick));+  async function sibling(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];+    if (n) { savePos(); location.href = `../watch/${n.id}`; } else player._hud(dir > 0 ? 'This is the last video in the library' : 'This is the first video in the library');+  }    // 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(); });+  function savePos() {+    if (!item || !v || !v.duration) return;+    lastSaved = v.currentTime;+    api('api/position', { id, t: v.currentTime, speed: player._hold ? player._hold.rate : v.playbackRate }).catch(() => {});+  }+  setInterval(() => { if (v && !v.paused && Math.abs(v.currentTime - lastSaved) > 2) savePos(); }, 3000);   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'));+  // ---------- theater ----------+  function syncTheater() {+    const on = document.body.classList.contains('theater');+    const b = player && player.shadowRoot.querySelector('[data-k=theater]');+    if (b) { b.classList.toggle('on', on); b.setAttribute('aria-pressed', String(on)); b.dataset.tip = on ? 'Default view (t)' : 'Theater mode (t)'; }+  }+  function theater(on) {+    document.body.classList.toggle('theater', on ?? !document.body.classList.contains('theater'));+    syncTheater();+    player._hud(document.body.classList.contains('theater') ? 'Theater mode' : 'Default view');   }-  $('play').onclick = toggle; $('back10').onclick = () => jump(-10); $('fwd10').onclick = () => jump(10);-  $('prevF').onclick = () => step(-1); $('nextF').onclick = () => step(1);-  $('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+  // ---------- header ----------+  $('copy').onclick = () => { navigator.clipboard?.writeText(location.href).then(() => toast('Link copied', 'success'), () => toast(location.href)); };   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); });+  $('sc-btn').onclick = () => { $('sc-pop').classList.remove('open'); player._help(); }; -  // YouTube's keyboard shortcuts (plus [ ] for speed), in the capture phase so the native controls never double-act-  document.addEventListener('keydown', (e) => {-    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 (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 === '<' || 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') 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 === '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(); e.stopPropagation(); }+  // The player takes keys while it has focus (several players share a wiki page). Here it is the+  // only one, so every key goes to it wherever focus is, except in a dialog or a text field.+  addEventListener('keydown', (e) => {+    if (!player || document.querySelector('dialog[open]')) return;+    if (/INPUT|TEXTAREA|SELECT/.test(e.target.tagName) || e.target.isContentEditable || e.metaKey || e.altKey) return;+    const sr = player.shadowRoot;+    if (e.key === 'Escape' && document.body.classList.contains('theater') && !sr.querySelector('.menu.on, .help.on')) {+      theater(false); e.preventDefault(); e.stopPropagation(); return;+    }+    if (e.key === 'Enter' || e.key === 'Tab') return;+    if (!sr.activeElement) player.p.focus({ preventScroll: true });   }, true);    // ---------- 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'); } },+    changed: async (d) => { if (d.id === id && d.etag !== etag && player) { 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)}`); }+      if (!player) return;+      if (d.type === 'seek') { player.seekTo(Number(d.t) || 0); toast(`AI jumped to ${fmt(Number(d.t) || 0)}`); }       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 === 'speed') player.speed(Number(d.rate) || 1);       else if (d.type === 'theater') theater();-      else if (d.type === 'captions') captions();+      else if (d.type === 'captions') player.captions();     },   });    (async () => {     const st = await api('state'); header(st); initSettings(); settings = st.settings;-    tick(); await load(false); libraryList(st.library);+    await load(false); libraryList(st.library);   })(); })();
skills/adom-video-player/SKILL.md+5−4
@@ -49,10 +49,11 @@ Other verbs (every reply starts `OK:` or `ERROR:` with a `Hint:`; add `--json` f  ## What the user gets -- A timeline that seeks anywhere (206 Partial Content, `If-Range`, `HEAD`, `ETag`).-- 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.+The same YouTube-style player as the wiki's `::adom-video` widget (vendored unchanged from adom/widgets):+- A timeline that seeks anywhere (206 Partial Content, `If-Range`, `HEAD`, `ETag`): drag to scrub, hover for a frame preview, chapters as bar segments.+- YouTube's keyboard shortcuts, all of them: Space/K, J/L, arrows (seek and volume), M, `,` `.` frame (with a frame N of total pill), `<` `>` speed (0.25x to 4x), hold the video for 2x, 0 to 9, Home/End, Ctrl+arrows chapters, F or double-click fullscreen, T theater, I picture-in-picture, R loop, C captions, + - caption size, O W caption opacity, Shift+N/P next/previous video in the library, ? list. Every button's tooltip names its key.+- Speed, captions, loop and the shortcut list in the player's settings menu; a chapter list beside the player.+- Re-render the file at the same path and the open player reloads it, keeping the position and speed. No stale cache. - Resumes where the user left off; a library of every file shown in this container (`/` on the server).  ## Iterating on a render
uninstall.sh