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,78 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>ISR Archive</title>
|
||||
<link rel="stylesheet" href="/ui/style.css">
|
||||
<script type="module" src="/ui/js/main.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="sr-announce" aria-live="polite" aria-atomic="true" class="sr"></div>
|
||||
<a href="#days" class="skip">Skip to recordings</a>
|
||||
|
||||
<header>
|
||||
<h1 id="page-title" tabindex="-1">ISR Archive</h1>
|
||||
<p id="subtitle">Loading…</p>
|
||||
<p id="storage-info"></p>
|
||||
<button id="refresh-btn">Refresh</button>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<details id="settings">
|
||||
<summary>Settings</summary>
|
||||
<div class="settings-grid">
|
||||
<label for="margin-input">Loudness margin</label>
|
||||
<input type="number" id="margin-input" min="1" max="60" step="1" aria-describedby="margin-hint">
|
||||
<span id="margin-hint" class="hint">dB above the background noise for a section to count as loud</span>
|
||||
|
||||
<label for="min-gap-input">Grace period</label>
|
||||
<input type="number" id="min-gap-input" min="0" max="300" step="0.5" aria-describedby="min-gap-hint">
|
||||
<span id="min-gap-hint" class="hint">seconds; loud sections closer than this are merged</span>
|
||||
|
||||
<label for="min-duration-input">Minimum length</label>
|
||||
<input type="number" id="min-duration-input" min="0" max="60" step="0.1" aria-describedby="min-duration-hint">
|
||||
<span id="min-duration-hint" class="hint">seconds; shorter loud sections are ignored</span>
|
||||
|
||||
<label for="preroll-input">Pre-roll</label>
|
||||
<input type="number" id="preroll-input" min="0" max="30" step="0.5" value="3" aria-describedby="preroll-hint">
|
||||
<span id="preroll-hint" class="hint">seconds played before each section starts</span>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="filter-bar" role="search" aria-label="Filter recordings">
|
||||
<label for="filter-name">Search</label>
|
||||
<input type="search" id="filter-name" placeholder="filename…" autocomplete="off">
|
||||
<label for="filter-from">From</label>
|
||||
<input type="date" id="filter-from">
|
||||
<label for="filter-to">To</label>
|
||||
<input type="date" id="filter-to">
|
||||
<button id="filter-clear">Clear filters</button>
|
||||
</div>
|
||||
|
||||
<div id="days" tabindex="-1"></div>
|
||||
<p id="empty" class="empty" hidden>No recordings found.</p>
|
||||
</main>
|
||||
|
||||
<section id="clip-bar" hidden aria-labelledby="clip-heading">
|
||||
<h2 id="clip-heading" class="sr">Clip player</h2>
|
||||
<div class="clip-row">
|
||||
<button id="clip-prev" aria-label="Previous section (J)" aria-keyshortcuts="J">Prev</button>
|
||||
<button id="clip-next" aria-label="Next section (K)" aria-keyshortcuts="K">Next</button>
|
||||
<p id="clip-label"></p>
|
||||
<button id="clip-context" aria-label="Open in file (O)" aria-keyshortcuts="O">Open in file</button>
|
||||
<button id="clip-close" aria-label="Close clip player">×</button>
|
||||
</div>
|
||||
<div class="clip-row">
|
||||
<audio id="clip-audio" controls preload="auto" aria-label="Section clip"></audio>
|
||||
<fieldset id="clip-adv">
|
||||
<legend>When a clip ends</legend>
|
||||
<label><input type="radio" name="clip-adv" value="off"> Stop</label>
|
||||
<label><input type="radio" name="clip-adv" value="all" checked> Next in time</label>
|
||||
<label><input type="radio" name="clip-adv" value="hl"> Next loudest</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,145 @@
|
||||
// Per-file loudness analysis: fetching (with a session cache) and the result
|
||||
// cell — a meta line, the section chips, a Re-analyse button.
|
||||
|
||||
import {fmtDur, fmtClock, fileStartEpoch, announce, chipToggle} from './util.js';
|
||||
import * as api from './api.js';
|
||||
import {fileByName, fileByIdx, fileSpan, sectionMap} from './store.js';
|
||||
import {analysisParams} from './settings.js';
|
||||
import {playFrom} from './clips.js';
|
||||
|
||||
// filename|margin|gap|minDur -> analysis result, so re-renders (filtering,
|
||||
// refresh) never refetch what this session already has
|
||||
const analysisCache = new Map();
|
||||
|
||||
export async function fetchAnalysis(filename, params, force = false) {
|
||||
const {margin, minGap, minDur} = params;
|
||||
const key = `${filename}|${margin}|${minGap}|${minDur}`;
|
||||
if (!force && analysisCache.has(key)) return analysisCache.get(key);
|
||||
const d = await api.analyze(filename, params);
|
||||
if (!d.error) {
|
||||
analysisCache.set(key, d);
|
||||
// Keep the listing's cache info current so re-renders (filtering) still
|
||||
// know the file is analysed without refetching /api/files
|
||||
const f = fileByName(filename);
|
||||
if (f) f.cached_analysis = {margin, min_gap: minGap, min_duration: minDur};
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
export function forgetAnalyses(filename) {
|
||||
for (const k of [...analysisCache.keys()])
|
||||
if (k.startsWith(filename + '|')) analysisCache.delete(k);
|
||||
}
|
||||
|
||||
export function playFileSection(idx, si) {
|
||||
const f = fileByIdx(idx);
|
||||
if (!f) return;
|
||||
const epoch = fileStartEpoch(f.date);
|
||||
const queue = (sectionMap.get(idx) || []).map(s => ({
|
||||
filename: f.name, start: s.start, end: s.end, score: s.score,
|
||||
absStart: epoch != null ? epoch + s.start : null,
|
||||
}));
|
||||
playFrom(queue, si);
|
||||
}
|
||||
|
||||
export function analyseButton(idx, cell, text) {
|
||||
const f = fileByIdx(idx);
|
||||
const b = document.createElement('button');
|
||||
b.textContent = text;
|
||||
b.setAttribute('aria-label', `${text} ${f ? fileSpan(f) : ''}`);
|
||||
b.addEventListener('click', () =>
|
||||
analyse(idx, cell, b, {force: text === 'Re-analyse', speak: true}));
|
||||
return b;
|
||||
}
|
||||
|
||||
const para = (cls, text) => {
|
||||
const p = document.createElement('p');
|
||||
p.className = cls;
|
||||
p.textContent = text;
|
||||
return p;
|
||||
};
|
||||
|
||||
// btn is the button that started it (optional). It stays in place and
|
||||
// focusable while busy — disabling or replacing a focused button drops the
|
||||
// screen reader at the top of the page. speak: the user asked for this one, so
|
||||
// say how it went; background autoloads stay quiet.
|
||||
export async function analyse(idx, cell, btn, {force = false, speak = false} = {}) {
|
||||
const f = fileByIdx(idx);
|
||||
if (!f || cell.dataset.busy) return;
|
||||
cell.dataset.busy = '1';
|
||||
const span = fileSpan(f);
|
||||
if (btn) {
|
||||
btn.textContent = 'Analysing…';
|
||||
btn.setAttribute('aria-label', `Analysing ${span}`);
|
||||
btn.setAttribute('aria-disabled', 'true');
|
||||
} else {
|
||||
cell.replaceChildren(para('quiet', 'Analysing…'));
|
||||
}
|
||||
const params = analysisParams();
|
||||
let d;
|
||||
try {
|
||||
d = await fetchAnalysis(f.name, params, force);
|
||||
} catch (e) {
|
||||
d = {error: e.message};
|
||||
}
|
||||
delete cell.dataset.busy;
|
||||
// Focus inside the cell is about to be destroyed: move it to the new content
|
||||
const hadFocus = cell.contains(document.activeElement);
|
||||
|
||||
if (d.error) {
|
||||
const b = analyseButton(idx, cell, 'Analyse');
|
||||
cell.replaceChildren(para('quiet', `Error: ${d.error}`), b);
|
||||
if (hadFocus) b.focus();
|
||||
if (speak) announce(`Analysis of ${span} failed: ${d.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const secs = d.sections || [];
|
||||
const n = secs.length;
|
||||
const box = document.createElement('div');
|
||||
box.className = 'wbox';
|
||||
box.append(para('analysis-meta', `${n} loud section${n !== 1 ? 's' : ''}`
|
||||
+ ` · margin ${params.margin} dB · gap ${params.minGap} s · min ${params.minDur} s`
|
||||
+ (d.cached ? ' · cached' : '')));
|
||||
|
||||
if (n) {
|
||||
sectionMap.set(idx, secs);
|
||||
const epoch = fileStartEpoch(f.date);
|
||||
const chips = document.createElement('div');
|
||||
chips.className = 'chips';
|
||||
chips.setAttribute('role', 'group');
|
||||
chips.setAttribute('aria-label', 'Loud sections');
|
||||
secs.forEach((s, si) => {
|
||||
const c = document.createElement('button');
|
||||
c.className = 'chip';
|
||||
// Wall-clock time like the day highlights and the clip label; the
|
||||
// in-file offset only matters for cutting and lives in the tooltip
|
||||
c.textContent = (epoch != null ? fmtClock(epoch + s.start) : `${fmtDur(s.start)} – ${fmtDur(s.end)}`)
|
||||
+ (s.score != null ? ` · +${Math.round(s.score)} dB` : '');
|
||||
c.title = `${fmtDur(s.start)}–${fmtDur(s.end)} into ${f.name}`;
|
||||
c.addEventListener('click', () => playFileSection(idx, si));
|
||||
chips.appendChild(c);
|
||||
});
|
||||
const tog = chipToggle(chips, `${n} sections`);
|
||||
if (tog) box.append(tog);
|
||||
box.append(chips);
|
||||
} else {
|
||||
sectionMap.delete(idx);
|
||||
}
|
||||
box.append(analyseButton(idx, cell, 'Re-analyse'));
|
||||
box.lastChild.classList.add('reanalyse-btn');
|
||||
|
||||
cell.replaceChildren(box);
|
||||
// First control in reading order: the first chip, the chip toggle when the
|
||||
// list is collapsed, or Re-analyse when there are no sections
|
||||
if (hadFocus) box.querySelector('button').focus();
|
||||
if (speak) announce(n ? `${n} loud section${n !== 1 ? 's' : ''} in ${span}` : `No loud sections in ${span}`);
|
||||
}
|
||||
|
||||
// Run the deferred analyses of a freshly expanded day
|
||||
export function autoloadDayAnalyses(dayEl) {
|
||||
dayEl.querySelectorAll('td[data-autoload]').forEach(cell => {
|
||||
cell.removeAttribute('data-autoload');
|
||||
analyse(Number(cell.dataset.idx), cell, cell.querySelector('button'));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Every URL the UI talks to, in one place. A leaf module.
|
||||
|
||||
const q = encodeURIComponent;
|
||||
|
||||
async function getJson(url) {
|
||||
return (await fetch(url)).json();
|
||||
}
|
||||
|
||||
export const getConfig = () => getJson('/api/config');
|
||||
export const getFiles = () => getJson('/api/files');
|
||||
export const getStatus = () => getJson('/api/status');
|
||||
export const getStorage = () => getJson('/api/storage');
|
||||
|
||||
export const analyze = (file, {margin, minGap, minDur}) =>
|
||||
getJson(`/api/analyze?file=${q(file)}&margin=${q(margin)}`
|
||||
+ `&min_gap=${q(minGap)}&min_duration=${q(minDur)}`);
|
||||
|
||||
export async function deleteFile(file) {
|
||||
const r = await fetch('/api/files/' + q(file), {method: 'DELETE'});
|
||||
if (!r.ok) {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
throw new Error(d.error || r.statusText);
|
||||
}
|
||||
}
|
||||
|
||||
export const clipUrl = (file, start, end) =>
|
||||
`/api/clip?file=${q(file)}&start=${start.toFixed(1)}&end=${end.toFixed(1)}`;
|
||||
export const cutUrl = (file, start, end) => `/api/cut?file=${q(file)}&start=${start}&end=${end}`;
|
||||
export const streamUrl = file => '/stream/' + q(file);
|
||||
export const downloadUrl = file => '/download/' + q(file);
|
||||
@@ -0,0 +1,193 @@
|
||||
// The clip player: sections play as small server-rendered WAV clips
|
||||
// (/api/clip) in the bottom bar instead of seeking the full recording, which
|
||||
// is slow for big FLACs. The queue holds the active review list (one file's
|
||||
// sections, or a whole day's); J/K and Prev/Next step through it in time
|
||||
// order, U/I step through the loudest-first ranking. The auto-advance radio
|
||||
// picks what plays when a clip ends: nothing, the next in time, or the next
|
||||
// by loudness.
|
||||
//
|
||||
// Two cursors, deliberately independent. clipCursor is the time-order position
|
||||
// and follows every play (chip click, J/K, U/I). scoreCursor is the loudness
|
||||
// walk's own position in scoreOrder(): only U/I (and "next loudest" auto-
|
||||
// advance) move it; J/K detours to review nearby clips leave it alone, so
|
||||
// coming back to U/I resumes the ranking exactly where you left it. An explicit
|
||||
// jump (chip click, new queue) resets it to -1 so the next U/I re-anchors there.
|
||||
|
||||
import {$, fmtDur, fmtClock, announce} from './util.js';
|
||||
import {fileIdxByName} from './store.js';
|
||||
import {advanceMode, getPreroll} from './settings.js';
|
||||
import {clipUrl} from './api.js';
|
||||
import {setCutFields, seekToSection} from './player.js';
|
||||
|
||||
let clipQueue = [];
|
||||
let clipCursor = -1;
|
||||
let scoreCursor = -1;
|
||||
// Where focus was when the bar opened, so closing it doesn't drop the user at
|
||||
// the top of the page
|
||||
let returnFocus = null;
|
||||
|
||||
export const hasQueue = () => clipQueue.length > 0;
|
||||
|
||||
// byScore: this play is part of a loudness walk (U/I or "next loudest"
|
||||
// auto-advance), so the position shown is scoreCursor — the rank in the
|
||||
// loudest-first ranking, labelled "by loudness" — not the time-order index.
|
||||
export function playClip(i, byScore) {
|
||||
if (i < 0 || i >= clipQueue.length) return;
|
||||
clipCursor = i;
|
||||
const c = clipQueue[i];
|
||||
const cs = Math.max(0, c.start - getPreroll());
|
||||
const ce = c.end + 1.5; // small tail after the section
|
||||
const a = $('clip-audio');
|
||||
a.src = clipUrl(c.filename, cs, ce);
|
||||
a.play().catch(() => {});
|
||||
// Label = wall-clock time of occurrence (absStart from the filename clock);
|
||||
// falls back to in-file offsets for non-standard filenames. The visible
|
||||
// label, the screen-reader announcement and the lock-screen title use one
|
||||
// identical string: "17:09:56 to 17:09:57 · +30 dB (30 / 426 by time)" —
|
||||
// the count is the time-order index "by time" (J/K) or the loudness rank
|
||||
// "by loudness" (U/I).
|
||||
const when = c.absStart != null
|
||||
? `${fmtClock(c.absStart)} to ${fmtClock(c.absStart + (c.end - c.start))}`
|
||||
: `${fmtDur(c.start)} to ${fmtDur(c.end)}`;
|
||||
const score = c.score != null ? ` · +${Math.round(c.score)} dB` : '';
|
||||
const pos = byScore ? scoreCursor : i;
|
||||
const dim = byScore ? 'by loudness' : 'by time';
|
||||
const text = `${when}${score} (${pos + 1} / ${clipQueue.length} ${dim})`;
|
||||
const label = $('clip-label');
|
||||
label.textContent = text;
|
||||
label.title = `${c.filename} @ ${fmtDur(c.start)}–${fmtDur(c.end)}`;
|
||||
|
||||
const bar = $('clip-bar');
|
||||
const ae = document.activeElement;
|
||||
if (ae && ae !== document.body && !bar.contains(ae)) returnFocus = ae;
|
||||
bar.hidden = false;
|
||||
document.body.classList.add('clip-open');
|
||||
const rowIdx = fileIdxByName(c.filename);
|
||||
if (rowIdx != null) setCutFields(rowIdx, c.start, c.end);
|
||||
if ('mediaSession' in navigator && 'MediaMetadata' in window)
|
||||
navigator.mediaSession.metadata = new MediaMetadata({title: text, artist: 'ISR', album: c.filename});
|
||||
announce(text);
|
||||
}
|
||||
|
||||
export function hideClipBar() {
|
||||
const a = $('clip-audio');
|
||||
a.pause();
|
||||
a.removeAttribute('src');
|
||||
$('clip-bar').hidden = true;
|
||||
document.body.classList.remove('clip-open');
|
||||
if ('mediaSession' in navigator) navigator.mediaSession.metadata = null;
|
||||
clipQueue = [];
|
||||
clipCursor = -1;
|
||||
scoreCursor = -1;
|
||||
}
|
||||
|
||||
// Point the queue at a new section list without playing: nothing is selected
|
||||
// yet, and the next U/I must re-anchor here rather than resume a walk through
|
||||
// the previous queue.
|
||||
export function armQueue(sections) {
|
||||
clipQueue = sections;
|
||||
clipCursor = -1;
|
||||
scoreCursor = -1;
|
||||
}
|
||||
|
||||
// Explicit jump (chip click): play section i of sections; the next U/I
|
||||
// re-anchors on it
|
||||
export function playFrom(sections, i) {
|
||||
clipQueue = sections;
|
||||
scoreCursor = -1;
|
||||
playClip(i);
|
||||
}
|
||||
|
||||
// Highlight order = section indices sorted loudest-first. U/I (and "next
|
||||
// loudest" auto-advance) walk this ranking, so a review runs from the most to
|
||||
// the least interesting event until the user stops — no top-N cutoff.
|
||||
// Cached against the section array's identity: a day queue runs to thousands
|
||||
// of entries and U/I would otherwise re-sort all of them on every keypress.
|
||||
// Every arming path assigns a fresh array, so identity is a safe key.
|
||||
let scoreOrderCache = {secs: null, order: null};
|
||||
export function scoreOrder(secs) {
|
||||
if (scoreOrderCache.secs === secs) return scoreOrderCache.order;
|
||||
const order = secs.map((s, i) => ({i, score: s.score || 0}))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map(r => r.i);
|
||||
scoreOrderCache = {secs, order};
|
||||
return order;
|
||||
}
|
||||
|
||||
// Step a position within an ordering: pos + dir, or straight to the extreme in
|
||||
// that direction when jump. Returns null when the step runs off either end.
|
||||
// pos === -1 means "not anchored yet" — start at the first entry going forward,
|
||||
// or off the front going backward so the caller announces the boundary.
|
||||
// Shared by both U/I walks (clip queue and in-player); they differ only in what
|
||||
// the anchor is and what "play" means, and drifting apart is exactly how the
|
||||
// two would stop agreeing.
|
||||
export function nextInOrder(order, pos, dir, jump) {
|
||||
const next = jump ? (dir > 0 ? order.length - 1 : 0)
|
||||
: pos === -1 ? (dir > 0 ? 0 : -1) : pos + dir;
|
||||
return (next >= 0 && next < order.length) ? next : null;
|
||||
}
|
||||
|
||||
// Step the clip queue by dir (±1): in time order, or with byScore down/up
|
||||
// the loudest-first ranking (next = next quieter). With jump=true, go straight
|
||||
// to the extreme in that direction (first/last section, or loudest/quietest).
|
||||
export function stepClip(dir, byScore, jump) {
|
||||
if (!clipQueue.length) return;
|
||||
if (byScore) {
|
||||
// Walk scoreCursor, not the shared clipCursor: a J/K detour never moved
|
||||
// it, so we resume the ranking where we left off. scoreCursor === -1 means
|
||||
// the walk hasn't started since the queue was armed or an explicit jump —
|
||||
// anchor on the section currently selected (clipCursor), else the loudest.
|
||||
const order = scoreOrder(clipQueue);
|
||||
const pos = scoreCursor !== -1 ? scoreCursor
|
||||
: clipCursor >= 0 ? order.indexOf(clipCursor)
|
||||
: -1;
|
||||
const next = nextInOrder(order, pos, dir, jump);
|
||||
if (next === null) { announce(dir < 0 ? 'Loudest highlight reached' : 'End of highlights'); return; }
|
||||
scoreCursor = next;
|
||||
playClip(order[next], true);
|
||||
return;
|
||||
}
|
||||
// J/K (time order) deliberately does NOT touch scoreCursor.
|
||||
const i = jump ? (dir > 0 ? clipQueue.length - 1 : 0) : clipCursor + dir;
|
||||
if (i >= 0 && i < clipQueue.length) playClip(i);
|
||||
else announce(dir < 0 ? 'Beginning of sections' : 'End of sections');
|
||||
}
|
||||
|
||||
// "Open in file": switch from the current clip to the full recording at the
|
||||
// same position. Bound to the clip-bar button and the O key.
|
||||
export function openClipInFile() {
|
||||
const c = clipQueue[clipCursor];
|
||||
if (!c) return;
|
||||
const idx = fileIdxByName(c.filename);
|
||||
if (idx == null) return;
|
||||
$('clip-audio').pause();
|
||||
const row = $('row-' + idx);
|
||||
if (row) {
|
||||
const wrap = row.closest('.table-wrap');
|
||||
if (wrap?.hidden) // file's day is collapsed: expand it
|
||||
$('daytgl-' + wrap.id.slice('daytbl-'.length))?.click();
|
||||
row.scrollIntoView({block: 'center'});
|
||||
}
|
||||
seekToSection(idx, c.start, c.end);
|
||||
}
|
||||
|
||||
function closeClipBar() {
|
||||
const back = returnFocus;
|
||||
returnFocus = null;
|
||||
hideClipBar();
|
||||
(back?.isConnected ? back : $('page-title')).focus();
|
||||
}
|
||||
|
||||
export function initClipBar() {
|
||||
$('clip-prev').addEventListener('click', () => stepClip(-1, false));
|
||||
$('clip-next').addEventListener('click', () => stepClip(1, false));
|
||||
$('clip-close').addEventListener('click', closeClipBar);
|
||||
$('clip-context').addEventListener('click', openClipInFile);
|
||||
$('clip-audio').addEventListener('ended', () => {
|
||||
const mode = advanceMode();
|
||||
if (mode !== 'off') stepClip(1, mode === 'hl');
|
||||
});
|
||||
$('clip-bar').addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); closeClipBar(); }
|
||||
});
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
// 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' : ''}.`);
|
||||
}
|
||||
@@ -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 */ }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Entry point: wire the page up and start.
|
||||
//
|
||||
// Modules import only from modules earlier in this list:
|
||||
// util, api, store, settings, player, clips, analysis, day, list, nav, main
|
||||
// Keep it that way. A module that needs something from a later one reaches it
|
||||
// through the DOM (openClipInFile clicks the day toggle) rather than an import.
|
||||
|
||||
import {$} from './util.js';
|
||||
import {getConfig} from './api.js';
|
||||
import {initSettings} from './settings.js';
|
||||
import {initClipBar} from './clips.js';
|
||||
import {initNav} from './nav.js';
|
||||
import {load, applyFilters, pollStatus} from './list.js';
|
||||
|
||||
initClipBar();
|
||||
initNav();
|
||||
|
||||
$('refresh-btn').addEventListener('click', () => load({speak: true}));
|
||||
|
||||
let filterDebounce;
|
||||
$('filter-name').addEventListener('input', () => {
|
||||
clearTimeout(filterDebounce);
|
||||
filterDebounce = setTimeout(() => applyFilters({speak: true}), 400);
|
||||
});
|
||||
$('filter-from').addEventListener('change', () => applyFilters({speak: true}));
|
||||
$('filter-to').addEventListener('change', () => applyFilters({speak: true}));
|
||||
$('filter-clear').addEventListener('click', () => {
|
||||
$('filter-name').value = '';
|
||||
$('filter-from').value = '';
|
||||
$('filter-to').value = '';
|
||||
applyFilters({speak: true});
|
||||
});
|
||||
|
||||
getConfig().catch(() => ({})).then(initSettings)
|
||||
.finally(() => load().then(() => setInterval(pollStatus, 5000)));
|
||||
@@ -0,0 +1,89 @@
|
||||
// Section navigation from the keyboard (J/K/U/I/O) and from media keys —
|
||||
// headset buttons, AirPods, the lock screen — via the Media Session API.
|
||||
//
|
||||
// J/K = previous/next section in time order, U/I = up/down the loudest-first
|
||||
// ranking, O = open the current clip in the full file. Holding Shift jumps to
|
||||
// the extreme in that direction: Shift+J/K = first/last section in time,
|
||||
// Shift+U/I = loudest/quietest. The clip queue takes priority; with no queue
|
||||
// armed, the keys step the playhead of the open full-file player instead.
|
||||
|
||||
import {fmtDur, fmtClock, fileStartEpoch, announce} from './util.js';
|
||||
import {hasQueue, stepClip, openClipInFile, scoreOrder, nextInOrder} from './clips.js';
|
||||
import {activePlayerIdx, setCutFields} from './player.js';
|
||||
import {sectionMap, fileByIdx} from './store.js';
|
||||
import {getPreroll} from './settings.js';
|
||||
|
||||
export function navigate(dir, byScore, jump) {
|
||||
if (hasQueue()) { stepClip(dir, byScore, jump); return; }
|
||||
|
||||
// Per-file in-player navigation (full-file listening, no clip queue)
|
||||
const idx = activePlayerIdx;
|
||||
if (idx === null) return;
|
||||
const all = sectionMap.get(idx) || [];
|
||||
const audio = document.getElementById('aud-' + idx);
|
||||
if (!all.length || !audio) return;
|
||||
const preroll = getPreroll();
|
||||
const cur = audio.currentTime;
|
||||
const epoch = fileStartEpoch(fileByIdx(idx)?.date);
|
||||
|
||||
const jumpTo = (s, i, n, word) => {
|
||||
audio.currentTime = Math.max(0, s.start - preroll);
|
||||
setCutFields(idx, s.start, s.end);
|
||||
const when = epoch != null
|
||||
? `${fmtClock(epoch + s.start)} to ${fmtClock(epoch + s.end)}`
|
||||
: `${fmtDur(s.start)} to ${fmtDur(s.end)}`;
|
||||
announce(`${word} ${i + 1} of ${n}: ${when}`);
|
||||
};
|
||||
|
||||
if (byScore) {
|
||||
// U/I walk the ranking; the anchor is the section the playhead sits in
|
||||
// (incl. its preroll lead-in). Anywhere else, I starts at the loudest.
|
||||
// Shift jumps straight to the loudest (Shift+U) or quietest (Shift+I).
|
||||
const order = scoreOrder(all);
|
||||
const curIdx = all.findIndex(s => cur >= s.start - preroll - 0.5 && cur <= s.end + 0.5);
|
||||
const pos = curIdx === -1 ? -1 : order.indexOf(curIdx);
|
||||
const next = nextInOrder(order, pos, dir, jump);
|
||||
if (next === null) { announce(dir < 0 ? 'Loudest highlight reached' : 'End of highlights'); return; }
|
||||
jumpTo(all[order[next]], next, order.length, 'Highlight');
|
||||
return;
|
||||
}
|
||||
|
||||
// Shift+J/K jump to the first/last section regardless of the playhead.
|
||||
if (jump) {
|
||||
const i = dir < 0 ? 0 : all.length - 1;
|
||||
jumpTo(all[i], i, all.length, 'Section');
|
||||
return;
|
||||
}
|
||||
|
||||
if (dir < 0) {
|
||||
for (let i = all.length - 1; i >= 0; i--)
|
||||
if (all[i].start < cur - 1) { jumpTo(all[i], i, all.length, 'Section'); return; }
|
||||
announce('Beginning of sections');
|
||||
} else {
|
||||
for (let i = 0; i < all.length; i++)
|
||||
if (all[i].start > cur + preroll) { jumpTo(all[i], i, all.length, 'Section'); return; }
|
||||
announce('End of sections');
|
||||
}
|
||||
}
|
||||
|
||||
export function initNav() {
|
||||
document.addEventListener('keydown', e => {
|
||||
const t = e.target;
|
||||
if (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable) return;
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
const key = e.key.toLowerCase();
|
||||
if (!['j', 'k', 'u', 'i', 'o'].includes(key)) return;
|
||||
e.preventDefault();
|
||||
if (key === 'o') { openClipInFile(); return; }
|
||||
navigate(key === 'j' || key === 'u' ? -1 : 1, key === 'u' || key === 'i', e.shiftKey);
|
||||
});
|
||||
|
||||
// Previous/next track = J/K. Works from AirPods, the iOS lock screen and
|
||||
// Control Center, and the media keys on a Windows keyboard.
|
||||
if ('mediaSession' in navigator) {
|
||||
for (const [action, dir] of [['previoustrack', -1], ['nexttrack', 1]]) {
|
||||
try { navigator.mediaSession.setActionHandler(action, () => navigate(dir, false, false)); }
|
||||
catch { /* action not supported here */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// The per-row full-file player and its cut fields. Section review normally
|
||||
// goes through clips.js; this is for listening around a section ("Open in
|
||||
// file") or to a whole recording.
|
||||
|
||||
import {$, fmtDur} from './util.js';
|
||||
import {fileByIdx, fileSpan} from './store.js';
|
||||
import {streamUrl} from './api.js';
|
||||
import {getPreroll} from './settings.js';
|
||||
|
||||
// Row of the full-file player opened last. The J/K/U/I fallback steps through
|
||||
// its sections when no clip queue is armed. Live binding — read, don't assign.
|
||||
export let activePlayerIdx = null;
|
||||
|
||||
export function forgetPlayer(idx) {
|
||||
if (activePlayerIdx === idx) activePlayerIdx = null;
|
||||
}
|
||||
|
||||
export function setCutFields(idx, startSec, endSec) {
|
||||
const startEl = $('cut-start-' + idx);
|
||||
const endEl = $('cut-end-' + idx);
|
||||
if (startEl) startEl.value = fmtDur(startSec);
|
||||
if (endEl && endSec != null) endEl.value = fmtDur(endSec);
|
||||
}
|
||||
|
||||
const isOpen = idx => $('pbtn-' + idx)?.getAttribute('aria-expanded') === 'true';
|
||||
|
||||
function setPlayButton(idx, open) {
|
||||
const btn = $('pbtn-' + idx);
|
||||
const f = fileByIdx(idx);
|
||||
if (!btn || !f) return;
|
||||
btn.setAttribute('aria-expanded', open);
|
||||
btn.textContent = open ? 'Hide' : 'Play';
|
||||
btn.setAttribute('aria-label', (open ? 'Hide player for ' : 'Play ') + fileSpan(f));
|
||||
}
|
||||
|
||||
// The player lives in its own table cell: the Play button stays, and the
|
||||
// audio element appears right after it
|
||||
export function closePlayer(idx) {
|
||||
const audio = $('aud-' + idx);
|
||||
if (!audio || audio.hidden) return;
|
||||
audio.pause();
|
||||
audio.hidden = true;
|
||||
setPlayButton(idx, false);
|
||||
}
|
||||
|
||||
export function togglePlayer(idx) {
|
||||
if (isOpen(idx)) { closePlayer(idx); return; }
|
||||
const f = fileByIdx(idx);
|
||||
const audio = $('aud-' + idx);
|
||||
if (!f || !audio) return;
|
||||
if (!audio.dataset.srcSet) {
|
||||
audio.preload = 'auto';
|
||||
audio.src = streamUrl(f.name);
|
||||
audio.load();
|
||||
audio.dataset.srcSet = '1';
|
||||
} else if (!$('rec-' + idx)?.hidden) {
|
||||
// Still recording: re-fetch so duration and seek range cover the audio
|
||||
// captured since the source was last loaded
|
||||
audio.load();
|
||||
}
|
||||
activePlayerIdx = idx;
|
||||
audio.hidden = false;
|
||||
setPlayButton(idx, true);
|
||||
audio.focus();
|
||||
}
|
||||
|
||||
export function seekToSection(idx, startSec, endSec) {
|
||||
if (!isOpen(idx)) togglePlayer(idx);
|
||||
activePlayerIdx = idx;
|
||||
const audio = $('aud-' + idx);
|
||||
if (!audio) return;
|
||||
audio.preload = 'auto';
|
||||
const seekTo = Math.max(0, startSec - getPreroll());
|
||||
const doSeek = () => { audio.currentTime = seekTo; audio.play().catch(() => {}); };
|
||||
if (audio.readyState >= 1) doSeek();
|
||||
else audio.addEventListener('loadedmetadata', doSeek, {once: true});
|
||||
setCutFields(idx, startSec, endSec);
|
||||
}
|
||||
|
||||
// Cut button: show/hide the start/end fields inside the options cell. Opening
|
||||
// puts focus in Start. Clip playback pre-fills the fields either way.
|
||||
export function toggleCut(idx) {
|
||||
const btn = $('cutbtn-' + idx);
|
||||
const panel = $('cut-' + idx);
|
||||
if (!btn || !panel) return;
|
||||
const open = panel.hidden;
|
||||
panel.hidden = !open;
|
||||
btn.setAttribute('aria-expanded', open);
|
||||
btn.querySelector('.day-arrow').textContent = open ? '▾ ' : '▸ ';
|
||||
if (open) $('cut-start-' + idx)?.focus();
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// The settings panel: analysis params, pre-roll and the auto-advance mode.
|
||||
|
||||
import {$, remember} from './util.js';
|
||||
|
||||
// The three analysis controls, read straight from the DOM. Deliberately no
|
||||
// hardcoded fallbacks: the inputs are seeded from /api/config at startup, so
|
||||
// web.py's MARGIN_DB / MIN_GAP_SECONDS / MIN_DURATION_SECONDS stay the single
|
||||
// source of truth. An empty input sends an empty param, which _qs_float()
|
||||
// falls back to the server default for.
|
||||
export const analysisParams = () => ({
|
||||
margin: $('margin-input').value,
|
||||
minGap: $('min-gap-input').value,
|
||||
minDur: $('min-duration-input').value,
|
||||
});
|
||||
// Same values as one string; a highlights panel built with other values is
|
||||
// stale and gets recomputed on the next expand
|
||||
export const hlParams = () => Object.values(analysisParams()).join('|');
|
||||
|
||||
export const getPreroll = () => {
|
||||
const v = parseFloat($('preroll-input').value);
|
||||
return isNaN(v) || v < 0 ? 0 : v;
|
||||
};
|
||||
|
||||
export const advanceMode = () =>
|
||||
document.querySelector('input[name="clip-adv"]:checked')?.value || 'off';
|
||||
|
||||
// Does a server-side cached analysis match the current control values?
|
||||
// Auto-loading on a mismatch would silently recompute every file.
|
||||
export function cachedParamsMatch(ca) {
|
||||
const p = analysisParams();
|
||||
return ca != null
|
||||
&& Number(ca.margin) === parseFloat(p.margin)
|
||||
&& Number(ca.min_gap) === parseFloat(p.minGap)
|
||||
&& Number(ca.min_duration) === parseFloat(p.minDur);
|
||||
}
|
||||
|
||||
// Seed the analysis params from /api/config, and restore the per-browser
|
||||
// conveniences. Margin/gap/min-duration are not remembered client-side: a
|
||||
// stale browser value would quietly disagree with the server's defaults.
|
||||
export function initSettings(cfg) {
|
||||
if (cfg.margin != null) $('margin-input').value = cfg.margin;
|
||||
if (cfg.min_gap != null) $('min-gap-input').value = cfg.min_gap;
|
||||
if (cfg.min_duration != null) $('min-duration-input').value = cfg.min_duration;
|
||||
|
||||
const pre = remember.get('preroll');
|
||||
if (pre != null) $('preroll-input').value = pre;
|
||||
$('preroll-input').addEventListener('change', e => remember.set('preroll', e.target.value));
|
||||
|
||||
const adv = remember.get('advance');
|
||||
const radio = adv && document.querySelector(`input[name="clip-adv"][value="${adv}"]`);
|
||||
if (radio) radio.checked = true;
|
||||
document.querySelectorAll('input[name="clip-adv"]').forEach(r =>
|
||||
r.addEventListener('change', () => remember.set('advance', r.value)));
|
||||
|
||||
const panel = $('settings');
|
||||
panel.open = remember.get('settingsOpen') === '1';
|
||||
panel.addEventListener('toggle', () => remember.set('settingsOpen', panel.open ? '1' : '0'));
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// The file listing and what the UI knows about each file. A leaf apart from
|
||||
// util.js.
|
||||
|
||||
import {fileStartEpoch, fmtHM} from './util.js';
|
||||
|
||||
// Full listing from /api/files. Reassigned on every load; importers see the
|
||||
// new array through the live binding.
|
||||
export let allFiles = [];
|
||||
|
||||
// _idx is per filename and stable for the whole session: assigned the first
|
||||
// time a name is seen and never reused. DOM ids (row-, pbtn-, aud-, …) are
|
||||
// built from it, so they survive reloads — that is what lets a re-rendered day
|
||||
// keep focus where it was. It used to be the position in the listing, which
|
||||
// shifted on every rollover and left stored indexes pointing one row off.
|
||||
const idxByName = new Map();
|
||||
const stableIdx = name => {
|
||||
if (!idxByName.has(name)) idxByName.set(name, idxByName.size);
|
||||
return idxByName.get(name);
|
||||
};
|
||||
|
||||
export function setFiles(list) {
|
||||
allFiles = list.map(f => ({...f, _idx: stableIdx(f.name)}));
|
||||
}
|
||||
|
||||
export function removeFile(name) {
|
||||
allFiles = allFiles.filter(f => f.name !== name);
|
||||
}
|
||||
|
||||
export const fileByName = name => allFiles.find(f => f.name === name) ?? null;
|
||||
export const fileByIdx = idx => allFiles.find(f => f._idx === idx) ?? null;
|
||||
// Queue entries carry the filename, never a row index; resolve at use time.
|
||||
// Null when the file is gone from the current listing.
|
||||
export const fileIdxByName = name => fileByName(name)?._idx ?? null;
|
||||
|
||||
export const isAnalysable = f => f.ext === 'wav' || f.ext === 'flac';
|
||||
|
||||
// idx -> [{start, end, score}], populated by analyse()
|
||||
export const sectionMap = new Map();
|
||||
|
||||
// What a recording is called wherever it is spoken: its wall-clock span from
|
||||
// the filename clock, "22:00 to 23:00". The filename itself ("20260523_220000")
|
||||
// reads as a string of numbers. Non-standard names fall back to the name.
|
||||
export function fileSpan(f) {
|
||||
const start = fileStartEpoch(f.date);
|
||||
if (start == null) return f.name;
|
||||
if (f.recording) return `${fmtHM(start)} to now`;
|
||||
if (f.duration == null) return fmtHM(start);
|
||||
return `${fmtHM(start)} to ${fmtHM(Math.round((start + f.duration) / 60) * 60)}`;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Formatting and screen-reader helpers shared by every module. A leaf: it
|
||||
// imports nothing, so any module can use it without creating a cycle.
|
||||
|
||||
export const $ = id => document.getElementById(id);
|
||||
|
||||
export const esc = s => String(s)
|
||||
.replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
export const pad = n => String(n).padStart(2, '0');
|
||||
|
||||
export const fmtDur = s => {
|
||||
if (s == null) return '—';
|
||||
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)}`;
|
||||
};
|
||||
|
||||
// "2 h 59 min" — for running text a screen reader speaks, where "2:59:59"
|
||||
// reads like a clock time
|
||||
export const fmtDurWords = s => {
|
||||
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
|
||||
if (h) return m ? `${h} h ${m} min` : `${h} h`;
|
||||
return m ? `${m} min` : `${Math.round(s)} s`;
|
||||
};
|
||||
|
||||
export const fmtSize = b => {
|
||||
if (b < 1024) return b + ' B';
|
||||
if (b < 1 << 20) return (b / 1024).toFixed(0) + ' KB';
|
||||
if (b < 1 << 30) return (b / (1 << 20)).toFixed(1) + ' MB';
|
||||
return (b / (1 << 30)).toFixed(2) + ' GB';
|
||||
};
|
||||
|
||||
// Epoch seconds -> local wall-clock "HH:MM:SS"
|
||||
export const fmtClock = ts => {
|
||||
const d = new Date(ts * 1000);
|
||||
return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
|
||||
};
|
||||
|
||||
// Epoch seconds -> local "HH:MM"
|
||||
export const fmtHM = ts => {
|
||||
const d = new Date(ts * 1000);
|
||||
return pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||
};
|
||||
|
||||
// Listing `date` ("YYYY-MM-DD HH:MM:SS", the recording start parsed out of
|
||||
// the filename server-side) -> epoch seconds, or null for unparseable values
|
||||
export const fileStartEpoch = date => {
|
||||
const t = Date.parse(String(date).replace(' ', 'T'));
|
||||
return isNaN(t) ? null : t / 1000;
|
||||
};
|
||||
|
||||
// "1:30", "0:01:30" or "90" -> seconds; null when unparseable
|
||||
export function parseTime(s) {
|
||||
if (!s || !s.trim()) return null;
|
||||
const parts = s.trim().split(':').map(v => parseFloat(v));
|
||||
if (parts.some(isNaN) || parts.length > 3) return null;
|
||||
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
if (parts.length === 2) return parts[0] * 60 + parts[1];
|
||||
return parts[0];
|
||||
}
|
||||
|
||||
// "2026-10-17" -> "Today, Saturday 17 October". Spoken far better than the
|
||||
// ISO date, which screen readers read digit group by digit group. en-GB to
|
||||
// match the page's lang="en", so the speech language never switches mid-page.
|
||||
export function dayLabel(day) {
|
||||
const [y, m, d] = day.split('-').map(Number);
|
||||
const date = new Date(y, m - 1, d);
|
||||
if (isNaN(date)) return day;
|
||||
const now = new Date();
|
||||
const opts = {weekday: 'long', day: 'numeric', month: 'long'};
|
||||
if (y !== now.getFullYear()) opts.year = 'numeric';
|
||||
const text = date.toLocaleDateString('en-GB', opts);
|
||||
// Math.round absorbs the 23/25 h days around DST switches
|
||||
const ago = Math.round((new Date(now.getFullYear(), now.getMonth(), now.getDate()) - date) / 86400000);
|
||||
return ago === 0 ? 'Today, ' + text : ago === 1 ? 'Yesterday, ' + text : text;
|
||||
}
|
||||
|
||||
export function announce(msg) {
|
||||
const el = $('sr-announce');
|
||||
if (!el) return;
|
||||
el.textContent = ''; // clear first so same text re-triggers
|
||||
setTimeout(() => { el.textContent = msg; }, 50);
|
||||
}
|
||||
|
||||
// A long chip list is a wall of buttons: hide it behind an aria-expanded
|
||||
// toggle. Returns the toggle to insert before the chips, or null when the list
|
||||
// is short enough to show as is. The separator space lives inside the
|
||||
// aria-hidden arrow — a leading space in the text node reads as an indent.
|
||||
export function chipToggle(chips, text) {
|
||||
if (chips.children.length <= 12) return null;
|
||||
chips.hidden = true;
|
||||
const tog = document.createElement('button');
|
||||
tog.className = 'chip-toggle';
|
||||
tog.setAttribute('aria-expanded', 'false');
|
||||
const arrow = document.createElement('span');
|
||||
arrow.className = 'day-arrow';
|
||||
arrow.setAttribute('aria-hidden', 'true');
|
||||
arrow.textContent = '▸ ';
|
||||
tog.append(arrow, text);
|
||||
tog.addEventListener('click', () => {
|
||||
const exp = chips.hidden;
|
||||
chips.hidden = !exp;
|
||||
tog.setAttribute('aria-expanded', exp);
|
||||
arrow.textContent = exp ? '▾ ' : '▸ ';
|
||||
});
|
||||
return tog;
|
||||
}
|
||||
|
||||
// localStorage for per-browser conveniences (pre-roll, auto-advance, whether
|
||||
// the settings are open). It can be missing or throw (private mode, blocked
|
||||
// storage), and nothing here may depend on it working.
|
||||
export const remember = {
|
||||
get(key) { try { return localStorage.getItem('isr.' + key); } catch { return null; } },
|
||||
set(key, value) { try { localStorage.setItem('isr.' + key, value); } catch { /* not kept */ } },
|
||||
};
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
:root{--bg:#0f1117;--surf:#1a1d27;--brd:#2c3040;--txt:#e2e8f0;--muted:#8b93ad;
|
||||
--accent:#4f9cf9;--orange:#f97316;--green:#22c55e;--red:#ef4444;
|
||||
--gutter:28px}
|
||||
@media (max-width:640px){:root{--gutter:16px}}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
/* One rule for every [hidden] element. Author display rules (flex chips, the
|
||||
inline-flex REC badge, the flex clip bar) beat the UA's [hidden] otherwise —
|
||||
the REC badge used to show on every row that way. */
|
||||
[hidden]{display:none !important}
|
||||
body{background:var(--bg);color:var(--txt);font:15px/1.5 system-ui,sans-serif}
|
||||
.sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px;
|
||||
overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
|
||||
.skip{position:absolute;left:-999px;top:0;padding:8px 14px;background:var(--accent);
|
||||
color:#000;border-radius:0 0 4px 4px;text-decoration:none;font-weight:600;z-index:30}
|
||||
.skip:focus{left:0}
|
||||
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||
#page-title:focus,#days:focus{outline:none}
|
||||
.muted{color:var(--muted)}
|
||||
.nowrap{white-space:nowrap}
|
||||
|
||||
header{padding:16px var(--gutter);border-bottom:1px solid var(--brd);
|
||||
display:flex;align-items:baseline;gap:6px 14px;flex-wrap:wrap}
|
||||
header h1{font-size:20px;font-weight:600}
|
||||
#subtitle{color:var(--muted)}
|
||||
#storage-info{color:var(--muted);font-size:13px;margin-right:auto}
|
||||
|
||||
button{cursor:pointer;border:1px solid var(--brd);background:var(--surf);color:var(--txt);
|
||||
padding:6px 12px;border-radius:6px;font:inherit;font-size:13px;white-space:nowrap;min-height:32px}
|
||||
button:hover{background:var(--brd)}
|
||||
button:disabled,button[aria-disabled=true]{opacity:.55;cursor:default}
|
||||
input{font:inherit;font-size:14px;background:var(--bg);border:1px solid var(--brd);
|
||||
color:var(--txt);padding:5px 8px;border-radius:5px;min-height:32px}
|
||||
input[type=date]{color-scheme:dark}
|
||||
input[type=radio]{min-height:0;accent-color:var(--accent)}
|
||||
|
||||
/* settings */
|
||||
#settings{border-bottom:1px solid var(--brd);background:var(--surf)}
|
||||
#settings summary{padding:10px var(--gutter);cursor:pointer;color:var(--muted);font-size:14px}
|
||||
#settings summary:hover{color:var(--txt)}
|
||||
.settings-grid{display:grid;grid-template-columns:max-content 90px 1fr;gap:8px 12px;
|
||||
align-items:center;padding:4px var(--gutter) 14px}
|
||||
.settings-grid label{font-size:14px}
|
||||
.hint{font-size:12px;color:var(--muted)}
|
||||
@media (max-width:640px){
|
||||
.settings-grid{grid-template-columns:1fr 90px}
|
||||
.settings-grid .hint{grid-column:1 / -1;margin-top:-6px}
|
||||
}
|
||||
|
||||
/* filters */
|
||||
.filter-bar{display:flex;align-items:center;gap:8px 10px;padding:10px var(--gutter);
|
||||
border-bottom:1px solid var(--brd);flex-wrap:wrap}
|
||||
.filter-bar label{font-size:14px;color:var(--muted)}
|
||||
#filter-name{width:200px}
|
||||
|
||||
/* days */
|
||||
#days{padding:20px var(--gutter)}
|
||||
.day-section{margin-bottom:18px}
|
||||
.day-heading-bar{background:var(--surf);padding:6px 10px;border:1px solid var(--brd);
|
||||
border-radius:8px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.day-heading-bar.open{border-radius:8px 8px 0 0}
|
||||
h2.day-heading{font-size:inherit;font-weight:inherit;flex:1 1 auto}
|
||||
.day-toggle{background:none;border:none;color:var(--txt);font-size:15px;font-weight:600;
|
||||
padding:4px 0;display:inline-flex;align-items:baseline;gap:4px 10px;flex-wrap:wrap;text-align:left;white-space:normal}
|
||||
.day-toggle:hover{background:none;color:var(--accent)}
|
||||
.day-meta{color:var(--muted);font-size:13px;font-weight:400}
|
||||
.day-arrow{font-size:10px}
|
||||
button.day-hl{color:var(--green);border-color:#166534;background:#052e16}
|
||||
button.day-hl:hover{background:#0a3d1f}
|
||||
.day-hl-status{opacity:.8}
|
||||
.day-hl-container{border:1px solid var(--brd);border-top:none;padding:10px 12px 12px}
|
||||
|
||||
/* recordings table; scrolls sideways on narrow screens instead of the page */
|
||||
.table-wrap{overflow-x:auto;border:1px solid var(--brd);border-top:none}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
th,td{padding:9px 10px;border-bottom:1px solid var(--brd);vertical-align:middle;text-align:left}
|
||||
thead th{color:var(--muted);font-weight:500;font-size:13px;white-space:nowrap}
|
||||
th.when{font-weight:600;white-space:nowrap}
|
||||
tr.data-row:hover>*{background:var(--surf)}
|
||||
td.file{color:var(--muted);font-size:12px;white-space:nowrap}
|
||||
.fn{font-family:ui-monospace,monospace}
|
||||
.badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;
|
||||
font-weight:700;border:1px solid}
|
||||
.badge-wav{color:var(--green);border-color:#166534;background:#052e16}
|
||||
.badge-mp3{color:var(--accent);border-color:#1e40af;background:#0c1a40}
|
||||
.badge-ogg{color:#c084fc;border-color:#6b21a8;background:#2d1157}
|
||||
.badge-flac{color:#fb923c;border-color:#7c2d12;background:#2c0e04}
|
||||
.badge-aac,.badge-opus{color:var(--muted);border-color:var(--brd);background:var(--surf)}
|
||||
.badge-rec{display:inline-flex;align-items:center;padding:1px 6px;border-radius:3px;
|
||||
font-size:11px;font-weight:700;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}}
|
||||
.actions{display:flex;gap:6px;align-items:center}
|
||||
a.dl{color:var(--accent);text-decoration:none;font-size:13px;padding:6px 4px}
|
||||
a.dl:hover{text-decoration:underline}
|
||||
button.del{color:var(--red);border-color:#7f1d1d}
|
||||
button.del:hover:not(:disabled){background:#2d0808}
|
||||
button.cut{color:var(--accent);border-color:#1e40af;background:#0c1a40}
|
||||
button.cut:hover:not(:disabled){background:#1e3a8a}
|
||||
|
||||
/* analysis */
|
||||
td.loudness{min-width:200px}
|
||||
.wbox{background:var(--surf);border:1px solid var(--brd);border-radius:6px;padding:8px 10px}
|
||||
.analysis-meta{font-size:12px;color:var(--muted)}
|
||||
.chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
|
||||
.chip{background:#431407;color:var(--orange);border:1px solid #7c2d12;border-radius:5px;
|
||||
padding:4px 9px;font-size:12px;font-family:ui-monospace,monospace;min-height:30px}
|
||||
.chip:hover{background:#6c1f08;border-color:#9a3412}
|
||||
.chip-toggle{margin-top:8px}
|
||||
button.reanalyse-btn{margin-top:8px;font-size:12px;color:var(--muted);background:transparent}
|
||||
.quiet{color:var(--muted);font-size:13px}
|
||||
.quiet+.quiet{margin-top:6px}
|
||||
.prog-bar{height:4px;background:var(--brd);border-radius:2px;margin:6px 0 4px;overflow:hidden}
|
||||
.prog-fill{height:100%;width:0;background:var(--accent);transition:width .15s ease}
|
||||
@media (prefers-reduced-motion:reduce){.prog-fill{transition:none}}
|
||||
.prog-file{font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.empty{text-align:center;padding:60px 16px;color:var(--muted)}
|
||||
|
||||
/* full-file player cell, options cell */
|
||||
audio{width:100%;height:40px;display:block;color-scheme:dark;accent-color:var(--accent)}
|
||||
td.player audio{width:300px;margin-top:6px}
|
||||
.cut-panel{display:flex;align-items:center;gap:8px 12px;margin-top:8px;flex-wrap:wrap}
|
||||
.cut-field{display:flex;align-items:center;gap:6px;font-size:13px;color:var(--muted)}
|
||||
.cut-time{width:110px;font-family:ui-monospace,monospace}
|
||||
|
||||
/* clip player bar */
|
||||
#clip-bar{position:fixed;bottom:0;left:0;right:0;z-index:20;background:var(--surf);
|
||||
border-top:1px solid var(--brd);padding:8px var(--gutter);display:flex;flex-direction:column;gap:6px}
|
||||
.clip-row{display:flex;align-items:center;gap:8px 10px;flex-wrap:wrap}
|
||||
#clip-label{flex:1 1 240px;font-family:ui-monospace,monospace;font-size:13px;min-width:0}
|
||||
#clip-audio{flex:1 1 260px;min-width:200px;width:auto}
|
||||
#clip-adv{border:none;display:flex;align-items:center;gap:4px 12px;flex-wrap:wrap}
|
||||
#clip-adv legend{float:left;font-size:12px;color:var(--muted);margin-right:4px}
|
||||
#clip-adv label{font-size:13px;display:flex;align-items:center;gap:5px;cursor:pointer}
|
||||
body.clip-open{padding-bottom:130px}
|
||||
Reference in New Issue
Block a user