feat: instant section playback via server-rendered clips
Add /api/clip: decodes a WAV/FLAC slice server-side and returns a small standalone 16-bit WAV with exact Content-Length (capped at 600s, cached client-side since finished recordings are immutable). Active recordings are refused like analyse/cut/delete. Section chips and J/K now play these clips through a bottom player bar instead of seeking the full recording - FLACs have no seek table, so browser seeks bisected hundreds of MB with Range requests and playback lagged or never started. The bar steps through a queue (one file's sections or a whole day's via Highlights), auto-advances to the next section on end for continuous review, and "Open in file" jumps to the same position in the full recording for context. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+99
-23
@@ -121,6 +121,16 @@ table.day-table{width:100%;border-collapse:collapse;border:1px solid var(--brd);
|
||||
svg.day-timeline{display:block;width:100%;height:22px}
|
||||
.day-tl-labels{display:flex;justify-content:space-between;font-size:10px;
|
||||
color:var(--muted);font-family:ui-monospace,monospace;margin-top:2px}
|
||||
/* clip player bar */
|
||||
#clip-bar{position:fixed;bottom:0;left:0;right:0;z-index:20;background:var(--surf);
|
||||
border-top:1px solid var(--brd);padding:8px 28px;display:flex;align-items:center;
|
||||
gap:10px;flex-wrap:wrap}
|
||||
#clip-bar audio{flex:1 1 240px;min-width:180px;height:32px}
|
||||
#clip-label{font-size:12px;color:var(--muted);font-family:ui-monospace,monospace;
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:38%}
|
||||
#clip-auto-label{font-size:12px;color:var(--muted);display:flex;align-items:center;
|
||||
gap:4px;white-space:nowrap;cursor:pointer}
|
||||
body.clip-open{padding-bottom:70px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -159,6 +169,15 @@ svg.day-timeline{display:block;width:100%;height:22px}
|
||||
<div id="tbody" role="region" aria-label="Recordings archive"></div>
|
||||
<div id="empty" class="empty" style="display:none" role="status">No recordings found.</div>
|
||||
</div>
|
||||
<div id="clip-bar" hidden role="region" aria-label="Section clip player">
|
||||
<button id="clip-prev" aria-label="Previous section (J)">⏮</button>
|
||||
<button id="clip-next" aria-label="Next section (K)">⏭</button>
|
||||
<span id="clip-label"></span>
|
||||
<audio id="clip-audio" controls preload="auto" aria-label="Section clip playback"></audio>
|
||||
<label id="clip-auto-label"><input type="checkbox" id="clip-auto" checked> Auto-advance</label>
|
||||
<button id="clip-context" title="Open the full recording at this position">⤴ Open in file</button>
|
||||
<button id="clip-close" aria-label="Close clip player">✕</button>
|
||||
</div>
|
||||
<script>
|
||||
const esc = s => String(s)
|
||||
.replace(/&/g,'&').replace(/</g,'<')
|
||||
@@ -203,9 +222,8 @@ let activePlayerIdx = null;
|
||||
let allFiles = [];
|
||||
// dayId -> boolean, persists expanded state across re-renders
|
||||
const dayExpanded = new Map();
|
||||
// cross-file day section navigation (populated by ★ Highlights)
|
||||
// cross-file day section list (populated by ★ Highlights)
|
||||
let dayActiveSections = [];
|
||||
let dayActiveSectionCursor = -1;
|
||||
let dayActiveId = null;
|
||||
|
||||
function groupByDay(files) {
|
||||
@@ -321,6 +339,70 @@ function seekToSection(idx, filename, startSec, endSec, sectionIdx) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- clip player -----------------------------------------------------------
|
||||
// Sections play as small server-rendered WAV clips (/api/clip) in the bottom
|
||||
// bar instead of seeking the full recording, which is slow for big FLACs.
|
||||
// clipQueue holds the active review list (one file's sections, or a whole
|
||||
// day's); J/K and ⏮/⏭ step through it.
|
||||
let clipQueue = [];
|
||||
let clipCursor = -1;
|
||||
|
||||
function playClip(i) {
|
||||
if (i < 0 || i >= clipQueue.length) return;
|
||||
clipCursor = i;
|
||||
const c = clipQueue[i];
|
||||
const cs = Math.max(0, c.start - getPreroll());
|
||||
const ce = c.end + 1.5; // small tail after the section
|
||||
const a = document.getElementById('clip-audio');
|
||||
a.src = '/api/clip?file=' + encodeURIComponent(c.filename)
|
||||
+ '&start=' + cs.toFixed(1) + '&end=' + ce.toFixed(1);
|
||||
a.play().catch(() => {});
|
||||
document.getElementById('clip-label').textContent =
|
||||
`${i + 1}/${clipQueue.length} · ${c.filename} @ ${fmtDur(c.start)}–${fmtDur(c.end)}`
|
||||
+ (c.score != null ? ` · +${Math.round(c.score)} dB` : '');
|
||||
document.getElementById('clip-bar').hidden = false;
|
||||
document.body.classList.add('clip-open');
|
||||
setCutFields(c.fileIdx, c.start, c.end);
|
||||
announce(`Clip ${i + 1} of ${clipQueue.length}: ${fmtDur(c.start)} to ${fmtDur(c.end)} in ${c.filename}`);
|
||||
}
|
||||
|
||||
function hideClipBar() {
|
||||
const a = document.getElementById('clip-audio');
|
||||
a.pause();
|
||||
a.removeAttribute('src');
|
||||
document.getElementById('clip-bar').hidden = true;
|
||||
document.body.classList.remove('clip-open');
|
||||
clipQueue = [];
|
||||
clipCursor = -1;
|
||||
}
|
||||
|
||||
function playFileSection(idx, filename, si) {
|
||||
const secs = sectionMap.get(idx) || [];
|
||||
clipQueue = secs.map(s => ({fileIdx: idx, filename, start: s.start, end: s.end, score: s.score}));
|
||||
playClip(si);
|
||||
}
|
||||
|
||||
document.getElementById('clip-prev').addEventListener('click', () => playClip(clipCursor - 1));
|
||||
document.getElementById('clip-next').addEventListener('click', () => playClip(clipCursor + 1));
|
||||
document.getElementById('clip-close').addEventListener('click', hideClipBar);
|
||||
document.getElementById('clip-audio').addEventListener('ended', () => {
|
||||
if (document.getElementById('clip-auto').checked && clipCursor + 1 < clipQueue.length)
|
||||
playClip(clipCursor + 1);
|
||||
});
|
||||
document.getElementById('clip-context').addEventListener('click', () => {
|
||||
const c = clipQueue[clipCursor];
|
||||
if (!c) return;
|
||||
document.getElementById('clip-audio').pause();
|
||||
const row = document.getElementById('row-' + c.fileIdx);
|
||||
if (row) {
|
||||
const tbl = row.closest('table');
|
||||
if (tbl && tbl.hidden) // file's day is collapsed: expand it
|
||||
document.getElementById('daytgl-' + tbl.id.slice('daytbl-'.length))?.click();
|
||||
row.scrollIntoView({block: 'center'});
|
||||
}
|
||||
seekToSection(c.fileIdx, c.filename, c.start, c.end, null);
|
||||
});
|
||||
|
||||
// filename|margin|gap -> analysis result, so re-renders (filtering,
|
||||
// refresh) never refetch what this session already has
|
||||
const analysisCache = new Map();
|
||||
@@ -369,10 +451,10 @@ async function analyse(idx, filename, cell, btn, force = false) {
|
||||
d.sections.forEach((s, si) => {
|
||||
const c = document.createElement('button');
|
||||
c.className='chip';
|
||||
c.title = 'Jump to this section (or use J/K keys)';
|
||||
c.title = 'Play this section (or use J/K keys)';
|
||||
c.textContent = `${fmtDur(s.start)} – ${fmtDur(s.end)}`
|
||||
+ (s.score != null ? ` · +${Math.round(s.score)} dB` : '');
|
||||
c.addEventListener('click', () => seekToSection(idx, filename, s.start, s.end, si));
|
||||
c.addEventListener('click', () => playFileSection(idx, filename, si));
|
||||
chips.appendChild(c);
|
||||
});
|
||||
} else {
|
||||
@@ -402,21 +484,19 @@ document.addEventListener('keydown', e => {
|
||||
if (e.key !== 'j' && e.key !== 'J' && e.key !== 'k' && e.key !== 'K') return;
|
||||
e.preventDefault();
|
||||
|
||||
// Day-level cross-file navigation when Highlights have been loaded
|
||||
if (dayActiveSections.length) {
|
||||
// Clip queue navigation (a chip was clicked or day highlights are loaded)
|
||||
if (clipQueue.length) {
|
||||
if (e.key === 'j' || e.key === 'J') {
|
||||
const ni = dayActiveSectionCursor > 0 ? dayActiveSectionCursor - 1 : -1;
|
||||
if (ni >= 0) jumpToDaySection(ni);
|
||||
else announce('Beginning of day sections');
|
||||
if (clipCursor > 0) playClip(clipCursor - 1);
|
||||
else announce('Beginning of sections');
|
||||
} else {
|
||||
const ni = dayActiveSectionCursor + 1;
|
||||
if (ni < dayActiveSections.length) jumpToDaySection(ni);
|
||||
else announce('End of day sections');
|
||||
if (clipCursor + 1 < clipQueue.length) playClip(clipCursor + 1);
|
||||
else announce('End of sections');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Per-file section navigation
|
||||
// Per-file in-player navigation (full-file listening, no clip queue)
|
||||
if (activePlayerIdx === null) return;
|
||||
const sections = sectionMap.get(activePlayerIdx) || [];
|
||||
if (!sections.length) return;
|
||||
@@ -735,8 +815,8 @@ function renderFiles(files) {
|
||||
document.getElementById('dayhl-' + dayId).hidden = true;
|
||||
if (dayActiveId === dayId) {
|
||||
dayActiveSections = [];
|
||||
dayActiveSectionCursor = -1;
|
||||
dayActiveId = null;
|
||||
hideClipBar();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -875,8 +955,10 @@ async function dayHighlights(dayId, analyzableFiles) {
|
||||
});
|
||||
});
|
||||
dayActiveSections.sort((a, b) => a.absStart - b.absStart);
|
||||
dayActiveSectionCursor = -1;
|
||||
dayActiveId = dayId;
|
||||
// Arm the clip queue so J/K steps through the day immediately
|
||||
clipQueue = dayActiveSections;
|
||||
clipCursor = -1;
|
||||
|
||||
const box = document.createElement('div');
|
||||
box.className = 'wbox';
|
||||
@@ -936,14 +1018,8 @@ async function dayHighlights(dayId, analyzableFiles) {
|
||||
|
||||
function jumpToDaySection(si) {
|
||||
if (si < 0 || si >= dayActiveSections.length) return;
|
||||
dayActiveSectionCursor = si;
|
||||
const { fileIdx, filename, start, end } = dayActiveSections[si];
|
||||
|
||||
// Close the previous player if switching to a different file
|
||||
if (activePlayerIdx !== null && activePlayerIdx !== fileIdx) closePlayer(activePlayerIdx);
|
||||
|
||||
seekToSection(fileIdx, filename, start, end, null);
|
||||
announce(`Day section ${si + 1} of ${dayActiveSections.length}: ${fmtDur(start)}–${fmtDur(end)} in ${filename}`);
|
||||
clipQueue = dayActiveSections;
|
||||
playClip(si);
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
|
||||
Reference in New Issue
Block a user