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: hold for 2x, speeds to 4x, frame step at the real frame rate, O/W caption opacity, Shift+N/P, media keys; ignore stray mouse-ups
2 files changed
+90−11
README.md+1−1@@ -59,5 +59,5 @@ Five load from adom/component-widgets and take a JSON `src`; adom-stock and adom | `::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{video="..."}` | 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-video{video="..."}` | a YouTube-style video player: every YouTube shortcut (Space/K, J/L, arrows, 0 to 9, `,` `.` frame step at the video's real frame rate, `<` `>` speed 0.25x to 4x, hold the mouse on the video for 2x, C captions, + / - size, O / W caption opacity, Ctrl+arrows chapters, F, I, M, Shift+N/P, media keys, ?), 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/video-widget.js+89−10@@ -42,7 +42,8 @@ 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 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;@@ -79,9 +80,11 @@ 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', '?'],+ ['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 = `@@ -154,6 +157,9 @@ @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}+ .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%}@@ -172,7 +178,7 @@ /* 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 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}@@ -225,6 +231,7 @@ <div class="seekfx r">${svg('fwd', 32)}<span></span></div> <div class="bezel"></div> <div class="hud"></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>@@ -474,11 +481,35 @@ 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); }+ // 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();+ } 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)]); }+ 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}%`); }@@ -564,6 +595,7 @@ 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';@@ -606,12 +638,51 @@ 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;+ 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;@@ -704,8 +775,8 @@ 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.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);@@ -717,6 +788,14 @@ 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();