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>
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
// 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 */ }
|
||||
}
|
||||
Reference in New Issue
Block a user