app
Adom Video Player
Public Made by Adomby adom
Show any video in a real player: seek, scrub, frame step, chapters. One command, no hand-rolled servers.
main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
// 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; } };
})();