← back to Fleet Tv

public/index.html

358 lines

<!DOCTYPE html>
<html lang="en">
<head>
    <link rel="icon" type="image/svg+xml" href="/favicon.svg">

<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Fleet TV — every site, on the wall</title>
<style>
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  :root {
    --bg: #07080a;
    --fg: #e8e6e0;
    --dim: #6a6862;
    --accent: #c9a14b;
    --pane: rgba(12,14,18,0.85);
    --rule: rgba(255,255,255,0.08);
    --rotation-ms: 5000;
    --xfade-ms: 600;
  }
  html, body { height: 100%; background: var(--bg); color: var(--fg); font-family: -apple-system, "Helvetica Neue", "Segoe UI", system-ui, sans-serif; overflow: hidden; }

  .stage {
    position: fixed; inset: 0;
    background: var(--bg);
    display: grid;
    grid-template-rows: auto 1fr auto;
  }

  /* — Top ribbon — */
  .ribbon {
    display: flex; align-items: center; gap: 1.25rem;
    padding: 0.75rem 1.25rem;
    background: linear-gradient(180deg, rgba(0,0,0,0.55), rgba(0,0,0,0));
    backdrop-filter: blur(8px);
    border-bottom: 1px solid var(--rule);
    z-index: 4;
  }
  .ribbon .badge {
    width: 12px; height: 12px; border-radius: 50%;
    background: var(--accent); box-shadow: 0 0 18px var(--accent);
    transition: background 0.45s ease, box-shadow 0.45s ease;
  }
  .ribbon h1 { font-size: 0.95rem; letter-spacing: 0.18em; text-transform: uppercase; color: var(--fg); font-weight: 500; }
  .ribbon .now {
    margin-left: auto; display: flex; gap: 1.5rem; align-items: baseline; color: var(--dim);
    font-variant-numeric: tabular-nums; font-size: 0.85rem; letter-spacing: 0.1em; text-transform: uppercase;
  }
  .ribbon .now strong { color: var(--fg); font-weight: 500; letter-spacing: 0.16em; }

  /* — Iframe stage — */
  .frame-wrap { position: relative; overflow: hidden; }
  .frame-wrap iframe {
    position: absolute; inset: 0;
    width: 100%; height: 100%;
    border: 0; background: var(--bg);
    opacity: 0;
    transition: opacity var(--xfade-ms) ease;
  }
  .frame-wrap iframe.live { opacity: 1; }
  .scrim {
    position: absolute; inset: 0; pointer-events: none;
    background: radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.18) 100%);
  }

  /* — Lower-third caption (cinema-style) — */
  .lower-third {
    position: absolute; left: 0; right: 0; bottom: 0;
    padding: 1rem 1.5rem 1.25rem;
    display: grid; grid-template-columns: auto 1fr auto; gap: 1.25rem; align-items: end;
    background: linear-gradient(0deg, rgba(0,0,0,0.78), rgba(0,0,0,0));
    z-index: 3; pointer-events: none;
  }
  .lower-third .swatch {
    width: 56px; height: 56px; border-radius: 4px;
    background: var(--accent);
    border: 1px solid rgba(255,255,255,0.2);
    box-shadow: 0 0 24px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.05);
    transition: background 0.45s ease;
  }
  .lower-third .name {
    font-size: 1.85rem; line-height: 1.05; letter-spacing: 0.02em; color: #fff;
    text-shadow: 0 2px 14px rgba(0,0,0,0.7);
  }
  .lower-third .url {
    margin-top: 0.25rem; font-size: 0.75rem; color: rgba(255,255,255,0.6);
    letter-spacing: 0.18em; text-transform: uppercase; font-variant-numeric: tabular-nums;
  }
  .lower-third .meta {
    text-align: right; font-size: 0.75rem; color: rgba(255,255,255,0.6);
    letter-spacing: 0.16em; text-transform: uppercase;
    display: flex; flex-direction: column; gap: 0.25rem;
  }
  .lower-third .meta b { color: #fff; font-weight: 500; font-size: 0.95rem; letter-spacing: 0.12em; }

  /* — Footer ticker — */
  .ticker {
    padding: 0.65rem 1.25rem;
    border-top: 1px solid var(--rule);
    background: rgba(0,0,0,0.55);
    color: var(--dim); font-size: 0.75rem; letter-spacing: 0.18em; text-transform: uppercase;
    display: flex; gap: 1.5rem; align-items: center; justify-content: space-between;
    z-index: 2;
  }
  .ticker .progress {
    flex: 1; max-width: 480px; height: 2px;
    background: rgba(255,255,255,0.06); border-radius: 1px; overflow: hidden;
    margin: 0 1.25rem;
  }
  .ticker .bar {
    height: 100%; width: 0; background: var(--accent);
    transition: background 0.45s ease;
  }
  .ticker .legend { display: flex; gap: 1.5rem; }
  .ticker .legend span { color: var(--dim); }
  .ticker .legend b { color: var(--fg); font-weight: 500; margin-left: 0.4rem; }

  /* — Idle / loading state — */
  .splash {
    position: fixed; inset: 0; display: grid; place-items: center;
    background: var(--bg); z-index: 10;
    transition: opacity 0.5s ease;
  }
  .splash.hide { opacity: 0; pointer-events: none; }
  .splash .mark {
    font-size: 1rem; letter-spacing: 0.42em; text-transform: uppercase;
    color: var(--accent);
    animation: pulse 1.6s ease-in-out infinite;
  }
  @keyframes pulse { 0%,100% { opacity: 0.4; } 50% { opacity: 1; } }

  /* — Pause overlay — */
  .paused-tag {
    position: fixed; top: 1rem; right: 1.25rem; z-index: 20;
    padding: 0.4rem 0.85rem; background: rgba(0,0,0,0.7);
    color: var(--accent); border: 1px solid var(--accent);
    font-size: 0.7rem; letter-spacing: 0.3em; text-transform: uppercase;
    opacity: 0; transition: opacity 0.3s ease;
  }
  .paused-tag.show { opacity: 1; }
</style>
</head>
<body>

<div class="splash" id="splash"><div class="mark">Fleet TV · loading channels</div></div>

<div class="paused-tag" id="paused-tag">paused — press space</div>

<div class="stage">

  <div class="ribbon">
    <span class="badge" id="badge"></span>
    <h1>Fleet TV</h1>
    <div class="now">
      <span>channel <strong id="ch-idx">—</strong> of <strong id="ch-total">—</strong></span>
      <span><strong id="clock">—</strong></span>
    </div>
  </div>

  <div class="frame-wrap" id="frame-wrap">
    <iframe id="iframe-a" referrerpolicy="no-referrer" loading="lazy"></iframe>
    <iframe id="iframe-b" referrerpolicy="no-referrer" loading="lazy"></iframe>

    <div class="scrim"></div>

    <div class="lower-third">
      <div class="swatch" id="swatch"></div>
      <div>
        <div class="name" id="ch-name">—</div>
        <div class="url" id="ch-url">—</div>
      </div>
      <div class="meta">
        <span id="meta-label">kind</span>
        <b id="ch-kind">—</b>
      </div>
    </div>
  </div>

  <div class="ticker">
    <div class="legend">
      <span>fleet · <b id="legend-total">—</b></span>
      <span>uptime · <b id="legend-uptime">0s</b></span>
    </div>
    <div class="progress"><div class="bar" id="bar"></div></div>
    <div class="legend"><span>next in <b id="legend-next">—</b></span></div>
  </div>
</div>

<script>
(() => {
  const ROTATE_MS = 5000;
  const XFADE_MS = 600;
  let channels = [];
  let idx = 0;
  let active = 'a';        // which iframe is currently showing
  let paused = false;
  let nextTimer = null;
  let progressRaf = null;
  let lastSwitchAt = 0;
  const startedAt = Date.now();

  const $ = (id) => document.getElementById(id);
  const splash = $('splash');
  const ifA = $('iframe-a');
  const ifB = $('iframe-b');
  const badge = $('badge');
  const swatch = $('swatch');
  const bar = $('bar');

  async function loadChannels() {
    try {
      const r = await fetch('/api/channels', { cache: 'no-store' });
      const j = await r.json();
      channels = j.channels || [];
      $('ch-total').textContent = String(channels.length);
      $('legend-total').textContent = String(channels.length);
      if (!channels.length) {
        $('ch-name').textContent = 'no channels';
        return;
      }
      // Preload first two so the very first transition is also smooth
      ifA.src = channels[0].url;
      ifB.src = channels[1 % channels.length].url;
      ifA.classList.add('live');
      paint(channels[0]);
      $('ch-idx').textContent = '1';
      lastSwitchAt = Date.now();
      setTimeout(() => splash.classList.add('hide'), 700);
      schedule();
    } catch (e) {
      $('ch-name').textContent = 'channels failed to load';
      console.error(e);
    }
  }

  function paint(ch) {
    $('ch-name').textContent = ch.name;
    $('ch-url').textContent = ch.url.replace(/^https?:\/\//, '').replace(/\/$/, '');
    if (typeof ch.productCount === 'number') {
      $('meta-label').textContent = 'products';
      $('ch-kind').textContent = ch.productCount.toLocaleString();
    } else {
      $('meta-label').textContent = 'kind';
      $('ch-kind').textContent = ch.kind || '—';
    }
    swatch.style.background = ch.accent || '#888';
    badge.style.background = ch.accent || '#888';
    badge.style.boxShadow = '0 0 18px ' + (ch.accent || '#888');
    bar.style.background = ch.accent || '#888';
  }

  function rotate() {
    if (!channels.length || paused) return;
    idx = (idx + 1) % channels.length;
    const next = channels[idx];
    const nextIdx = (idx + 1) % channels.length;
    const incoming = active === 'a' ? ifB : ifA;
    const outgoing = active === 'a' ? ifA : ifB;

    incoming.src = '/proxy?url=' + encodeURIComponent(next.url);
    incoming.classList.add('live');
    outgoing.classList.remove('live');

    paint(next);
    $('ch-idx').textContent = String(idx + 1);

    // After the cross-fade, preload the iframe AFTER the next one (warm cache, smooth chain)
    setTimeout(() => {
      outgoing.src = '/proxy?url=' + encodeURIComponent(channels[nextIdx].url);
    }, XFADE_MS + 50);

    active = active === 'a' ? 'b' : 'a';
    lastSwitchAt = Date.now();
  }

  function schedule() {
    clearTimeout(nextTimer);
    nextTimer = setTimeout(() => { rotate(); schedule(); }, ROTATE_MS);
  }

  function tick() {
    const elapsed = Math.min(ROTATE_MS, Date.now() - lastSwitchAt);
    const pct = paused ? bar.style.width : (elapsed / ROTATE_MS * 100) + '%';
    bar.style.width = pct;
    $('legend-next').textContent = paused
      ? '— paused —'
      : Math.max(0, Math.ceil((ROTATE_MS - elapsed) / 1000)) + 's';
    $('legend-uptime').textContent = humanUptime(Math.floor((Date.now() - startedAt) / 1000));
    const now = new Date();
    $('clock').textContent = now.toTimeString().slice(0, 8);
    progressRaf = requestAnimationFrame(tick);
  }

  function humanUptime(s) {
    if (s < 60) return s + 's';
    if (s < 3600) return Math.floor(s/60) + 'm ' + (s%60) + 's';
    return Math.floor(s/3600) + 'h ' + Math.floor((s%3600)/60) + 'm';
  }

  // — Keyboard: space pauses, ←/→ steps, R reloads channel list — //
  document.addEventListener('keydown', (e) => {
    if (e.code === 'Space') {
      e.preventDefault();
      paused = !paused;
      $('paused-tag').classList.toggle('show', paused);
      if (paused) clearTimeout(nextTimer);
      else { lastSwitchAt = Date.now(); schedule(); }
    } else if (e.code === 'ArrowRight') {
      clearTimeout(nextTimer); rotate(); schedule();
    } else if (e.code === 'ArrowLeft') {
      clearTimeout(nextTimer);
      idx = (idx - 2 + channels.length) % channels.length; // -2 because rotate() adds 1
      rotate(); schedule();
    } else if (e.key.toLowerCase() === 'r') {
      loadChannels();
    } else if (e.key.toLowerCase() === 'f') {
      if (document.fullscreenElement) document.exitFullscreen();
      else document.documentElement.requestFullscreen();
    } else if (e.key.toLowerCase() === 'm') {
      location.href = '/mosaic.html';
    } else if (e.key.toLowerCase() === 's') {
      location.href = '/stats.html';
    } else if (e.key === '/') {
      e.preventDefault();
      location.href = '/search.html';
    } else if (e.key.toLowerCase() === 't') {
      location.href = '/today.html';
    } else if (e.key.toLowerCase() === 'a') {
      location.href = '/atlas.html';
    } else if (e.key.toLowerCase() === 'x') {
      location.href = '/matrix.html';
    }
  });

  // — Refresh channel list every 60s so newly-started pm2 sites flow in automatically —
  setInterval(async () => {
    try {
      const r = await fetch('/api/channels', { cache: 'no-store' });
      const j = await r.json();
      if (j.channels && j.channels.length) {
        const cur = channels[idx]?.name;
        channels = j.channels;
        $('ch-total').textContent = String(channels.length);
        $('legend-total').textContent = String(channels.length);
        // re-anchor idx to the same name if still present
        const newIdx = channels.findIndex(c => c.name === cur);
        if (newIdx >= 0) idx = newIdx;
      }
    } catch {}
  }, 60_000);

  loadChannels();
  tick();
})();
</script>
</body>
</html>