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: frame-step overlay (direction, frame N of total, exact time, fps)
2 files changed
+27−3
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 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-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 with a frame-number overlay, `<` `>` 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+26−2@@ -33,6 +33,8 @@ 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"/>',@@ -51,6 +53,7 @@ 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;@@ -159,6 +162,13 @@ .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}@@ -231,6 +241,7 @@ <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>@@ -488,7 +499,20 @@ 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(); }@@ -628,7 +652,7 @@ ['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('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')));