// The 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. The queue holds the active review list (one file's // sections, or a whole day's); J/K and Prev/Next step through it in time // order, U/I step through the loudest-first ranking. The auto-advance radio // picks what plays when a clip ends: nothing, the next in time, or the next // by loudness. // // Two cursors, deliberately independent. clipCursor is the time-order position // and follows every play (chip click, J/K, U/I). scoreCursor is the loudness // walk's own position in scoreOrder(): only U/I (and "next loudest" auto- // advance) move it; J/K detours to review nearby clips leave it alone, so // coming back to U/I resumes the ranking exactly where you left it. An explicit // jump (chip click, new queue) resets it to -1 so the next U/I re-anchors there. import {$, fmtDur, fmtClock, announce} from './util.js'; import {fileIdxByName} from './store.js'; import {advanceMode, getPreroll} from './settings.js'; import {clipUrl} from './api.js'; import {setCutFields, seekToSection} from './player.js'; let clipQueue = []; let clipCursor = -1; let scoreCursor = -1; // Where focus was when the bar opened, so closing it doesn't drop the user at // the top of the page let returnFocus = null; export const hasQueue = () => clipQueue.length > 0; // byScore: this play is part of a loudness walk (U/I or "next loudest" // auto-advance), so the position shown is scoreCursor — the rank in the // loudest-first ranking, labelled "by loudness" — not the time-order index. export function playClip(i, byScore) { 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 = $('clip-audio'); a.src = clipUrl(c.filename, cs, ce); a.play().catch(() => {}); // Label = wall-clock time of occurrence (absStart from the filename clock); // falls back to in-file offsets for non-standard filenames. The visible // label, the screen-reader announcement and the lock-screen title use one // identical string: "17:09:56 to 17:09:57 · +30 dB (30 / 426 by time)" — // the count is the time-order index "by time" (J/K) or the loudness rank // "by loudness" (U/I). const when = c.absStart != null ? `${fmtClock(c.absStart)} to ${fmtClock(c.absStart + (c.end - c.start))}` : `${fmtDur(c.start)} to ${fmtDur(c.end)}`; const score = c.score != null ? ` · +${Math.round(c.score)} dB` : ''; const pos = byScore ? scoreCursor : i; const dim = byScore ? 'by loudness' : 'by time'; const text = `${when}${score} (${pos + 1} / ${clipQueue.length} ${dim})`; const label = $('clip-label'); label.textContent = text; label.title = `${c.filename} @ ${fmtDur(c.start)}–${fmtDur(c.end)}`; const bar = $('clip-bar'); const ae = document.activeElement; if (ae && ae !== document.body && !bar.contains(ae)) returnFocus = ae; bar.hidden = false; document.body.classList.add('clip-open'); const rowIdx = fileIdxByName(c.filename); if (rowIdx != null) setCutFields(rowIdx, c.start, c.end); if ('mediaSession' in navigator && 'MediaMetadata' in window) navigator.mediaSession.metadata = new MediaMetadata({title: text, artist: 'ISR', album: c.filename}); announce(text); } export function hideClipBar() { const a = $('clip-audio'); a.pause(); a.removeAttribute('src'); $('clip-bar').hidden = true; document.body.classList.remove('clip-open'); if ('mediaSession' in navigator) navigator.mediaSession.metadata = null; clipQueue = []; clipCursor = -1; scoreCursor = -1; } // Point the queue at a new section list without playing: nothing is selected // yet, and the next U/I must re-anchor here rather than resume a walk through // the previous queue. export function armQueue(sections) { clipQueue = sections; clipCursor = -1; scoreCursor = -1; } // Explicit jump (chip click): play section i of sections; the next U/I // re-anchors on it export function playFrom(sections, i) { clipQueue = sections; scoreCursor = -1; playClip(i); } // Highlight order = section indices sorted loudest-first. U/I (and "next // loudest" auto-advance) walk this ranking, so a review runs from the most to // the least interesting event until the user stops — no top-N cutoff. // Cached against the section array's identity: a day queue runs to thousands // of entries and U/I would otherwise re-sort all of them on every keypress. // Every arming path assigns a fresh array, so identity is a safe key. let scoreOrderCache = {secs: null, order: null}; export function scoreOrder(secs) { if (scoreOrderCache.secs === secs) return scoreOrderCache.order; const order = secs.map((s, i) => ({i, score: s.score || 0})) .sort((a, b) => b.score - a.score) .map(r => r.i); scoreOrderCache = {secs, order}; return order; } // Step a position within an ordering: pos + dir, or straight to the extreme in // that direction when jump. Returns null when the step runs off either end. // pos === -1 means "not anchored yet" — start at the first entry going forward, // or off the front going backward so the caller announces the boundary. // Shared by both U/I walks (clip queue and in-player); they differ only in what // the anchor is and what "play" means, and drifting apart is exactly how the // two would stop agreeing. export function nextInOrder(order, pos, dir, jump) { const next = jump ? (dir > 0 ? order.length - 1 : 0) : pos === -1 ? (dir > 0 ? 0 : -1) : pos + dir; return (next >= 0 && next < order.length) ? next : null; } // Step the clip queue by dir (±1): in time order, or with byScore down/up // the loudest-first ranking (next = next quieter). With jump=true, go straight // to the extreme in that direction (first/last section, or loudest/quietest). export function stepClip(dir, byScore, jump) { if (!clipQueue.length) return; if (byScore) { // Walk scoreCursor, not the shared clipCursor: a J/K detour never moved // it, so we resume the ranking where we left off. scoreCursor === -1 means // the walk hasn't started since the queue was armed or an explicit jump — // anchor on the section currently selected (clipCursor), else the loudest. const order = scoreOrder(clipQueue); const pos = scoreCursor !== -1 ? scoreCursor : clipCursor >= 0 ? order.indexOf(clipCursor) : -1; const next = nextInOrder(order, pos, dir, jump); if (next === null) { announce(dir < 0 ? 'Loudest highlight reached' : 'End of highlights'); return; } scoreCursor = next; playClip(order[next], true); return; } // J/K (time order) deliberately does NOT touch scoreCursor. const i = jump ? (dir > 0 ? clipQueue.length - 1 : 0) : clipCursor + dir; if (i >= 0 && i < clipQueue.length) playClip(i); else announce(dir < 0 ? 'Beginning of sections' : 'End of sections'); } // "Open in file": switch from the current clip to the full recording at the // same position. Bound to the clip-bar button and the O key. export function openClipInFile() { const c = clipQueue[clipCursor]; if (!c) return; const idx = fileIdxByName(c.filename); if (idx == null) return; $('clip-audio').pause(); const row = $('row-' + idx); if (row) { const wrap = row.closest('.table-wrap'); if (wrap?.hidden) // file's day is collapsed: expand it $('daytgl-' + wrap.id.slice('daytbl-'.length))?.click(); row.scrollIntoView({block: 'center'}); } seekToSection(idx, c.start, c.end); } function closeClipBar() { const back = returnFocus; returnFocus = null; hideClipBar(); (back?.isConnected ? back : $('page-title')).focus(); } export function initClipBar() { $('clip-prev').addEventListener('click', () => stepClip(-1, false)); $('clip-next').addEventListener('click', () => stepClip(1, false)); $('clip-close').addEventListener('click', closeClipBar); $('clip-context').addEventListener('click', openClipInFile); $('clip-audio').addEventListener('ended', () => { const mode = advanceMode(); if (mode !== 'off') stepClip(1, mode === 'hl'); }); $('clip-bar').addEventListener('keydown', e => { if (e.key === 'Escape') { e.preventDefault(); closeClipBar(); } }); }