app
AI Flow: Video
Public Made by Adomby adom
Turn a board build into a narrated walkthrough video on your wiki page and YouTube.
main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842
// <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"/>',
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 (pauses)', ', / .'], ['Slower / faster (0.25x to 4x)', '< / >'], ['2x while held', 'Hold the mouse on the video'],
['Previous / next chapter', 'Ctrl + ← / →'], ['Previous / next video on the page', 'Shift + P / Shift + N'],
['Captions on / off', 'C'], ['Caption size', '+ / −'], ['Caption text opacity', 'O'], ['Caption background opacity', 'W'],
['Fullscreen', 'F or double-click'], ['Open large in a new tab', 'T'], ['Picture-in-picture', 'I'], ['Close menu / exit', 'Esc'],
['Loop', 'R'], ['Media keys', 'Play/Pause, Stop, Next'], ['This list', '?'],
];
const CSS = `
: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}
.p.hide-ui{cursor: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}
.err code{background:rgba(255,255,255,.1);padding:1px 5px;border-radius:4px;word-break:break-all}
/* 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}
.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} }
`;
// 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="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" 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');
this._startAt = parseT(this.getAttribute('start') || (location.hash.match(/[#&]t=([^&]+)/) || [])[1]);
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'); }
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) { this.started = true; this.$('.big').hidden = true; } }
toggle() {
const v = this.v;
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) v.pause();
const k = Math.floor(v.currentTime / f + 0.02) + n;
v.currentTime = clamp((k + 0.5) * f, 0, v.duration || 0);
this._started(); this._show();
this._frameHud(n, f);
}
// 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 (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); }
_seekfx(s) {
const el = this.$(s < 0 ? '.seekfx.l' : '.seekfx.r');
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); this._t.fx = 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;
const v = this.v;
if (view === 'speed') {
m.innerHTML = `<button class="mhead" data-m="main">${svg('chevl')}Playback speed</button>` +
(SPEEDS.includes(v.playbackRate) ? '' : `<button class="mi" data-speed="${v.playbackRate}"><span class="ck">${svg('check')}</span><span class="lab">${v.playbackRate} (custom)</span></button>`) +
SPEEDS.map((s) => `<button class="mi" role="menuitemradio" data-speed="${s}"><span class="ck">${v.playbackRate === s ? svg('check') : ''}</span><span class="lab">${s === 1 ? 'Normal' : s}</span></button>`).join('');
} else {
const sp = v.playbackRate === 1 ? 'Normal' : v.playbackRate + 'x';
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="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._show(); }
_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); } });
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.`);
});
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 ($('.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;
}
this.toggle();
});
click.addEventListener('dblclick', (e) => { e.preventDefault(); this.fullscreen(); });
$('.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 }); }); });
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._menuClose() : this._menu('main')),
};
$('.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 === '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;
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 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 === ',') this.frame(-1);
else if (k === '.') this.frame(1);
else if (k === '<') this.speedStep(-1);
else if (k === '>') this.speedStep(1);
else if (k === 'Home') this.seekTo(0);
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 === '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') { 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);
})();