Files
Aime/app/web/src/main.js
T

724 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 &amp; 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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();