Files
ISR/webui/js/list.js
T
adminandClaude Opus 5.5 2a19515c27 feat: split the web UI into modules and rework it for screen readers
webui.html becomes webui/ (index.html, style.css, ES modules in js/, no
build step); web.py serves it under / and /ui/<path> with the same
containment check as recordings.

How it feels with a screen reader:
- Days read "Today, Saturday 17 October"; recordings are named by their
  wall-clock span ("22:00 to 23:00"), which is the row header and what
  every button says. The filename moves to the last column.
- Focus is never dropped: busy buttons stay focused (aria-disabled),
  finished analyses focus their first section, delete moves to the next
  row, closing the clip bar (x or Escape) returns to where you were.
- Reloads rebuild only the days whose content changed; DOM ids are stable
  per filename, so a rollover no longer resets the reading position.
- Only user-initiated actions are announced; highlights completion is
  spoken (the progress area was stuck aria-busy before).
- Media Session previous/next track step sections like J/K (AirPods,
  lock screen, media keys).
- Row layout: Time | Length | Loudness | Player | Options | File. The
  player opens inside its cell; Download/Cut/Delete are always there,
  and Cut toggles its start/end fields in place.
- Settings tucked behind a <details>; pre-roll and auto-advance are
  remembered per browser; no CSS uppercase on headers or badges.

Fixes the REC badge showing on every row: its inline-flex display beat
the UA [hidden] rule. One global [hidden]{display:none !important} now
covers every case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 01:05:15 +02:00

324 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The recordings listing: one collapsible section per day, a table of
// recordings inside, plus loading, filtering, deleting and status polling.
import {$, esc, fmtDur, fmtDurWords, fmtSize, pad, parseTime, dayLabel, announce} from './util.js';
import * as api from './api.js';
import {allFiles, setFiles, removeFile, sectionMap, fileSpan, isAnalysable} from './store.js';
import {cachedParamsMatch, hlParams} from './settings.js';
import {analyse, analyseButton, autoloadDayAnalyses, forgetAnalyses} from './analysis.js';
import {togglePlayer, closePlayer, forgetPlayer, toggleCut} from './player.js';
import {toggleHighlights, releaseDay} from './day.js';
// dayId -> boolean, persists expanded state across re-renders
const dayExpanded = new Map();
function groupByDay(files) {
const map = new Map();
files.forEach(f => {
const day = f.date.slice(0, 10);
if (!map.has(day)) map.set(day, []);
map.get(day).push(f);
});
return map;
}
// Local date, not toISOString() — f.date is the recording start in local
// time, so a UTC "today" leaves the current day collapsed for part of every
// day in any non-UTC timezone (00:00–02:00 in UTC+2, all evening in UTC−5).
const localToday = () => {
const now = new Date();
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
};
// Everything a day's markup is built from. A day whose signature is unchanged
// keeps its DOM across reloads — open players, analyses, the highlights panel
// and, above all, the screen reader's position in it.
const daySignature = dayFiles => hlParams() + '#'
+ dayFiles.map(f => `${f.name}:${f.recording ? 1 : 0}:${f.duration ?? ''}`).join('|');
function buildRow(f, tbody, expanded) {
const i = f._idx;
const isRec = !!f.recording;
const span = esc(fileSpan(f));
const off = isRec ? ' disabled' : '';
const tr = document.createElement('tr');
tr.className = 'data-row';
tr.id = 'row-' + i;
tr.dataset.idx = i;
// Time is the row header, so table navigation names the row by its span.
// The player opens inside its own cell; the options cell right after it is
// always there, and Cut expands its fields inside it. Format, size and
// filename come last: rarely needed, and the filename reads as a string of
// digits.
tr.innerHTML = `
<th scope="row" class="when">${span} <span id="rec-${i}" class="badge-rec"${isRec ? '' : ' hidden'}><span aria-hidden="true">● </span>Recording</span></th>
<td class="nowrap">${fmtDur(f.duration)}</td>
<td id="wave-${i}" class="loudness">${isAnalysable(f) ? '' : '<span class="muted">Not available for this format</span>'}</td>
<td class="player">
<button id="pbtn-${i}" aria-expanded="false" aria-controls="aud-${i}" aria-label="Play ${span}">Play</button>
<audio id="aud-${i}" controls preload="none" aria-label="Recording ${span}" hidden></audio>
</td>
<td class="options">
<div class="actions">
<a class="dl" href="${api.downloadUrl(f.name)}" aria-label="Download ${span}">Download</a>
<button id="cutbtn-${i}" class="cut" aria-expanded="false" aria-controls="cut-${i}" aria-label="Cut ${span}"${off}><span
class="day-arrow" aria-hidden="true">▸ </span>Cut</button>
<button id="delbtn-${i}" class="del" aria-label="Delete ${span}"${off}>Delete</button>
</div>
<div class="cut-panel" id="cut-${i}" role="group" aria-label="Cut" hidden>
<label class="cut-field">Start
<input type="text" id="cut-start-${i}" class="cut-time" placeholder="m:ss or h:mm:ss" autocomplete="off">
</label>
<label class="cut-field">End
<input type="text" id="cut-end-${i}" class="cut-time" placeholder="m:ss or h:mm:ss" autocomplete="off">
</label>
<button id="cut-dl-${i}" class="cut">Download cut</button>
</div>
</td>
<td class="file"><span class="badge badge-${esc(f.ext)}">${esc(f.ext.toUpperCase())}</span> ${fmtSize(f.size)} · <span class="fn">${esc(f.name)}</span></td>`;
tbody.append(tr);
if (isAnalysable(f)) {
const cell = tr.querySelector('#wave-' + i);
const abtn = analyseButton(i, cell, 'Analyse');
cell.appendChild(abtn);
if (isRec) {
abtn.disabled = true;
} else if (cachedParamsMatch(f.cached_analysis)) {
if (expanded) analyse(i, cell, abtn);
// Collapsed day: defer the fetch until the day is opened
else { cell.dataset.autoload = '1'; cell.dataset.idx = i; }
}
}
tr.querySelector('#pbtn-' + i).addEventListener('click', () => togglePlayer(i));
if (!isRec) {
tr.querySelector('#cutbtn-' + i).addEventListener('click', () => toggleCut(i));
tr.querySelector('#delbtn-' + i).addEventListener('click', () => deleteFile(f));
tr.querySelector('#cut-dl-' + i).addEventListener('click', () => {
const start = parseTime($('cut-start-' + i).value);
const end = parseTime($('cut-end-' + i).value);
if (start === null || end === null) { alert('Enter start and end times, e.g. 1:30 or 0:01:30'); return; }
if (start >= end) { alert('Start must be before end'); return; }
window.location.href = api.cutUrl(f.name, start, end);
});
}
}
function buildDay(day, dayFiles) {
const dayId = 'day-' + day.replace(/-/g, '');
if (!dayExpanded.has(dayId)) dayExpanded.set(dayId, day === localToday());
const expanded = dayExpanded.get(dayId);
const label = esc(dayLabel(day));
const totalSize = dayFiles.reduce((a, f) => a + f.size, 0);
const totalDur = dayFiles.reduce((a, f) => a + (f.duration || 0), 0);
const hlFiles = dayFiles.filter(f => isAnalysable(f) && !f.recording);
// Every analysable file already has a cached analysis for the current
// params -> the day's highlights are available without recomputing
const analysed = hlFiles.length > 0 && hlFiles.every(f => cachedParamsMatch(f.cached_analysis));
const meta = `${dayFiles.length} recording${dayFiles.length !== 1 ? 's' : ''}`
+ (totalDur > 0 ? ' · ' + fmtDurWords(totalDur) : '') + ' · ' + fmtSize(totalSize);
const el = document.createElement('div');
el.className = 'day-section';
el.id = 'daysec-' + dayId;
el.dataset.day = day;
// The Highlights button has no aria-label on purpose: its content is its
// name, so the " · analysed" / progress suffix is heard without mirroring.
el.innerHTML = `
<div class="day-heading-bar${expanded ? ' open' : ''}">
<h2 class="day-heading">
<button class="day-toggle" id="daytgl-${dayId}" aria-expanded="${expanded}" aria-controls="daytbl-${dayId}"><span
class="day-arrow" aria-hidden="true">${expanded ? '▾ ' : '▸ '}</span>${label}<span
class="day-meta"><span class="sr">, </span>${esc(meta)}</span></button>
</h2>
${hlFiles.length ? `<button class="day-hl" id="dayhln-${dayId}" aria-expanded="false" aria-controls="dayhl-${dayId}"><span
class="day-arrow" aria-hidden="true">▸ </span>Highlights<span class="sr"> for ${label}</span><span
class="day-hl-status" id="dayhls-${dayId}">${analysed ? ' · analysed' : ''}</span></button>` : ''}
</div>
<div class="day-hl-container" id="dayhl-${dayId}" hidden><div id="dayhlc-${dayId}"></div></div>
<div class="table-wrap" id="daytbl-${dayId}"${expanded ? '' : ' hidden'}>
<table class="day-table">
<caption class="sr">Recordings, ${label}</caption>
<thead><tr>
<th scope="col">Time</th>
<th scope="col">Length</th>
<th scope="col">Loudness</th>
<th scope="col">Player</th>
<th scope="col">Options</th>
<th scope="col">File</th>
</tr></thead>
<tbody></tbody>
</table>
</div>`;
const tbody = el.querySelector('tbody');
dayFiles.forEach(f => buildRow(f, tbody, expanded));
const headBar = el.querySelector('.day-heading-bar');
const wrap = el.querySelector('.table-wrap');
const tgl = el.querySelector('.day-toggle');
tgl.addEventListener('click', () => {
const nowExp = !dayExpanded.get(dayId);
dayExpanded.set(dayId, nowExp);
tgl.setAttribute('aria-expanded', nowExp);
tgl.querySelector('.day-arrow').textContent = nowExp ? '▾ ' : '▸ ';
headBar.classList.toggle('open', nowExp);
wrap.hidden = !nowExp;
if (nowExp) {
autoloadDayAnalyses(wrap);
} else {
dayFiles.forEach(f => closePlayer(f._idx));
releaseDay(dayId);
}
});
el.querySelector('.day-hl')?.addEventListener('click', () => toggleHighlights(dayId, hlFiles));
return el;
}
function discardDay(el) {
el.querySelectorAll('audio').forEach(a => a.pause());
el.querySelectorAll('tr.data-row').forEach(tr => {
const i = Number(tr.dataset.idx);
sectionMap.delete(i);
forgetPlayer(i);
});
el.remove();
}
// Re-render the listing, rebuilding only the days whose content changed. A
// rollover touches today alone, so the rest of the page — and whatever the
// screen reader is reading — stays put. Focus inside a rebuilt day is put back
// on the element with the same id (ids are stable per filename, see store.js).
function renderFiles(files) {
const container = $('days');
const focusId = document.activeElement?.id || null;
const total = allFiles.length, visible = files.length;
$('subtitle').textContent = total === visible
? `${total} recording${total !== 1 ? 's' : ''}`
: `${visible} of ${total} recording${total !== 1 ? 's' : ''} shown`;
$('empty').hidden = visible > 0;
const old = new Map([...container.children].map(el => [el.dataset.day, el]));
let prev = null;
groupByDay(files).forEach((dayFiles, day) => {
const sig = daySignature(dayFiles);
let el = old.get(day);
old.delete(day);
if (!el || el.dataset.sig !== sig) {
if (el) discardDay(el);
el = buildDay(day, dayFiles);
el.dataset.sig = sig;
}
// Only move nodes that are out of place: moving one blurs focus inside it
const ref = prev ? prev.nextSibling : container.firstChild;
if (el !== ref) container.insertBefore(el, ref);
prev = el;
});
old.forEach(discardDay);
if (focusId && document.activeElement?.id !== focusId) $(focusId)?.focus();
}
export function applyFilters({speak = false} = {}) {
const nameQ = $('filter-name').value.toLowerCase().trim();
const fromD = $('filter-from').value;
const toD = $('filter-to').value;
renderFiles(allFiles.filter(f => {
if (nameQ && !f.name.toLowerCase().includes(nameQ)) return false;
if (fromD && f.date < fromD + ' 00:00:00') return false;
if (toD && f.date > toD + ' 23:59:59') return false;
return true;
}));
if (speak) announce($('subtitle').textContent);
}
async function updateStorage() {
try {
const s = await api.getStorage();
const used = allFiles.reduce((a, f) => a + f.size, 0);
let txt = fmtSize(used) + ' used';
if (s.disk_free != null) txt += ' · ' + fmtSize(s.disk_free) + ' free';
if (s.disk_total != null) txt += ' of ' + fmtSize(s.disk_total);
$('storage-info').textContent = txt;
} catch { /* keep the last value */ }
}
let loading = false;
// speak: the user pressed Refresh, so say what came back. Poll-driven reloads
// stay silent — a rollover every hour must not interrupt whatever is being read.
export async function load({speak = false} = {}) {
if (loading) return;
loading = true;
const btn = $('refresh-btn');
btn.setAttribute('aria-disabled', 'true');
try {
setFiles(await api.getFiles());
updateStorage();
applyFilters();
if (speak) announce('Refreshed. ' + $('subtitle').textContent);
} catch {
$('subtitle').textContent = 'Error loading recordings';
if (speak) announce('Could not load recordings');
} finally {
loading = false;
btn.removeAttribute('aria-disabled');
}
}
async function deleteFile(f) {
const span = fileSpan(f);
if (!confirm(`Delete the recording ${span} (${f.name})?\nThis cannot be undone.`)) return;
const btn = $('delbtn-' + f._idx);
if (btn) btn.setAttribute('aria-disabled', 'true');
// Where focus goes once the row is gone: the next recording's Play button,
// else the previous one's, else the day's heading button
const row = $('row-' + f._idx);
const rows = [...row.parentNode.querySelectorAll('tr.data-row')];
const k = rows.indexOf(row);
const near = rows[k + 1] || rows[k - 1];
const dayToggle = row.closest('.day-section').querySelector('.day-toggle');
try {
await api.deleteFile(f.name);
} catch (e) {
if (btn) btn.removeAttribute('aria-disabled');
alert('Delete failed: ' + e.message);
return;
}
removeFile(f.name);
forgetAnalyses(f.name);
applyFilters();
updateStorage();
// The day was rebuilt without the row; look the targets up again by id
const target = (near && $('pbtn-' + near.dataset.idx)) || $(dayToggle.id) || $('page-title');
target.focus();
announce(`Deleted ${span}`);
}
// Poll recording status every 5 s to update REC badges
let lastActiveKey = null;
export async function pollStatus() {
try {
const s = await api.getStatus();
const active = new Set(s.active || []);
// Rows not currently rendered (filtered out) have no badge element, so
// getElementById skips them.
allFiles.forEach(f => {
const badge = $('rec-' + f._idx);
if (badge) badge.hidden = !active.has(f.name);
});
// Active set changed (recording started/stopped or rolled over to a new
// split file): refresh the list so new files appear — but never yank the
// DOM out from under an in-progress playback.
const key = [...active].sort().join('|');
if (lastActiveKey === null) { lastActiveKey = key; return; }
if (key !== lastActiveKey) {
const playing = [...document.querySelectorAll('audio')].some(a => !a.paused && !a.ended);
if (!playing) { lastActiveKey = key; load(); }
}
} catch { /* next poll */ }
}