← back to Clipreviewbydave

public/index.html

197 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Clip Review — mark in/out points & notes</title>
<style>
  :root { --bg:#0d1017; --card:#171b26; --accent:#F59E0B; --deep:#B45309; --text:#F3F4F6; --dim:#9CA3AF; --line:#262c3a; }
  * { box-sizing: border-box; }
  body { background: var(--bg); color: var(--text); font-family: -apple-system, Helvetica, sans-serif; margin: 0; padding: 24px; }
  h1 { font-size: 22px; margin: 0 0 4px; }
  .sub { color: var(--dim); margin-bottom: 18px; font-size: 14px; line-height: 1.5; }
  .toolbar { position: sticky; top: 0; background: var(--bg); padding: 12px 0; z-index: 10; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; border-bottom: 1px solid var(--line); margin-bottom: 20px; }
  .toolbar label { color: var(--dim); font-size: 12px; display: inline-flex; align-items: center; gap: 6px; }
  select { background: var(--card); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 6px 8px; font-size: 13px; }
  .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 20px; }
  .card { background: var(--card); border-radius: 14px; padding: 14px; }
  .card h3 { margin: 0 0 8px; font-size: 15px; display: flex; justify-content: space-between; gap: 10px; word-break: break-all; }
  .card h3 .dur { color: var(--dim); font-weight: normal; white-space: nowrap; }
  video { width: 100%; border-radius: 10px; background: #000; }
  .row { display: flex; gap: 6px; margin: 8px 0; flex-wrap: wrap; align-items: center; }
  button { background: var(--accent); color: #0d1017; border: 0; border-radius: 8px; padding: 7px 12px; font-size: 13px; font-weight: 700; cursor: pointer; }
  button:hover { filter: brightness(1.12); }
  button.alt { background: var(--line); color: var(--text); }
  button.gold { background: var(--deep); color: #fff; }
  .time { font-variant-numeric: tabular-nums; color: var(--accent); font-weight: 700; min-width: 64px; }
  textarea { width: 100%; min-height: 84px; background: #0a0d14; color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 8px; font-size: 13px; font-family: Menlo, Consolas, monospace; resize: vertical; }
  .hint { color: var(--dim); font-size: 12px; margin-top: 18px; line-height: 1.6; }
  .hint b { color: var(--text); }
  #drop { border: 2px dashed var(--line); border-radius: 14px; padding: 44px 20px; text-align: center; color: var(--dim); margin-bottom: 20px; transition: border-color .15s, background .15s; }
  #drop.hot { border-color: var(--accent); background: rgba(245,158,11,0.06); color: var(--text); }
  #drop strong { color: var(--text); }
  #saveState { color: var(--dim); font-size: 12px; }
</style>
</head>
<body>
<h1>🎬 Clip Review</h1>
<div class="sub">Load your videos, scrub each one, and stamp the moments worth keeping. Everything stays on your computer — nothing is uploaded anywhere. Export your notes when you're done.</div>

<div class="toolbar">
  <button onclick="document.getElementById('file').click()">+ Add videos</button>
  <input id="file" type="file" accept="video/*" multiple style="display:none" onchange="addFiles(this.files)">
  <label>From server
    <select id="srv"><option value="">— loading… —</option></select>
  </label>
  <input id="srvFilter" placeholder="filter (e.g. rentv)" style="display:none">
  <label>Frame rate
    <select id="fps" onchange="localStorage.setItem('cr_fps', this.value)">
      <option value="30">30 fps</option>
      <option value="24">24 fps</option>
      <option value="25">25 fps</option>
      <option value="60">60 fps</option>
    </select>
  </label>
  <button class="alt" onclick="exportNotes()">⬇ Export notes</button>
  <button class="alt" onclick="copyNotes()">Copy notes</button>
  <span id="saveState">notes autosave as you type</span>
</div>

<div id="drop">
  <strong>Drag video files here</strong>, or use “+ Add videos”.<br>
  Your clips never leave this computer.
</div>

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

<div class="hint">
  <b>IN</b> / <b>OUT</b> — stamp the current time as the start/end of a clip you want to keep (pair them up). <b>★</b> — mark a great single frame (a screenshot moment). Add plain-language notes too (“skip this bit”, “speed up”, “use this take”).<br>
  Transport: <b>▶︎/⏸</b> play-pause · <b>−3s / −1s</b> jump back · <b>◀︎ fr / fr ▶︎</b> step one frame (set the frame rate above to match your footage) · <b>½× / 1× / 2×</b> speed.<br>
  Keyboard (after clicking any video's buttons): <b>space</b> play/pause · <b>←/→</b> ±1s (<b>shift</b> = ±3s) · <b>,</b> / <b>.</b> frame back/forward · <b>i</b>=IN · <b>o</b>=OUT · <b>s</b>=★.<br>
  Notes are saved in this browser and keyed to each file name — reload the page or re-add the same files and your notes come back.
</div>

<script>
  const grid = document.getElementById('grid');
  const drop = document.getElementById('drop');
  let activeId = null;
  const loaded = new Set();      // filenames already on screen
  const order = [];              // preserve add order for export

  // restore saved frame rate
  const savedFps = localStorage.getItem('cr_fps');
  if (savedFps) document.getElementById('fps').value = savedFps;
  function fps() { return parseFloat(document.getElementById('fps').value) || 30; }

  function keyFor(name) { return 'crnotes_' + name; }
  function idFor(name) { return 'c_' + name.replace(/[^a-z0-9]/gi, '_'); }

  function addFiles(fileList) {
    [...fileList].forEach(f => {
      if (!f.type.startsWith('video/') && !/\.(mp4|mov|m4v|webm|mkv)$/i.test(f.name)) return;
      if (loaded.has(f.name)) return;
      loaded.add(f.name);
      order.push(f.name);
      buildCard(f);
    });
    drop.style.display = loaded.size ? 'none' : '';
  }

  function buildCard(file) { buildCardFromUrl(file.name, URL.createObjectURL(file)); }
  function buildCardFromUrl(name, url) {
    const id = idFor(name);
    const card = document.createElement('div');
    card.className = 'card';
    card.innerHTML = `
      <h3><span>${name}</span> <span class="dur" id="dur_${id}"></span></h3>
      <video id="v_${id}" src="${url}" controls preload="metadata" playsinline></video>
      <div class="row">
        <span class="time" id="t_${id}">0.00s</span>
        <button onclick="stamp('${id}','IN')">IN</button>
        <button onclick="stamp('${id}','OUT')">OUT</button>
        <button class="gold" onclick="stamp('${id}','★')">★</button>
      </div>
      <div class="row">
        <button class="alt" id="pp_${id}" onclick="playPause('${id}')">▶︎</button>
        <button class="alt" onclick="nudge('${id}',-3)">−3s</button>
        <button class="alt" onclick="nudge('${id}',-1)">−1s</button>
        <button class="alt" onclick="frameStep('${id}',-1)">◀︎ fr</button>
        <button class="alt" onclick="frameStep('${id}',1)">fr ▶︎</button>
        <button class="alt" onclick="rate('${id}',0.5)">½×</button>
        <button class="alt" onclick="rate('${id}',1)">1×</button>
        <button class="alt" onclick="rate('${id}',2)">2×</button>
      </div>
      <textarea id="n_${id}" data-name="${name}" placeholder="notes for this clip…"></textarea>`;
    grid.appendChild(card);

    const v = document.getElementById('v_'+id);
    v.addEventListener('timeupdate', () => document.getElementById('t_'+id).textContent = v.currentTime.toFixed(2)+'s');
    v.addEventListener('seeked',     () => document.getElementById('t_'+id).textContent = v.currentTime.toFixed(2)+'s');
    v.addEventListener('play',  () => { activeId = id; document.getElementById('pp_'+id).textContent = '⏸'; });
    v.addEventListener('pause', () => document.getElementById('pp_'+id).textContent = '▶︎');
    v.addEventListener('loadedmetadata', () => document.getElementById('dur_'+id).textContent = fmt(v.duration));

    const n = document.getElementById('n_'+id);
    n.value = localStorage.getItem(keyFor(name)) || '';
    n.addEventListener('input', () => localStorage.setItem(keyFor(name), n.value));
    n.addEventListener('focus', () => { activeId = id; });
  }

  function fmt(s) { s = Math.round(s); return Math.floor(s/60)+':'+String(s%60).padStart(2,'0'); }
  const vid = id => document.getElementById('v_'+id);

  function stamp(id, tag) {
    activeId = id;
    const v = vid(id), n = document.getElementById('n_'+id);
    n.value += (n.value && !n.value.endsWith('\n') ? '\n' : '') + `[${tag} ${v.currentTime.toFixed(2)}s] `;
    n.focus();
    localStorage.setItem(keyFor(n.dataset.name), n.value);
  }
  function rate(id, r) { activeId = id; vid(id).playbackRate = r; }
  function nudge(id, secs) { activeId = id; const v = vid(id); v.currentTime = Math.max(0, Math.min(v.duration || 1e9, v.currentTime + secs)); }
  function playPause(id) { activeId = id; const v = vid(id); if (v.paused) v.play(); else v.pause(); }
  function frameStep(id, dir) { activeId = id; const v = vid(id); v.pause(); v.currentTime = Math.max(0, Math.min(v.duration || 1e9, v.currentTime + dir*(1/fps()))); }

  document.addEventListener('keydown', (e) => {
    if (!activeId) return;
    if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
    const map = {
      ' ': () => playPause(activeId),
      'ArrowLeft': () => nudge(activeId, e.shiftKey ? -3 : -1),
      'ArrowRight': () => nudge(activeId, e.shiftKey ? 3 : 1),
      ',': () => frameStep(activeId, -1),
      '.': () => frameStep(activeId, 1),
      'i': () => stamp(activeId, 'IN'),
      'o': () => stamp(activeId, 'OUT'),
      's': () => stamp(activeId, '★'),
    };
    if (map[e.key]) { e.preventDefault(); map[e.key](); }
  });

  // drag & drop anywhere
  ['dragenter','dragover'].forEach(ev => document.addEventListener(ev, e => { e.preventDefault(); drop.classList.add('hot'); }));
  ['dragleave','drop'].forEach(ev => document.addEventListener(ev, e => { e.preventDefault(); if (ev !== 'drop') drop.classList.remove('hot'); }));
  document.addEventListener('drop', e => { drop.classList.remove('hot'); if (e.dataTransfer && e.dataTransfer.files) addFiles(e.dataTransfer.files); });

  function buildExport() {
    let out = '# Clip notes — ' + new Date().toLocaleString() + '\n\n';
    order.forEach(name => {
      const txt = (localStorage.getItem(keyFor(name)) || '').trim();
      if (txt) out += `## ${name}\n${txt}\n\n`;
    });
    return out;
  }
  function exportNotes() {
    const blob = new Blob([buildExport()], {type:'text/markdown'});
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = 'clip-notes.md';
    a.click();
  }
  function copyNotes() { navigator.clipboard.writeText(buildExport()).then(()=>{ document.getElementById('saveState').textContent='copied ✓'; }); }
</script>
<!-- Additive layer (not part of Dave's original): "Load from server" picker. See README / SKILL.md. -->
<script src="/server-load.js"></script>
</body>
</html>