main
John Lauer Publish 0.4.3 c1307a9 1d ago
// 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, '&amp;').replace(/</g, '&lt;')}</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);
  })();
})();