Compare commits

...

6 Commits

Author SHA1 Message Date
admin abfe81e734 docs: update README for all new web UI features 2026-04-29 20:41:39 +02:00
admin eff9240b5d feat: add ffmpeg audio trim / cut download
Adds a Cut panel inside every player row (✂ Cut button in the actions
column opens the row and focuses the Start field). Users type start and
end times in m:ss or h:mm:ss format; Download cut triggers
GET /api/cut which runs ffmpeg -c copy (no re-encode) and streams the
result as an attachment.

Clicking an analysis chip now also pre-fills the cut panel start/end
with the loud-section boundaries.

Server switched to ThreadingHTTPServer so ffmpeg runs don't block
other requests. ffmpeg added to Dockerfile apt install.
2026-04-29 20:41:18 +02:00
admin de667821b7 feat: add filename and date-range filters to recordings list
Client-side filter bar with live filename search and from/to date
pickers. Rendering is split into renderFiles() + applyFilters() so
filters can be re-applied without re-fetching. Subtitle shows
'N of M shown' when a filter is active. Clear button resets all fields.
2026-04-29 20:37:14 +02:00
admin d583620f8c feat: click loud-section chips to seek audio; J/K keyboard shortcuts
Analysis chips are now buttons. Clicking one opens the player (if
not already open) and seeks to the section start. J skips to the
previous loud section, K to the next. Shortcuts are suppressed when
focus is inside an input field.
2026-04-29 20:33:27 +02:00
admin 6d16b2c0a3 fix: resolve FLAC audio player showing 00:00 duration
With preload=none the browser never fetches metadata, so Chrome
cannot populate the duration field for FLAC files. On player open:
set preload=metadata and call audio.load() to trigger a metadata-only
fetch. Also render a server-computed duration label beneath the audio
element as a fallback for formats the browser cannot parse.
2026-04-29 20:25:05 +02:00
admin 7db0e0870f fix: skip duration read for active recordings to prevent garbage values
WAV nframes and FLAC total_samples are both unfinalized while the
recorder has the file open, producing wildly wrong durations
(e.g. 53375995583:39:01). Return None (shown as —) instead.
2026-04-29 19:54:38 +02:00
3 changed files with 293 additions and 41 deletions
+1
View File
@@ -2,6 +2,7 @@ FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
alsa-utils \ alsa-utils \
ffmpeg \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
+5 -2
View File
@@ -155,10 +155,13 @@ python web.py --threshold 0.03 # loudness threshold 01 (default 0.05)
Shows a table of all recordings sorted newest-first. Features: Shows a table of all recordings sorted newest-first. Features:
- **Inline playback** — collapsible `▶ Play` button per row; audio loads lazily via a seekable `/stream/` endpoint with HTTP Range support. - **Inline playback** — collapsible `▶ Play` button per row; audio loads lazily via a seekable `/stream/` endpoint with HTTP Range support. Metadata is fetched immediately so the duration is visible without pressing play.
- **Waveform analysis** — on demand per file; computes RMS per 100 ms window and highlights loud sections. Supported for WAV and FLAC (FLAC requires `numpy` + `soundfile`). Pure-Python fallback for WAV when numpy is absent. - **Waveform analysis** — on demand per file; computes RMS per 100 ms window and highlights loud sections. Supported for WAV and FLAC (FLAC requires `numpy` + `soundfile`). Pure-Python fallback for WAV when numpy is absent.
- **Timestamp jump** — after analysis, click any loud-section chip to seek the player to that position and pre-fill the cut panel. Use **J** / **K** keyboard shortcuts to jump to the previous / next section while audio is playing.
- **Cut & download** — `✂ Cut` button opens the player row and reveals a cut panel. Enter start and end times in `m:ss` or `h:mm:ss` format and click **↓ Download cut** to receive an ffmpeg-trimmed copy without re-encoding. Requires ffmpeg (included in the Docker image).
- **Filters** — live filename search and from/to date pickers above the table; applied client-side with no additional requests. Shows `N of M shown` when a filter is active.
- **Delete** — `✕ Delete` button per row with confirmation prompt; disabled for files currently being recorded; sends `DELETE /api/files/<name>` and removes the row without a full page reload. - **Delete** — `✕ Delete` button per row with confirmation prompt; disabled for files currently being recorded; sends `DELETE /api/files/<name>` and removes the row without a full page reload.
- **Live REC badge** — files currently being written by `isr.py` show an animated REC indicator, polled every 5 seconds via `/api/status`. - **Live REC badge** — files currently being written by `isr.py` show an animated REC indicator, polled every 5 seconds via `/api/status`. Duration for in-progress files shows `—` (header is unfinalized until recording stops).
- **WCAG-compliant** — skip link, `aria-expanded`/`aria-controls` on the player toggle, `aria-live` status, focus management, `role=img` on SVG waveforms. - **WCAG-compliant** — skip link, `aria-expanded`/`aria-controls` on the player toggle, `aria-live` status, focus management, `role=img` on SVG waveforms.
--- ---
+285 -37
View File
@@ -19,9 +19,11 @@ import os
import re import re
import shutil import shutil
import struct import struct
import subprocess
import tempfile
import wave import wave
from datetime import datetime from datetime import datetime
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse from urllib.parse import parse_qs, unquote, urlparse
@@ -224,8 +226,12 @@ def list_files(recordings_dir: str):
continue continue
stat = path.stat() stat = path.stat()
rel = str(path.relative_to(base)).replace('\\', '/') rel = str(path.relative_to(base)).replace('\\', '/')
is_active = rel in active_files
duration = _get_audio_duration(path) # Skip reading partial headers for in-progress files — the WAV nframes
# field and FLAC total_samples are both unfinalized while recording,
# producing wildly incorrect values (e.g. 53375995583:39:01).
duration = None if is_active else _get_audio_duration(path)
files.append({ files.append({
'name': rel, 'name': rel,
@@ -234,7 +240,7 @@ def list_files(recordings_dir: str):
'date': datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S'), 'date': datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
'duration': duration, 'duration': duration,
'ext': path.suffix.lower().lstrip('.'), 'ext': path.suffix.lower().lstrip('.'),
'recording': rel in active_files, 'recording': is_active,
}) })
files.sort(key=lambda f: f['mtime'], reverse=True) files.sort(key=lambda f: f['mtime'], reverse=True)
@@ -274,6 +280,8 @@ class _Handler(BaseHTTPRequestHandler):
self._api_storage() self._api_storage()
elif p == '/api/config': elif p == '/api/config':
self._api_config() self._api_config()
elif p == '/api/cut':
self._api_cut(qs)
elif p.startswith('/download/'): elif p.startswith('/download/'):
self._download(unquote(p[len('/download/'):])) self._download(unquote(p[len('/download/'):]))
elif p.startswith('/stream/'): elif p.startswith('/stream/'):
@@ -449,6 +457,79 @@ class _Handler(BaseHTTPRequestHandler):
self._send(200, json.dumps({'deleted': filename}).encode(), 'application/json') self._send(200, json.dumps({'deleted': filename}).encode(), 'application/json')
def _api_cut(self, qs):
filename = qs.get('file', [None])[0]
start_s = qs.get('start', [None])[0]
end_s = qs.get('end', [None])[0]
if not filename or start_s is None or end_s is None:
self._json_err(400, 'missing file, start, or end parameter')
return
try:
start = float(start_s)
end = float(end_s)
except (ValueError, TypeError):
self._json_err(400, 'start and end must be numbers (seconds)')
return
if start < 0 or end <= start:
self._json_err(400, 'start must be ≥ 0 and end must be > start')
return
path = self._safe_path(filename)
if path is None:
return
status_path = Path(self.recordings_dir) / 'status.json'
try:
with open(status_path) as fh:
if filename in set(json.load(fh).get('active', [])):
self._json_err(409, 'Cannot cut a file that is currently being recorded')
return
except Exception:
pass
if not shutil.which('ffmpeg'):
self._json_err(500, 'ffmpeg is not available on this server')
return
ext = path.suffix.lower()
out_name = f'{path.stem}_cut_{int(start)}s-{int(end)}s{ext}'
fd, tmp_path = tempfile.mkstemp(suffix=ext)
os.close(fd)
try:
cmd = ['ffmpeg', '-y', '-i', str(path),
'-ss', str(start), '-to', str(end),
'-c', 'copy', tmp_path]
result = subprocess.run(cmd, capture_output=True, timeout=120)
if result.returncode != 0:
err = result.stderr.decode('utf-8', errors='replace')[-400:]
self._json_err(500, f'ffmpeg error: {err}')
return
tmp_size = os.path.getsize(tmp_path)
content_type = MIME_TYPES.get(ext, 'application/octet-stream')
self.send_response(200)
self.send_header('Content-Type', content_type)
self.send_header('Content-Disposition', f'attachment; filename="{out_name}"')
self.send_header('Content-Length', str(tmp_size))
self.end_headers()
with open(tmp_path, 'rb') as fh:
while True:
chunk = fh.read(65536)
if not chunk:
break
self.wfile.write(chunk)
except subprocess.TimeoutExpired:
self._json_err(504, 'ffmpeg timed out — file may be too large')
finally:
try:
os.unlink(tmp_path)
except Exception:
pass
def _safe_path(self, filename: str): def _safe_path(self, filename: str):
base = Path(self.recordings_dir).resolve() base = Path(self.recordings_dir).resolve()
try: try:
@@ -550,6 +631,9 @@ svg.wave{display:block;width:100%;height:56px}
.chips{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px} .chips{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}
.chip{background:#431407;color:var(--orange);border:1px solid #7c2d12;border-radius:4px; .chip{background:#431407;color:var(--orange);border:1px solid #7c2d12;border-radius:4px;
padding:2px 8px;font-size:11px;font-family:ui-monospace,monospace} padding:2px 8px;font-size:11px;font-family:ui-monospace,monospace}
button.chip{cursor:pointer}
button.chip:hover{background:#6c1f08;border-color:#9a3412}
button.chip:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.quiet{color:var(--muted);font-size:12px;margin-top:6px} .quiet{color:var(--muted);font-size:12px;margin-top:6px}
.spin{color:var(--muted);font-style:italic;font-size:12px;padding:6px 0} .spin{color:var(--muted);font-style:italic;font-size:12px;padding:6px 0}
.empty{text-align:center;padding:60px;color:var(--muted)} .empty{text-align:center;padding:60px;color:var(--muted)}
@@ -557,6 +641,25 @@ svg.wave{display:block;width:100%;height:56px}
.player-row td{padding:0 10px 10px;background:var(--bg);border-bottom:1px solid var(--brd)} .player-row td{padding:0 10px 10px;background:var(--bg);border-bottom:1px solid var(--brd)}
audio{width:100%;height:36px;border-radius:4px;display:block; audio{width:100%;height:36px;border-radius:4px;display:block;
color-scheme:dark;accent-color:var(--accent)} color-scheme:dark;accent-color:var(--accent)}
/* cut panel */
.cut-panel{display:flex;align-items:center;gap:8px;margin-top:8px;flex-wrap:wrap;
padding-top:8px;border-top:1px solid var(--brd)}
.cut-label{font-size:12px;color:var(--muted);white-space:nowrap}
.cut-field{display:flex;align-items:center;gap:4px;font-size:12px;color:var(--muted)}
.cut-time{width:90px;background:var(--bg);border:1px solid var(--brd);color:var(--txt);
padding:3px 6px;border-radius:4px;font-size:12px;font-family:ui-monospace,monospace}
.cut-time:focus{outline:2px solid var(--accent);outline-offset:1px}
button.cut{color:var(--accent);border-color:#1e40af;background:#0c1a40}
button.cut:hover:not(:disabled){background:#1e3a8a}
/* filter bar */
.filter-bar{display:flex;align-items:center;gap:10px;padding:8px 28px;
border-bottom:1px solid var(--brd);background:var(--surf);flex-wrap:wrap}
.filter-bar label{font-size:13px;color:var(--muted);white-space:nowrap}
.filter-bar input[type=text]{width:180px;background:var(--bg);border:1px solid var(--brd);
color:var(--txt);padding:3px 6px;border-radius:4px;font-size:13px}
.filter-bar input[type=date]{background:var(--bg);border:1px solid var(--brd);
color:var(--txt);padding:3px 6px;border-radius:4px;font-size:13px;color-scheme:dark}
.filter-bar input:focus{outline:2px solid var(--accent);outline-offset:1px}
</style> </style>
</head> </head>
<body> <body>
@@ -573,6 +676,15 @@ audio{width:100%;height:36px;border-radius:4px;display:block;
aria-describedby="threshold-hint"> aria-describedby="threshold-hint">
<span id="threshold-hint" class="controls-hint">RMS 01 · sections above this value are marked loud</span> <span id="threshold-hint" class="controls-hint">RMS 01 · sections above this value are marked loud</span>
</div> </div>
<div class="filter-bar" role="search" aria-label="Filter recordings">
<label for="filter-name">Search:</label>
<input type="text" id="filter-name" placeholder="filename…" aria-label="Filter by filename">
<label for="filter-from">From:</label>
<input type="date" id="filter-from" aria-label="From date">
<label for="filter-to">To:</label>
<input type="date" id="filter-to" aria-label="To date">
<button id="filter-clear" aria-label="Clear all filters">✕ Clear</button>
</div>
<div class="wrap" id="main"> <div class="wrap" id="main">
<table aria-label="Recordings archive"> <table aria-label="Recordings archive">
<thead> <thead>
@@ -613,6 +725,11 @@ const pad = n => String(n).padStart(2,'0');
// idx -> filename, for live-status polling // idx -> filename, for live-status polling
const recMap = new Map(); const recMap = new Map();
// idx -> [{start,end}], populated after analysis
const sectionMap = new Map();
let activePlayerIdx = null;
// full file list from server, annotated with stable _idx
let allFiles = [];
function togglePlayer(idx, filename) { function togglePlayer(idx, filename) {
const prow = document.getElementById('prow-'+idx); const prow = document.getElementById('prow-'+idx);
@@ -622,14 +739,16 @@ function togglePlayer(idx, filename) {
if (!open) { if (!open) {
if (!audio.getAttribute('data-src-set')) { if (!audio.getAttribute('data-src-set')) {
audio.preload = 'metadata';
audio.src = '/stream/' + encodeURIComponent(filename); audio.src = '/stream/' + encodeURIComponent(filename);
audio.load();
audio.setAttribute('data-src-set','1'); audio.setAttribute('data-src-set','1');
} }
activePlayerIdx = idx;
prow.hidden = false; prow.hidden = false;
btn.setAttribute('aria-expanded','true'); btn.setAttribute('aria-expanded','true');
btn.textContent = '⏹ Hide'; btn.textContent = '⏹ Hide';
btn.setAttribute('aria-label','Hide player for '+filename); btn.setAttribute('aria-label','Hide player for '+filename);
// Move focus to audio control so keyboard users can operate it immediately
audio.focus(); audio.focus();
} else { } else {
audio.pause(); audio.pause();
@@ -676,7 +795,31 @@ function drawWave(rms, sections, duration, filename) {
return svg; return svg;
} }
async function analyse(filename, cell, btn) { function parseTime(s) {
if (!s || !s.trim()) return null;
const parts = s.trim().split(':').map(v => parseFloat(v));
if (parts.some(isNaN) || parts.length < 1 || 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];
}
function seekToSection(idx, filename, startSec, endSec) {
const pbtn = document.getElementById('pbtn-'+idx);
if (pbtn.getAttribute('aria-expanded') !== 'true') togglePlayer(idx, filename);
activePlayerIdx = idx;
const audio = document.getElementById('aud-'+idx);
const doSeek = () => { audio.currentTime = startSec; };
if (audio.readyState >= 1) doSeek();
else audio.addEventListener('loadedmetadata', doSeek, {once: true});
// Pre-fill cut panel fields with section boundaries
const startEl = document.getElementById('cut-start-'+idx);
const endEl = document.getElementById('cut-end-'+idx);
if (startEl) startEl.value = fmtT(startSec);
if (endEl && endSec != null) endEl.value = fmtT(endSec);
}
async function analyse(idx, filename, cell, btn) {
btn.disabled = true; btn.disabled = true;
btn.textContent = ''; btn.textContent = '';
cell.innerHTML = '<div class="spin" aria-live="polite" aria-busy="true">Analysing…</div>'; cell.innerHTML = '<div class="spin" aria-live="polite" aria-busy="true">Analysing…</div>';
@@ -696,15 +839,19 @@ async function analyse(filename, cell, btn) {
const chips = document.createElement('div'); const chips = document.createElement('div');
chips.className='chips'; chips.className='chips';
chips.setAttribute('role','list'); chips.setAttribute('role','list');
chips.setAttribute('aria-label','Loud sections'); chips.setAttribute('aria-label','Loud sections — click to jump, J/K to step');
if (d.sections && d.sections.length) { if (d.sections && d.sections.length) {
sectionMap.set(idx, d.sections);
d.sections.forEach(s => { d.sections.forEach(s => {
const c = document.createElement('span'); const c = document.createElement('button');
c.className='chip'; c.setAttribute('role','listitem'); c.className='chip'; c.setAttribute('role','listitem');
c.title = 'Jump to this section (or use J/K keys)';
c.textContent = `${fmtT(s.start)} ${fmtT(s.end)}`; c.textContent = `${fmtT(s.start)} ${fmtT(s.end)}`;
c.addEventListener('click', () => seekToSection(idx, filename, s.start, s.end));
chips.appendChild(c); chips.appendChild(c);
}); });
} else { } else {
sectionMap.delete(idx);
const q = document.createElement('span'); const q = document.createElement('span');
q.className='quiet'; q.setAttribute('role','listitem'); q.className='quiet'; q.setAttribute('role','listitem');
q.textContent='No loud sections found'; q.textContent='No loud sections found';
@@ -718,6 +865,31 @@ async function analyse(filename, cell, btn) {
} }
} }
// J = previous section, K = next section (only when focus is not in an input)
document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (activePlayerIdx === null) return;
const sections = sectionMap.get(activePlayerIdx) || [];
if (!sections.length) return;
const audio = document.getElementById('aud-'+activePlayerIdx);
if (!audio) return;
if (e.key === 'j' || e.key === 'J') {
const cur = audio.currentTime;
let target = sections[0].start;
for (let i = sections.length - 1; i >= 0; i--) {
if (sections[i].start < cur - 1) { target = sections[i].start; break; }
}
audio.currentTime = target;
e.preventDefault();
} else if (e.key === 'k' || e.key === 'K') {
const cur = audio.currentTime;
for (const s of sections) {
if (s.start > cur + 0.5) { audio.currentTime = s.start; break; }
}
e.preventDefault();
}
});
async function deleteFile(idx, filename) { async function deleteFile(idx, filename) {
if (!confirm(`Delete "${filename}"?\nThis cannot be undone.`)) return; if (!confirm(`Delete "${filename}"?\nThis cannot be undone.`)) return;
const btn = document.getElementById('delbtn-'+idx); const btn = document.getElementById('delbtn-'+idx);
@@ -729,10 +901,13 @@ async function deleteFile(idx, filename) {
document.getElementById('row-'+idx)?.remove(); document.getElementById('row-'+idx)?.remove();
document.getElementById('prow-'+idx)?.remove(); document.getElementById('prow-'+idx)?.remove();
recMap.delete(idx); recMap.delete(idx);
const remaining = document.querySelectorAll('tr.data-row').length; allFiles = allFiles.filter(f => f._idx !== idx);
document.getElementById('subtitle').textContent = const visible = document.querySelectorAll('tr.data-row').length;
`${remaining} recording${remaining!==1?'s':''} found`; const total = allFiles.length;
if (!remaining) document.getElementById('empty').style.display = ''; document.getElementById('subtitle').textContent = total === visible
? `${total} recording${total!==1?'s':''} found`
: `${visible} of ${total} recording${total!==1?'s':''} shown`;
if (!visible) document.getElementById('empty').style.display = '';
updateStorage(); updateStorage();
} else { } else {
const d = await r.json().catch(()=>({})); const d = await r.json().catch(()=>({}));
@@ -756,32 +931,21 @@ async function updateStorage() {
} catch(e) {} } catch(e) {}
} }
async function load() { function renderFiles(files) {
const refreshBtn = document.getElementById('refresh-btn');
refreshBtn.disabled = true;
document.getElementById('subtitle').textContent = 'Loading…';
recMap.clear();
let files;
try {
files = await (await fetch('/api/files')).json();
} catch(e) {
document.getElementById('subtitle').textContent = 'Error loading files';
refreshBtn.disabled = false;
return;
}
const tbody = document.getElementById('tbody'); const tbody = document.getElementById('tbody');
tbody.innerHTML = ''; tbody.innerHTML = '';
recMap.clear();
sectionMap.clear();
const n = files.length; const total = allFiles.length;
document.getElementById('subtitle').textContent = const visible = files.length;
`${n} recording${n!==1?'s':''} found`; document.getElementById('subtitle').textContent = total === visible
document.getElementById('empty').style.display = n ? 'none' : ''; ? `${total} recording${total!==1?'s':''} found`
updateStorage(); : `${visible} of ${total} recording${total!==1?'s':''} shown`;
if (!n) { refreshBtn.disabled = false; return; } document.getElementById('empty').style.display = visible ? 'none' : '';
files.forEach((f, i) => { files.forEach(f => {
const i = f._idx;
const ext = f.ext; const ext = f.ext;
const canAnalyse = ext === 'wav' || ext === 'flac'; const canAnalyse = ext === 'wav' || ext === 'flac';
const isRec = !!f.recording; const isRec = !!f.recording;
@@ -812,6 +976,9 @@ async function load() {
aria-label="Play ${esc(f.name)}">▶ Play</button> aria-label="Play ${esc(f.name)}">▶ Play</button>
<a class="dl" href="/download/${encodeURIComponent(f.name)}" <a class="dl" href="/download/${encodeURIComponent(f.name)}"
aria-label="Download ${esc(f.name)}">↓ Download</a> aria-label="Download ${esc(f.name)}">↓ Download</a>
<button id="cutbtn-${i}" class="cut"
aria-label="Cut ${esc(f.name)}"
${isRec ? 'disabled title="Cannot cut while recording"' : ''}>✂ Cut</button>
<button id="delbtn-${i}" class="del" <button id="delbtn-${i}" class="del"
aria-label="Delete ${esc(f.name)}" aria-label="Delete ${esc(f.name)}"
${isRec ? 'disabled title="Cannot delete while recording"' : ''}>✕ Delete</button> ${isRec ? 'disabled title="Cannot delete while recording"' : ''}>✕ Delete</button>
@@ -824,9 +991,24 @@ async function load() {
prow.className = 'player-row'; prow.className = 'player-row';
prow.id = 'prow-'+i; prow.id = 'prow-'+i;
prow.hidden = true; prow.hidden = true;
const durLabel = f.duration != null
? `<div class="muted" style="font-size:11px;margin-top:3px">Duration: ${fmtDur(f.duration)}</div>`
: '';
prow.innerHTML = `<td colspan="6"> prow.innerHTML = `<td colspan="6">
<audio id="aud-${i}" controls preload="none" <audio id="aud-${i}" controls preload="none"
aria-label="Playback: ${esc(f.name)}"></audio> aria-label="Playback: ${esc(f.name)}"></audio>${durLabel}
<div class="cut-panel">
<span class="cut-label">✂ Cut:</span>
<label class="cut-field">Start
<input type="text" id="cut-start-${i}" class="cut-time" placeholder="m:ss or h:mm:ss">
</label>
<label class="cut-field">End
<input type="text" id="cut-end-${i}" class="cut-time" placeholder="m:ss or h:mm:ss">
</label>
<button id="cut-dl-${i}" class="cut"
${isRec ? 'disabled title="Cannot cut while recording"' : ''}
aria-label="Download cut of ${esc(f.name)}">↓ Download cut</button>
</div>
</td>`; </td>`;
tbody.appendChild(prow); tbody.appendChild(prow);
@@ -840,7 +1022,7 @@ async function load() {
abtn.disabled = true; abtn.disabled = true;
abtn.title = 'Recording in progress — analyse after recording stops'; abtn.title = 'Recording in progress — analyse after recording stops';
} else { } else {
abtn.addEventListener('click', () => analyse(f.name, cell, abtn)); abtn.addEventListener('click', () => analyse(i, f.name, cell, abtn));
} }
cell.appendChild(abtn); cell.appendChild(abtn);
} }
@@ -849,16 +1031,72 @@ async function load() {
document.getElementById('pbtn-'+i) document.getElementById('pbtn-'+i)
.addEventListener('click', () => togglePlayer(i, f.name)); .addEventListener('click', () => togglePlayer(i, f.name));
// ---- attach cut button handler (opens player row, focuses start field) ----
if (!isRec) {
document.getElementById('cutbtn-'+i).addEventListener('click', () => {
const pbtn = document.getElementById('pbtn-'+i);
if (pbtn.getAttribute('aria-expanded') !== 'true') togglePlayer(i, f.name);
document.getElementById('cut-start-'+i)?.focus();
});
document.getElementById('cut-dl-'+i).addEventListener('click', () => {
const startStr = document.getElementById('cut-start-'+i).value;
const endStr = document.getElementById('cut-end-'+i).value;
const start = parseTime(startStr);
const end = parseTime(endStr);
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/cut?file=' + encodeURIComponent(f.name) +
'&start=' + start + '&end=' + end;
});
}
// ---- attach delete button handler ---- // ---- attach delete button handler ----
if (!isRec) { if (!isRec) {
document.getElementById('delbtn-'+i) document.getElementById('delbtn-'+i)
.addEventListener('click', () => deleteFile(i, f.name)); .addEventListener('click', () => deleteFile(i, f.name));
} }
// ---- register for live-status polling ----
recMap.set(i, f.name); recMap.set(i, f.name);
}); });
}
function applyFilters() {
const nameQ = document.getElementById('filter-name').value.toLowerCase().trim();
const fromD = document.getElementById('filter-from').value;
const toD = document.getElementById('filter-to').value;
const filtered = 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;
});
renderFiles(filtered);
}
async function load() {
const refreshBtn = document.getElementById('refresh-btn');
refreshBtn.disabled = true;
document.getElementById('subtitle').textContent = 'Loading…';
let files;
try {
files = await (await fetch('/api/files')).json();
} catch(e) {
document.getElementById('subtitle').textContent = 'Error loading files';
refreshBtn.disabled = false;
return;
}
allFiles = files.map((f, i) => ({...f, _idx: i}));
updateStorage();
applyFilters();
refreshBtn.disabled = false; refreshBtn.disabled = false;
} }
@@ -879,6 +1117,16 @@ async function pollStatus() {
document.getElementById('refresh-btn').addEventListener('click', load); document.getElementById('refresh-btn').addEventListener('click', load);
document.getElementById('filter-name').addEventListener('input', applyFilters);
document.getElementById('filter-from').addEventListener('change', applyFilters);
document.getElementById('filter-to').addEventListener('change', applyFilters);
document.getElementById('filter-clear').addEventListener('click', () => {
document.getElementById('filter-name').value = '';
document.getElementById('filter-from').value = '';
document.getElementById('filter-to').value = '';
applyFilters();
});
// Seed threshold input from server config, then start // Seed threshold input from server config, then start
fetch('/api/config').then(r => r.json()).then(cfg => { fetch('/api/config').then(r => r.json()).then(cfg => {
if (cfg.threshold != null) if (cfg.threshold != null)
@@ -913,7 +1161,7 @@ def main():
recordings_dir = str(rec_dir.resolve()) recordings_dir = str(rec_dir.resolve())
threshold = args.threshold threshold = args.threshold
server = HTTPServer((args.host, args.port), Handler) server = ThreadingHTTPServer((args.host, args.port), Handler)
print(f"ISR Web running → http://{args.host}:{args.port}/") print(f"ISR Web running → http://{args.host}:{args.port}/")
print(f"Recordings dir → {rec_dir.resolve()}") print(f"Recordings dir → {rec_dir.resolve()}")