app
Adom Video Player
Public Made by Adomby adom
Show any video in a real player: seek, scrub, frame step, chapters. One command, no hand-rolled servers.
← Commit history
Publish 0.4.3
6 files changed
+167−16
bin/adom-videopackage.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);