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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
// adom-video-player page: the wiki's <adom-video> player (adom-video.js, vendored unchanged from
// adom/widgets) in the app frame. The player owns playback, the timeline, captions, the
// settings menu and YouTube's shortcuts; this page adds what only the app has: the library
// (Shift+N/P), resume and settings on the server, reload on re-render,
// and the SSE channel that lets the AI drive it. All state lives on the server.
(() => {
const { api, toast, hoverPopover, header, initSettings, connect, uiCommon } = window.UI;
const $ = (id) => document.getElementById(id);
const id = location.pathname.split('/').filter(Boolean).pop();
let item = null, settings = {}, etag = null, player = null, v = null;
const fmt = (t) => {
if (!isFinite(t)) t = 0;
const h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), s = String(Math.floor(t % 60)).padStart(2, '0');
return h ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`;
};
const size = (b) => b > 1e9 ? (b / 1e9).toFixed(2) + ' GB' : b > 1e6 ? (b / 1e6).toFixed(1) + ' MB' : (b / 1e3).toFixed(0) + ' KB';
const mediaUrl = () => `../media/${id}?v=${encodeURIComponent(item.etag || '')}`;
// the player reads its chapters and captions from an inlined JSON manifest, as on the wiki
function manifest() {
let s = player.querySelector('script[type="application/json"]');
if (!s) { s = document.createElement('script'); s.type = 'application/json'; player.appendChild(s); }
s.textContent = JSON.stringify({ title: item.title, chapters: item.chapters || [], captions: item.captions ? `../captions/${id}` : null });
}
// after (re)load: speed, place, play state; once per load
function onMeta({ t, rate, play }) {
v.addEventListener('loadedmetadata', () => {
v.playbackRate = rate;
if (v.videoWidth) player.style.setProperty('--ar', String(v.videoWidth / v.videoHeight));
if (t > 0 && t < v.duration - 0.5 && Math.abs(v.currentTime - t) > 0.05) v.currentTime = t;
if (play) v.play().catch(() => {}); // a blocked autoplay leaves the big play button up
chapters();
}, { once: true });
}
async function load(keep) {
const r = await api(`api/item/${id}`);
if (!r.ok) { $('title').textContent = 'Unknown video'; toast('This video is not in the library. An AI adds it with adom-video play <file>.', 'error'); return; }
item = r.item; settings = r.settings || settings;
document.title = `${item.title} · Video Player`;
$('title').textContent = item.title;
$('meta').textContent = item.exists ? `${item.path} · ${size(item.size)}` : `${item.path} · file missing`;
$('download').href = `../media/${id}`; $('download').setAttribute('download', item.path.split('/').pop());
etag = item.etag;
if (!keep) {
const pos = item.position || {};
const t = settings.rememberPosition !== false ? Number(pos.t || 0) : 0;
mount();
if (item.fps) player._fdt = 1 / item.fps; // --fps given: frame step at that rate until the player measures one
player.setAttribute('title', item.title);
if (t > 0) player.setAttribute('start', String(t));
manifest();
onMeta({ t, rate: Number(pos.speed || settings.defaultSpeed || 1), play: !!settings.autoplay });
player.setAttribute('video', mediaUrl());
$('stage').appendChild(player);
} else {
// re-rendered on disk: same place, same speed, same play state
const t = v.currentTime, rate = player._hold ? player._hold.rate : v.playbackRate, play = !v.paused;
player.toggleAttribute('loop', v.loop);
player.setAttribute('start', String(t));
player.setAttribute('title', item.title);
manifest();
onMeta({ t, rate, play });
player.setAttribute('video', mediaUrl());
if (player._pv) player._pv.src = v.src; // the hover preview follows the new file
}
}
// ---------- the player and the app's additions to it ----------
function mount() {
player = document.createElement('adom-video');
v = player.v;
const sr = player.shadowRoot;
// in a wiki frame the pop-out button and T open the player large in a new tab; this app already
// is the player, so the button goes, T does nothing, and popout is only the fullscreen fallback
sr.querySelector('[data-k=pop]').hidden = true;
// Share links point at a wiki page; this app runs on localhost, so the button goes
sr.querySelector('[data-k=share]').hidden = true;
player.popout = () => player._hud('Fullscreen is not available here');
// Shift+N / Shift+P and the media keys step through the library
player.sibling = (dir) => sibling(dir);
// ? shows the app's list (the player's own names the wiki's keys)
player._help = function (on) {
const h = this.$('.help');
if (on == null) on = !h.classList.contains('on');
if (on) {
const rows = [...$('sc-pop').querySelectorAll('tr')].map((tr) => [tr.cells[1].textContent, tr.cells[0].textContent]);
h.innerHTML = `<h3>Keyboard shortcuts <button class="x" aria-label="Close">×</button></h3><div class="cols"><table>${rows.map(([a, k]) => `<tr><td>${a}</td><td>${k.split(/ or | \/ /).map((x) => `<kbd>${x.replace(/&/g, '&').replace(/</g, '<')}</kbd>`).join(' ')}</td></tr>`).join('')}</table></div>`;
h.querySelector('.x').onclick = () => this._help(false);
this._menuClose(); if (!v.paused) { this._wasPlaying = true; v.pause(); }
} else if (this._wasPlaying) { this._wasPlaying = false; v.play().catch(() => {}); }
h.classList.toggle('on', on);
};
['timeupdate', 'seeked'].forEach((e) => v.addEventListener(e, activeChapter));
v.addEventListener('pause', savePos);
v.addEventListener('seeked', () => { if (v.paused) savePos(); });
v.addEventListener('ratechange', () => { if (!player._hold) savePos(); });
}
// ---------- chapters, library ----------
function chapters() {
const ol = $('chapters'); const ch = item.chapters || [];
if (!ch.length) { ol.innerHTML = '<li class="empty">No chapters. An AI adds them with adom-video play --chapters.</li>'; return; }
ol.innerHTML = '';
ch.forEach((c, k) => {
const li = document.createElement('li'); li.dataset.k = k;
li.innerHTML = '<span class="t"></span><span></span>';
li.firstChild.textContent = fmt(c.t); li.lastChild.textContent = c.title;
li.setAttribute('data-tooltip', `Jump to "${c.title}" (Ctrl+arrows step chapters)`);
li.onclick = () => { player.seekTo(c.t); v.play().catch(() => {}); };
ol.appendChild(li);
});
activeChapter();
}
function activeChapter() {
const ch = (item && item.chapters) || []; let a = -1;
ch.forEach((c, k) => { if (v.currentTime + 0.05 >= c.t) a = k; });
document.querySelectorAll('#chapters li').forEach((li) => li.classList.toggle('active', Number(li.dataset.k) === a));
}
async function libraryList(items) {
if (!items) items = (await api('api/library')).items || [];
const ul = $('library'); ul.innerHTML = '';
items.forEach((x) => {
const li = document.createElement('li'); if (x.id === id) li.classList.add('active'); if (!x.exists) li.classList.add('gone');
li.textContent = x.title; li.setAttribute('data-tooltip', x.exists ? `Watch ${x.title} (Shift+N and Shift+P step through the library)` : `The file is gone: ${x.path}`);
li.onclick = () => { location.href = `../watch/${x.id}`; };
ul.appendChild(li);
});
}
async function sibling(dir) {
const items = ((await api('api/library')).items || []).filter((x) => x.exists); const k = items.findIndex((x) => x.id === id);
const n = items[k + dir];
if (n) { savePos(); location.href = `../watch/${n.id}`; } else player._hud(dir > 0 ? 'This is the last video in the library' : 'This is the first video in the library');
}
// positions live on the server (never localStorage)
let lastSaved = -1;
function savePos() {
if (!item || !v || !v.duration) return;
lastSaved = v.currentTime;
api('api/position', { id, t: v.currentTime, speed: player._hold ? player._hold.rate : v.playbackRate }).catch(() => {});
}
setInterval(() => { if (v && !v.paused && Math.abs(v.currentTime - lastSaved) > 2) savePos(); }, 3000);
addEventListener('pagehide', savePos);
// ---------- header ----------
$('copy').onclick = () => { navigator.clipboard?.writeText(location.href).then(() => toast('Link copied', 'success'), () => toast(location.href)); };
hoverPopover($('sc-btn'), $('sc-pop'));
$('sc-btn').onclick = () => { $('sc-pop').classList.remove('open'); player._help(); };
// The player takes keys while it has focus (several players share a wiki page). Here it is the
// only one, so every key goes to it wherever focus is, except in a dialog or a text field.
addEventListener('keydown', (e) => {
if (!player || document.querySelector('dialog[open]')) return;
if (/INPUT|TEXTAREA|SELECT/.test(e.target.tagName) || e.target.isContentEditable || e.metaKey || e.altKey) return;
const sr = player.shadowRoot;
// a browser leaves fullscreen on Esc by itself; Hydrogen's webview hands the key to the page instead
if (e.key === 'Escape' && document.fullscreenElement && !sr.querySelector('.menu.on, .help.on')) {
document.exitFullscreen().catch(() => {}); e.preventDefault(); e.stopPropagation(); return;
}
if ((e.key === 't' || e.key === 'T') && !e.ctrlKey) { e.stopPropagation(); return; } // the player's pop-out key
if (e.key === 'Enter' || e.key === 'Tab') return;
if (!sr.activeElement) player.p.focus({ preventScroll: true });
}, true);
// ---------- live channel: the AI drives this page from the CLI ----------
connect({
settings: (d) => { settings = d.settings; },
library: (d) => libraryList(d.items),
changed: async (d) => { if (d.id === id && d.etag !== etag && player) { const at = fmt(v.currentTime); await load(true); toast(`The file was updated; reloaded at ${at}`, 'success'); } },
ui: async (d) => {
if (await uiCommon(d)) return;
if (!player) return;
if (d.type === 'seek') { player.seekTo(Number(d.t) || 0); toast(`AI jumped to ${fmt(Number(d.t) || 0)}`); }
else if (d.type === 'play') v.play().catch(() => toast('The browser blocked autoplay; press Play', 'error'));
else if (d.type === 'pause') v.pause();
else if (d.type === 'speed') player.speed(Number(d.rate) || 1);
else if (d.type === 'captions') player.captions();
},
});
(async () => {
const st = await api('state'); header(st); initSettings(); settings = st.settings;
await load(false); libraryList(st.library);
})();
})();