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.
main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997
// <adom-video>: a YouTube-style video player for wiki pages.
//
// ::adom-video{video="/OWNER/SLUG/render/demo.mp4" title="Demo" chapters="/OWNER/SLUG/render/demo.chapters.json"}
//
// Every YouTube keyboard shortcut, a timeline you can actually hit (a 24px grab zone around a
// 4px bar, drag to scrub, hover for a frame preview and the time), chapters as bar segments,
// captions (WebVTT or SRT, drawn by the player so + / - can resize them), speed and loop in a
// settings menu, picture-in-picture, fullscreen. Self-contained: Shadow DOM, no dependencies.
//
// Attributes: video (required; never pass a video as `src`: the wiki inlines a registered tag's `src`
// file into the frame), src (optional JSON manifest {video, title, poster, chapters, captions}, which
// the wiki inlines, so chapters and captions work on private pages too), title, poster, chapters (JSON [{t,title}] or a WebVTT chapter
// file), captions (.vtt or .srt), start (seconds or 1m30s), loop, muted, autoplay.
//
// Inside a wiki widget frame the browser blocks fullscreen (the frame carries no allow=
// "fullscreen"), so F opens the same player full-window in a new tab at the current time,
// where F is real fullscreen. Keys reach the player once it is clicked, as on YouTube.
(() => {
if (customElements.get('adom-video')) return;
const ICON = {
play: '<path d="M8 5.14v13.72a1 1 0 0 0 1.5.86l11-6.86a1 1 0 0 0 0-1.72l-11-6.86A1 1 0 0 0 8 5.14z"/>',
pause: '<path d="M7 5h3.5v14H7zM13.5 5H17v14h-3.5z"/>',
replay: '<path d="M12 5V2L7 6l5 4V7a6 6 0 1 1-6 6H4a8 8 0 1 0 8-8z"/>',
vol: '<path d="M4 9v6h4l5 4V5L8 9H4zm12.5 3a4.5 4.5 0 0 0-2.5-4v8a4.5 4.5 0 0 0 2.5-4zM14 3.2v2.1a7 7 0 0 1 0 13.4v2.1a9 9 0 0 0 0-17.6z"/>',
vollow: '<path d="M4 9v6h4l5 4V5L8 9H4zm12.5 3a4.5 4.5 0 0 0-2.5-4v8a4.5 4.5 0 0 0 2.5-4z"/>',
mute: '<path d="M4 9v6h4l5 4V5L8 9H4zm12.6 3 2.7-2.7-1.4-1.4-2.7 2.7-2.7-2.7-1.4 1.4 2.7 2.7-2.7 2.7 1.4 1.4 2.7-2.7 2.7 2.7 1.4-1.4z"/>',
cc: '<path d="M19 4H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm-8 7H9.5v-.5h-2v3h2V13H11v1a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1zm7 0h-1.5v-.5h-2v3h2V13H18v1a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1z"/>',
gear: '<path d="M19.4 13a7.7 7.7 0 0 0 0-2l2.1-1.6a.5.5 0 0 0 .1-.6l-2-3.5a.5.5 0 0 0-.6-.2l-2.5 1a7.4 7.4 0 0 0-1.7-1l-.4-2.6A.5.5 0 0 0 14 2h-4a.5.5 0 0 0-.5.4l-.4 2.7a7.6 7.6 0 0 0-1.7 1l-2.5-1a.5.5 0 0 0-.6.2l-2 3.4a.5.5 0 0 0 .1.7L4.6 11a7.7 7.7 0 0 0 0 2l-2.1 1.6a.5.5 0 0 0-.1.6l2 3.5c.1.2.4.3.6.2l2.5-1c.5.4 1.1.7 1.7 1l.4 2.6c0 .3.2.5.5.5h4c.3 0 .5-.2.5-.4l.4-2.7c.6-.3 1.2-.6 1.7-1l2.5 1c.2.1.5 0 .6-.2l2-3.5a.5.5 0 0 0-.1-.6L19.4 13zM12 15.5a3.5 3.5 0 1 1 0-7 3.5 3.5 0 0 1 0 7z"/>',
pip: '<path d="M19 11h-8v6h8v-6zm4 8V5a2 2 0 0 0-2-2H3a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h18a2 2 0 0 0 2-2zm-2 0H3V5h18v14z"/>',
full: '<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/>',
exitfull: '<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/>',
popout: '<path d="M19 19H5V5h7V3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7h-2v7zM14 3v2h3.6l-9.8 9.8 1.4 1.4L19 6.4V10h2V3h-7z"/>',
back: '<path d="M20 18V6l-8.5 6 8.5 6zm-8.5 0V6L3 12l8.5 6z"/>',
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="M12 7c2.8 0 5 2.2 5 5 0 .6-.1 1.3-.4 1.8l2.9 2.9c1.5-1.3 2.7-2.9 3.4-4.7-1.7-4.4-6-7.5-11-7.5-1.4 0-2.7.3-4 .7l2.2 2.2c.6-.3 1.2-.4 1.9-.4zM2 4.3l2.3 2.3.5.4A11.8 11.8 0 0 0 1 12c1.7 4.4 6 7.5 11 7.5 1.6 0 3-.3 4.4-.8l.4.4 2.9 2.9 1.3-1.3L3.3 3 2 4.3zm5.5 5.5 1.6 1.6-.1.6c0 1.7 1.3 3 3 3l.6-.1 1.6 1.6c-.7.3-1.4.5-2.2.5-2.8 0-5-2.2-5-5 0-.8.2-1.5.5-2.2zm4.3-.8 3.2 3.2V12c0-1.7-1.3-3-3-3h-.2z"/>',
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"/>',
keys: '<path d="M20 5H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2zm-9 3h2v2h-2V8zm0 3h2v2h-2v-2zM8 8h2v2H8V8zm0 3h2v2H8v-2zm-1 2H5v-2h2v2zm0-3H5V8h2v2zm9 7H8v-2h8v2zm0-4h-2v-2h2v2zm0-3h-2V8h2v2zm3 3h-2v-2h2v2zm0-3h-2V8h2v2z"/>',
loop: '<path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/>',
speed: '<path d="M20.4 8.6 19.2 10.4a8 8 0 0 1-.2 7.6H5a8 8 0 0 1 10.6-11l1.8-1.2A10 10 0 0 0 3.3 19a2 2 0 0 0 1.7 1h14a2 2 0 0 0 1.7-1 10 10 0 0 0-.3-10.4zm-9.8 6.8a2 2 0 0 0 2.8 0l5.7-8.5-8.5 5.7a2 2 0 0 0 0 2.8z"/>',
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, 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;
t = Math.floor(t);
const h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), s = String(t % 60).padStart(2, '0');
return h ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`;
};
const fmtMs = (t) => { if (!isFinite(t) || t < 0) t = 0; const s = (t % 60).toFixed(3).padStart(6, '0'), m = Math.floor(t / 60) % 60, h = Math.floor(t / 3600); return h ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`; };
// "90", "1:30", "1m30s", "1h2m3s" -> seconds
const parseT = (v) => {
if (v == null || v === '') return 0;
v = String(v).trim();
if (/^\d+(\.\d+)?$/.test(v)) return Number(v);
if (v.includes(':')) return v.split(':').reduce((a, x) => a * 60 + Number(x || 0), 0);
const m = v.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s)?$/);
return m ? (Number(m[1] || 0) * 3600 + Number(m[2] || 0) * 60 + Number(m[3] || 0)) : 0;
};
const vttTime = (s) => { const p = s.trim().replace(',', '.').split(':').map(Number); return p.length === 3 ? p[0] * 3600 + p[1] * 60 + p[2] : p[0] * 60 + p[1]; };
// WebVTT or SRT -> [{start, end, text}]
function parseCues(txt) {
const out = [];
txt.replace(/\r/g, '').split(/\n\n+/).forEach((block) => {
const lines = block.split('\n').filter((l) => l.trim() !== '');
const k = lines.findIndex((l) => l.includes('-->'));
if (k < 0) return;
const [a, b] = lines[k].split('-->');
out.push({ start: vttTime(a), end: vttTime(b.trim().split(/\s+/)[0]), text: lines.slice(k + 1).join('\n') });
});
return out;
}
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
// caption markup: keep <b> <i> <u>, drop the rest (voice tags, classes, timestamps)
const capHTML = (s) => esc(s).replace(/<(\/?)(b|i|u)>/g, '<$1$2>').replace(/<[^&]*?>/g, '').replace(/\n/g, '<br>');
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 (while paused)', ', / .'], ['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'],
['Clean frame: hide everything for a screenshot', 'H'], ['Loop', 'R'], ['Media keys', 'Play/Pause, Stop, Next'], ['This list', '?'],
];
const CSS = `
:host{display:block;max-width:100%;--accent:#00b8b1;--accent-hi:#00e6dc;color-scheme:dark;font:400 13px/1.3 "Inter","Roboto","Segoe UI",system-ui,sans-serif}
*{box-sizing:border-box}
button{font:inherit;color:inherit;background:none;border:0;padding:0;margin:0;cursor:pointer}
.p{position:relative;width:100%;aspect-ratio:16/9;background:#000;border-radius:12px;overflow:hidden;color:#fff;outline:none;user-select:none;-webkit-user-select:none;container-type:inline-size}
.p:focus-visible{box-shadow:0 0 0 2px var(--accent)}
.p.fs,.p.page{border-radius:0;aspect-ratio:auto;width:100%;height:100%}
:host(.page){position:fixed;inset:0;max-width:none}
video.main{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;background:#000;display:block}
.click{position:absolute;inset:0}
/* bottom chrome */
.grad{position:absolute;left:0;right:0;bottom:0;height:120px;background:linear-gradient(transparent,rgba(0,0,0,.72));pointer-events:none;transition:opacity .25s}
.top{position:absolute;left:0;right:0;top:0;padding:14px 16px 40px;background:linear-gradient(rgba(0,0,0,.6),transparent);font-size:17px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:none;transition:opacity .25s}
.top:empty{display:none}
.chrome{position:absolute;left:0;right:0;bottom:0;padding:0 12px 4px;transition:opacity .25s;z-index:3}
.hide-ui .chrome,.hide-ui .grad,.hide-ui .top{opacity:0;pointer-events:none}
.hide-ui .chrome *{pointer-events:none}
/* the timeline: a 24px grab zone around a 4px bar */
.bar{position:relative;height:24px;margin:0 0 2px;cursor:pointer;touch-action:none;outline:none}
.segs{position:absolute;left:0;right:0;bottom:8px;height:4px;display:flex;gap:2px;transition:height .1s,bottom .1s}
.bar:hover .segs,.bar.drag .segs,.bar:focus-visible .segs{height:6px;bottom:7px}
.seg{position:relative;height:100%;background:rgba(255,255,255,.22);overflow:hidden;transition:transform .1s}
.seg.hov{transform:scaleY(1.6)}
.seg i{position:absolute;left:0;top:0;bottom:0;width:0}
.seg .b{background:rgba(255,255,255,.42)}
.seg .h{background:rgba(255,255,255,.5)}
.seg .f{background:var(--accent)}
.bar:not(:hover):not(.drag) .seg .h{width:0!important}
.knob{position:absolute;bottom:11px;width:14px;height:14px;margin-left:-7px;border-radius:50%;background:var(--accent);transform:translateY(50%) scale(0);transition:transform .1s;pointer-events:none}
.bar:hover .knob,.bar.drag .knob,.bar:focus-visible .knob{transform:translateY(50%) scale(1)}
.tip{position:absolute;bottom:30px;transform:translateX(-50%);display:none;flex-direction:column;align-items:center;gap:4px;pointer-events:none;text-shadow:0 0 3px rgba(0,0,0,.9)}
.bar:hover .tip,.bar.drag .tip{display:flex}
.tip .pv{display:none;width:170px;border:2px solid #fff;border-radius:8px;background:#000;overflow:hidden}
.tip .pv.on{display:block}
.tip .pv video{display:block;width:100%;height:100%;object-fit:cover}
.tip .ch{font-size:12px;max-width:220px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tip .ch:empty{display:none}
.tip .t{font-size:13px;font-weight:600;background:rgba(0,0,0,.6);padding:2px 6px;border-radius:4px}
/* control row */
.row{display:flex;align-items:center;height:44px;gap:2px}
.row .sp{flex:1;min-width:4px}
.b48{width:44px;height:40px;display:inline-flex;align-items:center;justify-content:center;border-radius:20px;opacity:.95;flex:none;position:relative}
.b48:hover{opacity:1;background:rgba(255,255,255,.1)}
.b48 svg{fill:#fff;filter:drop-shadow(0 0 2px rgba(0,0,0,.5))}
.b48.on::after{content:"";position:absolute;left:12px;right:12px;bottom:6px;height:3px;border-radius:2px;background:var(--accent)}
.b48[hidden]{display:none}
.vol{display:flex;align-items:center}
.vslide{width:0;height:40px;position:relative;overflow:hidden;transition:width .2s;cursor:pointer;touch-action:none;outline:none}
.vol:hover .vslide,.vol:focus-within .vslide,.vslide.drag{width:64px;margin-right:6px}
.vslide .rail{position:absolute;left:6px;right:6px;top:50%;height:3px;margin-top:-1.5px;background:rgba(255,255,255,.3);border-radius:2px}
.vslide .lvl{position:absolute;left:0;top:0;bottom:0;background:#fff;border-radius:2px}
.vslide .dot{position:absolute;top:50%;width:12px;height:12px;margin:-6px 0 0 -6px;border-radius:50%;background:#fff}
.time{padding:0 8px;white-space:nowrap;font-variant-numeric:tabular-nums;text-shadow:0 0 2px rgba(0,0,0,.5);flex:none}
.chap{display:flex;align-items:center;min-width:0;padding:0 4px 0 2px;opacity:.92;white-space:nowrap;overflow:hidden;cursor:pointer;border-radius:16px;height:32px}
.chap:hover{opacity:1;background:rgba(255,255,255,.1)}
.chap span{overflow:hidden;text-overflow:ellipsis}
.chap svg{fill:#fff;flex:none}
.chap:empty,.chap[hidden]{display:none}
.dotsep{opacity:.7;padding:0 4px}
/* button tooltips */
.tt{position:absolute;bottom:62px;padding:5px 9px;background:rgba(28,28,28,.92);border-radius:6px;font-size:12px;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .12s;z-index:5}
.tt.on{opacity:1}
/* center overlays */
.bezel{position:absolute;left:50%;top:50%;width:64px;height:64px;margin:-32px 0 0 -32px;border-radius:50%;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none}
.bezel svg{fill:#fff}
.bezel.go{animation:bz .5s linear}
@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}
.fstep{position:absolute;left:50%;top:14px;transform:translateX(-50%);display:flex;align-items:center;gap:10px;padding:6px 14px 6px 10px;background:rgba(0,0,0,.66);border-radius:16px;font-size:14px;font-weight:500;font-variant-numeric:tabular-nums;white-space:nowrap;pointer-events:none;z-index:4;opacity:0;transition:opacity .3s}
.fstep.on{opacity:1;transition:none}
.fstep svg{fill:#fff;flex:none}
.fstep b{font-weight:700}
.fstep .dim{opacity:.7}
.fstep.go svg{animation:fs .18s ease-out}
@keyframes fs{0%{transform:scale(1.35)}100%{transform:scale(1)}}
.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%}
.seekfx.on{opacity:1;transition:none}
.seekfx svg{fill:#fff}
.big{position:absolute;left:50%;top:50%;width:72px;height:50px;margin:-25px 0 0 -36px;border-radius:14px;background:rgba(15,15,15,.78);display:flex;align-items:center;justify-content:center;transition:background .15s}
.big svg{fill:#fff}
.big:hover,.p:hover .big{background:var(--accent)}
.big[hidden]{display:none}
.spin{position:absolute;left:50%;top:50%;width:52px;height:52px;margin:-26px 0 0 -26px;border:4px solid rgba(255,255,255,.25);border-top-color:#fff;border-radius:50%;animation:sp 0.9s linear infinite;display:none;pointer-events:none}
.p.wait .spin{display:block}
@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: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}
.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}
.menu.on{display:block}
.mi{display:flex;align-items:center;width:100%;height:40px;padding:0 12px 0 14px;gap:14px;text-align:left}
.mi:hover,.mi:focus-visible{background:rgba(255,255,255,.1);outline:none}
.mi svg{fill:#fff;flex:none}
.mi .lab{flex:1}
.mi .val{opacity:.8}
.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)}
.tog.on::after{left:18px;background:#fff}
/* shortcuts sheet */
.help{position:absolute;inset:0;background:rgba(0,0,0,.82);display:none;z-index:7;overflow:auto;padding:18px 22px}
.help.on{display:block}
.help h3{margin:0 0 10px;font-size:16px;font-weight:600;display:flex;justify-content:space-between;align-items:center}
.help table{border-collapse:collapse;width:100%;font-size:13px}
.help td{padding:4px 0;border-bottom:1px solid rgba(255,255,255,.08)}
.help td:last-child{text-align:right;white-space:nowrap;padding-left:12px}
.help kbd{font:inherit;background:rgba(255,255,255,.14);padding:1px 6px;border-radius:4px}
.help .x{font-size:20px;width:32px;height:32px;border-radius:16px}
.help .x:hover{background:rgba(255,255,255,.12)}
.help .cols{columns:2 260px;column-gap:28px}
@container (max-width: 480px){ .chap,.dotsep,.b48.opt{display:none} .time{padding:0 4px} }
@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;
class AdomVideo extends HTMLElement {
static get observedAttributes() { return ['video', 'src', 'title', 'poster', 'chapters', 'captions', 'start', 'loop', 'muted']; }
constructor() {
super();
this.chapters = []; this.cues = []; this.capOn = false; this.capSize = 100;
this.started = false; this.drag = null; this._t = {};
const r = this.attachShadow({ mode: 'open' });
r.innerHTML = `<style>${CSS}</style>
<div class="p" tabindex="0" role="region" aria-label="Video player">
<video class="main" playsinline preload="metadata"></video>
<div class="click"></div>
<div class="caps" aria-live="polite"></div>
<div class="top"></div>
<div class="seekfx l">${svg('back', 32)}<span></span></div>
<div class="seekfx r">${svg('fwd', 32)}<span></span></div>
<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>
<div class="grad"></div>
<div class="chrome">
<div class="bar" role="slider" tabindex="0" aria-label="Seek" aria-valuemin="0" aria-valuemax="0" aria-valuenow="0">
<div class="segs"></div><div class="knob"></div>
<div class="tip"><div class="pv"></div><div class="ch"></div><div class="t"></div></div>
</div>
<div class="row">
<button class="b48" data-k="play" data-tip="Play (k)">${svg('play')}</button>
<div class="vol">
<button class="b48" data-k="mute" data-tip="Mute (m)">${svg('vol')}</button>
<div class="vslide" role="slider" tabindex="0" aria-label="Volume" aria-valuemin="0" aria-valuemax="100"><div class="rail"><div class="lvl"></div><div class="dot"></div></div></div>
</div>
<div class="time">0:00 / 0:00</div>
<span class="dotsep" hidden>•</span>
<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>
<button class="b48" data-k="fs" data-tip="Full screen (f)">${svg('full')}</button>
</div>
</div>
<div class="tt"></div>
<div class="menu" role="menu"></div>
<div class="help" role="dialog" aria-label="Keyboard shortcuts"></div>
<div class="err"></div>
</div>`;
const $ = (s) => r.querySelector(s);
this.$ = $;
this.p = $('.p'); this.v = $('video.main'); this.bar = $('.bar'); this.segs = $('.segs');
this._wire();
}
connectedCallback() {
if (!active) active = this;
if (location.pathname.includes('/widget/')) {
if (window.top === window) { this.classList.add('page'); this.p.classList.add('page'); }
// the frame grows to fit the player; a scrollbar would narrow it, shrink the player and stick there
else document.documentElement.style.overflow = 'hidden';
}
// the wiki inlines the manifest as a child script, which is not parsed yet when a head script connects us
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => this._load(), { once: true });
else this._load();
}
disconnectedCallback() { if (active === this) active = null; clearTimeout(this._t.hide); this.v.pause(); }
attributeChangedCallback(n, a, b) { if (a !== b && this.isConnected && this._loaded) { if (n === 'video' || n === 'src') this._load(); else if (n === 'chapters') this._chapters(); else if (n === 'captions') this._captions(); else if (n === 'title') this.$('.top').textContent = b || ''; } }
// absolute URL for a wiki path (the frame is opaque-origin, so resolve against the real location)
_abs(u) { try { return new URL(u, location.href).href; } catch { return u; } }
// the page render route answers a video's Range requests with the whole file (200), which makes it
// unseekable; the files API answers 206, so stream /OWNER/SLUG/render/PATH from there
_media(u) {
const a = this._abs(u);
try {
const url = new URL(a);
if (url.hostname !== 'wiki.adom.inc' && url.origin !== location.origin) return a;
const m = url.pathname.match(/^\/([^/]+)\/([^/]+)\/render\/(.+)$/);
if (m) url.pathname = `/api/pages/${m[1]}/${m[2]}/files/${m[3]}`;
return url.href;
} catch { return a; }
}
// a JSON manifest (the wiki inlines a registered tag's `src` file as a child script):
// { "video": "...", "title", "poster", "chapters": [{t,title}] or a path, "captions": a path or WebVTT text }
_manifest() {
if (this._man !== undefined) return this._man;
this._man = null;
const el = this.querySelector('script[type="application/json"]');
if (el) { try { const j = JSON.parse(el.textContent); if (j && typeof j === 'object' && !Array.isArray(j)) this._man = j; } catch {} }
return this._man;
}
_opt(n) { const a = this.getAttribute(n); if (a != null && a !== '') return a; const m = this._manifest(); return m && m[n] != null ? m[n] : null; }
_load() {
this._loaded = true; this._man = undefined;
const m = this._manifest();
const src = this.getAttribute('video') || (m && m.video) || (m ? null : this.getAttribute('src'));
const v = this.v;
this.$('.top').textContent = this._opt('title') || '';
if (!src) return this._error('No video: add <code>video="/OWNER/SLUG/render/video.mp4"</code> to the tag.');
this.$('.err').classList.remove('on');
if (this._opt('poster')) v.poster = this._abs(this._opt('poster'));
v.loop = this.hasAttribute('loop');
v.muted = this.hasAttribute('muted');
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}`;
if (this._startAt > 0 && this._startAt < v.duration) { v.currentTime = this._startAt; this._started(); }
this._segments(); this._tick();
}, { once: true });
if (this.hasAttribute('autoplay')) { v.muted = true; v.play().then(() => this._started(), () => {}); }
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'); this.p.classList.add('failed'); }
async _fetchText(u) {
const r = await fetch(this._abs(u)).catch(() => null);
return r && r.ok ? r.text() : null;
}
async _chapters() {
const u = this._opt('chapters'); this.chapters = [];
if (u) {
const txt = typeof u === 'string' ? await this._fetchText(u) : JSON.stringify(u);
if (txt) {
try { const j = JSON.parse(txt); this.chapters = (Array.isArray(j) ? j : j.chapters || []).map((c) => ({ t: parseT(c.t ?? c.time ?? c.start), title: String(c.title ?? c.name ?? '') })); }
catch { this.chapters = parseCues(txt).map((c) => ({ t: c.start, title: c.text.replace(/\n/g, ' ') })); }
this.chapters.sort((a, b) => a.t - b.t);
if (this.chapters.length && this.chapters[0].t > 0) this.chapters.unshift({ t: 0, title: '' });
}
}
this._segments(); this._tick();
}
async _captions() {
const u = this._opt('captions'); this.cues = [];
if (u) { const txt = /-->/.test(u) ? u : await this._fetchText(u); if (txt) this.cues = parseCues(txt); }
this.$('[data-k=cc]').hidden = !this.cues.length;
this.capOn = this.cues.length > 0 && this.capOn;
this._caps(); this._sync();
}
// ---------- timeline ----------
_segments() {
const d = this.v.duration;
const ch = d && this.chapters.length > 1 ? this.chapters : [{ t: 0, title: '' }];
this.segs.innerHTML = '';
this._seg = ch.map((c, k) => {
const end = k + 1 < ch.length ? ch[k + 1].t : (d || 1);
const el = document.createElement('div'); el.className = 'seg';
el.style.flex = `${Math.max(0.0001, end - c.t)} 1 0`;
el.innerHTML = '<i class="b"></i><i class="h"></i><i class="f"></i>';
this.segs.appendChild(el);
return { el, t0: c.t, t1: end, title: c.title, b: el.children[0], h: el.children[1], f: el.children[2] };
});
}
_fill(key, t) {
(this._seg || []).forEach((s) => { s[key].style.width = (100 * clamp((t - s.t0) / Math.max(1e-6, s.t1 - s.t0), 0, 1)) + '%'; });
}
// fraction across the bar for a pointer x, mapped through the segment gaps so a click lands where it looks
_frac(x) {
const r = this.segs.getBoundingClientRect();
const d = this.v.duration || 0;
if (!this._seg || this._seg.length < 2 || !d) return clamp((x - r.left) / r.width, 0, 1);
for (const s of this._seg) {
const b = s.el.getBoundingClientRect();
if (x <= b.right + 1) return clamp((s.t0 + clamp((x - b.left) / b.width, 0, 1) * (s.t1 - s.t0)) / d, 0, 1);
}
return 1;
}
_xOf(t) {
const r = this.segs.getBoundingClientRect(), br = this.bar.getBoundingClientRect();
const d = this.v.duration || 0;
if (!d) return 0;
const s = (this._seg || []).find((s) => t < s.t1) || (this._seg || [])[this._seg.length - 1];
if (!s || this._seg.length < 2) return (t / d) * r.width + r.left - br.left;
const b = s.el.getBoundingClientRect();
return b.left - br.left + clamp((t - s.t0) / (s.t1 - s.t0), 0, 1) * b.width;
}
_chapterAt(t) { let c = null; for (const x of this.chapters) if (t + 0.05 >= x.t) c = x; return c; }
_hover(x) {
const d = this.v.duration; if (!d) return;
const t = this._frac(x) * d;
this._fill('h', t);
(this._seg || []).forEach((s) => s.el.classList.toggle('hov', this._seg.length > 1 && t >= s.t0 && t < s.t1));
const tip = this.$('.tip'), br = this.bar.getBoundingClientRect();
tip.querySelector('.t').textContent = fmt(t);
const c = this.chapters.length > 1 ? this._chapterAt(t) : null;
tip.querySelector('.ch').textContent = c ? c.title : '';
this._preview(t);
const w = tip.offsetWidth || 60;
tip.style.left = clamp(x - br.left, w / 2, br.width - w / 2) + 'px';
}
// a second, muted video seeks to the hovered time and shows that frame (lazy: made on first hover)
_preview(t) {
const box = this.$('.pv');
if (!this._pv) {
if (this._pvFailed) return;
const pv = document.createElement('video');
pv.muted = true; pv.preload = 'auto'; pv.playsInline = true; pv.src = this.v.currentSrc || this.v.src;
pv.addEventListener('seeked', () => { box.classList.add('on'); if (this._pvWant != null && Math.abs(pv.currentTime - this._pvWant) > 0.3) pv.currentTime = this._pvWant; else this._pvWant = null; });
pv.addEventListener('error', () => { this._pvFailed = true; box.classList.remove('on'); });
box.appendChild(pv); this._pv = pv;
const ar = this.v.videoWidth ? this.v.videoHeight / this.v.videoWidth : 9 / 16;
box.style.height = Math.round(170 * ar) + 'px';
}
if (this._pv.seeking) { this._pvWant = t; return; }
this._pv.currentTime = t;
}
_scrubTo(x, final) {
const v = this.v, d = v.duration; if (!d) return;
const t = this._frac(x) * d;
this._hover(x);
this._fill('f', t);
this.$('.knob').style.left = this._xOf(t) + 'px';
this.$('.time').textContent = `${fmt(t)} / ${fmt(d)}`;
if (final || !this._seekBusy) {
this._seekBusy = true;
if (!final && v.fastSeek) v.fastSeek(t); else v.currentTime = t;
} else this._seekPending = t;
}
_tick() {
const v = this.v, d = v.duration || 0, t = this.drag ? null : v.currentTime;
if (t != null) {
this.$('.time').textContent = `${fmt(t)} / ${fmt(d)}`;
this._fill('f', t);
this.$('.knob').style.left = this._xOf(t) + 'px';
this.bar.setAttribute('aria-valuemax', String(Math.floor(d)));
this.bar.setAttribute('aria-valuenow', String(Math.floor(t)));
this.bar.setAttribute('aria-valuetext', `${fmt(t)} of ${fmt(d)}`);
}
if (v.buffered.length) { let e = 0; for (let i = 0; i < v.buffered.length; i++) if (v.buffered.start(i) <= v.currentTime + 1) e = Math.max(e, v.buffered.end(i)); this._fill('b', e); }
const c = this.chapters.length > 1 ? this._chapterAt(v.currentTime) : null;
const chap = this.$('.chap');
chap.hidden = !(c && c.title); this.$('.dotsep').hidden = chap.hidden;
if (c) chap.firstChild.textContent = c.title;
const pb = this.$('[data-k=play]');
const ended = v.ended && !v.loop, want = ended ? 'replay' : v.paused ? 'play' : 'pause';
if (pb.dataset.s !== want) { pb.dataset.s = want; pb.innerHTML = svg(want); pb.dataset.tip = want === 'pause' ? 'Pause (k)' : want === 'replay' ? 'Replay' : 'Play (k)'; }
this._caps();
}
_caps() {
const box = this.$('.caps');
if (!this.capOn) { if (box.innerHTML) box.innerHTML = ''; return; }
const t = this.v.currentTime;
const html = this.cues.filter((c) => t >= c.start && t < c.end).map((c) => `<span>${capHTML(c.text)}</span>`).join('<br>');
if (box.innerHTML !== html) box.innerHTML = html;
}
// ---------- actions ----------
_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; 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'); }
}
seekBy(s, fx) {
const v = this.v; if (!v.duration) return;
v.currentTime = clamp(v.currentTime + s, 0, v.duration); this._started();
if (fx) this._seekfx(s);
this._show();
}
seekTo(t) { const v = this.v; if (!v.duration) return; v.currentTime = clamp(t, 0, v.duration); this._started(); this._show(); }
// 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) return;
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();
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;
const idx = Math.max(0, Math.floor(v.currentTime / f + 0.02)), total = Math.max(1, Math.round((v.duration || 0) / f));
const fps = 1 / f, fpsTxt = Math.abs(fps - Math.round(fps)) < 0.05 ? Math.round(fps) : fps.toFixed(2);
el.innerHTML = `${svg(n > 0 ? 'fstepf' : 'fstepb', 18)}<span>Frame <b>${(idx + 1).toLocaleString()}</b> <span class="dim">of ${total.toLocaleString()}</span></span><span class="dim">·</span><span>${fmtMs(v.currentTime)}</span><span class="dim">· ${fpsTxt} fps${this._fdt ? '' : ' (assumed)'}</span>`;
el.classList.remove('go'); void el.offsetWidth; el.classList.add('go', 'on');
this.$('.top').style.opacity = '0';
clearTimeout(this._t.fstep);
this._t.fstep = setTimeout(() => this._frameHudOff(), 2000);
}
_frameHudOff() { clearTimeout(this._t.fstep); this.$('.fstep').classList.remove('on'); this.$('.top').style.opacity = ''; }
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) { 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}%`); }
chapter(dir) {
const ch = this.chapters; if (ch.length < 2) return this._hud('This video has no chapters');
const t = this.v.currentTime;
const n = dir > 0 ? ch.find((c) => c.t > t + 0.5) : [...ch].reverse().find((c) => c.t < t - 1.5) || ch[0];
if (n) { this.seekTo(n.t); if (n.title) this._hud(n.title); }
}
async pip() {
try {
if (document.pictureInPictureElement) await document.exitPictureInPicture();
else { await this.v.requestPictureInPicture(); this._started(); }
} catch { this._hud('Picture-in-picture is not available here'); }
}
// the player alone, full window, in a new tab at the current time
popout() {
const t = Math.floor(this.v.currentTime);
let url;
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);
this._hud('Opened in a new tab');
}
// window.open with noopener always returns null, so open through a link (the frame allows popups)
_openTab(url) { const a = document.createElement('a'); a.href = url; a.target = '_blank'; a.rel = 'noopener'; this.shadowRoot.appendChild(a); a.click(); a.remove(); }
async fullscreen() {
if (document.fullscreenElement) { await document.exitFullscreen().catch(() => {}); return; }
if (document.fullscreenEnabled && this.p.requestFullscreen) {
try { await this.p.requestFullscreen(); return; } catch {}
}
// a sandboxed wiki frame cannot go fullscreen: open the player full-window instead
this.popout();
}
// ---------- feedback ----------
_bezel(icon) { const b = this.$('.bezel'); b.innerHTML = svg(icon, 36); b.classList.remove('go'); void b.offsetWidth; b.classList.add('go'); }
_hud(txt) { const h = this.$('.hud'); h.textContent = txt; h.classList.add('on'); clearTimeout(this._t.hud); this._t.hud = setTimeout(() => h.classList.remove('on'), 800); }
// the "10 seconds" overlays: one timer per side (a shared timer let a back press, then a quick forward
// press, cancel the back overlay's hide and strand it on screen); a press on one side clears the other
_seekfx(s) {
const side = s < 0 ? 'l' : 'r', other = side === 'l' ? 'r' : 'l';
const el = this.$(`.seekfx.${side}`), op = this.$(`.seekfx.${other}`);
clearTimeout(this._t['fx' + other]); op.classList.remove('on');
this._acc = (this._accSide === Math.sign(s) && el.classList.contains('on')) ? this._acc + Math.abs(s) : Math.abs(s);
this._accSide = Math.sign(s);
el.querySelector('span').textContent = `${this._acc} seconds`;
el.classList.add('on'); clearTimeout(this._t['fx' + side]); this._t['fx' + side] = setTimeout(() => el.classList.remove('on'), 650);
}
_show() {
this.p.classList.remove('hide-ui');
clearTimeout(this._t.hide);
this._t.hide = setTimeout(() => {
if (this.v.paused || this.drag || this._overChrome || this.$('.menu').classList.contains('on') || this.$('.help').classList.contains('on')) return;
this.p.classList.add('hide-ui'); this._tipOff();
}, 2500);
}
_volUI() {
const v = this.v, lv = v.muted ? 0 : v.volume;
const s = this.$('.vslide'), w = 52;
s.querySelector('.lvl').style.width = (lv * w) + 'px';
s.querySelector('.dot').style.left = (lv * w) + 'px';
s.setAttribute('aria-valuenow', String(Math.round(lv * 100)));
const mb = this.$('[data-k=mute]');
const ic = lv === 0 ? 'mute' : lv < 0.5 ? 'vollow' : 'vol';
if (mb.dataset.s !== ic) { mb.dataset.s = ic; mb.innerHTML = svg(ic); mb.dataset.tip = lv === 0 ? 'Unmute (m)' : 'Mute (m)'; }
}
_sync() {
const on = (k, x) => { const b = this.$(`[data-k=${k}]`); b.classList.toggle('on', x); b.setAttribute('aria-pressed', String(x)); };
on('cc', this.capOn);
const f = this.$('[data-k=fs]'), isFs = document.fullscreenElement === this.p;
this.p.classList.toggle('fs', isFs);
f.innerHTML = svg(isFs ? 'exitfull' : 'full'); f.dataset.tip = isFs ? 'Exit full screen (f)' : 'Full screen (f)';
}
_tipOn(btn) {
const tt = this.$('.tt'), t = btn.dataset.tip; if (!t || this.$('.menu').classList.contains('on')) return;
tt.textContent = t; tt.classList.add('on');
const pr = this.p.getBoundingClientRect(), br = btn.getBoundingClientRect();
tt.style.left = clamp(br.left - pr.left + br.width / 2 - tt.offsetWidth / 2, 6, pr.width - tt.offsetWidth - 6) + 'px';
}
_tipOff() { this.$('.tt').classList.remove('on'); }
// ---------- 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>`) +
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';
m.innerHTML =
`<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>`;
}
m.classList.add('on'); this._tipOff();
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.$('[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');
if (on) {
h.innerHTML = `<h3>Keyboard shortcuts <button class="x" aria-label="Close">×</button></h3><div class="cols"><table>${SHORTCUTS.map(([a, k]) => `<tr><td>${a}</td><td>${k.split(' or ').map((x) => `<kbd>${esc(x)}</kbd>`).join(' or ')}</td></tr>`).join('')}</table></div>`;
h.querySelector('.x').onclick = () => this._help(false);
this._menuClose(); if (!this.v.paused) { this._wasPlaying = true; this.v.pause(); }
} else if (this._wasPlaying) { this._wasPlaying = false; this.v.play().catch(() => {}); }
h.classList.toggle('on', on);
}
// ---------- wiring ----------
_wire() {
const v = this.v, p = this.p, bar = this.bar, $ = this.$;
['timeupdate', 'play', 'pause', 'seeked', 'progress', 'durationchange', 'ratechange', 'ended'].forEach((e) => v.addEventListener(e, () => this._tick()));
v.addEventListener('durationchange', () => this._segments());
v.addEventListener('volumechange', () => this._volUI());
v.addEventListener('play', () => { this._started(); this._show(); this._frameHudOff(); });
v.addEventListener('pause', () => this._show());
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', () => {
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) {
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, 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 (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;
if (now - lastTap < 300 && (fx < 0.35 || fx > 0.65)) { clearTimeout(tapTimer); this.seekBy(fx < 0.5 ? -10 : 10, true); lastTap = now; return; }
lastTap = now;
tapTimer = setTimeout(() => { if (p.classList.contains('hide-ui')) this._show(); else this.toggle(); }, 280);
return;
}
// a double-click is measured here, not left to the OS setting (Windows allows 500 ms, so a pause
// then a quick unpause counted): two clicks within 300 ms and 10 px. Each click still toggles, as on
// YouTube, so a double-click ends where it started and goes fullscreen.
this.toggle();
const now = performance.now(), last = this._lastClick;
if (last && now - last.t <= 300 && Math.hypot(e.clientX - last.x, e.clientY - last.y) <= 10) { this._lastClick = null; this.fullscreen(); }
else this._lastClick = { t: now, x: e.clientX, y: e.clientY };
});
$('.big').addEventListener('click', () => this.toggle());
p.addEventListener('pointermove', (e) => { if (e.pointerType !== 'touch') this._show(); });
// 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');
chrome.addEventListener('pointerenter', () => { this._overChrome = true; });
chrome.addEventListener('pointerleave', () => { this._overChrome = false; this._show(); });
// the timeline
bar.addEventListener('pointermove', (e) => { if (!this.drag) this._hover(e.clientX); });
bar.addEventListener('pointerleave', () => { if (!this.drag) (this._seg || []).forEach((s) => s.el.classList.remove('hov')); });
bar.addEventListener('pointerdown', (e) => {
if (e.button !== 0 || !v.duration) return;
e.preventDefault(); bar.setPointerCapture(e.pointerId); bar.classList.add('drag');
this.drag = { resume: !v.paused }; v.pause(); this._started();
this._scrubTo(e.clientX);
});
bar.addEventListener('pointermove', (e) => { if (this.drag) this._scrubTo(e.clientX); });
const end = (e) => {
if (!this.drag) return;
const resume = this.drag.resume; this.drag = null; bar.classList.remove('drag');
this._seekPending = null; this._scrubTo(e.clientX, true);
if (resume) v.play().catch(() => {});
this._show();
};
bar.addEventListener('pointerup', end); bar.addEventListener('pointercancel', end);
bar.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { e.preventDefault(); e.stopPropagation(); this.seekBy(e.key === 'ArrowLeft' ? -5 : 5, true); }
});
// volume slider
const vs = $('.vslide');
const setVol = (x) => { const r = vs.getBoundingClientRect(); v.muted = false; v.volume = clamp((x - r.left - 6) / 52, 0, 1); };
vs.addEventListener('pointerdown', (e) => { e.preventDefault(); vs.setPointerCapture(e.pointerId); vs.classList.add('drag'); setVol(e.clientX); });
vs.addEventListener('pointermove', (e) => { if (vs.classList.contains('drag')) setVol(e.clientX); });
const vend = () => vs.classList.remove('drag');
vs.addEventListener('pointerup', vend); vs.addEventListener('pointercancel', vend);
vs.addEventListener('wheel', (e) => { e.preventDefault(); this.volBy(e.deltaY < 0 ? 0.05 : -0.05); }, { passive: false });
// buttons
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._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));
$('.row').addEventListener('pointerover', (e) => { const b = e.target.closest('[data-tip]'); if (b) this._tipOn(b); });
$('.row').addEventListener('pointerout', (e) => { if (e.target.closest('[data-tip]')) this._tipOff(); });
$('.menu').addEventListener('click', (e) => {
const b = e.target.closest('button'); if (!b) return;
if (b.dataset.m) this._menu(b.dataset.m);
else if (b.dataset.speed) { this.speed(Number(b.dataset.speed)); this._menu('main'); }
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) { 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/');
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; }
else if (k === ' ' || k === 'k' || k === 'K') this.toggle();
else if (k === 'j' || k === 'J') this.seekBy(-10, true);
else if (k === 'l' || k === 'L') this.seekBy(10, true);
else if (k === 'ArrowLeft') this.seekBy(-5, true);
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 === '<' || (k === ',' && e.shiftKey)) this.speedStep(-1);
else if (k === '>' || (k === '.' && e.shiftKey)) this.speedStep(1);
else if (k === ',' && v.paused) this.frame(-1);
else if (k === '.' && v.paused) this.frame(1);
else if (k === 'Home') this.seekTo(0);
else if (k === 'End') this.seekTo(v.duration || 0);
else if (/^[0-9]$/.test(k)) this.seekTo((v.duration || 0) * Number(k) / 10);
else if (k === 'f' || k === 'F') this.fullscreen();
else if (k === 't' || k === 'T') this.popout();
else if (k === 'i' || k === 'I') this.pip();
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);
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();
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(); }
}, true);
}
// for the wiki's widget driver and AI checks
get adomDrive() {
return {
ready: () => new Promise((ok) => (this.v.readyState >= 1 ? ok() : this.v.addEventListener('loadedmetadata', () => ok(), { once: true }))),
state: () => ({ state: this.v.paused ? 'paused' : 'playing', t: this.v.currentTime, duration: this.v.duration, speed: this.v.playbackRate, chapters: this.chapters.length, captions: this.cues.length }),
set: (x) => { if (x === 'play' || x === 'playing') return this.v.play(); if (x === 'pause' || x === 'paused') return this.v.pause(); if (!isNaN(parseT(x))) this.seekTo(parseT(x)); },
};
}
}
customElements.define('adom-video', AdomVideo);
})();