Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0113vDKdSNtD3Dwcw4QMu9yV
724 lines
40 KiB
JavaScript
724 lines
40 KiB
JavaScript
import './style.css';
|
||
|
||
const api = {
|
||
async get(u) { const r = await fetch(u); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); },
|
||
async post(u, b) { const r = await fetch(u, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b || {}) }); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); },
|
||
async del(u) { const r = await fetch(u, { method: 'DELETE' }); return r.json(); },
|
||
async put(u, b) { const r = await fetch(u, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b || {}) }); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); }
|
||
};
|
||
|
||
const app = document.querySelector('#app');
|
||
const state = { tab: 'record', recording: false, recStart: 0, timer: null, selectedId: null, collapsedDates: new Set(), lastListSig: null };
|
||
|
||
function fmt(ms) { const s = Math.floor(ms / 1000); return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`; }
|
||
function toast(msg, err) { const t = document.createElement('div'); t.className = 'toast' + (err ? ' err' : ''); t.textContent = msg; document.body.appendChild(t); setTimeout(() => t.remove(), 4000); }
|
||
|
||
// Theme: data-theme on <html>, chosen in index.html before first paint (no
|
||
// flash); the toggle persists to localStorage.
|
||
function applyTheme(t) {
|
||
document.documentElement.dataset.theme = t;
|
||
localStorage.setItem('aime-theme', t);
|
||
const b = document.querySelector('#themetoggle');
|
||
if (b) b.textContent = t === 'dark' ? '☀️' : '🌙';
|
||
}
|
||
|
||
function render() {
|
||
app.classList.toggle('wide', state.tab === 'recordings');
|
||
const theme = document.documentElement.dataset.theme || 'dark';
|
||
app.innerHTML = `
|
||
<header class="topbar">
|
||
<div class="brand">
|
||
<svg class="brand-wave" viewBox="0 0 34 20" aria-hidden="true"><g fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><line x1="3" y1="7" x2="3" y2="13"/><line x1="9" y1="4" x2="9" y2="16"/><line x1="15" y1="1" x2="15" y2="19"/><line x1="21" y1="5" x2="21" y2="15"/><line x1="27" y1="8" x2="27" y2="12"/><line x1="33" y1="9.5" x2="33" y2="10.5"/></g></svg>
|
||
<h1>Aime <span class="sub">Anruf-Assistent</span></h1>
|
||
</div>
|
||
<nav>
|
||
${['record', 'bluetooth', 'recordings', 'voices', 'glossary'].map(t =>
|
||
`<button class="tab ${state.tab === t ? 'active' : ''}" data-tab="${t}">${({ record: 'Aufnahme', bluetooth: 'Bluetooth', recordings: 'Aufnahmen', voices: 'Stimmen', glossary: 'Glossar' })[t]}</button>`).join('')}
|
||
</nav>
|
||
<button id="themetoggle" class="btn icon theme" title="Hell/Dunkel umschalten">${theme === 'dark' ? '☀️' : '🌙'}</button>
|
||
</header>
|
||
<main id="view"></main>`;
|
||
app.querySelectorAll('.tab').forEach(b => b.onclick = () => { state.tab = b.dataset.tab; render(); });
|
||
app.querySelector('#themetoggle').onclick = () =>
|
||
applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark');
|
||
({ record: viewRecord, bluetooth: viewBluetooth, recordings: viewRecordings, voices: viewVoices, glossary: viewGlossary })[state.tab]();
|
||
}
|
||
|
||
// ---------- Bluetooth ----------
|
||
async function viewBluetooth() {
|
||
const v = document.querySelector('#view');
|
||
v.innerHTML = `
|
||
<div class="card">
|
||
<div class="row">
|
||
<button id="scan" class="btn primary">🔍 Geräte suchen (10s)</button>
|
||
<button id="refresh" class="btn">Aktualisieren</button>
|
||
<span id="btmsg" class="muted"></span>
|
||
</div>
|
||
<div id="btlist" class="list">Lade…</div>
|
||
</div>`;
|
||
const listEl = v.querySelector('#btlist');
|
||
const msg = v.querySelector('#btmsg');
|
||
|
||
async function load(devices) {
|
||
const d = devices || await api.get('/api/bt/devices');
|
||
if (!d.length) { listEl.innerHTML = '<div class="muted">Keine Geräte. „Suchen“ drücken (Headset in Pairing-Modus).</div>'; return; }
|
||
listEl.innerHTML = d.map(dev => `
|
||
<div class="item">
|
||
<div class="grow">
|
||
<b>${dev.name || '(unbenannt)'}</b> ${dev.audio ? '🎧' : ''}
|
||
<div class="mono muted">${dev.mac}</div>
|
||
</div>
|
||
<span class="badge ${dev.connected ? 'ok' : dev.paired ? 'warn' : ''}">${dev.connected ? 'verbunden' : dev.paired ? 'gepaart' : 'neu'}</span>
|
||
${dev.connected
|
||
? `<button class="btn" data-act="disconnect" data-mac="${dev.mac}">Trennen</button>`
|
||
: dev.paired
|
||
? `<button class="btn primary" data-act="connect" data-mac="${dev.mac}">Verbinden</button>`
|
||
: `<button class="btn primary" data-act="pair" data-mac="${dev.mac}">Pairen</button>`}
|
||
${dev.audio ? `<button class="btn" data-act="repair" data-mac="${dev.mac}" title="Bond löschen und komplett neu koppeln — wenn Verbinden/Pairen scheitert (Headset in Pairing-Modus)">Neu verbinden</button>` : ''}
|
||
</div>`).join('');
|
||
listEl.querySelectorAll('button[data-act]').forEach(b => b.onclick = () => act(b.dataset.act, b.dataset.mac, b));
|
||
}
|
||
async function act(a, mac, btn) {
|
||
btn.disabled = true; btn.textContent = a === 'repair' ? 'neu koppeln… (~15s)' : '…';
|
||
try {
|
||
const r = await api.post('/api/bt/' + a, { mac });
|
||
if (a === 'repair') toast(r.ok ? 'neu verbunden ✓' : 'fehlgeschlagen: ' + (r.msg || r.connect || 'siehe Headset im Pairing-Modus?'), !r.ok);
|
||
else toast(`${a}: ${r.connected ? 'verbunden' : 'ok'}`);
|
||
if ((a === 'pair' || a === 'connect' || a === 'repair') && (r.connected || r.ok)) await api.post('/api/audio/headset-mic', {});
|
||
}
|
||
catch (e) { toast(a + ' fehlgeschlagen: ' + e.message, true); }
|
||
await load();
|
||
}
|
||
v.querySelector('#scan').onclick = async (e) => { e.target.disabled = true; msg.textContent = 'suche…'; try { await load(await api.post('/api/bt/scan', { seconds: 10 })); } catch (err) { toast(err.message, true); } msg.textContent = ''; e.target.disabled = false; };
|
||
v.querySelector('#refresh').onclick = () => load();
|
||
load().catch(e => listEl.innerHTML = '<div class="err">' + e.message + '</div>');
|
||
}
|
||
|
||
// ---------- Record ----------
|
||
async function viewRecord() {
|
||
const v = document.querySelector('#view');
|
||
v.innerHTML = `
|
||
<div class="card">
|
||
<label class="lbl">Audioquelle (Mikrofon)</label>
|
||
<div class="row">
|
||
<select id="src" class="grow"></select>
|
||
<button id="reloadsrc" class="btn">↻</button>
|
||
</div>
|
||
<label class="lbl">HFP-Codec (nur Bluetooth) — A/B-Test</label>
|
||
<select id="codec" class="grow">
|
||
<option value="auto" selected>Auto (mSBC bevorzugt)</option>
|
||
<option value="msbc">mSBC — 16 kHz Breitband</option>
|
||
<option value="cvsd">CVSD — 8 kHz Schmalband</option>
|
||
</select>
|
||
<label class="lbl">Sprecheranzahl (optional)</label>
|
||
<input id="nspk" class="input" type="number" min="1" max="8" placeholder="leer = automatisch erkennen"/>
|
||
<div class="hint">Wenn du die Anzahl der Sprecher kennst, hier eintragen — verbessert die Trennung. Sonst leer lassen (Auto). Titel, Zusammenfassung, ToDos & Sprecher-Erkennung laufen automatisch per KI.</div>
|
||
<div class="rec-panel">
|
||
<div id="timer" class="timer">00:00</div>
|
||
<button id="recbtn" class="btn big ${state.recording ? 'danger' : 'primary'}">${state.recording ? '■ Stop & Transkribieren' : '● Aufnahme starten'}</button>
|
||
</div>
|
||
<div id="recmsg" class="muted"></div>
|
||
</div>`;
|
||
const sel = v.querySelector('#src');
|
||
async function loadSrc() {
|
||
try {
|
||
const s = await api.get('/api/audio/sources');
|
||
sel.innerHTML = '<option value="">(Standardquelle)</option>' + s.map(x => `<option value="${x.name}" ${x.isBluetooth ? 'selected' : ''}>${x.isBluetooth ? '🎧 ' : '🎙️ '}${x.label || x.name}</option>`).join('');
|
||
} catch { sel.innerHTML = '<option value="">(keine gefunden)</option>'; }
|
||
}
|
||
await loadSrc();
|
||
v.querySelector('#reloadsrc').onclick = loadSrc;
|
||
const timerEl = v.querySelector('#timer');
|
||
if (state.recording) startTimerUI(timerEl);
|
||
|
||
v.querySelector('#recbtn').onclick = async (e) => {
|
||
if (!state.recording) {
|
||
try {
|
||
await api.post('/api/record/start', { source: sel.value, codec: v.querySelector('#codec').value, numSpeakers: +v.querySelector('#nspk').value || undefined });
|
||
state.recording = true; state.recStart = Date.now(); render();
|
||
} catch (err) { toast(err.message, true); }
|
||
} else {
|
||
e.target.disabled = true; e.target.textContent = 'Transkribiere… (kann dauern)';
|
||
clearInterval(state.timer);
|
||
try {
|
||
const r = await api.post('/api/record/stop', {});
|
||
state.recording = false;
|
||
toast('Aufnahme gespeichert – wird transkribiert…');
|
||
state.tab = 'recordings'; render(); openRecording(r.id);
|
||
} catch (err) { state.recording = false; toast('Stop fehlgeschlagen: ' + err.message, true); render(); }
|
||
}
|
||
};
|
||
}
|
||
function startTimerUI(el) { clearInterval(state.timer); state.timer = setInterval(() => { el.textContent = fmt(Date.now() - state.recStart); }, 500); }
|
||
|
||
// ---------- Voices (enrollment) ----------
|
||
async function viewVoices() {
|
||
const v = document.querySelector('#view');
|
||
v.innerHTML = `
|
||
<div class="card">
|
||
<h3>Stimme anlernen</h3>
|
||
<div class="hint">Person spricht ~15-20s frei (Headset). Danach wird ihr Stimmprofil gespeichert und in Aufnahmen automatisch erkannt.</div>
|
||
<label class="lbl">Name</label>
|
||
<input id="vname" class="input" placeholder="z.B. Sandra" value="${escapeHtml(state.enrollName || '')}" ${state.enrolling ? 'disabled' : ''}/>
|
||
<div class="rec-panel">
|
||
<div id="vtimer" class="timer">00:00</div>
|
||
<button id="vbtn" class="btn big ${state.enrolling ? 'danger' : 'primary'}">${state.enrolling ? '■ Stop & Speichern' : '● Aufnahme starten'}</button>
|
||
</div>
|
||
<div id="vmsg" class="muted"></div>
|
||
</div>
|
||
<div class="card"><h3>Angelernte Stimmen</h3><div id="vplist" class="list">Lade…</div></div>`;
|
||
const listEl = v.querySelector('#vplist');
|
||
async function load() {
|
||
try {
|
||
const vps = await api.get('/api/voiceprints');
|
||
listEl.innerHTML = vps.length ? vps.map(p => `
|
||
<div class="item"><div class="grow"><b>👤 ${escapeHtml(p.name)}</b>
|
||
<div class="muted">${new Date(p.created_at).toLocaleString('de-DE')} · ${p.dim}-dim</div></div>
|
||
<button class="btn danger" data-del="${p.id}">🗑</button></div>`).join('')
|
||
: '<div class="muted">Noch keine Stimmen. Oben anlernen.</div>';
|
||
listEl.querySelectorAll('[data-del]').forEach(b => b.onclick = async () => { await api.del('/api/voiceprints/' + b.dataset.del); load(); });
|
||
} catch (e) { listEl.innerHTML = '<div class="err">' + e.message + '</div>'; }
|
||
}
|
||
await load();
|
||
const btn = v.querySelector('#vbtn'), msg = v.querySelector('#vmsg'), timerEl = v.querySelector('#vtimer');
|
||
if (state.enrolling) startTimerUI(timerEl);
|
||
btn.onclick = async () => {
|
||
if (!state.enrolling) {
|
||
const name = v.querySelector('#vname').value.trim();
|
||
if (!name) { toast('Bitte Namen eingeben', true); return; }
|
||
state.enrollName = name;
|
||
try { await api.post('/api/enroll/start', {}); state.enrolling = true; state.recStart = Date.now(); render(); }
|
||
catch (e) { toast(e.message, true); }
|
||
} else {
|
||
btn.disabled = true; btn.textContent = 'Speichere…'; clearInterval(state.timer);
|
||
try {
|
||
const r = await api.post('/api/enroll/stop', { name: state.enrollName });
|
||
state.enrolling = false; state.enrollName = ''; toast(`Stimme „${r.name}" gespeichert ✓`); render();
|
||
} catch (e) { state.enrolling = false; toast('Fehlgeschlagen: ' + e.message, true); render(); }
|
||
}
|
||
};
|
||
}
|
||
|
||
// ---------- Glossary ----------
|
||
async function viewGlossary() {
|
||
const v = document.querySelector('#view');
|
||
v.innerHTML = `
|
||
<div class="card">
|
||
<h3 id="gformtitle">Begriff hinzufügen</h3>
|
||
<div class="hint">Eigennamen & Fachbegriffe, die die Spracherkennung verhört (z.B. „Aime" wird als „Amy" gehört). Die KI-Korrektur ersetzt lautähnliche Wörter dann durch die richtige Schreibweise.</div>
|
||
<label class="lbl">Begriff (korrekte Schreibweise)</label>
|
||
<input id="gterm" class="input" placeholder="z.B. Aime"/>
|
||
<label class="lbl">Verhörer / Soundalikes (optional, Komma-getrennt)</label>
|
||
<input id="gsound" class="input" placeholder="z.B. Amy, Ami"/>
|
||
<button id="gadd" class="btn primary">Hinzufügen</button>
|
||
<button id="gcancel" class="btn" style="display:none">Abbrechen</button>
|
||
</div>
|
||
<div class="card"><h3>Glossar</h3><div id="glist" class="list">Lade…</div></div>`;
|
||
const listEl = v.querySelector('#glist');
|
||
let editId = null; // id of the entry currently loaded into the form
|
||
const formTitle = v.querySelector('#gformtitle'), saveBtn = v.querySelector('#gadd'), cancelBtn = v.querySelector('#gcancel');
|
||
function setEdit(g) {
|
||
editId = g ? g.id : null;
|
||
formTitle.textContent = g ? `„${g.term}" bearbeiten` : 'Begriff hinzufügen';
|
||
saveBtn.textContent = g ? 'Speichern' : 'Hinzufügen';
|
||
cancelBtn.style.display = g ? '' : 'none';
|
||
v.querySelector('#gterm').value = g ? g.term : '';
|
||
v.querySelector('#gsound').value = g ? (g.soundalikes || '') : '';
|
||
if (g) v.querySelector('#gterm').focus();
|
||
}
|
||
async function load() {
|
||
try {
|
||
const gs = await api.get('/api/glossary');
|
||
listEl.innerHTML = gs.length ? gs.map(g => `
|
||
<div class="item"><div class="grow"><b>📖 ${escapeHtml(g.term)}</b>
|
||
${g.soundalikes ? `<div class="muted">↔ ${escapeHtml(g.soundalikes)}</div>` : ''}</div>
|
||
<button class="btn" data-edit="${g.id}">✏️</button>
|
||
<button class="btn danger" data-del="${g.id}">🗑</button></div>`).join('')
|
||
: '<div class="muted">Noch keine Begriffe. Oben hinzufügen.</div>';
|
||
listEl.querySelectorAll('[data-del]').forEach(b => b.onclick = async () => { await api.del('/api/glossary/' + b.dataset.del); if (editId === +b.dataset.del) setEdit(null); load(); });
|
||
listEl.querySelectorAll('[data-edit]').forEach(b => b.onclick = () => setEdit(gs.find(g => g.id === +b.dataset.edit)));
|
||
} catch (e) { listEl.innerHTML = '<div class="err">' + e.message + '</div>'; }
|
||
}
|
||
await load();
|
||
cancelBtn.onclick = () => setEdit(null);
|
||
saveBtn.onclick = async () => {
|
||
const term = v.querySelector('#gterm').value.trim();
|
||
if (!term) { toast('Bitte Begriff eingeben', true); return; }
|
||
const soundalikes = v.querySelector('#gsound').value.trim();
|
||
try {
|
||
if (editId != null) await api.put('/api/glossary/' + editId, { term, soundalikes });
|
||
else await api.post('/api/glossary', { term, soundalikes });
|
||
toast(`„${term}" gespeichert ✓`);
|
||
setEdit(null);
|
||
load();
|
||
} catch (e) { toast(e.message, true); }
|
||
};
|
||
}
|
||
|
||
// ---------- Recordings ----------
|
||
// Analysis-duration counter. Shows the total wall time of a processing run
|
||
// (transcribe+diar+LLM): a live 1-Hz counter while it runs (from analysis_started_at),
|
||
// frozen to analysis_ms once done. Returns the inner <span> only; callers add the ⏱.
|
||
function anaTimeSpan(r) {
|
||
if (r.analysis_ms != null) return `<span class="ana-time">${fmt(r.analysis_ms)}</span>`;
|
||
if (r.analysis_started_at && (r.analysis_status === 'pending' || r.status === 'transcribing'))
|
||
return `<span class="ana-time live" data-ana-start="${r.analysis_started_at}">${fmt(Date.now() - r.analysis_started_at)}</span>`;
|
||
return '';
|
||
}
|
||
function ensureAnaTicker() {
|
||
if (state.anaTicker) return;
|
||
state.anaTicker = setInterval(() => {
|
||
document.querySelectorAll('.ana-time.live').forEach(el => {
|
||
const t0 = +el.dataset.anaStart;
|
||
if (t0) el.textContent = fmt(Date.now() - t0);
|
||
});
|
||
}, 1000);
|
||
}
|
||
|
||
// Split layout: LEFT = date-grouped, collapsible recording list; RIGHT = the
|
||
// selected recording's detail (with its own audio player). Crucially, the
|
||
// while-analysing auto-refresh only rebuilds the LEFT list — it never touches
|
||
// the RIGHT detail pane, so you can keep listening to an old recording while a
|
||
// new one is being transcribed/analysed (the old bug: the whole view — detail +
|
||
// audio element — was re-rendered every 5s, stopping playback).
|
||
function viewRecordings() {
|
||
const v = document.querySelector('#view');
|
||
v.innerHTML = `
|
||
<div class="rec-layout">
|
||
<aside class="rec-list-pane"><div id="reclist" class="list">Lade…</div></aside>
|
||
<section class="rec-detail-pane"><div id="detail"><div class="detail-empty muted">← Aufnahme wählen</div></div></section>
|
||
</div>`;
|
||
refreshRecList();
|
||
if (state.selectedId != null) openRecording(state.selectedId);
|
||
}
|
||
|
||
// (Re)build ONLY the left list pane. Called on entry and by the auto-refresh
|
||
// timer; safe to run while the right pane holds a playing recording.
|
||
async function refreshRecList() {
|
||
const listEl = document.querySelector('#reclist');
|
||
if (!listEl) return; // left the tab
|
||
let recs;
|
||
try { recs = await api.get('/api/recordings'); }
|
||
catch (e) { listEl.innerHTML = '<div class="err">' + e.message + '</div>'; return; }
|
||
// Reschedule the poll ONLY while something is still processing.
|
||
const stillRunning = recs.some(r => r.analysis_status === 'pending' || r.status === 'transcribing');
|
||
const scheduleNext = () => { if (stillRunning) setTimeout(() => { if (state.tab === 'recordings') refreshRecList(); }, 5000); };
|
||
try {
|
||
if (!recs.length) { listEl.innerHTML = '<div class="muted">Noch keine Aufnahmen.</div>'; state.lastListSig = 'empty'; scheduleNext(); return; }
|
||
// Signature of everything the list RENDERS except the live ⏱ seconds (those
|
||
// tick via ensureAnaTicker without a rebuild). If nothing meaningful changed,
|
||
// DON'T touch the DOM at all — no flicker, no scroll jump, selection kept.
|
||
// This is the real fix for "I get pulled away from my recording every 5s":
|
||
// the 5s poll now no-ops unless a status/title/todo actually changed.
|
||
const sig = recs.map(r => `${r.id}|${r.status}|${r.analysis_status}|${r.title || ''}|${(r.todos || []).length}|${r.summary ? 1 : 0}|${r.duration_ms || 0}`).join('~');
|
||
if (sig === state.lastListSig && listEl.querySelector('.day-group')) { ensureAnaTicker(); scheduleNext(); return; }
|
||
state.lastListSig = sig;
|
||
// Preserve the left pane's scroll position across the rebuild.
|
||
const scroller = listEl.closest('.rec-list-pane') || listEl;
|
||
const savedTop = scroller.scrollTop;
|
||
// Group by calendar day (recs come newest-first). Each day is a collapsible
|
||
// section; the day header shows the recording count.
|
||
const groups = [];
|
||
const byDay = {};
|
||
for (const r of recs) {
|
||
const day = new Date(r.created_at).toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' });
|
||
if (!byDay[day]) { byDay[day] = []; groups.push(day); }
|
||
byDay[day].push(r);
|
||
}
|
||
listEl.innerHTML = groups.map(day => {
|
||
const collapsed = state.collapsedDates.has(day);
|
||
const items = byDay[day].map(r => { const at = anaTimeSpan(r); return `
|
||
<div class="item rec ${state.selectedId == r.id ? 'sel' : ''}" data-open="${r.id}">
|
||
<div class="grow">
|
||
<b>${escapeHtml(r.title) || 'Aufnahme #' + r.id}</b> ${analysisBadge(r.analysis_status)}
|
||
<div class="muted">${new Date(r.created_at).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} · ${fmt(r.duration_ms || 0)} · ${r.language || ''}${(r.todos && r.todos.length) ? ' · ✅ ' + r.todos.length : ''}${at ? ' · ⏱ ' + at : ''}</div>
|
||
${r.summary ? `<div class="summary-snip">${escapeHtml(r.summary)}</div>` : ''}
|
||
</div>
|
||
<button class="btn danger icon" data-del="${r.id}" title="Löschen">🗑</button>
|
||
</div>`; }).join('');
|
||
return `
|
||
<div class="day-group ${collapsed ? 'collapsed' : ''}">
|
||
<div class="day-head" data-day="${escapeHtml(day)}"><span class="day-caret">▾</span> ${escapeHtml(day)} <span class="day-count">${byDay[day].length}</span></div>
|
||
<div class="day-items">${items}</div>
|
||
</div>`;
|
||
}).join('');
|
||
// Click a row (not the trash button) → open in the right pane.
|
||
listEl.querySelectorAll('.item.rec').forEach(it => it.onclick = (e) => {
|
||
if (e.target.closest('[data-del]')) return;
|
||
openRecording(+it.dataset.open);
|
||
});
|
||
listEl.querySelectorAll('[data-del]').forEach(b => b.onclick = async (e) => {
|
||
e.stopPropagation();
|
||
const rec = recs.find(r => r.id == b.dataset.del);
|
||
const name = (rec && rec.title) ? `„${rec.title}"` : 'Aufnahme #' + b.dataset.del;
|
||
if (!confirm(`${name} wirklich löschen?\nDie Aufnahme, das Transkript und die Analyse werden unwiderruflich entfernt.`)) return;
|
||
await api.del('/api/recordings/' + b.dataset.del);
|
||
if (state.selectedId == b.dataset.del) { state.selectedId = null; const d = document.querySelector('#detail'); if (d) d.innerHTML = '<div class="detail-empty muted">← Aufnahme wählen</div>'; }
|
||
state.lastListSig = null; refreshRecList();
|
||
});
|
||
listEl.querySelectorAll('.day-head').forEach(h => h.onclick = () => {
|
||
const day = h.dataset.day;
|
||
if (state.collapsedDates.has(day)) state.collapsedDates.delete(day); else state.collapsedDates.add(day);
|
||
h.closest('.day-group').classList.toggle('collapsed');
|
||
});
|
||
scroller.scrollTop = savedTop; // keep the list where the user had it
|
||
ensureAnaTicker();
|
||
scheduleNext();
|
||
} catch (e) { listEl.innerHTML = '<div class="err">' + e.message + '</div>'; }
|
||
}
|
||
|
||
async function openRecording(id) {
|
||
state.selectedId = id;
|
||
const detail = document.querySelector('#detail');
|
||
if (!detail) { state.tab = 'recordings'; render(); return setTimeout(() => openRecording(id), 100); }
|
||
// Highlight the selected row without rebuilding the list (keeps it stable).
|
||
document.querySelectorAll('#reclist .item.rec').forEach(it => it.classList.toggle('sel', it.dataset.open == id));
|
||
detail.innerHTML = '<div class="card">Lade…</div>';
|
||
const r = await api.get('/api/recordings/' + id);
|
||
const pending = r.analysis_status === 'pending';
|
||
const errored = r.analysis_status === 'error';
|
||
const transcribing = r.status === 'transcribing';
|
||
const transcribeFailed = r.status === 'error';
|
||
const scls = speakerClasses(r.words || []); // rawSpeaker -> sp0..sp3
|
||
const spkOrder = Object.keys(scls); // first-appearance order
|
||
const names = r.speaker_names || {}; // rawSpeaker -> name
|
||
const spkLabel = (sp, i) => names[sp] || ('Sprecher ' + (i + 1));
|
||
// Speaker segments + turns for the lane visualizer and the live-text box.
|
||
const segsBySpk = speakerSegments(r.words || []);
|
||
const turns = wordTurns(r.words || []);
|
||
const durMs = r.duration_ms || (r.words && r.words.length ? r.words[r.words.length - 1].t_end_ms : 0) || 1;
|
||
|
||
// Draggable module blocks; the saved order wins, unknown/new modules append.
|
||
// NOT modules: the transcript (own sticky right column) and the speaker-lane
|
||
// visualizer (fixed full-width strip under the player, spanning both columns).
|
||
const MOD_TITLES = { analyse: 'Zusammenfassung', todos: 'ToDos', speakers: 'Sprecher' };
|
||
const vizHtml = spkOrder.length ? `
|
||
<div class="viz-top">
|
||
<div class="viz-wrap">
|
||
<div class="lanes" id="lanes">
|
||
${spkOrder.map((sp, i) => `
|
||
<div class="lane-row ${scls[sp]}" data-sp="${sp}">
|
||
<span class="lane-label"><span class="spk-dot"></span>${escapeHtml(spkLabel(sp, i))}</span>
|
||
<div class="lane">${(segsBySpk[sp] || []).map(s =>
|
||
`<span class="seg" style="left:${(s.start / durMs * 100).toFixed(3)}%;width:${Math.max(.18, (s.end - s.start) / durMs * 100).toFixed(3)}%"></span>`).join('')}
|
||
</div>
|
||
</div>`).join('')}
|
||
<div class="playhead" id="playhead"></div>
|
||
</div>
|
||
<div class="livebox" id="livebox"><div class="muted">▶ Abspielen — hier erscheint, was gerade gesprochen wird.</div></div>
|
||
</div>
|
||
</div>` : '';
|
||
const mods = {
|
||
analyse: `
|
||
${r.summary ? `<p class="summary">${escapeHtml(r.summary)}</p>` : '<div class="muted">Noch keine Zusammenfassung.</div>'}
|
||
${(r.participants && r.participants.length) ? `<h3>Teilnehmer</h3><ul class="parts">${r.participants.map(p => `<li>👤 ${escapeHtml(p)}</li>`).join('')}</ul>` : ''}`,
|
||
todos: (r.todos && r.todos.length) ? `<ul class="todos">${r.todos.map(t => `<li>${escapeHtml(t)}</li>`).join('')}</ul>` : '<div class="muted">Keine ToDos erkannt.</div>',
|
||
speakers: spkOrder.length ? `
|
||
<div class="hint">Namen vergeben → App lernt die Stimme.</div>
|
||
<div class="spk-edit">${spkOrder.map((sp, i) => `
|
||
<div class="spk-row ${scls[sp]}">
|
||
<span class="spk-dot"></span>
|
||
<input class="spk-name" data-sp="${sp}" value="${escapeHtml(names[sp] || '')}" placeholder="${escapeHtml(spkLabel(sp, i))}">
|
||
<label class="spk-learn-l"><input type="checkbox" class="spk-learn" checked> als Stimme lernen</label>
|
||
<button class="btn small spk-save" data-sp="${sp}">Speichern</button>
|
||
</div>`).join('')}
|
||
</div>` : '',
|
||
};
|
||
const defOrder = ['analyse', 'todos', 'speakers'];
|
||
let order = [];
|
||
try { order = JSON.parse(localStorage.getItem('aime-detail-order') || '[]'); } catch {}
|
||
order = order.filter(m => mods[m] !== undefined).concat(defOrder.filter(m => !order.includes(m)));
|
||
|
||
detail.innerHTML = `
|
||
<div class="card">
|
||
<h2>${escapeHtml(r.title) || 'Aufnahme #' + r.id} ${analysisBadge(r.analysis_status)}</h2>
|
||
<audio id="player" controls src="/media/${r.mp3_file}" preload="metadata"></audio>
|
||
${vizHtml}
|
||
<div class="status-line">
|
||
${anaTimeSpan(r) ? `<div class="muted ana-line">⏱ Analysezeit: ${anaTimeSpan(r)}</div>` : ''}
|
||
${transcribing ? '<div class="muted">📝 Transkription läuft… lange Aufnahmen dauern (A76-Takt gegen Überhitzung gedrosselt).</div>' : ''}
|
||
${transcribeFailed ? `<div class="err-inline">Transkription fehlgeschlagen. <button class="btn" id="retranscribe">Neu transkribieren</button></div>` : ''}
|
||
${(pending && !transcribing && !transcribeFailed) ? '<div class="muted">🧠 KI-Analyse läuft… (NPU)</div>' : ''}
|
||
${(errored && !transcribeFailed) ? `<div class="err-inline">KI-Analyse fehlgeschlagen. <button class="btn" id="reanalyze">Erneut versuchen</button></div>` : ''}
|
||
${(!pending && !errored && !transcribing && !transcribeFailed) ? `<button class="btn small" id="reanalyze">↻ Analyse neu</button>` : ''}
|
||
</div>
|
||
<div class="detail-cols">
|
||
<div class="detail-main">
|
||
<div id="modules">
|
||
${order.filter(m => mods[m]).map(m => `
|
||
<section class="module" data-mod="${m}">
|
||
<div class="mod-head" draggable="true"><span class="mod-grip" title="Ziehen zum Umsortieren">⠿</span><h3>${MOD_TITLES[m]}</h3></div>
|
||
<div class="mod-body">${mods[m]}</div>
|
||
</section>`).join('')}
|
||
</div>
|
||
</div>
|
||
<div class="detail-side">
|
||
<section class="module transcript-panel" id="transcript-panel">
|
||
<div class="mod-head"><h3>Transkript</h3></div>
|
||
<div class="mod-body">
|
||
${r.transcript_clean ? `<h3>Dialog <span class="muted" style="font-weight:400">(KI · Sprecher aus Kontext)</span></h3><div class="dialog">${renderDialog(r.transcript_clean)}</div>` : ''}
|
||
<div class="hint">Klick auf ein Wort → Audio springt dorthin. Leertaste = Play/Pause.</div>
|
||
<div id="transcript" class="transcript">${renderTurns(r.words, names, scls, spkOrder)}</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
// Drag & drop: grab a module by its header, drop it above/below the others;
|
||
// the order is saved and applies to every recording.
|
||
const modWrap = detail.querySelector('#modules');
|
||
let dragged = null;
|
||
modWrap.querySelectorAll('.mod-head').forEach(h => {
|
||
h.addEventListener('dragstart', (e) => {
|
||
dragged = h.closest('.module');
|
||
dragged.classList.add('dragging');
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
e.dataTransfer.setData('text/plain', dragged.dataset.mod); // Firefox needs data
|
||
});
|
||
h.addEventListener('dragend', () => {
|
||
if (!dragged) return;
|
||
dragged.classList.remove('dragging');
|
||
dragged = null;
|
||
const now = [...modWrap.querySelectorAll('.module')].map(m => m.dataset.mod);
|
||
localStorage.setItem('aime-detail-order', JSON.stringify(now));
|
||
});
|
||
});
|
||
modWrap.addEventListener('dragover', (e) => {
|
||
if (!dragged) return;
|
||
e.preventDefault();
|
||
const others = [...modWrap.querySelectorAll('.module:not(.dragging)')];
|
||
const next = others.find(m => e.clientY < m.getBoundingClientRect().top + m.offsetHeight / 2);
|
||
if (next) modWrap.insertBefore(dragged, next); else modWrap.appendChild(dragged);
|
||
});
|
||
const reBtn = detail.querySelector('#reanalyze');
|
||
if (reBtn) reBtn.onclick = async () => {
|
||
// Re-runs speaker separation every time: empty = automatic detection (now
|
||
// consolidated, so no more "14 speakers" for a 2-person call), a number pins
|
||
// the count. Cancel aborts.
|
||
const ans = window.prompt('Sprecher neu erkennen?\n(leer = automatisch erkennen · Zahl = feste Anzahl vorgeben)', '');
|
||
if (ans === null) return;
|
||
const numSpeakers = parseInt(ans, 10);
|
||
const body = numSpeakers > 0 ? { numSpeakers } : {};
|
||
reBtn.disabled = true; reBtn.textContent = 'trennt neu…';
|
||
try { await api.post('/api/recordings/' + id + '/reanalyze', body); setTimeout(() => openRecording(id), 1500); }
|
||
catch (e) { toast(e.message, true); }
|
||
};
|
||
const rtBtn = detail.querySelector('#retranscribe');
|
||
if (rtBtn) rtBtn.onclick = async () => {
|
||
rtBtn.disabled = true; rtBtn.textContent = 'startet…';
|
||
try { await api.post('/api/recordings/' + id + '/retranscribe', {}); setTimeout(() => openRecording(id), 1500); }
|
||
catch (e) { toast(e.message, true); rtBtn.disabled = false; }
|
||
};
|
||
ensureAnaTicker();
|
||
// Poll while transcription OR the NPU analysis is still running. The old
|
||
// version re-ran openRecording(id) blindly every 5s — a stale closure: after
|
||
// switching to ANOTHER recording the timer still fired and yanked the user
|
||
// (and the playing audio) back to the analysing one. Now: stop when the
|
||
// selection changed, poll ONLY the status while busy (no DOM rebuild), and
|
||
// rebuild once when the results are in — restoring the player position.
|
||
if (pending || transcribing) {
|
||
const pollDetail = async () => {
|
||
if (state.selectedId !== id || state.tab !== 'recordings') return; // user moved on
|
||
if (!document.querySelector('#detail')) return; // view gone
|
||
let cur;
|
||
try { cur = await api.get('/api/recordings/' + id); } catch { return; }
|
||
if (state.selectedId !== id || state.tab !== 'recordings') return; // re-check after await
|
||
const busy = cur.analysis_status === 'pending' || cur.status === 'transcribing';
|
||
if (busy) { setTimeout(pollDetail, 5000); return; }
|
||
// Results arrived → one rebuild; keep the audio where the user had it.
|
||
const a = document.querySelector('#detail #player');
|
||
const keep = a && !a.paused ? { t: a.currentTime, play: true } : (a ? { t: a.currentTime, play: false } : null);
|
||
await openRecording(id);
|
||
if (keep && keep.t > 0) {
|
||
const na = document.querySelector('#detail #player');
|
||
if (na) { na.currentTime = keep.t; if (keep.play) na.play().catch(() => {}); }
|
||
}
|
||
};
|
||
setTimeout(pollDetail, 5000);
|
||
}
|
||
const audio = detail.querySelector('#player');
|
||
const spans = [...detail.querySelectorAll('.w')];
|
||
spans.forEach(s => s.onclick = () => { audio.currentTime = (+s.dataset.start) / 1000 + 0.001; audio.play(); });
|
||
// Whisper (batched) word timestamps run slightly AHEAD of the audio, so a
|
||
// pure [start,end] match lit the NEXT word ("eine Zeile weiter") before it was
|
||
// spoken. Compensate with a fixed lead offset and highlight the LAST word whose
|
||
// start has passed (stays lit through inter-word gaps → no flicker).
|
||
// Tune SYNC_MS if the marker still leads (raise) or lags (lower).
|
||
const SYNC_MS = 250;
|
||
// Lane visualizer wiring: playhead + speaking lane + live-text box.
|
||
const lanesEl = detail.querySelector('#lanes');
|
||
const playheadEl = detail.querySelector('#playhead');
|
||
const liveboxEl = detail.querySelector('#livebox');
|
||
let liveTurnIdx = -1;
|
||
if (lanesEl) {
|
||
// Click (or drag) inside the lanes seeks; ignore the label column.
|
||
const seek = (e) => {
|
||
const lane = lanesEl.querySelector('.lane');
|
||
if (!lane) return;
|
||
const rc = lane.getBoundingClientRect();
|
||
const frac = Math.min(1, Math.max(0, (e.clientX - rc.left) / rc.width));
|
||
audio.currentTime = frac * durMs / 1000;
|
||
audio.play().catch(() => {});
|
||
};
|
||
lanesEl.addEventListener('click', seek);
|
||
}
|
||
const updateViz = (t) => {
|
||
if (!lanesEl) return;
|
||
if (playheadEl) {
|
||
const lane = lanesEl.querySelector('.lane');
|
||
if (lane) {
|
||
const left = lane.offsetLeft + Math.min(1, Math.max(0, t / durMs)) * lane.offsetWidth;
|
||
playheadEl.style.left = left + 'px';
|
||
playheadEl.style.display = 'block';
|
||
}
|
||
}
|
||
// Which speakers talk right now → light their lane rows.
|
||
lanesEl.querySelectorAll('.lane-row').forEach(row => {
|
||
const sp = row.dataset.sp;
|
||
const on = (segsBySpk[sp] || []).some(s => t >= s.start && t <= s.end);
|
||
row.classList.toggle('speaking', on && !audio.paused);
|
||
});
|
||
// Live text: last turn whose start has passed (stays through gaps).
|
||
if (liveboxEl) {
|
||
let idx = -1;
|
||
for (let i = 0; i < turns.length; i++) { if (turns[i].start <= t) idx = i; else break; }
|
||
if (idx !== liveTurnIdx && idx >= 0) {
|
||
liveTurnIdx = idx;
|
||
const tu = turns[idx];
|
||
const i = spkOrder.indexOf(tu.sp);
|
||
liveboxEl.innerHTML = `
|
||
<div class="live-turn ${scls[tu.sp] || ''}">
|
||
<span class="spk">${escapeHtml(spkLabel(tu.sp, i >= 0 ? i : 0))}</span>
|
||
<span class="live-text">${escapeHtml(tu.text)}</span>
|
||
</div>`;
|
||
}
|
||
}
|
||
};
|
||
audio.onpause = () => { if (lanesEl) lanesEl.querySelectorAll('.lane-row.speaking').forEach(r2 => r2.classList.remove('speaking')); };
|
||
audio.ontimeupdate = () => {
|
||
const t = audio.currentTime * 1000 - SYNC_MS;
|
||
let active = null;
|
||
for (const s of spans) { if (+s.dataset.start <= t) active = s; else break; }
|
||
spans.forEach(s => s.classList.toggle('active', s === active));
|
||
// Follow the active word INSIDE the transcript panel (it scrolls itself now —
|
||
// scrollIntoView would also yank the page).
|
||
if (active) {
|
||
const panel = detail.querySelector('#transcript-panel');
|
||
if (panel) {
|
||
const pr = panel.getBoundingClientRect(), ar = active.getBoundingClientRect();
|
||
if (ar.top < pr.top + 40 || ar.bottom > pr.bottom - 40)
|
||
panel.scrollTo({ top: panel.scrollTop + (ar.top - pr.top) - panel.clientHeight / 2, behavior: 'smooth' });
|
||
}
|
||
}
|
||
updateViz(t);
|
||
};
|
||
// Rename a speaker + (optionally) learn their voice into the DB.
|
||
detail.querySelectorAll('.spk-save').forEach(btn => {
|
||
btn.onclick = async () => {
|
||
const row = btn.closest('.spk-row');
|
||
const name = row.querySelector('.spk-name').value.trim();
|
||
const enroll = row.querySelector('.spk-learn').checked;
|
||
btn.disabled = true; btn.textContent = '…';
|
||
try {
|
||
const res = await api.post('/api/recordings/' + id + '/speaker', { speaker: btn.dataset.sp, name, enroll });
|
||
toast(res.enrolled ? `„${name}" gespeichert + Stimme gelernt` : (res.learnMsg ? 'Gespeichert (' + res.learnMsg + ')' : 'Gespeichert'));
|
||
openRecording(id);
|
||
} catch (e) { toast(e.message, true); btn.disabled = false; btn.textContent = 'Speichern'; }
|
||
};
|
||
});
|
||
|
||
// Spacebar toggles play/pause of the open recording (unless typing in a field).
|
||
if (state.spaceHandler) document.removeEventListener('keydown', state.spaceHandler);
|
||
state.spaceHandler = (e) => {
|
||
if (e.code !== 'Space' && e.key !== ' ') return;
|
||
const t = e.target, tag = (t.tagName || '').toLowerCase();
|
||
if (tag === 'input' || tag === 'textarea' || t.isContentEditable) return;
|
||
const a = document.querySelector('#player');
|
||
if (!a) return;
|
||
e.preventDefault();
|
||
if (a.paused) a.play(); else a.pause();
|
||
};
|
||
document.addEventListener('keydown', state.spaceHandler);
|
||
|
||
// Two-pane layout: scroll the detail pane to its top. On a narrow (stacked)
|
||
// screen also bring the detail into view since it sits below the list.
|
||
const pane = detail.closest('.rec-detail-pane');
|
||
if (pane) pane.scrollTo({ top: 0, behavior: 'smooth' });
|
||
if (window.matchMedia('(max-width: 800px)').matches) detail.scrollIntoView({ behavior: 'smooth' });
|
||
}
|
||
|
||
function escapeHtml(s) { return (s || '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
|
||
|
||
// Per-speaker talk segments for the lane visualizer: consecutive words of the
|
||
// same speaker, gaps ≤ 1.2 s bridged (word-level gaps would render as dust).
|
||
function speakerSegments(words) {
|
||
const by = {};
|
||
for (const w of words) {
|
||
if (!w.speaker) continue;
|
||
const list = by[w.speaker] || (by[w.speaker] = []);
|
||
const last = list[list.length - 1];
|
||
if (last && w.t_start_ms - last.end <= 1200) last.end = Math.max(last.end, w.t_end_ms);
|
||
else list.push({ start: w.t_start_ms, end: w.t_end_ms });
|
||
}
|
||
return by;
|
||
}
|
||
// Turn list (speaker + joined text) for the live-text box.
|
||
function wordTurns(words) {
|
||
const turns = [];
|
||
for (const w of words) {
|
||
const last = turns[turns.length - 1];
|
||
if (last && last.sp === w.speaker) { last.text += ' ' + w.word; last.end = w.t_end_ms; }
|
||
else turns.push({ sp: w.speaker, text: w.word, start: w.t_start_ms, end: w.t_end_ms });
|
||
}
|
||
return turns;
|
||
}
|
||
// Map distinct raw speaker labels (speaker_00…) to colour classes sp0..sp3.
|
||
function speakerClasses(words) {
|
||
const m = {}; let i = 0; const palette = ['sp0', 'sp1', 'sp2', 'sp3'];
|
||
for (const w of words) { if (w.speaker && !(w.speaker in m)) { m[w.speaker] = palette[i % palette.length]; i++; } }
|
||
return m;
|
||
}
|
||
// Render the raw transcript as speaker turns with a NAME header per turn. Each
|
||
// word stays its own .w span (click-to-seek + active highlight still work).
|
||
function renderTurns(words, names, scls, order) {
|
||
if (!words || !words.length) return '<span class="muted">Kein Transkript.</span>';
|
||
const idx = {}; order.forEach((s, i) => idx[s] = i);
|
||
const label = sp => (names && names[sp]) || (sp ? 'Sprecher ' + ((idx[sp] ?? 0) + 1) : '');
|
||
let html = '', cur = '\0', buf = [];
|
||
const flush = () => {
|
||
if (!buf.length) return;
|
||
const nm = label(cur === '\0' ? null : cur);
|
||
html += `<div class="turn ${cur && cur !== '\0' ? (scls[cur] || '') : ''}">${nm ? `<b class="spk">${escapeHtml(nm)}</b> ` : ''}<span class="wt">${buf.join(' ')}</span></div>`;
|
||
buf = [];
|
||
};
|
||
for (const w of words) {
|
||
const sp = w.speaker || null;
|
||
if (sp !== cur) { flush(); cur = sp; }
|
||
buf.push(`<span class="w" data-start="${w.t_start_ms}" data-end="${w.t_end_ms}">${escapeHtml(w.word)}</span>`);
|
||
}
|
||
flush();
|
||
return html;
|
||
}
|
||
// Render "Name: Text" lines as a colour-coded dialogue (speakers inferred by
|
||
// the LLM from context — not acoustic).
|
||
function renderDialog(text) {
|
||
const lines = (text || '').split(/\n+/).map(l => l.trim()).filter(Boolean);
|
||
const colors = {}; let ci = 0; const palette = ['sp0', 'sp1', 'sp2', 'sp3'];
|
||
return lines.map(l => {
|
||
const m = l.match(/^([^:]{1,30}):\s*(.*)$/);
|
||
if (!m) return `<div class="turn"><span>${escapeHtml(l)}</span></div>`;
|
||
const spk = m[1].trim();
|
||
if (!(spk in colors)) { colors[spk] = palette[ci % palette.length]; ci++; }
|
||
return `<div class="turn ${colors[spk]}"><b class="spk">${escapeHtml(spk)}</b> <span>${escapeHtml(m[2])}</span></div>`;
|
||
}).join('');
|
||
}
|
||
function analysisBadge(st) {
|
||
if (st === 'pending') return '<span class="badge warn">🧠 Analyse…</span>';
|
||
if (st === 'error') return '<span class="badge err">KI-Fehler</span>';
|
||
if (st === 'done') return '<span class="badge ok">KI ✓</span>';
|
||
return '';
|
||
}
|
||
|
||
render();
|