app
Adom Video Player
Public Made by Adomby adom
Show any video in a real player: seek, scrub, frame step, chapters. One command, no hand-rolled servers.
← Commit history
Publish 0.4.0
11 files changed
+1024−244
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-videoinstall.shpackage.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" }-}+}
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));+ // caption markup: keep <b> <i> <u>, drop the rest (voice tags, classes, timestamps)+ const capHTML = (s) => esc(s).replace(/<(\/?)(b|i|u)>/g, '<$1$2>').replace(/<[^&]*?>/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>← / →</td><td>Back or forward 5 seconds</td></tr> <tr><td>↑ / ↓</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>< / ></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>< / ></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+← / Ctrl+→</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>← / →</td><td>Back or forward 5 seconds</td></tr>- <tr><td>↑ / ↓</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>< / ></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+← / Ctrl+→</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, '&').replace(/</g, '<')}</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