← 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

John Lauer ·ef2d010f56 ·7d ago ·parent 5b1b30b
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();