main
John Lauer Publish 0.2.0 490b8f0 21h ago
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
  // caption markup: keep <b> <i> <u>, drop the rest (voice tags, classes, timestamps)
  const capHTML = (s) => esc(s).replace(/&lt;(\/?)(b|i|u)&gt;/g, '<$1$2>').replace(/&lt;[^&]*?&gt;/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);
})();