← 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>