← Commit history

adom-video: Clean frame (H) hides every overlay for screenshots; Copy / Save this frame as a full-resolution PNG

John Lauer ·d510d61a4e ·1d ago ·parent cf48962
2 files changed +53−2
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 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-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, ?), Clean frame (H: hides title, controls, captions and cursor for a screenshot; the mouse does not bring them back), Copy / Save this frame as a full-resolution PNG, 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+52−1
@@ -37,6 +37,8 @@     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"/>',+    clean: '<path d="M4 4h5v2H6v3H4V4zm11 0h5v5h-2V6h-3V4zM4 15h2v3h3v2H4v-5zm14 3v-3h2v5h-5v-2h3z"/>',+    image: '<path d="M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm0 16H5V5h14v14zm-5.5-7.5L11 15l-2-2.5L6 17h12l-4.5-5.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"/>',@@ -89,7 +91,7 @@     ['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', '?'],+    ['Clean frame: hide everything for a screenshot', 'H'], ['Loop', 'R'], ['Media keys', 'Play/Pause, Stop, Next'], ['This list', '?'],   ];    const CSS = `@@ -186,6 +188,11 @@   @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}+  .p.clean .chrome,.p.clean .grad,.p.clean .top,.p.clean .caps,.p.clean .hud,.p.clean .bezel,.p.clean .fstep,.p.clean .hold,.p.clean .seekfx,.p.clean .tt,.p.clean .menu,.p.clean .big,.p.clean .spin,.p.clean .help{display:none!important}+  .p.clean{cursor:none}+  .p.clean:focus-visible{box-shadow:none}+  .cleanhint{position:absolute;left:50%;bottom:18px;transform:translateX(-50%);padding:6px 12px;border-radius:14px;background:rgba(0,0,0,.6);font-size:12px;opacity:0;pointer-events:none;transition:opacity .4s;z-index:9}+  .cleanhint.on{opacity:1;transition:none}   .failed .chrome,.failed .grad,.failed .big,.failed .top{display:none}   .err{z-index:8}   .err code{background:rgba(255,255,255,.1);padding:1px 5px;border-radius:4px;word-break:break-all}@@ -269,6 +276,7 @@         <div class="bezel"></div>         <div class="hud"></div>         <div class="fstep" aria-live="polite"></div>+        <div class="cleanhint">Clean frame: press H or Esc, or click, to bring the controls back</div>         <div class="hold">2x ${svg('fwd', 18)}</div>         <div class="spin"></div>         <button class="big" aria-label="Play (k)">${svg('play', 34)}</button>@@ -289,6 +297,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 opt" data-k="clean" data-tip="Clean frame: hide everything for a screenshot (h)">${svg('clean')}</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>@@ -543,6 +552,38 @@       this._started(); this._show();       this._frameHud(n, f);     }+    // clean frame: nothing but the picture, for screenshots; the mouse does not bring the controls back+    clean(on) {+      if (on == null) on = !this._clean;+      this._clean = on;+      this.p.classList.toggle('clean', on);+      const hint = this.$('.cleanhint');+      clearTimeout(this._t.clean);+      if (on) {+        this._menuClose(); this._tipOff(); if (this.$('.help').classList.contains('on')) this._help(false);+        hint.classList.add('on'); this._t.clean = setTimeout(() => hint.classList.remove('on'), 1400);+      } else { hint.classList.remove('on'); this._show(); }+    }+    // the current frame at the video's own resolution, as a PNG: copy to the clipboard or save as a file+    async grabFrame(mode) {+      const v = this.v;+      if (!v.videoWidth) return this._hud('No frame to grab yet');+      const c = document.createElement('canvas'); c.width = v.videoWidth; c.height = v.videoHeight;+      let blob;+      try {+        c.getContext('2d').drawImage(v, 0, 0);+        blob = await new Promise((ok, no) => c.toBlob((b) => (b ? ok(b) : no(new Error('empty'))), 'image/png'));+      } catch { return this._hud("This video's host does not allow frame copies: use Clean frame (H) and a screenshot"); }+      const t = v.currentTime, stamp = `${Math.floor(t / 60)}m${(t % 60).toFixed(3).padStart(6, '0')}s`;+      const name = `${this._vid()}-${stamp}.png`, size = `${v.videoWidth}×${v.videoHeight}`;+      const save = () => { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name; this.shadowRoot.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 4000); this._hud(`Saved ${name} (${size})`); };+      if (mode === 'copy' && navigator.clipboard && window.ClipboardItem) {+        try { await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); this._hud(`Frame copied (${size}, ${fmtMs(t)})`); return; }+        catch { /* clipboard refused: fall back to a file */ }+      }+      save();+    }+     // 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;@@ -684,6 +725,9 @@           `<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="clean">${svg('clean')}<span class="lab">Clean frame</span><span class="val">H</span></button>` ++          `<button class="mi" data-act="copyframe">${svg('image')}<span class="lab">Copy this frame</span></button>` ++          `<button class="mi" data-act="saveframe">${svg('dl')}<span class="lab">Save this frame as PNG</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>`;       }@@ -796,6 +840,7 @@         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 (this._clean) { this.clean(false); 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;@@ -855,6 +900,7 @@         play: () => this.toggle(), mute: () => this.mute(), cc: () => this.captions(), pip: () => this.pip(),         pop: () => this.popout(), fs: () => this.fullscreen(),         gear: () => ($('.menu').classList.contains('on') && this._menuView !== 'share' ? this._menuClose() : this._menu('main')),+        clean: () => this.clean(true),         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(); } });@@ -869,6 +915,9 @@         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 === 'clean') this.clean(true);+        else if (b.dataset.act === 'copyframe') { this._menuClose(); this.grabFrame('copy'); }+        else if (b.dataset.act === 'saveframe') { this._menuClose(); this.grabFrame('save'); }         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(); }       });@@ -909,6 +958,7 @@         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 === 'h' || k === 'H') this.clean();         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);@@ -920,6 +970,7 @@         else if (k === '+' || k === '=') this.capFont(25);         else if (k === '-' || k === '_') this.capFont(-25);         else if (k === '?') this._help();+        else if (k === 'Escape' && this._clean) this.clean(false);         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(); }