// 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 = ` ${span} Recording ${fmtDur(f.duration)} ${isAnalysable(f) ? '' : 'Not available for this format'}
Download
${esc(f.ext.toUpperCase())} ${fmtSize(f.size)} · ${esc(f.name)}`; 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 = `

${hlFiles.length ? `` : ''}
Recordings, ${label}
Time Length Loudness Player Options File
`; 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 */ } }