feat: loudness walk (U/I) keeps its own cursor, independent of J/K

The clip bar had one shared cursor, so pressing U/I re-derived the
loudness rank from wherever you currently were. A J/K detour to review
clips around an interesting spot therefore hijacked the loudness walk:
returning to U/I continued from the J/K position, not from where the
ranking left off.

Add a separate scoreCursor that only U/I (and "highlights only" auto-
advance) move; J/K never touches it. So: U/I to a loud moment, J/K to
review the time-adjacent clips, U/I again resumes the ranking exactly
where you left it. scoreCursor resets to -1 on every explicit jump /
queue re-arm (hideClipBar, chip clicks, day-highlights arm) so the next
U/I re-anchors on the selected section.

Also label the position count "by time" (J/K) or "by loudness" (U/I) so
the blind user can hear which dimension the count is in — the two looked
identical before and switched meaning silently when changing keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 10:28:52 +02:00
parent 46efbd6d75
commit 89c95a70a2
3 changed files with 35 additions and 11 deletions
+33 -9
View File
@@ -349,12 +349,20 @@ function seekToSection(idx, filename, startSec, endSec, sectionIdx) {
// 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.
//
// 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 "highlights only" 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.
let clipQueue = [];
let clipCursor = -1;
let scoreCursor = -1;
// byScore: this play is part of a loudness walk (U/I or "highlights only"
// auto-advance), so the position shown is the rank in the loudest-first
// ranking ("3rd loudest of 426"), not the time-order index.
// auto-advance), so the position shown is scoreCursor — the rank in the
// loudest-first ranking, labelled "by loudness" — not the time-order index.
function playClip(i, byScore) {
if (i < 0 || i >= clipQueue.length) return;
clipCursor = i;
@@ -368,13 +376,15 @@ function playClip(i, byScore) {
// Label = wall-clock time of occurrence (absStart from the filename clock);
// falls back to in-file offsets for non-standard filenames. The visible
// label and the screen-reader announcement use one identical string:
// "17:09:56 to 17:09:57 · +30 dB (30 / 426)".
// "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 ? scoreOrder(clipQueue).indexOf(i) : i;
const text = `${when}${score} (${pos + 1} / ${clipQueue.length})`;
const pos = byScore ? scoreCursor : i;
const dim = byScore ? 'by loudness' : 'by time';
const text = `${when}${score} (${pos + 1} / ${clipQueue.length} ${dim})`;
const label = document.getElementById('clip-label');
label.textContent = text;
label.title = `${c.filename} @ ${fmtDur(c.start)}${fmtDur(c.end)}`;
@@ -392,6 +402,7 @@ function hideClipBar() {
document.body.classList.remove('clip-open');
clipQueue = [];
clipCursor = -1;
scoreCursor = -1;
}
function playFileSection(idx, filename, si) {
@@ -400,6 +411,7 @@ function playFileSection(idx, filename, si) {
const epoch = f ? fileStartEpoch(f.date) : null;
clipQueue = secs.map(s => ({fileIdx: idx, filename, start: s.start, end: s.end, score: s.score,
absStart: epoch != null ? epoch + s.start : null}));
scoreCursor = -1; // explicit jump: next U/I re-anchors here
playClip(si);
}
@@ -422,14 +434,23 @@ const advanceMode = () =>
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 = order.indexOf(clipCursor); // -1: nothing played yet
const next = jump ? (dir > 0 ? order.length - 1 : 0)
: pos === -1 ? (dir > 0 ? 0 : -1) : pos + dir;
if (next >= 0 && next < order.length) playClip(order[next], true);
let next;
if (jump) next = dir > 0 ? order.length - 1 : 0;
else if (scoreCursor !== -1) next = scoreCursor + dir;
else {
const anchor = clipCursor >= 0 ? order.indexOf(clipCursor) : -1;
next = anchor === -1 ? (dir > 0 ? 0 : -1) : anchor + dir;
}
if (next >= 0 && next < order.length) { scoreCursor = next; playClip(order[next], true); }
else announce(dir < 0 ? 'Loudest highlight reached' : 'End of highlights');
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');
@@ -926,6 +947,7 @@ function renderFiles(files) {
dayActiveId = dayId;
clipQueue = dayActiveSections;
clipCursor = -1;
scoreCursor = -1;
return;
}
dayHighlights(dayId, hlFiles);
@@ -1016,6 +1038,7 @@ async function dayHighlights(dayId, analyzableFiles) {
// Arm the clip queue so J/K steps through the day immediately
clipQueue = dayActiveSections;
clipCursor = -1;
scoreCursor = -1;
const box = document.createElement('div');
box.className = 'wbox';
@@ -1102,6 +1125,7 @@ async function dayHighlights(dayId, analyzableFiles) {
function jumpToDaySection(si) {
if (si < 0 || si >= dayActiveSections.length) return;
clipQueue = dayActiveSections;
scoreCursor = -1; // explicit jump: next U/I re-anchors here
playClip(si);
}