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: Share button (link to the page that opens on this video, optional start time; copy the file link); #video=<id>&t= deep links
2 files changed
+52−4
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 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); a narrated video gets captions and chapters free: gang-takes (adom/adom-video-post) writes the `.vtt` from the adom-tts narration text, the `.chapters.json` from its segment labels, and the embed line; on a private page pass `src=` a manifest with them inlined; a private page's VIDEO plays only where the browser sends third-party cookies until adom/wiki#228 (signed media URLs) ships, elsewhere the widget offers Play it in a new tab; 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 Share button (copies a link to the page that opens on this video, optionally at the current time: `#video=<file-name>&t=90`, plus the file's own link), 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); a narrated video gets captions and chapters free: gang-takes (adom/adom-video-post) writes the `.vtt` from the adom-tts narration text, the `.chapters.json` from its segment labels, and the embed line; on a private page pass `src=` a manifest with them inlined; a private page's VIDEO plays only where the browser sends third-party cookies until adom/wiki#228 (signed media URLs) ships, elsewhere the widget offers Play it in a new tab; 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+51−3@@ -35,6 +35,8 @@ 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"/>',+ share: '<path d="M14 9V5l7 7-7 7v-4.1c-5 0-8.5 1.6-11 5.1 1-5 4-10 11-11z"/>',+ link: '<path d="M10.6 13.4a1 1 0 0 1 0-1.4l3.4-3.4a1 1 0 1 1 1.4 1.4L12 13.4a1 1 0 0 1-1.4 0zM8.5 19a4.5 4.5 0 0 1-3.2-7.7l2.1-2.1 1.4 1.4-2.1 2.1a2.5 2.5 0 0 0 3.5 3.5l2.1-2.1 1.4 1.4-2.1 2.1A4.5 4.5 0 0 1 8.5 19zm7.5-4.4-1.4-1.4 2.1-2.1a2.5 2.5 0 0 0-3.5-3.5l-2.1 2.1-1.4-1.4 2.1-2.1a4.5 4.5 0 0 1 6.4 6.4z"/>', 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"/>',@@ -209,6 +211,12 @@ .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}+ .sh{display:flex;gap:8px;padding:6px 14px}+ .shurl{flex:1;min-width:0;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.18);border-radius:8px;color:#fff;padding:8px 10px;font:inherit;font-size:12px}+ .shcopy{padding:0 14px;border-radius:18px;background:var(--accent);color:#04201e;font-weight:600}+ .shcopy:hover{background:var(--accent-hi)}+ .shat{display:flex;align-items:center;gap:8px;padding:8px 14px;font-size:13px;cursor:pointer}+ .shat input{accent-color:var(--accent)} .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)}@@ -270,6 +278,7 @@ <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="share" data-tip="Share: copy a link to this video">${svg('share')}</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>@@ -338,7 +347,9 @@ 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]);+ const h = new URLSearchParams(location.hash.slice(1)), linked = h.get('video') && h.get('video') === this._vid();+ this._startAt = parseT(this.getAttribute('start') || (linked ? h.get('t') : '') || (location.pathname.includes('/widget/') ? (location.hash.match(/[#&]t=([^&]+)/) || [])[1] : ''));+ if (linked) setTimeout(() => { this.scrollIntoView({ block: 'center' }); active = this; this._engaged = true; this.p.focus({ preventScroll: true }); this.p.animate([{ boxShadow: '0 0 0 3px #00b8b1' }, { boxShadow: '0 0 0 0 transparent' }], { duration: 1600 }); }, 400); v.src = this._media(src); v.addEventListener('loadedmetadata', () => { if (v.videoWidth && !this.p.classList.contains('page')) this.p.style.aspectRatio = `${v.videoWidth} / ${v.videoHeight}`;@@ -637,7 +648,21 @@ // ---------- settings menu ---------- _menu(view) { const m = this.$('.menu'); this._menuView = view;+ this.$('[data-k=gear]').classList.remove('on'); this.$('[data-k=share]').classList.remove('on'); const v = this.v;+ if (view === 'share') {+ const t = Math.floor(v.currentTime), at = this._shareAt ?? (t > 0);+ m.innerHTML = `<div class="mhead">${svg('share')}Share</div>+ <div class="sh"><input class="shurl" readonly value="${esc(this._pageLink(at ? t : 0))}" aria-label="Link to this video">+ <button class="shcopy">Copy</button></div>+ <label class="shat"><input type="checkbox" ${at ? 'checked' : ''}> Start at ${fmt(t)}</label>+ <button class="mi" data-act="copyfile">${svg('link')}<span class="lab">Copy the video file link</span></button>`;+ m.querySelector('.shat input').onchange = (e) => { this._shareAt = e.target.checked; this._menu('share'); };+ m.querySelector('.shcopy').onclick = () => this._copy(m.querySelector('.shurl').value, 'Link copied');+ m.querySelector('.shurl').onclick = (e) => e.target.select();+ m.classList.add('on'); this._tipOff(); this.$('[data-k=share]').classList.add('on');+ return;+ } 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>`) +@@ -655,7 +680,28 @@ 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(); }+ _menuClose() { this.$('.menu').classList.remove('on'); this.$('[data-k=gear]').classList.remove('on'); this.$('[data-k=share]').classList.remove('on'); this._show(); }+ // this video's id on the page: its file name, numbered when the same file appears twice+ _vid() {+ if (this._vidCache) return this._vidCache;+ const base = (s) => (String(s || '').split('?')[0].split('/').pop() || 'video').replace(/\.[a-z0-9]+$/i, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'video';+ const mine = base(this.getAttribute('video') || (this._manifest() || {}).video || this.getAttribute('src'));+ const same = [...document.querySelectorAll('adom-video')].filter((e) => base(e.getAttribute('video') || (e._manifest ? (e._manifest() || {}).video : '') || e.getAttribute('src')) === mine);+ const k = same.indexOf(this);+ return (this._vidCache = k > 0 ? `${mine}-${k + 1}` : mine);+ }+ // a link to the wiki page that opens on this video (and, optionally, cues it at t seconds)+ _pageLink(t) {+ const inFrame = location.pathname.includes('/widget/');+ if (inFrame) { const u = new URL(location.href); if (t) u.searchParams.set('start', String(t)); else u.searchParams.delete('start'); return u.href; }+ const u = new URL(location.href); u.hash = `video=${this._vid()}` + (t ? `&t=${t}` : '');+ return u.href;+ }+ _copy(text, msg) {+ const done = () => { this._hud(msg); this._menuClose(); };+ const fallback = () => { const i = document.createElement('textarea'); i.value = text; this.shadowRoot.appendChild(i); i.select(); try { document.execCommand('copy'); done(); } catch { this._hud('Select the link and copy it'); } i.remove(); };+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, fallback); else fallback();+ } _help(on) { const h = this.$('.help'); if (on == null) on = !h.classList.contains('on');@@ -797,7 +843,8 @@ 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')),+ gear: () => ($('.menu').classList.contains('on') && this._menuView !== 'share' ? this._menuClose() : this._menu('main')),+ share: () => ($('.menu').classList.contains('on') && this._menuView === 'share' ? this._menuClose() : this._menu('share')), }; $('.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));@@ -811,6 +858,7 @@ 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 === 'copyfile') this._copy((v.currentSrc || v.src).split('#')[0], 'Video file link copied'); else if (b.dataset.act === 'dl') { this._openTab((v.currentSrc || v.src).split('#')[0]); this._menuClose(); } });