← Commit history

Publish 0.4.3

John Lauer ·c1307a95ca ·1d ago ·parent 7ba3dad
6 files changed +167−16
bin/adom-video

  
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-video-player",   "type": "app",-  "version": "0.4.2",+  "version": "0.4.3",   "title": "Adom Video Player",   "brief": "Show any video in a real player: seek, scrub, frame step, chapters. One command, no hand-rolled servers.",   "summary": "When an AI shows you a video or audio file, it plays here: a shared server that answers HTTP Range requests, so the timeline seeks, in the same YouTube-style player as the wiki's ::adom-video widget, with YouTube's shortcuts, chapters, captions, frame stepping and auto-reload when the file is re-rendered.",
page.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-video-player",   "type": "app",-  "version": "0.4.2",+  "version": "0.4.3",   "title": "Adom Video Player",   "brief": "Show any video in a real player: seek, scrub, frame step, chapters. One command, no hand-rolled servers.",   "summary": "When an AI shows you a video or audio file, it plays here: a shared server that answers HTTP Range requests, so the timeline seeks, in the same YouTube-style player as the wiki's ::adom-video widget, with YouTube's shortcuts, chapters, captions, frame stepping and auto-reload when the file is re-rendered.",
public/adom-video.js+161−13
@@ -35,6 +35,10 @@     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"/>',+    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"/>',@@ -87,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 = `@@ -184,7 +188,20 @@   @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}+  .err>div{display:flex;flex-direction:column;align-items:center;gap:14px;max-width:560px}+  .errt{font-size:17px;font-weight:600}+  .errbtn{display:inline-flex;align-items:center;gap:8px;padding:10px 18px;border-radius:22px;background:var(--accent);color:#04201e;font-weight:600}+  .errbtn svg{fill:#04201e}+  .errbtn:hover{background:var(--accent-hi)}+  .errn{opacity:.75;font-size:13px}   /* 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}@@ -201,6 +218,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)}@@ -220,6 +243,17 @@   @container (max-width: 340px){ .vol .vslide{display:none} }   `; +  // ?avdebug=1 on the page URL logs presses, focus and keys to window.__adomVideoLog (for focus bugs)+  const DEBUG = /[?&]avdebug=1/.test(location.search);+  const dbg = (...a) => { if (!DEBUG) return; const L = (window.__adomVideoLog = window.__adomVideoLog || []); L.push([Math.round(performance.now()), ...a]); if (L.length > 400) L.shift(); };+  const descr = (el) => !el ? 'null' : (el.localName || '?') + (el.className && typeof el.className === 'string' ? '.' + el.className.split(' ')[0] : '');+  if (DEBUG) {+    document.addEventListener('focusin', (e) => dbg('focusin', descr(e.composedPath()[0]), 'doc.active=' + descr(document.activeElement)), true);+    document.addEventListener('focusout', (e) => dbg('focusout', descr(e.composedPath()[0])), true);+    window.addEventListener('keydown', (e) => dbg('win-keydown', e.key, 'target=' + descr(e.composedPath()[0]), 'doc.active=' + descr(document.activeElement)), true);+    window.addEventListener('blur', () => dbg('window-blur')); window.addEventListener('focus', () => dbg('window-focus'));+  }+   // one player owns the keyboard at a time: the one last clicked or focused (or the first on the page)   let active = null; @@ -242,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>@@ -262,6 +297,8 @@             <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>             <button class="b48 opt" data-k="pop" data-tip="Open large in a new tab (t)">${svg('popout')}</button>@@ -330,7 +367,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}`;@@ -341,7 +380,7 @@       this._chapters(); this._captions(); this._volUI();     } -    _error(html) { const e = this.$('.err'); e.innerHTML = `<div>${html}</div>`; e.classList.add('on'); this.p.classList.remove('wait'); }+    _error(html) { const e = this.$('.err'); e.innerHTML = `<div>${html}</div>`; e.classList.add('on'); this.p.classList.remove('wait'); this.p.classList.add('failed'); }      async _fetchText(u) {       const r = await fetch(this._abs(u)).catch(() => null);@@ -479,9 +518,21 @@     }      // ---------- actions -----------    _started() { if (!this.started) { this.started = true; this.$('.big').hidden = true; } }+    _started() {+      if (this.started) return;+      this.started = true;+      const big = this.$('.big'), had = this.shadowRoot.activeElement === big;+      big.hidden = true;+      if (had || this._engaged) this._keepFocus();+    }+    // focus on the player itself unless a visible control inside it already holds it+    _keepFocus() {+      const a = this.shadowRoot.activeElement;+      if (a && a.offsetParent !== null) return;+      this.p.focus({ preventScroll: true });+    }     toggle() {-      const v = this.v;+      const v = this.v; dbg('toggle', this._vid(), v.paused ? 'play' : 'pause');       if (v.paused || v.ended) { v.play().then(() => this._started(), (e) => { if (e && e.name === 'NotAllowedError') this._hud('Press play to start'); }); this._bezel('play'); }       else { v.pause(); this._bezel('pause'); }     }@@ -501,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;@@ -553,7 +636,8 @@     popout() {       const t = Math.floor(this.v.currentTime);       let url;-      if (location.pathname.includes('/widget/')) { const u = new URL(location.href); u.searchParams.set('start', String(t)); url = u.href; }+      if (this._failed) url = (this.v.currentSrc || this.v.src).split('#')[0] + `#t=${t}`;+      else if (location.pathname.includes('/widget/')) { const u = new URL(location.href); u.searchParams.set('start', String(t)); url = u.href; }       else url = (this.v.currentSrc || this.v.src).split('#')[0] + `#t=${t}`;       this.v.pause();       this._openTab(url);@@ -616,7 +700,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>`) +@@ -627,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>`;       }@@ -634,7 +735,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');@@ -657,9 +779,20 @@       v.addEventListener('waiting', () => p.classList.add('wait'));       ['playing', 'canplay', 'seeked', 'pause'].forEach((e) => v.addEventListener(e, () => p.classList.remove('wait')));       v.addEventListener('seeked', () => { this._seekBusy = false; if (this._seekPending != null) { const t = this._seekPending; this._seekPending = null; this._seekBusy = true; v.fastSeek ? v.fastSeek(t) : (v.currentTime = t); } });+      // A private page's video 404s inside the frame when the browser blocks third-party cookies (Safari+      // always, Chrome with that setting): the sandboxed frame is opaque-origin, so the wiki login is not+      // sent. Opened as a page of its own the file request is first-party and plays, with seeking.       v.addEventListener('error', () => {-        const s = this.getAttribute('video') || this.getAttribute('src') || '';-        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.`);+        if (!v.currentSrc && !v.src) return;+        this._failed = true;+        const s = this.getAttribute('video') || (this._manifest() || {}).video || this.getAttribute('src') || '';+        const direct = (v.currentSrc || v.src).split('#')[0];+        this._error(`<div class="errt">This video can't play inside the page</div>+          <button class="errbtn">${svg('play', 20)} Play it in a new tab</button>+          <div class="errn">On a private page, most browsers keep your wiki login out of embedded players, so the+          video is refused here but plays in its own tab. If it fails there too, the file is missing:+          check <code>${esc(s)}</code>.</div>`);+        const b = this.$('.errbtn'); if (b) b.onclick = () => this._openTab(direct + (v.currentTime > 1 ? `#t=${Math.floor(v.currentTime)}` : ''));       });       document.addEventListener('fullscreenchange', () => this._sync());       if (v.requestVideoFrameCallback) {@@ -707,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;@@ -721,7 +855,9 @@       $('.big').addEventListener('click', () => this.toggle());        p.addEventListener('pointermove', (e) => { if (e.pointerType !== 'touch') this._show(); });-      p.addEventListener('pointerdown', (e) => { active = this; if (!this.shadowRoot.activeElement || e.composedPath()[0] === v || e.target === this) setTimeout(() => { if (!this.shadowRoot.activeElement) p.focus({ preventScroll: true }); }); });+      // YouTube's rule: click a player and it owns the keyboard until you click somewhere else+      p.addEventListener('pointerdown', (e) => { dbg('pointerdown', this._vid(), descr(e.composedPath()[0]), e.pointerType, 'trusted=' + e.isTrusted); active = this; this._engaged = true; setTimeout(() => { this._keepFocus(); dbg('after-press focus', descr(this.shadowRoot.activeElement), 'doc.active=' + descr(document.activeElement)); }); });+      document.addEventListener('pointerdown', (e) => { if (!e.composedPath().includes(this)) this._engaged = false; }, true);       p.addEventListener('focusin', () => { active = this; });       p.addEventListener('pointerleave', () => { if (!v.paused && !this.drag) { clearTimeout(this._t.hide); this._t.hide = setTimeout(() => { if (!v.paused && !this.drag && !$('.menu').classList.contains('on')) p.classList.add('hide-ui'); }, 600); } });       const chrome = $('.chrome');@@ -763,7 +899,9 @@       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')),+        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(); } });       $('.chap').addEventListener('click', () => this.chapter(1));@@ -777,18 +915,26 @@         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(); }       });        // YouTube's keyboard shortcuts, for whichever player owns the keyboard       document.addEventListener('keydown', (e) => {-        if (active !== this) return;+        if (active !== this) { dbg('key-skip not-active', this._vid(), e.key); return; }         const tgt = e.composedPath()[0];         if (tgt && /^(INPUT|TEXTAREA|SELECT)$/.test(tgt.tagName || '') || (tgt && tgt.isContentEditable)) return;         if (e.metaKey || e.altKey) return;         // outside our own frame, only take keys while the player has focus         const inFrame = location.pathname.includes('/widget/');-        if (!inFrame && !this.shadowRoot.activeElement && document.activeElement !== this) return;+        const focusIn = !!this.shadowRoot.activeElement || document.activeElement === this;+        const nothingElse = !document.activeElement || document.activeElement === document.body || document.activeElement === document.documentElement;+        dbg('player-keydown', this._vid(), e.key, 'active=' + (active === this), 'focusIn=' + focusIn, 'engaged=' + !!this._engaged, 'nothingElse=' + nothingElse, 'shadow.active=' + descr(this.shadowRoot.activeElement));+        if (!inFrame && !focusIn && !(this._engaged && nothingElse)) return;+        if (!focusIn && this._engaged) this._keepFocus();         const k = e.key; let hit = true;         if ($('.help').classList.contains('on') && k !== '?' && k !== 'Escape') return;         if (e.ctrlKey) { if (k === 'ArrowLeft') this.chapter(-1); else if (k === 'ArrowRight') this.chapter(1); else return; }@@ -812,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);@@ -823,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(); }
public/player.html+2−1
@@ -49,9 +49,10 @@         <tr><td>O / W</td><td>Caption text / background opacity</td></tr>         <tr><td>F or double-click</td><td>Fullscreen</td></tr>         <tr><td>I</td><td>Picture-in-picture</td></tr>+        <tr><td>H</td><td>Clean frame: hide everything for a screenshot</td></tr>         <tr><td>R</td><td>Loop</td></tr>         <tr><td>?</td><td>Shortcuts sheet on the video</td></tr>-        <tr><td>Esc</td><td>Close a menu, or leave fullscreen</td></tr>+        <tr><td>Esc</td><td>Close a menu, leave clean frame, or leave fullscreen</td></tr>   </table><div class="sc-foot">The same keys as YouTube. Press ? for this list on the video.</div></div> <dialog id="settings" aria-label="Settings">   <div class="dlg-head"><h3>Settings</h3><button class="icon-btn" id="settings-close" aria-label="Close settings" data-tooltip="Close settings (Esc)" data-icon="close"></button></div>
public/player.js+2
@@ -76,6 +76,8 @@     // in a wiki frame the pop-out button and T open the player large in a new tab; this app already     // is the player, so the button goes, T does nothing, and popout is only the fullscreen fallback     sr.querySelector('[data-k=pop]').hidden = true;+    // Share links point at a wiki page; this app runs on localhost, so the button goes+    sr.querySelector('[data-k=share]').hidden = true;     player.popout = () => player._hud('Fullscreen is not available here');     // Shift+N / Shift+P and the media keys step through the library     player.sibling = (dir) => sibling(dir);