main
John Lauer Publish 0.3.0 c375974 8d ago
// Shared UI layer for every adom-video-player page, following adom-ui-design:
// body-appended tooltips with a 600ms delay and viewport clamp, bottom-center
// toasts, the header info popover, the Settings dialog (server-side settings),
// a console relay to the server, the SSE live channel, and the fake AI cursor.
(() => {
  const BASE = location.pathname.replace(/watch\/[^/]*$/, '').replace(/[^/]*$/, '');   // relative root, proxy-safe
  const HUMAN = 'human (player page)';
  const $ = (s, r = document) => r.querySelector(s);
  const api = (p, body) => fetch(BASE + p, body === undefined ? { cache: 'no-store' } :
    { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ aiThread: HUMAN, ...body }) }).then((r) => r.json());

  // ---------- console relay: the AI reads /console instead of DevTools ----------
  const queue = []; let flushT;
  const flush = () => { const lines = queue.splice(0); if (lines.length) navigator.sendBeacon?.(BASE + 'console', new Blob([JSON.stringify({ lines })], { type: 'application/json' })) || fetch(BASE + 'console', { method: 'POST', body: JSON.stringify({ lines }) }); };
  for (const level of ['log', 'info', 'warn', 'error']) {
    const orig = console[level].bind(console);
    console[level] = (...a) => { orig(...a); queue.push({ level, page: location.pathname, msg: a.map((x) => typeof x === 'string' ? x : (() => { try { return JSON.stringify(x); } catch { return String(x); } })()).join(' ') }); clearTimeout(flushT); flushT = setTimeout(flush, 300); };
  }
  addEventListener('error', (e) => console.error('window.onerror:', e.message, e.filename + ':' + e.lineno));
  addEventListener('unhandledrejection', (e) => console.error('unhandledrejection:', String(e.reason)));

  // ---------- tooltips: one body-level fixed div, 600ms, clamped on both axes ----------
  const tip = document.createElement('div'); tip.id = 'global-tooltip'; tip.setAttribute('role', 'tooltip'); document.body.appendChild(tip);
  let cx = 0, cy = 0, tipT, tipFor = null;
  addEventListener('mousemove', (e) => { cx = e.clientX; cy = e.clientY; }, true);
  function place(el, w, h, anchorRect) {
    const O = 14, vw = innerWidth, vh = innerHeight;
    const a = anchorRect || { left: cx, right: cx, top: cy, bottom: cy };
    // cursor quadrants first, then just below / above the trigger so the tip never covers it
    const cands = [{ x: cx + O, y: cy + O }, { x: cx - w - O, y: cy + O }, { x: cx + O, y: cy - h - O }, { x: cx - w - O, y: cy - h - O },
      { x: cx - w / 2, y: a.bottom + 8 }, { x: cx - w / 2, y: a.top - h - 8 }, { x: a.right + 8, y: cy - h / 2 }, { x: a.left - w - 8, y: cy - h / 2 }];
    const score = (c) => {
      let p = 0;
      if (c.x < 8 || c.x + w > vw - 8) p += 1000; if (c.y < 8 || c.y + h > vh - 8) p += 1000;
      if (anchorRect) { const ox = Math.max(0, Math.min(c.x + w, anchorRect.right) - Math.max(c.x, anchorRect.left)); const oy = Math.max(0, Math.min(c.y + h, anchorRect.bottom) - Math.max(c.y, anchorRect.top)); p += ox * oy; }
      return p;
    };
    const clamp = (c) => ({ x: Math.max(8, Math.min(c.x, vw - w - 8)), y: Math.max(8, Math.min(c.y, vh - h - 8)) });
    const b = cands.map((c) => ({ c, s: score(c) + score(clamp(c)) })).sort((p, z) => p.s - z.s)[0].c;
    return { x: Math.max(8, Math.min(b.x, vw - w - 8)), y: Math.max(8, Math.min(b.y, vh - h - 8)) };
  }
  function hideTip() { clearTimeout(tipT); tip.classList.remove('visible', 'shown'); tipFor = null; }
  document.addEventListener('mouseover', (e) => {
    const t = e.target.closest?.('[data-tooltip]');
    if (t === tipFor) return;
    hideTip(); if (!t || matchMedia('(hover: none)').matches) return;
    tipFor = t;
    tipT = setTimeout(() => {
      tip.textContent = t.getAttribute('data-tooltip'); tip.classList.add('visible');
      const { x, y } = place(tip, tip.offsetWidth, tip.offsetHeight, t.getBoundingClientRect());
      tip.style.left = x + 'px'; tip.style.top = y + 'px';
      requestAnimationFrame(() => tip.classList.add('shown'));
    }, 600);
  });
  addEventListener('scroll', hideTip, true); addEventListener('mousedown', hideTip, true);

  // a hover popover (the brand info card, the shortcuts list): 600ms show, 220ms hide grace, clamped
  function hoverPopover(trigger, pop) {
    let showT, hideT;
    const show = () => {
      clearTimeout(hideT); hideTip();
      showT = setTimeout(() => {
        pop.classList.add('open'); const r = trigger.getBoundingClientRect();
        const w = pop.offsetWidth, h = pop.offsetHeight;
        let x = Math.min(r.left, innerWidth - w - 8), y = r.bottom + 6;
        if (y + h > innerHeight - 8) y = Math.max(8, r.top - h - 6);
        pop.style.left = Math.max(8, x) + 'px'; pop.style.top = Math.max(8, Math.min(y, innerHeight - h - 8)) + 'px';
      }, 600);
    };
    const hide = () => { clearTimeout(showT); hideT = setTimeout(() => pop.classList.remove('open'), 220); };
    for (const el of [trigger, pop]) { el.addEventListener('mouseenter', show); el.addEventListener('mouseleave', hide); }
  }

  // ---------- toasts ----------
  const stack = document.createElement('div'); stack.className = 'toast-stack'; document.body.appendChild(stack);
  function toast(text, kind = 'info', action) {
    const el = document.createElement('div'); el.className = `toast ${kind}`;
    el.setAttribute('role', kind === 'error' ? 'alert' : 'status'); el.setAttribute('aria-live', kind === 'error' ? 'assertive' : 'polite');
    el.innerHTML = `<span class="ticon">${icon(kind === 'success' ? 'check' : kind === 'error' ? 'alert' : 'info', 16)}</span><span class="tmsg"></span>`;
    el.querySelector('.tmsg').textContent = text;
    if (action) { const a = document.createElement('button'); a.className = 'taction'; a.textContent = action.label; a.onclick = () => { action.run(); close(); }; el.appendChild(a); }
    const x = document.createElement('button'); x.className = 'tclose'; x.setAttribute('aria-label', 'Dismiss'); x.innerHTML = icon('close', 14); x.onclick = () => close(); el.appendChild(x);
    stack.prepend(el);
    while (stack.children.length > 3) stack.lastChild.remove();
    let left = kind === 'error' ? Infinity : 4000, t0 = Date.now(), timer;
    const arm = () => { if (isFinite(left)) timer = setTimeout(close, left); t0 = Date.now(); };
    el.addEventListener('mouseenter', () => { clearTimeout(timer); left -= Date.now() - t0; });
    el.addEventListener('mouseleave', arm);
    function close() { clearTimeout(timer); el.classList.add('leaving'); setTimeout(() => el.remove(), 150); }
    arm(); return el;
  }

  // ---------- header: brand info popover + icons ----------
  function header(state) {
    $('#brand-icon').innerHTML = icon('mark', 18);
    document.querySelectorAll('[data-icon]').forEach((b) => { b.insertAdjacentHTML('afterbegin', icon(b.dataset.icon, Number(b.dataset.iconSize || 18))); });
    const pop = $('#brand-pop');
    pop.innerHTML = `<div class="pop-title">Video Player</div>
      <div class="pop-row"><span>Version</span><span class="mono">${state.version}</span></div>
      <div class="pop-row"><span>Server</span><span class="mono">port ${state.port}, started by ${state.startedBy}</span></div>
      <div class="pop-row"><span>Engine</span><span>Range-capable Node server, SSE live channel</span></div>
      <div class="pop-row"><span>Wiki</span><a href="https://wiki.adom.inc/adom/adom-video-player" target="_blank" rel="noopener">adom/adom-video-player</a></div>`;
    hoverPopover($('#brand'), pop);
  }

  // ---------- Settings dialog: built from the server's schema ----------
  let settingsState = { settings: {}, schema: {} };
  function renderSettings() {
    const body = $('#settings-body'); body.innerHTML = '';
    for (const [k, s] of Object.entries(settingsState.schema)) {
      const v = settingsState.settings[k];
      const row = document.createElement('div'); row.className = 'set-row';
      const label = document.createElement('h4'); label.className = 'set-name'; label.textContent = s.label;
      const help = document.createElement('p'); help.className = 'set-help'; help.textContent = s.help;
      let ctl;
      if (s.type === 'boolean') {
        ctl = document.createElement('button'); ctl.className = 'toggle' + (v ? ' active' : ''); ctl.setAttribute('role', 'switch'); ctl.setAttribute('aria-checked', String(!!v));
        ctl.setAttribute('aria-label', s.label); ctl.innerHTML = '<span class="toggle-track"><span class="toggle-thumb"></span></span>';
        ctl.onclick = () => save(k, !settingsState.settings[k]);
      } else if (s.type === 'enum') {
        ctl = document.createElement('select'); ctl.className = 'select'; ctl.setAttribute('aria-label', s.label);
        for (const o of s.values) { const op = document.createElement('option'); op.value = o; op.textContent = s.labels?.[o] || (typeof o === 'number' ? o + 'x' : o); if (String(o) === String(v)) op.selected = true; ctl.appendChild(op); }
        ctl.onchange = () => save(k, ctl.value);
      } else {
        ctl = document.createElement('input'); ctl.type = 'number'; ctl.className = 'select'; ctl.min = s.min; ctl.max = s.max; ctl.value = v; ctl.setAttribute('aria-label', s.label);
        ctl.onchange = () => save(k, Number(ctl.value));
      }
      const def = document.createElement('span'); def.className = 'default-hint';
      def.textContent = 'Default: ' + (s.type === 'boolean' ? (s.default ? 'On' : 'Off') : (s.labels?.[s.default] || (s.type === 'enum' && typeof s.default === 'number' ? s.default + 'x' : s.default)));
      row.append(label, help, ctl, def); body.appendChild(row);
    }
  }
  async function save(k, v) {
    const r = await api('settings', { [k]: v });
    if (!r.ok) return toast(r.error, 'error');
    settingsState.settings = r.settings; renderSettings();
    toast(`${settingsState.schema[k].label}: ${typeof v === 'boolean' ? (v ? 'on' : 'off') : (settingsState.schema[k].labels?.[v] || v)}`, 'success');
  }
  function initSettings() {
    const dlg = $('#settings');
    $('#settings-btn').onclick = async () => { const r = await api('settings'); settingsState = r; renderSettings(); dlg.showModal(); };
    $('#settings-close').onclick = () => dlg.close();
    dlg.addEventListener('click', (e) => {
      if (e.target !== dlg) return; const r = dlg.getBoundingClientRect();
      if (!(e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom)) dlg.close();
    });
    const rb = $('#restore'); let armed = false, armT;
    rb.onclick = async () => {
      if (!armed) { armed = true; rb.classList.add('armed'); rb.lastChild.textContent = ' Click again to confirm'; armT = setTimeout(() => { armed = false; rb.classList.remove('armed'); rb.lastChild.textContent = ' Restore defaults'; }, 4000); return; }
      clearTimeout(armT); armed = false; rb.classList.remove('armed'); rb.lastChild.textContent = ' Restore defaults';
      const r = await api('settings', { restoreDefaults: true }); settingsState.settings = r.settings; renderSettings(); toast('Settings restored to defaults', 'success');
    };
  }

  // ---------- fake AI cursor ----------
  const cursor = document.createElement('div'); cursor.className = 'ai-cursor';
  cursor.innerHTML = '<svg viewBox="0 0 24 24" width="22" height="22"><path d="M5 3l14 8-6 1.5L10 19z" fill="#00b8b1" stroke="#05221f" stroke-width="1.2" stroke-linejoin="round"/></svg><span class="ai-label"></span>';
  document.body.appendChild(cursor);
  function glide(selector, label) {
    const el = document.querySelector(selector); if (!el) { toast(`AI could not find ${selector}`, 'error'); return Promise.resolve(null); }
    el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
    const r = el.getBoundingClientRect();
    cursor.querySelector('.ai-label').textContent = label || '';
    cursor.classList.add('on'); cursor.style.transform = `translate(${r.left + r.width / 2}px, ${r.top + r.height / 2}px)`;
    return new Promise((res) => setTimeout(() => res(el), 380));
  }

  // ---------- SSE live channel ----------
  function connect(handlers) {
    const es = new EventSource(BASE + 'events');
    for (const [ev, fn] of Object.entries(handlers)) es.addEventListener(ev, (m) => { try { fn(JSON.parse(m.data)); } catch (e) { console.error('event', ev, e); } });
    es.onerror = () => console.warn('live channel reconnecting');
    return es;
  }
  async function uiCommon(d) {
    if (d.type === 'toast') toast(d.text || '', d.kind || 'info');
    else if (d.type === 'reload') location.reload();
    else if (d.type === 'cursor') await glide(d.selector, d.label || 'AI');
    else if (d.type === 'click') { const el = await glide(d.selector, d.label || 'AI clicks'); el?.click(); setTimeout(() => cursor.classList.remove('on'), 900); }
    else if (d.type === 'reveal') { const el = document.querySelector(d.selector); el?.closest('details')?.setAttribute('open', ''); el?.scrollIntoView({ block: 'center', behavior: 'smooth' }); el?.classList.add('revealed'); setTimeout(() => el?.classList.remove('revealed'), 1500); }
    else if (d.type === 'eval') { try { const v = await (0, eval)(d.js); console.log('eval result:', v); } catch (e) { console.error('eval error:', String(e)); } }
    else return false;
    return true;
  }

  window.UI = { BASE, HUMAN, api, toast, tip, hoverPopover, header, initSettings, connect, uiCommon, glide, setSettings: (s) => { settingsState = s; } };
})();