← back to Fleet Tv

public/matrix.html

319 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Fleet TV — Matrix</title>
<style>
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  :root {
    --bg: #07080a;
    --fg: #e8e6e0;
    --dim: #6a6862;
    --rule: rgba(255,255,255,0.08);
    --accent: #c9a14b;
  }
  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 {
    height: 100vh; display: grid; grid-template-rows: auto 1fr auto;
  }

  .ribbon {
    display: flex; align-items: center; gap: 1.25rem;
    padding: 0.65rem 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 h1 { font-size: 0.85rem; letter-spacing: 0.18em; text-transform: uppercase; font-weight: 500; }
  .ribbon .crumb { color: var(--dim); font-size: 0.72rem; letter-spacing: 0.16em; text-transform: uppercase; }
  .ribbon .crumb a { color: var(--dim); text-decoration: none; border-bottom: 1px solid transparent; }
  .ribbon .crumb a:hover { color: var(--fg); border-color: var(--rule); }
  .ribbon .now { margin-left: auto; color: var(--dim); font-size: 0.72rem; letter-spacing: 0.18em; text-transform: uppercase; font-variant-numeric: tabular-nums; }
  .ribbon .now strong { color: var(--fg); font-weight: 500; }

  /* — Matrix grid: 4 cols × 2 rows = 8 tiles. Adjusts to viewport. — */
  .grid {
    display: grid; gap: 4px;
    padding: 4px;
    grid-template-columns: repeat(4, minmax(0, 1fr));
    grid-template-rows: repeat(2, minmax(0, 1fr));
    min-height: 0;
  }

  .tile {
    position: relative; overflow: hidden;
    background: #0c0e12;
    border: 1px solid var(--rule);
    border-radius: 3px;
  }
  .tile iframe {
    position: absolute; inset: 0; width: 100%; height: 100%;
    border: 0;
    opacity: 0;
    transition: opacity 0.4s ease;
  }
  .tile iframe.live { opacity: 1; }

  .tile .pill {
    position: absolute; top: 0.4rem; left: 0.4rem; z-index: 2;
    padding: 0.18rem 0.55rem 0.2rem;
    font-size: 0.58rem; letter-spacing: 0.18em; text-transform: uppercase;
    background: rgba(0,0,0,0.78);
    border: 1px solid currentColor;
    border-radius: 999px;
    color: #fff;
    backdrop-filter: blur(4px);
    pointer-events: none;
  }
  .tile .open {
    position: absolute; top: 0.4rem; right: 0.4rem; z-index: 2;
    padding: 0.18rem 0.55rem 0.2rem;
    font-size: 0.58rem; letter-spacing: 0.18em; text-transform: uppercase;
    background: rgba(0,0,0,0.78);
    border: 1px solid var(--rule);
    border-radius: 999px;
    color: var(--fg);
    text-decoration: none;
    transition: border-color 0.2s ease;
  }
  .tile .open:hover { border-color: var(--accent); color: var(--accent); }
  .tile .progress {
    position: absolute; left: 0; right: 0; bottom: 0; height: 2px; z-index: 1;
    background: rgba(255,255,255,0.04);
  }
  .tile .progress > div {
    height: 100%; width: 0; background: var(--accent);
    transition: background 0.4s ease;
  }
  .tile .scrim {
    position: absolute; inset: 0; pointer-events: none;
    background: radial-gradient(ellipse at center, transparent 60%, rgba(0,0,0,0.18) 100%);
  }

  .ticker {
    padding: 0.5rem 1.25rem;
    border-top: 1px solid var(--rule);
    background: rgba(0,0,0,0.5);
    color: var(--dim); font-size: 0.7rem; letter-spacing: 0.18em; text-transform: uppercase;
    display: flex; gap: 1.5rem; align-items: center; justify-content: space-between;
  }
  .ticker b { color: var(--fg); font-weight: 500; }
  .ticker .keys { color: var(--dim); }
  .ticker .keys kbd {
    background: rgba(255,255,255,0.04); border: 1px solid var(--rule); border-radius: 3px;
    padding: 0.1rem 0.4rem; margin-right: 0.15rem;
    font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 0.65rem; color: var(--fg);
  }

  .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; } }

  /* — Tile-count toggle — */
  .stage[data-cols="3"] .grid { grid-template-columns: repeat(3, 1fr); grid-template-rows: repeat(3, 1fr); }
  .stage[data-cols="2"] .grid { grid-template-columns: repeat(2, 1fr); grid-template-rows: repeat(2, 1fr); }
  .stage[data-cols="5"] .grid { grid-template-columns: repeat(5, 1fr); grid-template-rows: repeat(2, 1fr); }
</style>
</head>
<body>

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

<div class="stage" id="stage" data-cols="4">

  <div class="ribbon">
    <h1>Fleet TV</h1>
    <span class="crumb"><a href="/">wall</a> · <a href="/mosaic.html">mosaic</a> · <a href="/stats.html">stats</a> · <a href="/search.html">search</a> · <a href="/today.html">today</a> · <a href="/atlas.html">atlas</a> · matrix</span>
    <div class="now">
      tiles <strong id="tiles-count">8</strong> · pool <strong id="pool-count">—</strong> · <strong id="clock">—</strong>
    </div>
  </div>

  <div class="grid" id="grid"></div>

  <div class="ticker">
    <span>matrix · 8 fleet sites · independent rotation 15s · staggered phase</span>
    <span class="keys"><kbd>2</kbd><kbd>3</kbd><kbd>4</kbd><kbd>5</kbd> tiles · <kbd>R</kbd> reload · <kbd>Space</kbd> pause · <kbd>W</kbd>all <kbd>M</kbd>osaic <kbd>S</kbd>tats <kbd>T</kbd>oday <kbd>A</kbd>tlas <kbd>/</kbd>search</span>
  </div>

</div>

<script>
(() => {
  const ROTATE_MS = 15000;
  let channels = [];
  let tiles = [];
  let paused = false;
  let pickIdx = 0; // global cursor through the channel pool
  const $ = (id) => document.getElementById(id);

  function nextChannel() {
    if (!channels.length) return null;
    const c = channels[pickIdx % channels.length];
    pickIdx = (pickIdx + 1) % channels.length;
    return c;
  }

  function buildTiles(n) {
    const grid = $('grid');
    grid.innerHTML = '';
    tiles.forEach(t => clearTimeout(t._timer));
    tiles = [];
    pickIdx = 0;
    for (let i = 0; i < n; i++) {
      const tile = document.createElement('div');
      tile.className = 'tile';
      tile.innerHTML = `
        <span class="pill">—</span>
        <a class="open" target="_blank" rel="noopener noreferrer" href="#">open ↗</a>
        <iframe loading="lazy" referrerpolicy="no-referrer"></iframe>
        <iframe loading="lazy" referrerpolicy="no-referrer"></iframe>
        <div class="scrim"></div>
        <div class="progress"><div></div></div>
      `;
      grid.appendChild(tile);
      const t = {
        el: tile,
        pill: tile.querySelector('.pill'),
        open: tile.querySelector('.open'),
        ifA: tile.querySelectorAll('iframe')[0],
        ifB: tile.querySelectorAll('iframe')[1],
        bar: tile.querySelector('.progress > div'),
        active: 'a',
        startedAt: 0,
        _timer: null,
        _channel: null,
      };
      tiles.push(t);

      // Initial channel + staggered first rotation
      const ch = nextChannel(); if (!ch) continue;
      t._channel = ch;
      t.ifA.src = '/proxy?url=' + encodeURIComponent(ch.url);
      t.ifA.classList.add('live');
      paint(t, ch);
      // Stagger the first rotation so tiles don't all flip at the same instant
      const phase = Math.floor((ROTATE_MS / n) * i);
      t.startedAt = Date.now() - phase;
      scheduleRotation(t);
    }
    $('tiles-count').textContent = String(n);
  }

  function paint(t, ch) {
    t._channel = ch;
    t.pill.textContent = ch.name.replace(/wallpaper$|wallcoverings?$|walls$/i, m => '·' + m);
    t.pill.style.color = ch.accent || '#c9a14b';
    t.bar.style.background = ch.accent || '#c9a14b';
    t.open.href = ch.url;
  }

  function rotate(t) {
    if (paused) { scheduleRotation(t); return; }
    const ch = nextChannel(); if (!ch) return;
    const incoming = t.active === 'a' ? t.ifB : t.ifA;
    const outgoing = t.active === 'a' ? t.ifA : t.ifB;
    incoming.src = '/proxy?url=' + encodeURIComponent(ch.url);
    incoming.classList.add('live');
    outgoing.classList.remove('live');
    t.active = t.active === 'a' ? 'b' : 'a';
    paint(t, ch);
    t.startedAt = Date.now();
    scheduleRotation(t);
  }

  function scheduleRotation(t) {
    clearTimeout(t._timer);
    const elapsed = Date.now() - t.startedAt;
    const remain = Math.max(500, ROTATE_MS - elapsed);
    t._timer = setTimeout(() => rotate(t), remain);
  }

  function tickProgress() {
    const now = Date.now();
    for (const t of tiles) {
      const elapsed = Math.min(ROTATE_MS, now - t.startedAt);
      t.bar.style.width = (elapsed / ROTATE_MS * 100) + '%';
    }
    $('clock').textContent = new Date().toTimeString().slice(0,8);
    requestAnimationFrame(tickProgress);
  }

  async function loadChannels() {
    const r = await fetch('/api/channels', { cache: 'no-store' });
    const j = await r.json();
    channels = j.channels || [];
    $('pool-count').textContent = String(channels.length);
    if (!channels.length) {
      $('splash').querySelector('.mark').textContent = 'no channels available';
      return;
    }
    // Shuffle so different visits show different sets initially
    for (let i = channels.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      [channels[i], channels[j]] = [channels[j], channels[i]];
    }
    setTimeout(() => $('splash').classList.add('hide'), 300);
    buildTiles(8);
  }

  // Keyboard: number keys swap tile counts; space pauses; nav letters
  document.addEventListener('keydown', (e) => {
    if (['2','3','4','5'].includes(e.key)) {
      const n = parseInt(e.key, 10) ** 2 / (e.key === '5' ? 2.5 : (e.key === '2' ? 1 : (e.key === '3' ? 1 : 1.6)));
      // Direct mapping is cleaner — 2→4, 3→6, 4→8, 5→10
      const map = { '2': 4, '3': 6, '4': 8, '5': 10 };
      const cols = parseInt(e.key, 10);
      $('stage').dataset.cols = String(cols);
      buildTiles(map[e.key]);
    } else if (e.code === 'Space') {
      e.preventDefault();
      paused = !paused;
    } else if (e.key.toLowerCase() === 'r') {
      loadChannels();
    } else if (e.key.toLowerCase() === 'w') {
      location.href = '/';
    } else if (e.key.toLowerCase() === 'm') {
      location.href = '/mosaic.html';
    } else if (e.key.toLowerCase() === 's' && !e.metaKey && !e.ctrlKey) {
      location.href = '/stats.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 === '/') {
      e.preventDefault(); location.href = '/search.html';
    }
  });

  // Refresh channel pool every 60s so newly-started sites appear
  setInterval(async () => {
    try {
      const r = await fetch('/api/channels', { cache: 'no-store' });
      const j = await r.json();
      if (j.channels && j.channels.length) {
        channels = j.channels;
        $('pool-count').textContent = String(channels.length);
      }
    } catch {}
  }, 60_000);

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