app
Widget Registry
Public Made by Adomby adom
The reviewed registry of widgets renderable from plain markdown on any wiki page
← Commit history
adom-video: a YouTube-style video player widget (all YouTube shortcuts, 24px timeline grab zone, drag scrub, hover frame preview, chapters, captions)
3 files changed
+725−1
README.md+2−1@@ -45,7 +45,7 @@ Registry caps: 32 widgets, 12 attrs each. A malformed entry is dropped, never br ## Current widgets -Five load from adom/component-widgets and take a JSON `src`; adom-stock loads from this page and takes a stock route `src`; adom-symbol-variants loads from adom/adom-symbol and takes a component page.json `src`:+Five load from adom/component-widgets and take a JSON `src`; adom-stock and adom-video load from this page (adom-video takes a video `src`); adom-symbol-variants loads from adom/adom-symbol and takes a component page.json `src`: | tag | shows | |---|---|@@ -59,4 +59,5 @@ Five load from adom/component-widgets and take a JSON `src`; adom-stock loads fr | `::adom-stock{src="/api/pages/OWNER/SLUG/stock"}` | live stock and price at Mouser, DigiKey and LCSC for a hardware page, from the wiki's cached vendor lookups (adom/wiki#186); component pages show it in their sidebar by themselves; `heading` | | `::adom-verified{src="..."}` | the "verified by" trust mark: big green check, who verified the page and when, per-variant coverage, and the full verification log with evidence; turns amber when a verified file changes; takes the page's `verification.json` — loads from adom/component-widgets | | `::adom-models{src="..."}` | every 3D file on a component page as a card: thumbnail, STEP or GLB, etched or unmarked and where the part number sits, what makes it different from its siblings, what it was derived from, a download, and whether the page still serves the file that was described (checked against the file listing, nothing downloaded); previous versions fold away; takes the page's `models.json` (adom/models@1) — loads from adom/component-widgets |+| `::adom-video{src="..."}` | a YouTube-style video player: every YouTube keyboard shortcut (Space/K, J/L, arrows, 0 to 9, `,` `.` frames, `<` `>` speed, C captions, F, I, M, ?), a 24px timeline grab zone with drag-to-scrub and a hover frame preview, chapters as bar segments, captions, speed and loop; `title` `poster` `chapters` (JSON `[{"t":12,"title":"..."}]` or WebVTT) `captions` (.vtt or .srt) `start` (`90` or `1m30s`) `loop` `muted` `autoplay`; F opens the player full-window in a new tab (widget frames cannot go fullscreen); loads from this page | | `::adom-3d{src="..."}` | any GLB in the canonical Adom 3D Viewer, click to load (nothing loads until the reader asks; it unloads offscreen and releases the WebGL context); `poster` `heading` `caption`; publish the GLB without Draco, the frame cannot reach the decoder CDN; loads from adom/component-widgets |
widgets.json+18@@ -154,5 +154,23 @@ "heading" ], "height": "auto"+ },+ {+ "name": "adom-video",+ "mode": "frontend",+ "script": "https://wiki.adom.inc/adom/widgets/render/widgets/video-widget.js",+ "element": "adom-video",+ "attrs": [+ "src",+ "title",+ "poster",+ "chapters",+ "captions",+ "start",+ "loop",+ "muted",+ "autoplay"+ ],+ "height": "auto" } ]
widgets/video-widget.jsadded+705@@ -0,0 +1,705 @@+// <adom-video>: a YouTube-style video player for wiki pages.+//+// ::adom-video{src="/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: src (required), 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"/>',+ 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];+ 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}`;+ };+ // "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 (paused)', ', / .'], ['Slower / faster', '< / >'], ['Previous / next chapter', 'Ctrl + ← / →'],+ ['Captions on / off', 'C'], ['Caption size', '+ / −'], ['Fullscreen', 'F or double-click'], ['Open large in a new tab', 'T'],+ ['Picture-in-picture', 'I'], ['Loop', 'R'], ['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}+ .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,.75);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 ['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="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/') && window.top === window) { this.classList.add('page'); this.p.classList.add('page'); }+ 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 === '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; } }++ _load() {+ this._loaded = true;+ const src = this.getAttribute('src');+ const v = this.v;+ this.$('.top').textContent = this.getAttribute('title') || '';+ if (!src) return this._error('No video: add <code>src="/OWNER/SLUG/render/video.mp4"</code> to the tag.');+ this.$('.err').classList.remove('on');+ if (this.getAttribute('poster')) v.poster = this._abs(this.getAttribute('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._abs(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), { credentials: 'include' }).catch(() => null);+ return r && r.ok ? r.text() : null;+ }+ async _chapters() {+ const u = this.getAttribute('chapters'); this.chapters = [];+ if (u) {+ const txt = await this._fetchText(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.getAttribute('captions'); this.cues = [];+ if (u) { const txt = 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(); }+ frame(n) { const v = this.v; v.pause(); v.currentTime = clamp(v.currentTime + n / 30, 0, v.duration || 0); }+ 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) { let i = SPEEDS.indexOf(this.v.playbackRate); if (i < 0) i = 3; this.speed(SPEEDS[clamp(i + d, 0, SPEEDS.length - 1)]); }+ 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();+ const w = window.open(url, '_blank', 'noopener');+ if (!w) this._hud('Your browser blocked the new tab');+ }+ 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.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(); });+ 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('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());++ // 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;+ click.addEventListener('pointerup', (e) => {+ if (e.button !== 0) return;+ 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') { window.open((v.currentSrc || v.src).split('#')[0], '_blank', 'noopener'); 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 === ',' && v.paused) this.frame(-1);+ else if (k === '.' && v.paused) 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 === '+' || 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);+})();