feat: auto-advance radio, J/K decoupled from highlights, U/I ranked stepping
- J/K and Prev/Next now always step the clip queue in time order; the old "Highlights only" checkbox could silently change what J/K did. - Auto-advance is a three-way radio: don't auto-advance / auto-advance (next in time) / auto-advance highlights only (next by loudness). It only affects what plays when a clip ends. - The "Top" highlight-count input is gone. U/I (and highlights-only auto-advance) walk the full loudest-first ranking from scoreOrder() with no top-N cutoff - review simply stops when the user stops. - In-player U/I (full-file playback) step the same ranking, anchored on the section under the playhead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+66
-49
@@ -133,9 +133,9 @@ svg.day-timeline{display:block;width:100%;height:22px}
|
||||
#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,#clip-hl-label,#clip-top-label{font-size:12px;color:var(--muted);
|
||||
#clip-adv{display:flex;align-items:center;gap:10px}
|
||||
#clip-adv label{font-size:12px;color:var(--muted);
|
||||
display:flex;align-items:center;gap:4px;white-space:nowrap;cursor:pointer}
|
||||
#clip-top{width:52px}
|
||||
body.clip-open{padding-bottom:70px}
|
||||
</style>
|
||||
</head>
|
||||
@@ -184,9 +184,11 @@ body.clip-open{padding-bottom:70px}
|
||||
<button id="clip-next" aria-label="Next section (K)">Next</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>
|
||||
<label id="clip-hl-label" title="J/K, Prev/Next and auto-advance skip everything outside the top sections"><input type="checkbox" id="clip-hl-only"> Highlights only</label>
|
||||
<label id="clip-top-label" title="How many top-scored sections count as highlights">Top <input type="number" id="clip-top" min="1" step="1" value="50" aria-label="Number of top-scored sections treated as highlights"></label>
|
||||
<span id="clip-adv" role="radiogroup" aria-label="Auto-advance mode">
|
||||
<label><input type="radio" name="clip-adv" value="off"> Don't auto-advance</label>
|
||||
<label><input type="radio" name="clip-adv" value="all" checked> Auto-advance</label>
|
||||
<label title="When a clip ends, play the next-loudest section instead of the next in time"><input type="radio" name="clip-adv" value="hl"> Auto-advance highlights only</label>
|
||||
</span>
|
||||
<button id="clip-context" title="Open the full recording at this position (O)" aria-label="Open in file (O)">Open in file</button>
|
||||
<button id="clip-close" aria-label="Close clip player">×</button>
|
||||
</div>
|
||||
@@ -383,8 +385,9 @@ function seekToSection(idx, filename, startSec, endSec, sectionIdx) {
|
||||
// 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 the Prev/Next buttons step through it, U/I (or the
|
||||
// "Highlights only" toggle) restrict stepping to the top-N entries by score.
|
||||
// day's); J/K and the Prev/Next buttons 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.
|
||||
let clipQueue = [];
|
||||
let clipCursor = -1;
|
||||
|
||||
@@ -432,37 +435,42 @@ function playFileSection(idx, filename, si) {
|
||||
playClip(si);
|
||||
}
|
||||
|
||||
// Highlights = the top-N entries of a section list by score (N from the
|
||||
// clip-bar "Top" input). Returns the indices into the original list.
|
||||
function topScoreSet(secs) {
|
||||
const n = Math.max(1, parseInt(document.getElementById('clip-top').value, 10) || 50);
|
||||
return new Set(secs.map((s, i) => ({i, score: s.score || 0}))
|
||||
// Highlight order = section indices sorted loudest-first. U/I (and
|
||||
// auto-advance in "highlights only" mode) walk this ranking, so a review
|
||||
// runs from the most to the least interesting event until the user stops —
|
||||
// no top-N cutoff.
|
||||
function scoreOrder(secs) {
|
||||
return secs.map((s, i) => ({i, score: s.score || 0}))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, n)
|
||||
.map(r => r.i));
|
||||
.map(r => r.i);
|
||||
}
|
||||
|
||||
function highlightsOnly() {
|
||||
return document.getElementById('clip-hl-only').checked;
|
||||
}
|
||||
const advanceMode = () =>
|
||||
document.querySelector('input[name="clip-adv"]:checked')?.value || 'off';
|
||||
|
||||
// Step the clip queue by dir (±1); with hlOnly, skip non-highlight entries.
|
||||
function stepClip(dir, hlOnly) {
|
||||
// Step the clip queue by dir (±1): in time order, or with byScore down/up
|
||||
// the loudest-first ranking (next = next quieter).
|
||||
function stepClip(dir, byScore) {
|
||||
if (!clipQueue.length) return;
|
||||
const hs = hlOnly ? topScoreSet(clipQueue) : null;
|
||||
const word = hlOnly ? 'highlights' : 'sections';
|
||||
for (let i = clipCursor + dir; i >= 0 && i < clipQueue.length; i += dir) {
|
||||
if (!hs || hs.has(i)) { playClip(i); return; }
|
||||
if (byScore) {
|
||||
const order = scoreOrder(clipQueue);
|
||||
const pos = order.indexOf(clipCursor); // -1: nothing played yet
|
||||
const next = pos === -1 ? (dir > 0 ? 0 : -1) : pos + dir;
|
||||
if (next >= 0 && next < order.length) playClip(order[next]);
|
||||
else announce(dir < 0 ? 'Loudest highlight reached' : 'End of highlights');
|
||||
return;
|
||||
}
|
||||
announce(dir < 0 ? `Beginning of ${word}` : `End of ${word}`);
|
||||
const i = clipCursor + dir;
|
||||
if (i >= 0 && i < clipQueue.length) playClip(i);
|
||||
else announce(dir < 0 ? 'Beginning of sections' : 'End of sections');
|
||||
}
|
||||
|
||||
document.getElementById('clip-prev').addEventListener('click', () => stepClip(-1, highlightsOnly()));
|
||||
document.getElementById('clip-next').addEventListener('click', () => stepClip(1, highlightsOnly()));
|
||||
document.getElementById('clip-prev').addEventListener('click', () => stepClip(-1, false));
|
||||
document.getElementById('clip-next').addEventListener('click', () => stepClip(1, false));
|
||||
document.getElementById('clip-close').addEventListener('click', hideClipBar);
|
||||
document.getElementById('clip-audio').addEventListener('ended', () => {
|
||||
if (document.getElementById('clip-auto').checked)
|
||||
stepClip(1, highlightsOnly());
|
||||
const mode = advanceMode();
|
||||
if (mode !== 'off') stepClip(1, mode === 'hl');
|
||||
});
|
||||
// "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.
|
||||
@@ -531,7 +539,7 @@ async function analyse(idx, filename, cell, btn, force = false) {
|
||||
const chips = document.createElement('div');
|
||||
chips.className='chips';
|
||||
chips.setAttribute('role','group');
|
||||
chips.setAttribute('aria-label','Loud sections — click to jump, J/K to step, U/I for highlights only');
|
||||
chips.setAttribute('aria-label','Loud sections — click to jump, J/K to step in time order, U/I by loudness');
|
||||
if (d.sections && d.sections.length) {
|
||||
sectionMap.set(idx, d.sections);
|
||||
d.sections.forEach((s, si) => {
|
||||
@@ -564,9 +572,8 @@ async function analyse(idx, filename, cell, btn, force = false) {
|
||||
}
|
||||
}
|
||||
|
||||
// J/K = previous/next section, U/I = previous/next highlight (top-N by score),
|
||||
// O = open the current clip in the full file.
|
||||
// With "Highlights only" checked, J/K behave like U/I.
|
||||
// J/K = previous/next section in time order, U/I = up/down the loudest-first
|
||||
// ranking, O = open the current clip in the full file.
|
||||
// Only when focus is not in an input.
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||
@@ -575,12 +582,12 @@ document.addEventListener('keydown', e => {
|
||||
if (key !== 'j' && key !== 'k' && key !== 'u' && key !== 'i' && key !== 'o') return;
|
||||
e.preventDefault();
|
||||
if (key === 'o') { openClipInFile(); return; }
|
||||
const dir = (key === 'j' || key === 'u') ? -1 : 1;
|
||||
const hlOnly = key === 'u' || key === 'i' || highlightsOnly();
|
||||
const dir = (key === 'j' || key === 'u') ? -1 : 1;
|
||||
const byScore = key === 'u' || key === 'i';
|
||||
|
||||
// Clip queue navigation (a chip was clicked or day highlights are loaded)
|
||||
if (clipQueue.length) {
|
||||
stepClip(dir, hlOnly);
|
||||
stepClip(dir, byScore);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -590,29 +597,39 @@ document.addEventListener('keydown', e => {
|
||||
if (!all.length) return;
|
||||
const audio = document.getElementById('aud-'+activePlayerIdx);
|
||||
if (!audio) return;
|
||||
const hs = hlOnly ? topScoreSet(all) : null;
|
||||
const sections = all.filter((s, i) => !hs || hs.has(i));
|
||||
if (!sections.length) return;
|
||||
const preroll = getPreroll();
|
||||
const cur = audio.currentTime;
|
||||
const word = hlOnly ? 'Highlight' : 'Section';
|
||||
|
||||
const jumpTo = (s, i) => {
|
||||
const jumpTo = (s, i, n, word) => {
|
||||
audio.currentTime = Math.max(0, s.start - preroll);
|
||||
setCutFields(activePlayerIdx, s.start, s.end);
|
||||
announce(`${word} ${i + 1} of ${sections.length}: ${fmtDur(s.start)} to ${fmtDur(s.end)}`);
|
||||
announce(`${word} ${i + 1} of ${n}: ${fmtDur(s.start)} to ${fmtDur(s.end)}`);
|
||||
};
|
||||
|
||||
if (byScore) {
|
||||
// U/I walk the ranking; the anchor is the section the playhead sits in
|
||||
// (incl. its preroll lead-in). Anywhere else, I starts at the loudest.
|
||||
const order = scoreOrder(all);
|
||||
const curIdx = all.findIndex(s => cur >= s.start - preroll - 0.5 && cur <= s.end + 0.5);
|
||||
const pos = curIdx === -1 ? -1 : order.indexOf(curIdx);
|
||||
const next = pos === -1 ? (dir > 0 ? 0 : -1) : pos + dir;
|
||||
if (next >= 0 && next < order.length)
|
||||
jumpTo(all[order[next]], next, order.length, 'Highlight');
|
||||
else
|
||||
announce(dir < 0 ? 'Loudest highlight reached' : 'End of highlights');
|
||||
return;
|
||||
}
|
||||
|
||||
if (dir < 0) {
|
||||
for (let i = sections.length - 1; i >= 0; i--) {
|
||||
if (sections[i].start < cur - 1) { jumpTo(sections[i], i); return; }
|
||||
for (let i = all.length - 1; i >= 0; i--) {
|
||||
if (all[i].start < cur - 1) { jumpTo(all[i], i, all.length, 'Section'); return; }
|
||||
}
|
||||
announce(`Beginning of ${word.toLowerCase()}s`);
|
||||
announce('Beginning of sections');
|
||||
} else {
|
||||
for (let i = 0; i < sections.length; i++) {
|
||||
if (sections[i].start > cur + preroll) { jumpTo(sections[i], i); return; }
|
||||
for (let i = 0; i < all.length; i++) {
|
||||
if (all[i].start > cur + preroll) { jumpTo(all[i], i, all.length, 'Section'); return; }
|
||||
}
|
||||
announce(`End of ${word.toLowerCase()}s`);
|
||||
announce('End of sections');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1094,13 +1111,13 @@ async function dayHighlights(dayId, analyzableFiles) {
|
||||
const note = document.createElement('p');
|
||||
note.className = 'quiet';
|
||||
note.style.marginTop = '6px';
|
||||
note.textContent = `${dayActiveSections.length} sections — chips show the top ${MAX_DAY_CHIPS} by loudness; J / K steps through all in time order, U / I through highlights only`;
|
||||
note.textContent = `${dayActiveSections.length} sections — chips show the top ${MAX_DAY_CHIPS} by loudness; J / K steps through all in time order, U / I by loudness (loudest first)`;
|
||||
box.appendChild(note);
|
||||
}
|
||||
const chips = document.createElement('div');
|
||||
chips.className = 'chips';
|
||||
chips.setAttribute('role', 'group');
|
||||
chips.setAttribute('aria-label', 'Day loud sections — click to jump, J/K to step across files, U/I for highlights only');
|
||||
chips.setAttribute('aria-label', 'Day loud sections — click to jump, J/K to step across files in time order, U/I by loudness');
|
||||
chipList.forEach(({sec, si}) => {
|
||||
const c = document.createElement('button');
|
||||
c.className = 'chip';
|
||||
|
||||
Reference in New Issue
Block a user