← back to Mp3 Agentabrams
public/index.html
461 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>mp3.agentabrams.com · every recording, one grid</title>
<style>
:root {
--bg: #0a0a0a;
--panel: #111;
--panel2: #161616;
--fg: #e6e6e6;
--dim: #888;
--dim2: #5a5a5a;
--accent: #d4af37; /* gold */
--accent2: #4ec9b0; /* teal */
--line: #1f1f1f;
--cols: 5; /* density slider drives this */
}
html, body {
margin: 0; padding: 0;
background: var(--bg); color: var(--fg);
font: 14px/1.45 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
}
a { color: var(--accent); text-decoration: none; }
header {
padding: 18px 22px 14px;
border-bottom: 1px solid var(--line);
background: linear-gradient(180deg, #131313, #0a0a0a);
display: flex; align-items: baseline; gap: 16px; flex-wrap: wrap;
}
h1 {
margin: 0;
font: 600 18px/1 -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
letter-spacing: .5px;
}
h1 .gold { color: var(--accent); font-weight: 700; }
.stats { color: var(--dim); font-size: 12px; }
.stats b { color: var(--fg); font-weight: 600; margin-right: 4px; }
.ctrl {
padding: 10px 22px;
border-bottom: 1px solid var(--line);
display: flex; gap: 16px; align-items: center; flex-wrap: wrap;
background: var(--panel);
font-size: 12px;
}
.ctrl label { color: var(--dim); display: flex; align-items: center; gap: 6px; }
.ctrl input[type="text"] {
background: var(--bg); color: var(--fg);
border: 1px solid var(--line); border-radius: 4px;
padding: 6px 10px; font: inherit; min-width: 240px;
}
.ctrl input[type="text"]:focus { border-color: var(--accent2); outline: none; }
.ctrl select {
background: var(--bg); color: var(--fg);
border: 1px solid var(--line); border-radius: 4px;
padding: 6px 10px; font: inherit;
}
.ctrl input[type="range"] { width: 130px; }
.view-toggle {
display: flex; gap: 0;
border: 1px solid var(--line); border-radius: 4px; overflow: hidden;
}
.view-toggle button {
background: var(--bg); color: var(--dim);
border: 0; padding: 6px 10px; font: inherit; cursor: pointer;
border-right: 1px solid var(--line);
}
.view-toggle button:last-child { border-right: 0; }
.view-toggle button.active { background: var(--accent); color: #000; font-weight: 600; }
/* ---------- GRID VIEW ---------- */
main.grid {
display: grid;
grid-template-columns: repeat(var(--cols), 1fr);
gap: 12px;
padding: 14px;
}
.card {
background: var(--panel2);
border: 1px solid var(--line);
border-radius: 8px;
padding: 10px 12px 8px;
display: flex; flex-direction: column; gap: 6px;
transition: border-color .15s ease, transform .12s ease;
cursor: pointer;
}
.card:hover { border-color: var(--accent); }
.card.playing { border-color: var(--accent2); box-shadow: 0 0 0 1px var(--accent2) inset; }
.card .ico {
aspect-ratio: 1 / 1.15;
background:
radial-gradient(circle at 30% 30%, #2a2a2a 0%, #161616 70%),
linear-gradient(180deg, #1f1f1f, #0a0a0a);
border-radius: 6px;
position: relative;
display: flex; align-items: center; justify-content: center;
overflow: hidden;
}
.card .ico::after {
content: '▶';
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
color: var(--accent); font-size: 36px;
opacity: .85; transition: transform .15s ease;
}
.card:hover .ico::after { transform: scale(1.15); color: #fff; }
.card.playing .ico::after { content: '⏸'; color: var(--accent2); }
.wave {
position: absolute; inset: 60% 8% 12% 8%;
display: flex; align-items: end; gap: 2px;
opacity: .55;
}
.wave span {
flex: 1; background: var(--accent);
border-radius: 1px;
}
.card .title {
font: 600 13px/1.3 -apple-system, system-ui, sans-serif;
color: var(--fg);
overflow: hidden; text-overflow: ellipsis;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
min-height: 2.6em;
}
.card .meta {
font-size: 11px; color: var(--dim);
display: flex; gap: 8px; flex-wrap: wrap;
}
.card .src {
font-size: 10px; color: var(--accent);
text-transform: uppercase; letter-spacing: .5px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* ---------- LIST VIEW ---------- */
main.list {
padding: 0 14px 14px;
}
table.list {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
table.list th {
text-align: left;
padding: 10px 8px;
border-bottom: 1px solid var(--line);
color: var(--dim);
font-weight: 500;
font-size: 11px;
text-transform: uppercase;
letter-spacing: .5px;
position: sticky; top: 0;
background: var(--bg);
}
table.list td {
padding: 8px;
border-bottom: 1px solid var(--line);
vertical-align: middle;
}
table.list tr { cursor: pointer; }
table.list tr:hover td { background: var(--panel2); }
table.list tr.playing td { background: rgba(78,201,176,.08); }
table.list .name { color: var(--fg); font-weight: 500; }
table.list .src-cell { color: var(--accent); font-size: 11px; }
table.list .num { font-variant-numeric: tabular-nums; color: var(--dim); }
/* ---------- PLAYER BAR ---------- */
.player {
position: fixed; bottom: 0; left: 0; right: 0;
background: var(--panel);
border-top: 1px solid var(--accent);
padding: 10px 16px;
display: flex; gap: 14px; align-items: center;
z-index: 100;
transform: translateY(100%);
transition: transform .25s ease;
}
.player.active { transform: translateY(0); }
.player .now {
flex: 1;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.player .now b { color: var(--accent); margin-right: 8px; }
.player audio { flex: 2; max-width: 600px; }
.empty {
padding: 60px 22px;
color: var(--dim);
text-align: center;
}
.empty b { color: var(--accent); }
</style>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
</head>
<body>
<header>
<h1>mp3<span class="gold">.agentabrams</span>.com</h1>
<span class="stats">
<b id="stat-count">0</b>tracks
<b id="stat-size" style="margin-left:14px">0</b>MB
<b id="stat-hours" style="margin-left:14px">0h</b>total
<span style="margin-left:14px; color: var(--dim2);">manifest: <span id="stat-gen">—</span></span>
</span>
</header>
<div class="ctrl">
<input id="filter" type="text" placeholder="filter by name or source…" autocomplete="off" />
<label>
sort:
<select id="sort">
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="name">Title A→Z</option>
<option value="name-desc">Title Z→A</option>
<option value="size">Size ↑</option>
<option value="size-desc">Size ↓</option>
<option value="duration">Duration ↑</option>
<option value="duration-desc">Duration ↓</option>
<option value="source">Source A→Z</option>
</select>
</label>
<label>
source:
<select id="source-filter"><option value="">(all)</option></select>
</label>
<label>
density:
<input id="density" type="range" min="2" max="10" step="1" />
<span id="density-val" class="num" style="color:var(--dim); font-size:11px; min-width:18px;">5</span>
</label>
<div class="view-toggle">
<button data-view="grid" class="active">grid</button>
<button data-view="list">list</button>
</div>
<span style="margin-left:auto; color: var(--dim); font-size: 11px;">
<span id="shown">—</span> shown · <a href="#" id="refresh">refresh manifest</a>
</span>
</div>
<main id="root" class="grid"><div class="empty">loading…</div></main>
<div class="player" id="player">
<span class="now"><b>—</b><span id="now-title">nothing playing</span></span>
<audio id="audio" controls preload="none"></audio>
</div>
<script>
const root = document.getElementById('root');
const filterEl = document.getElementById('filter');
const sortEl = document.getElementById('sort');
const srcEl = document.getElementById('source-filter');
const densityEl = document.getElementById('density');
const densityVal = document.getElementById('density-val');
const viewBtns = document.querySelectorAll('.view-toggle button');
const stats = {
count: document.getElementById('stat-count'),
size: document.getElementById('stat-size'),
hours: document.getElementById('stat-hours'),
gen: document.getElementById('stat-gen'),
shown: document.getElementById('shown'),
};
const player = document.getElementById('player');
const audio = document.getElementById('audio');
const nowTitle = document.getElementById('now-title');
// -------- persistence ----------
const LS_PREFIX = 'mp3aa.';
function load(k, d) { try { const v = localStorage.getItem(LS_PREFIX + k); return v === null ? d : JSON.parse(v); } catch { return d; } }
function save(k, v) { try { localStorage.setItem(LS_PREFIX + k, JSON.stringify(v)); } catch {} }
// hydrate BEFORE first render
let view = load('view', 'grid');
let density = load('density', 5);
let sortMode = load('sort', 'newest');
let srcFilter = load('src', '');
let textFilter = load('filter', '');
filterEl.value = textFilter;
sortEl.value = sortMode;
densityEl.value = density;
densityVal.textContent = density;
document.documentElement.style.setProperty('--cols', density);
for (const b of viewBtns) b.classList.toggle('active', b.dataset.view === view);
root.className = view;
// -------- data ----------
let items = [];
let currentlyPlayingId = null;
function fmtSize(b) {
if (b < 1024) return b + ' B';
if (b < 1024 * 1024) return (b / 1024).toFixed(0) + ' KB';
return (b / (1024 * 1024)).toFixed(1) + ' MB';
}
function fmtDur(s) {
if (s == null) return '—';
const m = Math.floor(s / 60);
const r = Math.floor(s % 60);
return m + ':' + String(r).padStart(2, '0');
}
function fmtDate(ms) {
const d = new Date(ms);
return d.toISOString().slice(0, 16).replace('T', ' ');
}
// tiny pseudo-waveform decoration — deterministic per id
function waveHtml(id) {
let seed = 0; for (const c of id) seed = (seed * 31 + c.charCodeAt(0)) & 0xffffffff;
const bars = [];
for (let i = 0; i < 16; i++) {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
const h = 15 + (seed % 65);
bars.push(`<span style="height:${h}%"></span>`);
}
return `<div class="wave">${bars.join('')}</div>`;
}
function applyFilters(arr) {
const q = textFilter.trim().toLowerCase();
let out = arr;
if (q) {
out = out.filter((it) =>
it.name.toLowerCase().includes(q) ||
(it.parent || '').toLowerCase().includes(q) ||
(it.parent2 || '').toLowerCase().includes(q)
);
}
if (srcFilter) out = out.filter((it) => it.parent === srcFilter || it.parent2 === srcFilter);
switch (sortMode) {
case 'oldest': out = [...out].sort((a, b) => a.mtime - b.mtime); break;
case 'name': out = [...out].sort((a, b) => a.name.localeCompare(b.name)); break;
case 'name-desc': out = [...out].sort((a, b) => b.name.localeCompare(a.name)); break;
case 'size': out = [...out].sort((a, b) => a.size - b.size); break;
case 'size-desc': out = [...out].sort((a, b) => b.size - a.size); break;
case 'duration': out = [...out].sort((a, b) => (a.duration ?? 1e9) - (b.duration ?? 1e9)); break;
case 'duration-desc':out = [...out].sort((a, b) => (b.duration ?? 0) - (a.duration ?? 0)); break;
case 'source': out = [...out].sort((a, b) => (a.parent || '').localeCompare(b.parent || '')); break;
default: out = [...out].sort((a, b) => b.mtime - a.mtime); /* newest */
}
return out;
}
function render() {
const arr = applyFilters(items);
stats.shown.textContent = arr.length;
if (!arr.length) {
root.innerHTML = `<div class="empty">no MP3s match. <b>filter:</b> "${textFilter}" / <b>source:</b> "${srcFilter || 'all'}"</div>`;
return;
}
if (view === 'grid') {
root.className = 'grid';
root.innerHTML = arr.map((it) => `
<div class="card${it.id === currentlyPlayingId ? ' playing' : ''}" data-id="${it.id}" title="${escapeAttr(it.path)}">
<div class="ico">${waveHtml(it.id)}</div>
<div class="title">${escapeHtml(it.name)}</div>
<div class="meta">
<span>${fmtDur(it.duration)}</span>
<span>${fmtSize(it.size)}</span>
</div>
<div class="src">${escapeHtml(it.parent || '?')}</div>
<div class="meta" style="font-size:10px">${fmtDate(it.mtime)}</div>
</div>
`).join('');
} else {
root.className = 'list';
root.innerHTML = `
<table class="list">
<thead><tr>
<th>Title</th><th>Source</th><th class="num">Dur</th><th class="num">Size</th><th>Date</th>
</tr></thead>
<tbody>
${arr.map((it) => `
<tr class="${it.id === currentlyPlayingId ? 'playing' : ''}" data-id="${it.id}">
<td class="name">${escapeHtml(it.name)}</td>
<td class="src-cell">${escapeHtml(it.parent || '?')}</td>
<td class="num">${fmtDur(it.duration)}</td>
<td class="num">${fmtSize(it.size)}</td>
<td class="num">${fmtDate(it.mtime)}</td>
</tr>`).join('')}
</tbody>
</table>`;
}
}
function escapeHtml(s) { return String(s).replace(/[&<>"]/g, (c) => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
function escapeAttr(s) { return escapeHtml(s).replace(/'/g, '''); }
function play(id) {
const item = items.find((x) => x.id === id);
if (!item) return;
currentlyPlayingId = id;
audio.src = '/audio?id=' + encodeURIComponent(id);
audio.play();
nowTitle.textContent = item.name + ' · ' + (item.parent || '');
player.classList.add('active');
render();
}
root.addEventListener('click', (e) => {
const el = e.target.closest('[data-id]');
if (!el) return;
const id = el.dataset.id;
if (id === currentlyPlayingId && !audio.paused) { audio.pause(); currentlyPlayingId = null; render(); }
else play(id);
});
audio.addEventListener('ended', () => { currentlyPlayingId = null; render(); });
filterEl.addEventListener('input', () => { textFilter = filterEl.value; save('filter', textFilter); render(); });
sortEl.addEventListener('change', () => { sortMode = sortEl.value; save('sort', sortMode); render(); });
srcEl.addEventListener('change', () => { srcFilter = srcEl.value; save('src', srcFilter); render(); });
densityEl.addEventListener('input', () => {
density = +densityEl.value;
densityVal.textContent = density;
document.documentElement.style.setProperty('--cols', density);
save('density', density);
});
for (const b of viewBtns) {
b.addEventListener('click', () => {
view = b.dataset.view;
for (const x of viewBtns) x.classList.toggle('active', x === b);
save('view', view);
render();
});
}
document.getElementById('refresh').addEventListener('click', async (e) => {
e.preventDefault();
const a = e.target;
a.textContent = 'scanning…';
try {
const r = await fetch('/api/refresh', { method: 'POST' });
const j = await r.json();
a.textContent = j.ok ? `refreshed (${j.count})` : 'refresh error';
await loadManifest();
} catch { a.textContent = 'refresh failed'; }
setTimeout(() => { a.textContent = 'refresh manifest'; }, 2200);
});
async function loadManifest() {
const r = await fetch('/api/manifest');
const m = await r.json();
items = m.items || [];
stats.count.textContent = items.length.toLocaleString();
stats.size.textContent = ((m.totalSizeBytes || 0) / 1024 / 1024).toFixed(0).toLocaleString();
const totalSec = items.reduce((a, b) => a + (b.duration || 0), 0);
stats.hours.textContent = (totalSec / 3600).toFixed(1) + 'h';
stats.gen.textContent = m.generatedAt ? m.generatedAt.slice(0, 16).replace('T', ' ') : '(none)';
// populate source filter dropdown from unique parents
const sources = [...new Set(items.map((i) => i.parent).filter(Boolean))].sort();
srcEl.innerHTML = '<option value="">(all)</option>' + sources.map((s) => `<option ${s === srcFilter ? 'selected' : ''} value="${escapeAttr(s)}">${escapeHtml(s)}</option>`).join('');
render();
}
loadManifest();
</script>
</body>
</html>