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