refactor: deduplicate web UI JS and improve screen reader support

- Remove fmtT (identical duplicate of fmtDur)
- Extract closePlayer() shared by togglePlayer, day collapse, and
  cross-file day section jumps
- Reuse seekToSection from jumpToDaySection instead of a copied
  open/seek/announce block
- Fix chip semantics: role=listitem on <button> overrode the button
  role for assistive tech; use role=group on the container instead
- Drop redundant aria-hidden toggling on the REC badge (hidden already
  removes it from the accessibility tree)
- Respect prefers-reduced-motion for the REC pulse animation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 11:44:30 +02:00
parent b9089f9c18
commit 2e3945dfa0
+47 -83
View File
@@ -680,6 +680,7 @@ tr.data-row:hover td{background:var(--surf)}
color:var(--red);border:1px solid #7f1d1d;background:#2d0808;
animation:pulse 1.5s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.45}}
@media (prefers-reduced-motion:reduce){.badge-rec{animation:none}}
.muted{color:var(--muted)}
button{cursor:pointer;border:1px solid var(--brd);background:var(--surf);
color:var(--txt);padding:4px 10px;border-radius:5px;font-size:12px;white-space:nowrap}
@@ -806,10 +807,6 @@ const fmtSize = b => {
if (b<1<<30) return (b/(1<<20)).toFixed(1)+' MB';
return (b/(1<<30)).toFixed(2)+' GB';
};
const fmtT = s => {
const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),sec=Math.floor(s%60);
return h?`${h}:${pad(m)}:${pad(sec)}`:`${m}:${pad(sec)}`;
};
const pad = n => String(n).padStart(2,'0');
function announce(msg) {
@@ -825,8 +822,8 @@ const getPreroll = () => {
function setCutFields(idx, startSec, endSec) {
const startEl = document.getElementById('cut-start-'+idx);
const endEl = document.getElementById('cut-end-'+idx);
if (startEl) startEl.value = fmtT(startSec);
if (endEl && endSec != null) endEl.value = fmtT(endSec);
if (startEl) startEl.value = fmtDur(startSec);
if (endEl && endSec != null) endEl.value = fmtDur(endSec);
}
// idx -> filename, for live-status polling
@@ -853,32 +850,41 @@ function groupByDay(files) {
return map;
}
function closePlayer(idx) {
const prow = document.getElementById('prow-'+idx);
if (!prow || prow.hidden) return;
document.getElementById('aud-'+idx)?.pause();
prow.hidden = true;
const btn = document.getElementById('pbtn-'+idx);
if (btn) {
btn.setAttribute('aria-expanded','false');
btn.textContent = '▶ Play';
btn.setAttribute('aria-label','Play '+(recMap.get(idx) || ''));
}
}
function togglePlayer(idx, filename) {
const prow = document.getElementById('prow-'+idx);
const btn = document.getElementById('pbtn-'+idx);
const audio = document.getElementById('aud-'+idx);
const open = btn.getAttribute('aria-expanded') === 'true';
if (!open) {
if (!audio.getAttribute('data-src-set')) {
audio.preload = 'auto';
audio.src = '/stream/' + encodeURIComponent(filename);
audio.load();
audio.setAttribute('data-src-set','1');
}
activePlayerIdx = idx;
prow.hidden = false;
btn.setAttribute('aria-expanded','true');
btn.textContent = '⏹ Hide';
btn.setAttribute('aria-label','Hide player for '+filename);
audio.focus();
} else {
audio.pause();
prow.hidden = true;
btn.setAttribute('aria-expanded','false');
btn.textContent = '▶ Play';
btn.setAttribute('aria-label','Play '+filename);
if (open) {
closePlayer(idx);
return;
}
if (!audio.getAttribute('data-src-set')) {
audio.preload = 'auto';
audio.src = '/stream/' + encodeURIComponent(filename);
audio.load();
audio.setAttribute('data-src-set','1');
}
activePlayerIdx = idx;
prow.hidden = false;
btn.setAttribute('aria-expanded','true');
btn.textContent = '⏹ Hide';
btn.setAttribute('aria-label','Hide player for '+filename);
audio.focus();
}
function drawWave(rms, sections, duration, filename) {
@@ -939,7 +945,7 @@ function seekToSection(idx, filename, startSec, endSec, sectionIdx) {
setCutFields(idx, startSec, endSec);
if (sectionIdx != null) {
const total = (sectionMap.get(idx) || []).length;
announce(`Section ${sectionIdx + 1} of ${total}: ${fmtT(startSec)} to ${fmtT(endSec)}`);
announce(`Section ${sectionIdx + 1} of ${total}: ${fmtDur(startSec)} to ${fmtDur(endSec)}`);
}
}
@@ -972,22 +978,22 @@ async function analyse(idx, filename, cell, btn) {
const chips = document.createElement('div');
chips.className='chips';
chips.setAttribute('role','list');
chips.setAttribute('role','group');
chips.setAttribute('aria-label','Loud sections — click to jump, J/K to step');
if (d.sections && d.sections.length) {
sectionMap.set(idx, d.sections);
d.sections.forEach((s, si) => {
const c = document.createElement('button');
c.className='chip'; c.setAttribute('role','listitem');
c.className='chip';
c.title = 'Jump to this section (or use J/K keys)';
c.textContent = `${fmtT(s.start)} ${fmtT(s.end)}`;
c.textContent = `${fmtDur(s.start)} ${fmtDur(s.end)}`;
c.addEventListener('click', () => seekToSection(idx, filename, s.start, s.end, si));
chips.appendChild(c);
});
} else {
sectionMap.delete(idx);
const q = document.createElement('span');
q.className='quiet'; q.setAttribute('role','listitem');
q.className='quiet';
q.textContent='No loud sections found';
chips.appendChild(q);
}
@@ -1043,7 +1049,7 @@ document.addEventListener('keydown', e => {
const s = sections[targetIdx];
audio.currentTime = Math.max(0, s.start - preroll);
setCutFields(activePlayerIdx, s.start, s.end);
announce(`Section ${targetIdx + 1} of ${sections.length}: ${fmtT(s.start)} to ${fmtT(s.end)}`);
announce(`Section ${targetIdx + 1} of ${sections.length}: ${fmtDur(s.start)} to ${fmtDur(s.end)}`);
} else {
announce('Beginning of sections');
}
@@ -1055,7 +1061,7 @@ document.addEventListener('keydown', e => {
const s = sections[i];
audio.currentTime = Math.max(0, s.start - preroll);
setCutFields(activePlayerIdx, s.start, s.end);
announce(`Section ${i + 1} of ${sections.length}: ${fmtT(s.start)} to ${fmtT(s.end)}`);
announce(`Section ${i + 1} of ${sections.length}: ${fmtDur(s.start)} to ${fmtDur(s.end)}`);
jumped = true;
break;
}
@@ -1242,7 +1248,7 @@ function renderFiles(files) {
tr.id = 'row-'+i;
const recBadge = `<span id="rec-${i}" class="badge-rec"${isRec?'':' hidden'}
aria-label="Currently recording" aria-hidden="${isRec?'false':'true'}">
aria-label="Currently recording">
<span aria-hidden="true">●</span> REC</span>`;
tr.innerHTML = `
@@ -1310,19 +1316,7 @@ function renderFiles(files) {
headBar.classList.toggle('open', nowExp);
document.getElementById('daytbl-' + dayId).hidden = !nowExp;
if (!nowExp) {
dayFiles.forEach(f => {
const prow = document.getElementById('prow-' + f._idx);
if (prow && !prow.hidden) {
document.getElementById('aud-' + f._idx)?.pause();
prow.hidden = true;
const pbtn = document.getElementById('pbtn-' + f._idx);
if (pbtn) {
pbtn.setAttribute('aria-expanded', 'false');
pbtn.textContent = '▶ Play';
pbtn.setAttribute('aria-label', 'Play ' + f.name);
}
}
});
dayFiles.forEach(f => closePlayer(f._idx));
document.getElementById('dayhl-' + dayId).hidden = true;
if (dayActiveId === dayId) {
dayActiveSections = [];
@@ -1492,13 +1486,12 @@ async function dayHighlights(dayId, analyzableFiles) {
} else {
const chips = document.createElement('div');
chips.className = 'chips';
chips.setAttribute('role', 'list');
chips.setAttribute('role', 'group');
chips.setAttribute('aria-label', 'Day loud sections — click to jump, J/K to step across files');
dayActiveSections.forEach((sec, si) => {
const c = document.createElement('button');
c.className = 'chip';
c.setAttribute('role', 'listitem');
c.title = sec.filename + ' @ ' + fmtT(sec.start);
c.title = sec.filename + ' @ ' + fmtDur(sec.start);
const d = new Date(sec.absStart * 1000);
const hms = d.getHours().toString().padStart(2,'0') + ':'
+ d.getMinutes().toString().padStart(2,'0') + ':'
@@ -1525,39 +1518,13 @@ async function dayHighlights(dayId, analyzableFiles) {
function jumpToDaySection(si) {
if (si < 0 || si >= dayActiveSections.length) return;
dayActiveSectionCursor = si;
const sec = dayActiveSections[si];
const { fileIdx, filename, start, end } = sec;
const { fileIdx, filename, start, end } = dayActiveSections[si];
// Close the previous player if switching to a different file
if (activePlayerIdx !== null && activePlayerIdx !== fileIdx) {
const prevProw = document.getElementById('prow-' + activePlayerIdx);
if (prevProw && !prevProw.hidden) {
document.getElementById('aud-' + activePlayerIdx)?.pause();
prevProw.hidden = true;
const prevPbtn = document.getElementById('pbtn-' + activePlayerIdx);
if (prevPbtn) {
prevPbtn.setAttribute('aria-expanded', 'false');
prevPbtn.textContent = '▶ Play';
prevPbtn.setAttribute('aria-label', 'Play ' + (recMap.get(activePlayerIdx) || ''));
}
}
}
if (activePlayerIdx !== null && activePlayerIdx !== fileIdx) closePlayer(activePlayerIdx);
// Open this file's player
const pbtn = document.getElementById('pbtn-' + fileIdx);
if (pbtn && pbtn.getAttribute('aria-expanded') !== 'true') togglePlayer(fileIdx, filename);
activePlayerIdx = fileIdx;
const audio = document.getElementById('aud-' + fileIdx);
if (!audio) return;
audio.preload = 'auto';
const seekTo = Math.max(0, start - getPreroll());
const doSeek = () => { audio.currentTime = seekTo; audio.play().catch(() => {}); };
if (audio.readyState >= 1) doSeek();
else audio.addEventListener('loadedmetadata', doSeek, { once: true });
setCutFields(fileIdx, start, end);
announce(`Day section ${si + 1} of ${dayActiveSections.length}: ${fmtT(start)}${fmtT(end)} in ${filename}`);
seekToSection(fileIdx, filename, start, end, null);
announce(`Day section ${si + 1} of ${dayActiveSections.length}: ${fmtDur(start)}${fmtDur(end)} in ${filename}`);
}
function applyFilters() {
@@ -1600,10 +1567,7 @@ async function pollStatus() {
const active = new Set(s.active || []);
recMap.forEach((filename, idx) => {
const badge = document.getElementById('rec-'+idx);
if (!badge) return;
const on = active.has(filename);
badge.hidden = !on;
badge.setAttribute('aria-hidden', on ? 'false' : 'true');
if (badge) badge.hidden = !active.has(filename);
});
} catch(e) {}
}