// Day review: the chronological cross-file section list behind a day's // Highlights button. // // The user is blind and uses a screen reader — there is deliberately no day // timeline graphic. The panel is linear text and buttons: summary line -> // key-hint note -> chips toggle -> chips. import {$, fmtDur, fmtClock, fileStartEpoch, announce, chipToggle} from './util.js'; import {fetchAnalysis} from './analysis.js'; import {armQueue, playFrom, hideClipBar} from './clips.js'; import {analysisParams, hlParams} from './settings.js'; let dayActiveSections = []; let dayActiveId = null; // dayId -> section list of an already-built highlights panel, so collapsing // and re-expanding it re-arms J/K without recomputing const dayHlSections = new Map(); export function setHlExpanded(dayId, exp) { const btn = $('dayhln-' + dayId); if (!btn) return; btn.setAttribute('aria-expanded', exp); const arrow = btn.querySelector('.day-arrow'); if (arrow) arrow.textContent = exp ? '▾ ' : '▸ '; } // The status suffix inside the Highlights button (" · analysed", progress). // The button has no aria-label, so this text is part of its accessible name. const setHlStatus = (dayId, text) => { const el = $('dayhls-' + dayId); if (el) el.textContent = text; }; // Point the clip queue at a day's cross-file section list, from either the // fresh-build path or the re-open-a-kept-panel path. Coupled globals set in // two places is how the stale-_idx and diverging-label bugs happened. function armDayQueue(dayId, sections) { dayActiveSections = sections; dayActiveId = dayId; armQueue(sections); } function jumpToDaySection(si) { if (si < 0 || si >= dayActiveSections.length) return; playFrom(dayActiveSections, si); } // The day was collapsed: close its panel and drop its queue export function releaseDay(dayId) { const panel = $('dayhl-' + dayId); if (panel) panel.hidden = true; setHlExpanded(dayId, false); if (dayActiveId === dayId) { dayActiveSections = []; dayActiveId = null; hideClipBar(); } } // Highlights button: expand/collapse the panel; only (re)compute when it has // not been built yet this session or the analysis params changed export function toggleHighlights(dayId, files) { const panel = $('dayhl-' + dayId); const btn = $('dayhln-' + dayId); if (btn?.dataset.busy) return; if (!panel.hidden) { // collapse, keep the panel panel.hidden = true; setHlExpanded(dayId, false); return; } if (panel.dataset.loaded === hlParams()) { // re-open and re-arm J/K panel.hidden = false; setHlExpanded(dayId, true); armDayQueue(dayId, dayHlSections.get(dayId) || []); return; } dayHighlights(dayId, files); } async function dayHighlights(dayId, files) { const panel = $('dayhl-' + dayId); const content = $('dayhlc-' + dayId); const btn = $('dayhln-' + dayId); const n = files.length; panel.hidden = false; setHlExpanded(dayId, true); // Busy, not disabled: disabling the focused button would throw the screen // reader's focus to the top of the page btn.dataset.busy = '1'; btn.setAttribute('aria-disabled', 'true'); const progBar = document.createElement('div'); progBar.className = 'prog-bar'; const progFill = document.createElement('div'); progFill.className = 'prog-fill'; progBar.setAttribute('aria-hidden', 'true'); progBar.appendChild(progFill); const progFile = document.createElement('p'); progFile.className = 'prog-file'; content.replaceChildren(progBar, progFile); const params = analysisParams(); const results = []; let nCached = 0; for (let i = 0; i < n; i++) { const f = files[i]; progFile.textContent = `${i + 1} / ${n} — ${f.name}`; progFill.style.width = `${(i / n) * 100}%`; setHlStatus(dayId, ` · ${i + 1} of ${n}`); try { const d = await fetchAnalysis(f.name, params); if (!d.error) { results.push({f, data: d}); if (d.cached) nCached++; } } catch { /* counted as missing below */ } } delete btn.dataset.busy; btn.removeAttribute('aria-disabled'); // Every file is now cached with the current params setHlStatus(dayId, results.length === n ? ' · analysed' : ''); // Map files onto the day. The filename is the clock: f.date is the // recording start parsed server-side; mtime (≈ file end) is only a fallback // for non-standard names. const sections = []; results.forEach(({f, data}) => { const fileDur = data.duration || f.duration || 0; if (fileDur <= 0) return; const startEpoch = fileStartEpoch(f.date); const fileStart = startEpoch != null ? startEpoch : f.mtime - fileDur; (data.sections || []).forEach(s => sections.push({ filename: f.name, start: s.start, end: s.end, score: s.score, absStart: fileStart + s.start, })); }); sections.sort((a, b) => a.absStart - b.absStart); // Arm the clip queue so J/K steps through the day immediately armDayQueue(dayId, sections); const box = document.createElement('div'); box.className = 'wbox'; const nRes = results.length, nSec = sections.length; const missing = n - nRes; // Totals first, then the key hint, then the chips: linear reading order const summary = document.createElement('p'); summary.className = 'quiet'; summary.textContent = `${nRes} recording${nRes !== 1 ? 's' : ''} analysed` + (nCached ? ` (${nCached} from cache)` : '') + (missing ? `, ${missing} failed` : '') + ` · ${nSec} loud section${nSec !== 1 ? 's' : ''}`; box.appendChild(summary); if (nSec) { const note = document.createElement('p'); note.className = 'quiet'; note.textContent = 'J / K plays through all sections in time order, U / I by loudness (loudest first). Hold Shift to jump to the first / last section (Shift + J / K) or the loudest / quietest (Shift + U / I)'; box.appendChild(note); const MAX_DAY_CHIPS = 50; // When there are too many sections to show them all, show the ones most // worth reviewing: the top MAX_DAY_CHIPS by score, loudest first. let chipList = sections.map((sec, si) => ({sec, si})); const truncated = chipList.length > MAX_DAY_CHIPS; if (truncated) { chipList = chipList .sort((a, b) => (b.sec.score || 0) - (a.sec.score || 0)) .slice(0, MAX_DAY_CHIPS); } const chips = document.createElement('div'); chips.className = 'chips'; chips.setAttribute('role', 'group'); chips.setAttribute('aria-label', 'Day loud sections'); chipList.forEach(({sec, si}) => { const c = document.createElement('button'); c.className = 'chip'; c.title = sec.filename + ' @ ' + fmtDur(sec.start); c.textContent = fmtClock(sec.absStart) + (sec.score != null ? ` · +${Math.round(sec.score)} dB` : ''); c.addEventListener('click', () => jumpToDaySection(si)); chips.appendChild(c); }); const tog = chipToggle(chips, truncated ? `Top ${chipList.length} sections by loudness` : `${chipList.length} sections`); if (tog) box.appendChild(tog); box.appendChild(chips); } content.replaceChildren(box); panel.dataset.loaded = hlParams(); dayHlSections.set(dayId, sections); announce(nSec ? `${nSec} loud section${nSec !== 1 ? 's' : ''} across ${nRes} recording${nRes !== 1 ? 's' : ''}. K plays the first, I the loudest.` : `No loud sections in ${nRes} recording${nRes !== 1 ? 's' : ''}.`); }