← 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) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
    function escapeAttr(s) { return escapeHtml(s).replace(/'/g, '&#39;'); }

    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>