Files
Aime/app/web/src/main.js
T
Claude db50c9616d fix(gui): Ventilator-Icon und Speicheranzeige auf Aufnahme-Vorrat
Das erste Icon las sich als Kleeblatt. Jetzt vier gebogene Pinwheel-Fluegel
in einem feststehenden Gehaeusering; nur die Fluegel drehen. Form vorab ohne
Browser geprueft (lab/preview_fan.py rendert die Bezier-Kandidaten als PNG).

Die Prozentzahl misst nicht mehr die Partition, sondern den Aufnahme-Vorrat:
100 % = freier Platz + alle Audiodateien unter recordings/, belegt = diese
Dateien. System, Modelle und node_modules fallen aus Zaehler und Nenner
heraus; der df-Wert steht als Fusszeile in der Klappkarte.

sysstat.js scannt recordings/ per readdir+stat (kein du-Prozess) und puffert
60 s; status() ist dadurch async, die Route laeuft ueber wrap().

Doku: Kapitel 07 + Changelog.
2026-08-17 22:03:17 +02:00

1925 lines
103 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(); },
async patch(u, b) { const r = await fetch(u, { method: 'PATCH', 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: 'recordings', recording: false, recStart: 0, timer: null, selectedId: null, collapsedDates: new Set(), lastListSig: null, markers: [], mkDraft: '',
// search: `q` is what was actually submitted (not what is being typed), so a
// tab switch and back restores the result page instead of an empty form.
searchQ: '', searchRes: null, searchAnswer: null, searchBusy: false, trashOpen: false,
// Projektansicht: welcher Ordner ist offen (null = Ordnerübersicht) und
// welche Ebene darin (0 = direkt im Ordner, sonst die id des Unterreiters).
projectId: null, projectTabId: 0,
// Kontextnotiz: `ctxPromptFor` = die Aufnahme, deren Kontextbox nach dem Stop
// aufgeklappt und fokussiert dasteht (bis gespeichert oder weggeklickt).
// `ctxDrafts` hält getippten, noch nicht gespeicherten Text pro Aufnahme —
// die Detailansicht baut sich nach der Analyse komplett neu, sonst wäre das
// Getippte weg.
ctxPromptFor: null, ctxDrafts: {} };
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() {
const theme = document.documentElement.dataset.theme || 'dark';
app.innerHTML = `
<header class="topbar">
<div class="brand" id="brandhome" title="Zu den Aufnahmen">
<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>
<div class="rec-ctl">
<button id="recbtn" class="recbtn ${state.recording ? 'rec' : ''}" title="${state.recording ? 'Stop & Transkribieren' : 'Aufnahme starten'}">${state.recording ? '■' : '●'}</button>
<span class="rectime mono" data-rectime>${state.recording ? fmt(Date.now() - state.recStart) : '00:00'}</span>
${state.recording ? `
<input id="mkname" class="input mkin" placeholder="Wer spricht? Name + Enter" value="${escapeHtml(state.mkDraft)}"
title="Namen der Person eintippen, die JETZT spricht — der Zeitstempel ordnet ihn später dem Sprecher zu">
<span id="mkcount" class="badge ${state.markers.length ? 'ok' : ''}" title="gesetzte Namensmarker">🏷 ${state.markers.length}</span>` : ''}
</div>
<nav>
${['recordings', 'projects', 'search', 'voices', 'glossary', 'bluetooth'].map(t =>
`<button class="tab ${state.tab === t ? 'active' : ''}" data-tab="${t}">${({ bluetooth: 'Bluetooth', recordings: 'Aufnahmen', projects: 'Projektansicht', search: 'Suche', voices: 'Stimmen', glossary: 'Glossar' })[t]}</button>`).join('')}
</nav>
${sysmonMarkup()}
<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(); });
// Logo + Schriftzug = Heimweg zurück auf die Aufnahmen.
app.querySelector('#brandhome').onclick = () => {
if (state.tab === 'recordings') return;
state.tab = 'recordings';
render();
};
app.querySelector('#themetoggle').onclick = () =>
applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark');
app.querySelector('#recbtn').onclick = toggleRecord;
paintSysmon(sysmon.last); // Werte sofort zurückmalen, render() leert das DOM
startSysmon();
bindMarkerInput();
if (state.recording) startTimerUI();
({ bluetooth: viewBluetooth, recordings: viewRecordings, projects: viewProjects, search: viewSearch, voices: viewVoices, glossary: viewGlossary })[state.tab]();
}
// ---- Systemanzeige in der Kopfzeile -------------------------------------
// Zwei Symbole zwischen den Tabs und dem Hell/Dunkel-Schalter: ein Ring mit der
// Plattenbelegung in Prozent und eine Turbine, die sich um so schneller dreht,
// je höher die CPU-Last ist. Bei Mauskontakt klappt jeweils eine Karte mit den
// Zahlen auf (CPU, RAM, Temperatur bzw. belegt/frei/gesamt).
const sysmon = { timer: null, last: null };
// Ventilator: vier gebogene Flügel im feststehenden Gehäusering. Nur die
// Flügelgruppe dreht sich, der Ring bleibt stehen — sonst sähe man die
// Drehung am runden Gehäuse gar nicht.
const FAN_BLADES = [0, 90, 180, 270].map(a =>
`<path transform="rotate(${a} 12 12)" d="M12 12 C11.4 8.6 11.4 5.4 12.7 3.1 C15.6 4.2 18.2 6.6 18.9 9.4 C17.2 11.2 14.6 12 12 12 Z"/>`).join('');
function sysmonMarkup() {
return `
<div class="sysmon">
<div class="sys-chip" id="sysdisk" tabindex="0" role="group" aria-label="Speicherbelegung">
<svg class="ring" viewBox="0 0 36 36" aria-hidden="true">
<circle class="ring-bg" cx="18" cy="18" r="15.9155"/>
<circle class="ring-fg" cx="18" cy="18" r="15.9155" stroke-dasharray="0 100" data-disk-ring/>
</svg>
<span class="sys-val mono" data-disk-pct>–</span>
<div class="sys-pop">
<div class="sys-pop-h">Speicher für Aufnahmen</div>
<div class="sys-row"><span>Aufnahmen</span><b data-disk-used>–</b></div>
<div class="sys-bar"><i data-disk-bar style="width:0%"></i></div>
<div class="sys-row"><span>Noch frei</span><b data-disk-free>–</b></div>
<div class="sys-row"><span>Gesamt für Aufnahmen</span><b data-disk-total>–</b></div>
<div class="sys-note">100 % = freier Platz + alle vorhandenen Aufnahmen<br>(also der Platz eines frisch aufgesetzten Systems)</div>
<div class="sys-row sys-sub"><span>Partition gesamt</span><b data-disk-part>–</b></div>
</div>
</div>
<div class="sys-chip" id="sysload" tabindex="0" role="group" aria-label="Systemlast">
<svg class="fan" viewBox="0 0 24 24" aria-hidden="true">
<circle class="fan-case" cx="12" cy="12" r="10.4"/>
<g class="fan-blades" data-turbine>${FAN_BLADES}</g>
<circle class="fan-hub" cx="12" cy="12" r="2.1"/>
</svg>
<div class="sys-pop">
<div class="sys-pop-h">Systemlast</div>
<div class="sys-row"><span>CPU</span><b data-cpu-pct>–</b></div>
<div class="sys-bar"><i data-cpu-bar style="width:0%"></i></div>
<div class="sys-row"><span>RAM</span><b data-ram-pct>–</b></div>
<div class="sys-bar"><i data-ram-bar style="width:0%"></i></div>
<div class="sys-row"><span>Temperatur</span><b data-temp>–</b></div>
</div>
</div>
</div>`;
}
function sysLevel(pct) { return pct >= 90 ? 'danger' : pct >= 75 ? 'warn' : 'ok'; }
function sysGb(bytes) { return (bytes / 1073741824).toFixed(1).replace('.', ',') + ' GB'; }
// Drehzahl in Stufen statt stufenlos: ein geänderter animation-duration setzt
// die laufende Drehung neu an, das ruckt. Grobe Stufen = seltener Sprung.
function spinSeconds(cpuPct) {
const steps = [[5, 7], [15, 4.5], [30, 3], [45, 2], [60, 1.3], [75, .85], [90, .55]];
for (const [limit, sec] of steps) if (cpuPct < limit) return sec;
return .35;
}
function paintSysmon(s) {
const disk = document.querySelector('#sysdisk');
const load = document.querySelector('#sysload');
if (!disk || !load) return;
const set = (sel, txt) => { const el = document.querySelector(sel); if (el) el.textContent = txt; };
if (!s) { // Server nicht erreichbar: Anzeige grau, Turbine steht
disk.className = 'sys-chip off'; load.className = 'sys-chip off';
return;
}
if (s.disk) {
// Prozent = Aufnahmen / (freier Platz + Aufnahmen). Das System selbst zählt
// bewusst nicht mit: gezeigt wird, wie viel vom Aufnahme-Vorrat weg ist.
const p = Math.round(s.disk.usedPct);
disk.className = 'sys-chip ' + sysLevel(p);
disk.title = `Aufnahmen belegen ${p} % des verfügbaren Platzes`;
set('[data-disk-pct]', p + '%');
const ring = document.querySelector('[data-disk-ring]');
if (ring) ring.setAttribute('stroke-dasharray', `${Math.max(0, Math.min(100, s.disk.usedPct)).toFixed(1)} 100`);
const db = document.querySelector('[data-disk-bar]'); if (db) db.style.width = Math.min(100, s.disk.usedPct) + '%';
set('[data-disk-used]', `${sysGb(s.disk.recBytes)}${s.disk.recFiles ? ` (${s.disk.recFiles} Dateien)` : ''}`);
set('[data-disk-free]', sysGb(s.disk.freeBytes));
set('[data-disk-total]', sysGb(s.disk.budgetBytes));
set('[data-disk-part]', `${sysGb(s.disk.totalBytes)} — ${Math.round(s.disk.partPct)} % belegt`);
}
const cpu = s.cpu ? s.cpu.pct : 0;
const ram = s.ram ? s.ram.usedPct : 0;
const tc = s.temp ? s.temp.c : null;
// Die Farbe der Turbine folgt dem schlimmsten der drei Werte; Temperatur ab
// 80 °C gelb, ab 90 °C rot (darüber drosselt der RK3588 sichtbar).
const worst = Math.max(cpu, ram, tc == null ? 0 : (tc - 45) / 45 * 100);
load.className = 'sys-chip ' + sysLevel(worst);
load.title = `CPU ${Math.round(cpu)} % · RAM ${Math.round(ram)} %${tc == null ? '' : ` · ${tc.toFixed(0)} °C`}`;
set('[data-cpu-pct]', `${cpu.toFixed(0)} %${s.cpu && s.cpu.cores ? ` (${s.cpu.cores} Kerne)` : ''}`);
set('[data-ram-pct]', s.ram ? `${ram.toFixed(0)} % — ${sysGb(s.ram.usedBytes)} von ${sysGb(s.ram.totalBytes)}` : '–');
set('[data-temp]', tc == null ? '–' : `${tc.toFixed(1)} °C${s.temp.zone ? ` (${s.temp.zone})` : ''}`);
const cb = document.querySelector('[data-cpu-bar]'); if (cb) cb.style.width = Math.min(100, cpu) + '%';
const rb = document.querySelector('[data-ram-bar]'); if (rb) rb.style.width = Math.min(100, ram) + '%';
const tb = document.querySelector('[data-turbine]');
if (tb) tb.style.setProperty('--spin', spinSeconds(cpu) + 's');
}
async function tickSysmon() {
if (document.hidden) return; // im Hintergrund kein Polling
try {
sysmon.last = await api.get('/api/sysstat');
} catch (err) { sysmon.last = null; }
paintSysmon(sysmon.last);
}
function startSysmon() {
if (sysmon.timer) return; // ein Ticker reicht für alle render()
tickSysmon();
sysmon.timer = setInterval(tickSysmon, 5000);
document.addEventListener('visibilitychange', () => { if (!document.hidden) tickSysmon(); });
}
// Header record button: start = defaults only (default source = CallRecMix,
// codec auto, speaker count auto — the old options tab is gone on purpose).
// A live placeholder card appears at the top of the recordings list instantly.
async function toggleRecord() {
const btn = document.querySelector('#recbtn');
if (!state.recording) {
try {
await api.post('/api/record/start', {});
state.recording = true; state.recStart = Date.now();
state.markers = []; state.mkDraft = '';
render();
if (state.tab === 'recordings') { state.lastListSig = null; refreshRecList(); }
} catch (err) { toast(err.message, true); }
} else {
if (btn) { btn.disabled = true; }
clearInterval(state.timer); state.timer = null;
try {
const r = await api.post('/api/record/stop', {});
state.recording = false;
toast('Aufnahme gespeichert – wird transkribiert…');
// Kontextbox der frischen Aufnahme aufgeklappt anbieten: die Transkription
// läuft jetzt minutenlang, der getippte Kontext ist also rechtzeitig da,
// wenn die KI-Analyse danach startet (server.js analyzeRecording).
state.ctxPromptFor = r.id;
state.tab = 'recordings'; render();
state.lastListSig = null; refreshRecList();
openRecording(r.id);
} catch (err) { state.recording = false; toast('Stop fehlgeschlagen: ' + err.message, true); render(); }
}
}
// ---------- live speaker markers ----------
// While recording: type the name of whoever is talking, Enter. The server stamps
// the offset; after diarization the name lands on that acoustic speaker. Only
// reliable way to tell 20+ conference participants apart.
function bindMarkerInput() {
const inp = document.querySelector('#mkname');
if (!inp) return;
inp.oninput = () => { state.mkDraft = inp.value; };
inp.onkeydown = async (e) => {
if (e.key !== 'Enter') return;
const name = inp.value.trim();
if (!name) return;
inp.value = ''; state.mkDraft = '';
try {
const r = await api.post('/api/record/marker', { name });
state.markers = r.markers || [];
updateMarkerUI();
toast(`🏷 ${fmt(r.t_ms)} ${name}`);
} catch (err) { toast('Marker fehlgeschlagen: ' + err.message, true); }
};
}
// Refresh the marker count + chip list in place (no re-render → focus stays in
// the input, so names can be typed back-to-back).
function updateMarkerUI() {
const c = document.querySelector('#mkcount');
if (c) { c.textContent = '🏷 ' + state.markers.length; c.classList.toggle('ok', !!state.markers.length); }
const chips = document.querySelector('#mkchips');
if (chips) chips.innerHTML = markerChipsHtml();
bindMarkerChips();
}
function markerChipsHtml() {
if (!state.markers.length) return '<span class="muted">noch keine Namen markiert</span>';
return state.markers.map((m, i) =>
`<span class="mkchip"><span class="mono">${fmt(m.t_ms)}</span> ${escapeHtml(m.name)}<button class="mkdel" data-mk="${i}" title="Marker entfernen">✕</button></span>`).join('');
}
function bindMarkerChips() {
document.querySelectorAll('.mkdel').forEach(b => b.onclick = async (e) => {
e.stopPropagation();
try {
const r = await api.del('/api/record/marker/' + b.dataset.mk);
state.markers = r.markers || [];
updateMarkerUI();
} catch (err) { toast(err.message, true); }
});
}
// Tick every .rectime element (header + list placeholder) from one interval.
function startTimerUI() {
clearInterval(state.timer);
state.timer = setInterval(() => {
const t = fmt(Date.now() - state.recStart);
document.querySelectorAll('[data-rectime]').forEach(el => el.textContent = t);
}, 500);
}
// ---------- 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>');
}
// ---------- 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" data-rectime>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>Personen</h3>
<div class="hint">Eine Person darf mehrere Namen haben („Sascha“ und „Sascha Wolf“ = ein Mensch) und
mehrere Stimmproben aus verschiedenen Aufnahmen. Erkannt wird über die beste Probe.</div>
<div id="vplist" class="list">Lade…</div></div>`;
const listEl = v.querySelector('#vplist');
async function load() {
try {
const persons = await api.get('/api/persons');
const opts = persons.map(p => `<option value="${p.id}">${escapeHtml(p.display_name)}</option>`).join('');
listEl.innerHTML = persons.length ? persons.map(p => `
<div class="item person" data-pid="${p.id}">
<div class="grow">
<b>👤 ${escapeHtml(p.display_name)}</b>
<div class="chips">${p.names.map(n => `<span class="chip">${escapeHtml(n)}${p.names.length > 1
? `<button class="chipx" data-delname="${encodeURIComponent(n)}" title="Namen entfernen">✕</button>` : ''}</span>`).join('')}
<button class="btn tiny" data-addname="${p.id}">+ Name</button></div>
<div class="muted">${p.voiceprints.length} Stimmprobe${p.voiceprints.length === 1 ? '' : 'n'}${p.last_print
? ' · letzte ' + new Date(p.last_print).toLocaleDateString('de-DE') : ''}${p.voiceprints.some(x => x.stale)
? ' · ⚠ andere Modellversion' : ''}</div>
</div>
${persons.length > 1 ? `<select class="input tiny" data-merge="${p.id}">
<option value="">↳ zusammenführen mit…</option>${opts}</select>` : ''}
${p.voiceprints.length ? `<button class="btn" data-clear="${p.id}" title="Stimmproben löschen, Person behalten">🎙✕</button>` : ''}
<button class="btn danger" data-delperson="${p.id}" title="Person samt Proben löschen">🗑</button>
</div>`).join('')
: '<div class="muted">Noch keine Personen. Oben eine Stimme anlernen oder in einer Aufnahme einen Sprecher benennen.</div>';
listEl.querySelectorAll('[data-addname]').forEach(b => b.onclick = async () => {
const name = prompt('Weiterer Name für diese Person:');
if (!name || !name.trim()) return;
try {
const r = await api.post(`/api/persons/${b.dataset.addname}/names`, { name: name.trim() });
toast(r.merged ? 'Name gehörte einer anderen Person → zusammengeführt ✓' : 'Name hinzugefügt ✓');
load();
} catch (e) { toast(e.message, true); }
});
listEl.querySelectorAll('[data-delname]').forEach(b => b.onclick = async () => {
try { await api.del('/api/persons/names/' + b.dataset.delname); load(); }
catch (e) { toast(e.message, true); }
});
listEl.querySelectorAll('[data-merge]').forEach(sel => sel.onchange = async () => {
const fromId = sel.value; if (!fromId) return;
if (fromId === sel.dataset.merge) { sel.value = ''; return; }
if (!confirm('Beide Personen als EIN Mensch führen? Namen und Stimmproben wandern zusammen.')) { sel.value = ''; return; }
try { await api.post(`/api/persons/${sel.dataset.merge}/merge`, { fromId: +fromId }); toast('Zusammengeführt ✓'); load(); }
catch (e) { toast(e.message, true); }
});
listEl.querySelectorAll('[data-clear]').forEach(b => b.onclick = async () => {
if (!confirm('Alle Stimmproben dieser Person löschen? Die Person und ihre Namen bleiben.')) return;
const p = persons.find(x => x.id === +b.dataset.clear);
for (const vp of p.voiceprints) await api.del('/api/voiceprints/' + vp.id);
load();
});
listEl.querySelectorAll('[data-delperson]').forEach(b => b.onclick = async () => {
if (!confirm('Person samt allen Namen und Stimmproben löschen?')) return;
await api.del('/api/persons/' + b.dataset.delperson); 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); }
};
}
// ---------- Search ----------
// Two-stage on purpose (see search.js): the hit list is SQL and lands in
// milliseconds, the written answer is a ~30-90 s LLM call that streams in
// afterwards. Never make the user wait on the slow half to see the fast half.
const SEARCH_EXAMPLES = [
'Welches Projekt hat Johannes Jeckel letzte Woche vorgestellt?',
'Was wurde über die Preiserhöhung besprochen?',
'Welche Aufgaben hat Markus im Juli übernommen?',
];
function viewSearch() {
const v = document.querySelector('#view');
const hasResults = !!state.searchRes;
v.innerHTML = `
<div class="search-page ${hasResults ? 'has-results' : 'landing'}">
<form class="search-box" id="searchform" autocomplete="off">
<span class="search-ico">🔍</span>
<input id="searchq" class="search-input" type="text" placeholder="Frage zu deinen Meetings…"
value="${escapeHtml(state.searchQ)}" />
${state.searchQ ? '<button type="button" class="search-clear" id="searchclear" title="Leeren">✕</button>' : ''}
<button type="submit" class="search-go">Suchen</button>
</form>
${hasResults ? '' : `
<div class="search-hints">
<div class="muted small">Durchsucht alle Mitschriften. Beispiele:</div>
${SEARCH_EXAMPLES.map(e => `<button class="example" data-ex="${escapeHtml(e)}">${escapeHtml(e)}</button>`).join('')}
</div>`}
<div id="searchout"></div>
</div>`;
const input = v.querySelector('#searchq');
v.querySelector('#searchform').onsubmit = (e) => { e.preventDefault(); runSearch(input.value.trim()); };
const clr = v.querySelector('#searchclear');
if (clr) clr.onclick = () => { state.searchQ = ''; state.searchRes = null; state.searchAnswer = null; viewSearch(); };
v.querySelectorAll('.example').forEach(b => b.onclick = () => { input.value = b.dataset.ex; runSearch(b.dataset.ex); });
if (!hasResults) input.focus();
if (hasResults) renderSearchResults();
}
async function runSearch(q) {
if (!q || q.length < 2) return;
state.searchQ = q;
state.searchAnswer = null;
state.searchBusy = true;
const out = document.querySelector('#searchout');
if (out) out.innerHTML = '<div class="muted searching">Suche…</div>';
document.querySelector('.search-page').classList.add('has-results');
try {
state.searchRes = await api.get('/api/search?q=' + encodeURIComponent(q));
} catch (e) {
state.searchRes = null;
if (out) out.innerHTML = '<div class="err">' + escapeHtml(e.message) + '</div>';
state.searchBusy = false;
return;
}
state.searchBusy = false;
renderSearchResults();
// Only bother the NPU when there is something to summarise.
if (state.searchRes.hits.length) askSearchAnswer(q);
}
// The written answer. Fired after the hits are already on screen; the box shows
// a live elapsed counter because on this hardware it genuinely takes a while.
async function askSearchAnswer(q) {
const box = document.querySelector('#answerbox');
if (!box) return;
const t0 = Date.now();
const tick = setInterval(() => {
const el = box.querySelector('.answer-elapsed');
if (el) el.textContent = Math.round((Date.now() - t0) / 1000) + ' s';
}, 1000);
try {
const a = await api.post('/api/search/answer', { q });
// A late answer to a query the user has already replaced must not overwrite
// the newer result.
if (state.searchQ !== q) return;
state.searchAnswer = a;
renderAnswer();
} catch (e) {
if (state.searchQ !== q) return;
box.innerHTML = `<div class="answer-head">Antwort</div><div class="err">${escapeHtml(e.message)}</div>`;
} finally { clearInterval(tick); }
}
// Highlight the query's content words inside a snippet, the way a search engine
// bolds the matched terms. Escaped first, so the highlight cannot inject markup.
function highlight(text, terms) {
let html = escapeHtml(text);
for (const t of (terms || [])) {
if (t.length < 3) continue;
const safe = t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
html = html.replace(new RegExp(`(${safe})`, 'gi'), '<mark>$1</mark>');
}
return html;
}
function renderSearchResults() {
const out = document.querySelector('#searchout');
if (!out || !state.searchRes) return;
const { hits, terms, range, semantic, total } = state.searchRes;
if (!hits.length) {
out.innerHTML = `<div class="search-empty">
<b>Keine Treffer für „${escapeHtml(state.searchQ)}".</b>
<div class="muted small">${range ? `Eingeschränkt auf ${escapeHtml(range.label)} — vielleicht liegt es daran. ` : ''}
Andere Formulierung oder weniger Wörter versuchen.</div></div>`;
return;
}
out.innerHTML = `
<div id="answerbox" class="answer-box">
<div class="answer-head">Antwort <span class="muted small answer-elapsed">…</span></div>
<div class="answer-body skeleton"><span></span><span></span><span></span></div>
</div>
<div class="result-meta muted small">
${total} Fundstelle${total === 1 ? '' : 'n'}${range ? ` · Zeitraum: ${escapeHtml(range.label)}` : ''}
· ${semantic ? 'Volltext + semantisch' : 'nur Volltext'}
</div>
<ol class="results">
${hits.map(h => `
<li class="result" data-rec="${h.recording_id}" data-t="${h.t_start_ms}">
<div class="result-src">${escapeHtml(new Date(h.created_at).toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' }))}
· Aufnahme #${h.recording_id}${h.speaker_name ? ' · ' + escapeHtml(h.speaker_name) : ''}</div>
<a class="result-title" href="#">${escapeHtml(h.title) || 'Aufnahme #' + h.recording_id}
<span class="result-at">bei ${fmt(h.t_start_ms)}</span></a>
<div class="result-snip">${highlight(h.text, terms)}</div>
</li>`).join('')}
</ol>`;
out.querySelectorAll('.result').forEach(li => li.onclick = (e) => {
e.preventDefault();
openFromSearch(+li.dataset.rec, +li.dataset.t);
});
}
function renderAnswer() {
const box = document.querySelector('#answerbox');
if (!box || !state.searchAnswer) return;
const { answer, sources } = state.searchAnswer;
box.innerHTML = `
<div class="answer-head">Antwort</div>
<div class="answer-body">${escapeHtml(answer)}</div>
${sources && sources.length ? `<div class="answer-src">
${sources.map(s => `<button class="src-chip" data-rec="${s.recording_id}" data-t="${s.t_start_ms}">
[${s.n}] ${escapeHtml(s.title) || '#' + s.recording_id} · ${fmt(s.t_start_ms)}</button>`).join('')}
</div>` : ''}`;
box.querySelectorAll('.src-chip').forEach(b => b.onclick = () => openFromSearch(+b.dataset.rec, +b.dataset.t));
}
// Jump from a hit into the recording, positioned at the spoken minute. The
// detail pane lives in the recordings tab, so switch there first and wait for
// the player element to exist before seeking.
async function openFromSearch(recId, tMs) {
state.tab = 'recordings';
state.selectedId = recId;
state.trashOpen = false;
render();
for (let i = 0; i < 40; i++) {
const p = document.querySelector('#player');
if (p) {
const seek = () => { try { p.currentTime = tMs / 1000; p.play().catch(() => {}); } catch {} };
if (p.readyState >= 1) seek(); else p.addEventListener('loadedmetadata', seek, { once: true });
return;
}
await new Promise(r => setTimeout(r, 100));
}
}
// ---------- Modal ----------
// Kleiner Dialog-Baustein für die Pin-Auswahl. Bewusst kein Framework: Inhalt
// als HTML rein, `onOk` bekommt den Wurzelknoten und kann die Felder auslesen;
// gibt onOk `false` zurück, bleibt der Dialog offen (Validierungsfehler).
function openModal(title, bodyHtml, { okText = 'Speichern', onOk, onReady } = {}) {
const back = document.createElement('div');
back.className = 'modal-back';
back.innerHTML = `
<div class="modal card">
<h3>${title}</h3>
<div class="modal-body">${bodyHtml}</div>
<div class="modal-foot">
<button class="btn" data-cancel>Abbrechen</button>
${okText ? `<button class="btn primary" data-ok>${okText}</button>` : ''}
</div>
</div>`;
document.body.appendChild(back);
const root = back.querySelector('.modal');
const close = () => { document.removeEventListener('keydown', onKey); back.remove(); };
const onKey = (e) => { if (e.key === 'Escape') close(); };
document.addEventListener('keydown', onKey);
back.onclick = (e) => { if (e.target === back) close(); };
back.querySelector('[data-cancel]').onclick = close;
const ok = back.querySelector('[data-ok]');
if (ok && onOk) ok.onclick = async () => {
ok.disabled = true;
try { if (await onOk(root, close) !== false) close(); }
catch (e) { toast(e.message, true); }
finally { ok.disabled = false; }
};
if (onReady) onReady(root, close);
return { root, close };
}
// ---------- Projektansicht ----------
// Zwei Ebenen: Ordner („Project X") und darin Unterreiter („Projektrelevante
// Aufzeichnungen"). Auf BEIDEN Ebenen lassen sich Aufnahmen anpinnen; ein Pin
// ist nur eine Referenz — die Aufnahme bleibt in der Aufnahmenliste, kann in
// mehreren Projekten hängen und wird beim Entpinnen nicht gelöscht.
function viewProjects() {
const v = document.querySelector('#view');
if (state.projectId == null) return renderProjectList(v);
return renderProjectDetail(v);
}
async function renderProjectList(v) {
v.innerHTML = `
<div class="card">
<div class="proj-head">
<h3>Projekte</h3>
<button id="pnew" class="btn primary">+ Neuer Ordner</button>
</div>
<div class="hint">Ordner bündeln Aufnahmen zu einem Projekt. In jedem Ordner lassen sich Unterreiter anlegen und auf beiden Ebenen Aufnahmen anpinnen.</div>
<div id="plist" class="proj-grid">Lade…</div>
</div>`;
v.querySelector('#pnew').onclick = async () => {
const name = prompt('Name des Projektordners:', '');
if (name === null || !name.trim()) return;
try {
const p = await api.post('/api/projects', { name: name.trim() });
toast(`Ordner „${p.name}" angelegt`);
state.projectId = p.id; state.projectTabId = 0;
viewProjects();
} catch (e) { toast(e.message, true); }
};
const listEl = v.querySelector('#plist');
let ps;
try { ps = await api.get('/api/projects'); }
catch (e) { listEl.innerHTML = '<div class="err">' + e.message + '</div>'; return; }
if (!ps.length) { listEl.innerHTML = '<div class="muted">Noch keine Projekte. Oben einen Ordner anlegen.</div>'; return; }
listEl.innerHTML = ps.map(p => `
<div class="proj-card" data-open="${p.id}">
<div class="proj-ico">📁</div>
<div class="grow">
<b>${escapeHtml(p.name)}</b>
<div class="muted small">${p.pins} Aufnahme${p.pins === 1 ? '' : 'n'} · ${p.tabs} Unterreiter</div>
</div>
<button class="btn icon" data-ren="${p.id}" title="Ordner umbenennen">✏️</button>
<button class="btn danger icon" data-del="${p.id}" title="Ordner löschen">🗑</button>
</div>`).join('');
listEl.querySelectorAll('.proj-card').forEach(c => c.onclick = (e) => {
if (e.target.closest('[data-ren]') || e.target.closest('[data-del]')) return;
state.projectId = +c.dataset.open; state.projectTabId = 0;
viewProjects();
});
listEl.querySelectorAll('[data-ren]').forEach(b => b.onclick = async (e) => {
e.stopPropagation();
const p = ps.find(x => x.id == b.dataset.ren);
const name = prompt('Ordner umbenennen:', p ? p.name : '');
if (name === null || !name.trim()) return;
await api.patch('/api/projects/' + b.dataset.ren, { name: name.trim() });
renderProjectList(v);
});
listEl.querySelectorAll('[data-del]').forEach(b => b.onclick = async (e) => {
e.stopPropagation();
const p = ps.find(x => x.id == b.dataset.del);
if (!confirm(`Ordner „${p ? p.name : ''}" löschen?\nUnterreiter und Anheftungen verschwinden mit. Die Aufnahmen selbst bleiben erhalten.`)) return;
await api.del('/api/projects/' + b.dataset.del);
toast('Ordner gelöscht');
renderProjectList(v);
});
}
async function renderProjectDetail(v) {
v.innerHTML = '<div class="card">Lade…</div>';
let p;
try { p = await api.get('/api/projects/' + state.projectId); }
catch (e) { v.innerHTML = '<div class="err">' + e.message + '</div>'; return; }
if (!p) { state.projectId = null; return viewProjects(); }
// Der zuletzt gewählte Unterreiter kann gelöscht worden sein → zurück auf die
// Ordner-Ebene, statt eine leere Geisterebene anzuzeigen.
if (state.projectTabId && !p.tabs.some(t => t.id === state.projectTabId)) state.projectTabId = 0;
const cur = state.projectTabId;
const pins = p.pins.filter(x => x.tab_id === cur);
// Die Ordner-Ebene ist umbenennbar; ohne eigenen Namen heißt sie „Ordner".
const rootName = p.root_tab_name || 'Ordner';
const levelName = cur ? (p.tabs.find(t => t.id === cur) || {}).name : rootName;
v.innerHTML = `
<div class="card">
<div class="proj-head">
<button id="pback" class="btn small ghost">← Projekte</button>
<h3>📁 ${escapeHtml(p.name)}</h3>
<button id="prename" class="btn icon" title="Ordner umbenennen">✏️</button>
</div>
<div class="proj-tabs">
<button class="ptab ${cur === 0 ? 'active' : ''}" data-ptab="0">${escapeHtml(rootName)}
<span class="ptab-n">${p.pins.filter(x => x.tab_id === 0).length}</span></button>
${p.tabs.map(t => `<button class="ptab ${cur === t.id ? 'active' : ''}" data-ptab="${t.id}">${escapeHtml(t.name)}
<span class="ptab-n">${p.pins.filter(x => x.tab_id === t.id).length}</span></button>`).join('')}
<button id="ptabnew" class="ptab add" title="Unterreiter anlegen">+ Reiter</button>
</div>
<div class="proj-level">
<div class="proj-level-head">
<b>${escapeHtml(levelName)}</b>
<span class="muted small">${pins.length} angeheftete Aufnahme${pins.length === 1 ? '' : 'n'}${p.tabs.length && pins.length ? ' · auf einen Reiter ziehen zum Verschieben' : ''}</span>
<span class="grow"></span>
<button id="ptabren" class="btn small">✏️ ${cur ? 'Reiter' : 'Ebene'} umbenennen</button>
${cur ? '<button id="ptabdel" class="btn small danger">🗑 Reiter löschen</button>' : ''}
<button id="ppin" class="btn primary small">📌 Aufnahme anheften</button>
</div>
<div id="ppins" class="list">${pins.length ? pins.map(r => `
<div class="item rec" data-open="${r.id}" data-pin="${r.pin_id}" draggable="true">
<div class="grow">
<b>${escapeHtml(r.title) || 'Aufnahme #' + r.id}</b> ${analysisBadge(r.analysis_status)}
<div class="muted">${new Date(r.created_at).toLocaleDateString('de-DE')} · ${fmt(r.duration_ms || 0)}</div>
${r.summary ? `<div class="summary-snip">${escapeHtml(r.summary)}</div>` : ''}
</div>
<button class="btn icon" data-unpin="${r.pin_id}" title="Von dieser Ebene lösen">📌✕</button>
</div>`).join('')
: '<div class="muted">Hier ist noch nichts angeheftet. Rechts oben eine Aufnahme anheften.</div>'}</div>
</div>
</div>`;
v.querySelector('#pback').onclick = () => { state.projectId = null; state.projectTabId = 0; viewProjects(); };
v.querySelector('#prename').onclick = async () => {
const name = prompt('Ordner umbenennen:', p.name);
if (name === null || !name.trim()) return;
await api.patch('/api/projects/' + p.id, { name: name.trim() });
renderProjectDetail(v);
};
v.querySelectorAll('[data-ptab]').forEach(b => b.onclick = () => {
state.projectTabId = +b.dataset.ptab;
renderProjectDetail(v);
});
v.querySelector('#ptabnew').onclick = async () => {
const name = prompt('Name des Unterreiters:', '');
if (name === null || !name.trim()) return;
const t = await api.post(`/api/projects/${p.id}/tabs`, { name: name.trim() });
state.projectTabId = t.id;
renderProjectDetail(v);
};
const renBtn = v.querySelector('#ptabren');
if (renBtn) renBtn.onclick = async () => {
// Auf der Ordner-Ebene ist ein leeres Feld erlaubt: es setzt die
// Beschriftung auf die Vorgabe „Ordner" zurück.
const name = prompt(cur ? 'Unterreiter umbenennen:' : 'Ordner-Ebene umbenennen (leer = „Ordner"):', levelName);
if (name === null || (cur && !name.trim())) return;
await api.patch(`/api/projects/${p.id}/tabs/${cur}`, { name: name.trim() });
renderProjectDetail(v);
};
const delBtn = v.querySelector('#ptabdel');
if (delBtn) delBtn.onclick = async () => {
if (!confirm(`Unterreiter „${levelName}" löschen?\nDie dort angehefteten Aufnahmen werden nur gelöst, nicht gelöscht.`)) return;
await api.del(`/api/projects/${p.id}/tabs/${cur}`);
state.projectTabId = 0;
renderProjectDetail(v);
};
v.querySelector('#ppin').onclick = () => pinPickRecordingDialog(p.id, cur, () => renderProjectDetail(v));
// Klick auf eine angeheftete Aufnahme → direkt in die Aufnahmen-Ansicht.
v.querySelectorAll('#ppins .item.rec').forEach(it => it.onclick = (e) => {
if (e.target.closest('[data-unpin]')) return;
jumpToRecording(+it.dataset.open);
});
v.querySelectorAll('[data-unpin]').forEach(b => b.onclick = async (e) => {
e.stopPropagation();
await api.del(`/api/projects/${p.id}/pins/${b.dataset.unpin}`);
toast('Anheftung gelöst');
renderProjectDetail(v);
});
bindPinDrag(v, p, cur, rootName);
}
// Drag&Drop innerhalb eines Ordners: eine angeheftete Aufnahme auf einen
// anderen Reiter ziehen verschiebt den Pin dorthin. Verschoben wird nur die
// Referenz — die Aufnahme selbst bleibt, wo sie ist.
function bindPinDrag(v, p, cur, rootName) {
let dragPin = null;
const clearMarks = () => v.querySelectorAll('.ptab.drop').forEach(t => t.classList.remove('drop'));
v.querySelectorAll('#ppins .item.rec').forEach(it => {
it.ondragstart = (e) => {
dragPin = it.dataset.pin;
it.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
// Ohne gesetzte Daten startet Firefox den Drag gar nicht erst.
e.dataTransfer.setData('text/plain', it.dataset.pin);
};
it.ondragend = () => { dragPin = null; it.classList.remove('dragging'); clearMarks(); };
});
v.querySelectorAll('.ptab[data-ptab]').forEach(t => {
const target = +t.dataset.ptab;
t.ondragover = (e) => {
if (dragPin == null || target === cur) return; // eigener Reiter = kein Ziel
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
t.classList.add('drop');
};
t.ondragleave = () => t.classList.remove('drop');
t.ondrop = async (e) => {
e.preventDefault();
clearMarks();
const pinId = dragPin || e.dataTransfer.getData('text/plain');
dragPin = null;
if (!pinId || target === cur) return;
const name = target ? (p.tabs.find(x => x.id === target) || {}).name : rootName;
try {
const r = await api.patch(`/api/projects/${p.id}/pins/${pinId}`, { tab_id: target });
toast(r.merged ? `Hängt dort bereits — Anheftung zusammengeführt`
: `Verschoben nach „${name}"`);
} catch (err) { toast(err.message, true); }
renderProjectDetail(v);
};
});
}
// Aus einer Projekt-Ebene heraus: Aufnahme per Dropdown wählen und anheften.
// Das Filterfeld schreibt die Optionsliste neu — bei dreistelligen Aufnahme-
// zahlen ist Scrollen im Select sonst unbrauchbar.
async function pinPickRecordingDialog(projectId, tabId, done) {
let recs;
try { recs = await api.get('/api/recordings'); }
catch (e) { return toast(e.message, true); }
if (!recs.length) return toast('Es gibt noch keine Aufnahmen.', true);
const opt = r => `<option value="${r.id}">${escapeHtml(r.title) || 'Aufnahme #' + r.id} — ${new Date(r.created_at).toLocaleDateString('de-DE')}</option>`;
openModal('Aufnahme anheften', `
<label class="lbl">Suche (optional)</label>
<input id="pfilter" class="input" placeholder="Titel filtern…"/>
<label class="lbl">Aufnahme</label>
<select id="prec" class="input" size="10">${recs.map(opt).join('')}</select>`, {
okText: '📌 Anheften',
onReady: (root) => {
const sel = root.querySelector('#prec'), f = root.querySelector('#pfilter');
f.oninput = () => {
const q = f.value.trim().toLowerCase();
const hits = recs.filter(r => !q || (r.title || ('Aufnahme #' + r.id)).toLowerCase().includes(q));
sel.innerHTML = hits.map(opt).join('');
};
f.focus();
},
onOk: async (root) => {
const id = +root.querySelector('#prec').value;
if (!id) { toast('Bitte eine Aufnahme wählen', true); return false; }
await api.post(`/api/projects/${projectId}/pins`, { recording_id: id, tab_id: tabId });
toast('Angeheftet ✓');
if (done) done();
}
});
}
// Aus der Aufnahmenliste heraus: Ziel (Ordner + Ebene) wählen. Zeigt zugleich
// alle bestehenden Anheftungen dieser Aufnahme, damit man sie hier auch wieder
// lösen kann.
async function pinToProjectDialog(rec, done) {
let projects, pins;
try { [projects, pins] = await Promise.all([api.get('/api/projects'), api.get(`/api/recordings/${rec.id}/pins`)]); }
catch (e) { return toast(e.message, true); }
const title = rec.title || 'Aufnahme #' + rec.id;
const pinsHtml = () => pins.length ? `
<label class="lbl">Bereits angeheftet</label>
<div class="list pin-current">${pins.map(p => `
<div class="item"><div class="grow">📁 ${escapeHtml(p.project_name)}${p.tab_name ? ' › ' + escapeHtml(p.tab_name) : ''}</div>
<button class="btn danger icon" data-unpin="${p.pin_id}" data-proj="${p.project_id}" title="Lösen">✕</button></div>`).join('')}</div>` : '';
const m = openModal(`„${escapeHtml(title)}" anheften`, `
<label class="lbl">Projektordner</label>
<select id="pp" class="input">
${projects.map(p => `<option value="${p.id}">${escapeHtml(p.name)}</option>`).join('')}
<option value="new">+ Neuen Ordner anlegen…</option>
</select>
<label class="lbl">Ebene</label>
<select id="pt" class="input"><option value="0">Ordner-Ebene</option></select>
<div id="pcur">${pinsHtml()}</div>`, {
okText: '📌 Anheften',
onReady: (root) => {
const pp = root.querySelector('#pp'), pt = root.querySelector('#pt');
// Unterreiter hängen am gewählten Ordner → bei jedem Wechsel nachladen.
const loadTabs = async () => {
if (pp.value === 'new') { pt.innerHTML = '<option value="0">Ordner-Ebene</option>'; return; }
const p = await api.get('/api/projects/' + pp.value);
pt.innerHTML = `<option value="0">${escapeHtml(p.root_tab_name || 'Ordner-Ebene')}</option>` +
p.tabs.map(t => `<option value="${t.id}">${escapeHtml(t.name)}</option>`).join('');
};
pp.onchange = async () => {
if (pp.value === 'new') {
const name = prompt('Name des neuen Projektordners:', '');
if (name && name.trim()) {
const np = await api.post('/api/projects', { name: name.trim() });
pp.insertAdjacentHTML('afterbegin', `<option value="${np.id}">${escapeHtml(np.name)}</option>`);
pp.value = String(np.id);
} else { pp.value = projects.length ? String(projects[0].id) : 'new'; }
}
loadTabs();
};
bindUnpin(root);
if (projects.length) loadTabs();
},
onOk: async (root) => {
const pid = root.querySelector('#pp').value;
if (pid === 'new' || !pid) { toast('Bitte einen Ordner wählen oder anlegen', true); return false; }
await api.post(`/api/projects/${pid}/pins`, { recording_id: rec.id, tab_id: +root.querySelector('#pt').value });
toast('Angeheftet ✓');
if (done) done();
}
});
function bindUnpin(root) {
root.querySelectorAll('[data-unpin]').forEach(b => b.onclick = async () => {
await api.del(`/api/projects/${b.dataset.proj}/pins/${b.dataset.unpin}`);
pins = pins.filter(p => p.pin_id != b.dataset.unpin);
root.querySelector('#pcur').innerHTML = pinsHtml();
bindUnpin(root);
toast('Anheftung gelöst');
if (done) done();
});
}
return m;
}
// Aus der Projektansicht in die Aufnahme springen (gleiche Mechanik wie aus der
// Suche, nur ohne Zeitsprung).
function jumpToRecording(id) {
state.tab = 'recordings';
state.selectedId = id;
state.trashOpen = false;
render();
}
// ---------- 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');
// Entering the tab: show only the newest day expanded, the rest collapsed
// (applied in refreshRecList once the day groups are known).
state.resetCollapse = true;
v.innerHTML = `
<div class="rec-layout">
<aside class="rec-list-pane">
<div class="list-bar">
<button id="trashtoggle" class="btn small ghost ${state.trashOpen ? 'active' : ''}"
title="Gelöschte Aufnahmen — bleiben 30 Tage wiederherstellbar">🗑 Papierkorb</button>
</div>
<div id="reclist" class="list">Lade…</div>
</aside>
<div class="rec-splitter" title="Ziehen: Listenbreite ändern"></div>
<section class="rec-detail-pane"><div id="detail"><div class="detail-empty muted">← Aufnahme wählen</div></div></section>
</div>`;
v.querySelector('#trashtoggle').onclick = () => {
state.trashOpen = !state.trashOpen;
state.lastListSig = null;
v.querySelector('#trashtoggle').classList.toggle('active', state.trashOpen);
refreshRecList();
};
// Draggable splitter: list width between LIST_MIN (the old fixed width) and
// twice that; persisted for all sessions.
const LIST_MIN = 340, LIST_MAX = 680;
const listPane = v.querySelector('.rec-list-pane');
const applyW = (w) => { listPane.style.flex = `0 0 ${Math.min(LIST_MAX, Math.max(LIST_MIN, w))}px`; };
applyW(+localStorage.getItem('aime-list-width') || LIST_MIN);
v.querySelector('.rec-splitter').onmousedown = (e) => {
e.preventDefault();
const startX = e.clientX, startW = listPane.getBoundingClientRect().width;
document.body.classList.add('resizing');
const move = (ev) => applyW(startW + (ev.clientX - startX));
const up = () => {
document.removeEventListener('mousemove', move);
document.removeEventListener('mouseup', up);
document.body.classList.remove('resizing');
localStorage.setItem('aime-list-width', Math.round(listPane.getBoundingClientRect().width));
};
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', up);
};
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
if (state.trashOpen) return refreshTrashList(listEl);
let recs, pinCounts = {};
try { recs = await api.get('/api/recordings'); }
catch (e) { listEl.innerHTML = '<div class="err">' + e.message + '</div>'; return; }
// Wie oft steckt jede Aufnahme in einem Projekt? Ein Request für die ganze
// Liste; scheitert er, bleibt der 📌-Button einfach unmarkiert.
try { pinCounts = await api.get('/api/pins/counts'); } catch { pinCounts = {}; }
// 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 && !state.recording) { 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}|${pinCounts[r.id] || 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);
}
// Fresh tab entry: only the newest day (first group, recs are newest-first)
// stays open; manual toggles afterwards are respected until the next entry.
if (state.resetCollapse) { state.collapsedDates = new Set(groups.slice(1)); state.resetCollapse = false; }
// Live placeholder while a recording runs: its own card at the very top.
const liveHtml = state.recording ? `
<div class="item rec-live">
<span class="rec-dot"></span>
<div class="grow"><b>Aufnahme läuft…</b>
<div class="muted">wird nach Stop transkribiert &amp; analysiert</div>
<div class="mkchips" id="mkchips">${markerChipsHtml()}</div></div>
<span class="rectime mono" data-rectime>${fmt(Date.now() - state.recStart)}</span>
</div>` : '';
listEl.innerHTML = liveHtml + 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 icon pinbtn ${pinCounts[r.id] ? 'pinned' : ''}" data-pin="${r.id}"
title="${pinCounts[r.id] ? `In ${pinCounts[r.id]} Projekt(en) angeheftet — verwalten` : 'An ein Projekt anheften'}">${ICON_PIN}${pinCounts[r.id] ? `<span class="pin-n">${pinCounts[r.id]}</span>` : ''}</button>
<button class="btn icon" data-ren="${r.id}" title="Titel umbenennen">✏️</button>
<button class="btn danger icon" data-del="${r.id}" title="In den Papierkorb">🗑</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 one of its buttons) → open in the right pane.
listEl.querySelectorAll('.item.rec').forEach(it => it.onclick = (e) => {
if (e.target.closest('[data-del]') || e.target.closest('[data-ren]') || e.target.closest('[data-pin]')) return;
openRecording(+it.dataset.open);
});
listEl.querySelectorAll('[data-pin]').forEach(b => b.onclick = (e) => {
e.stopPropagation();
const rec = recs.find(r => r.id == b.dataset.pin);
pinToProjectDialog(rec || { id: +b.dataset.pin }, () => { state.lastListSig = null; refreshRecList(); });
});
listEl.querySelectorAll('[data-ren]').forEach(b => b.onclick = async (e) => {
e.stopPropagation();
const rec = recs.find(r => r.id == b.dataset.ren);
await renameRecording(+b.dataset.ren, (rec && rec.title) || '');
});
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} in den Papierkorb verschieben?\nWiederherstellbar für 30 Tage, danach wird sie automatisch endgültig gelöscht.`)) 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>'; }
toast('In den Papierkorb verschoben');
state.lastListSig = null; refreshRecList();
});
bindMarkerChips();
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>'; }
}
// Rename a recording's title. An empty answer clears the title and hands
// naming back to the LLM on the next analysis (see db.renameRecording).
async function renameRecording(id, current) {
const t = prompt('Titel der Aufnahme:', current || '');
if (t === null) return false; // cancelled
try {
const r = await api.patch(`/api/recordings/${id}/title`, { title: t });
state.lastListSig = null;
refreshRecList();
// Keep the open detail pane in sync without rebuilding it (audio keeps playing).
const h = document.querySelector('#detail h2');
if (h && state.selectedId == id) {
const badge = h.querySelector('.badge');
h.textContent = r.title || 'Aufnahme #' + id;
if (badge) h.appendChild(badge);
}
toast(r.title ? 'Titel geändert' : 'Titel zurückgesetzt');
return true;
} catch (e) { toast('Umbenennen fehlgeschlagen: ' + e.message, true); return false; }
}
// Trash pane: same list slot, different contents. Deliberately flat (no day
// grouping) — the interesting order here is "when did I delete it", and the
// remaining days matter more than the recording date.
async function refreshTrashList(listEl) {
let data;
try { data = await api.get('/api/trash'); }
catch (e) { listEl.innerHTML = '<div class="err">' + e.message + '</div>'; return; }
const items = data.items || [];
if (!items.length) {
listEl.innerHTML = `<div class="muted trash-empty">Papierkorb ist leer.<br><span class="small">Gelöschte Aufnahmen bleiben ${data.days} Tage hier.</span></div>`;
return;
}
listEl.innerHTML = `<div class="trash-hint muted small">Wird nach ${data.days} Tagen automatisch endgültig gelöscht.</div>` + items.map(r => `
<div class="item rec trashed">
<div class="grow">
<b>${escapeHtml(r.title) || 'Aufnahme #' + r.id}</b>
<div class="muted">${new Date(r.created_at).toLocaleDateString('de-DE')} · ${fmt(r.duration_ms || 0)}
· <span class="days-left ${r.days_left <= 3 ? 'urgent' : ''}">noch ${r.days_left} Tag${r.days_left === 1 ? '' : 'e'}</span></div>
</div>
<button class="btn small" data-restore="${r.id}" title="Wiederherstellen">↩︎</button>
<button class="btn danger icon" data-purge="${r.id}" title="Endgültig löschen">✕</button>
</div>`).join('');
listEl.querySelectorAll('[data-restore]').forEach(b => b.onclick = async () => {
await api.post(`/api/trash/${b.dataset.restore}/restore`, {});
toast('Wiederhergestellt');
refreshTrashList(listEl);
});
listEl.querySelectorAll('[data-purge]').forEach(b => b.onclick = async () => {
const rec = items.find(r => r.id == b.dataset.purge);
const name = (rec && rec.title) ? `„${rec.title}"` : 'Aufnahme #' + b.dataset.purge;
if (!confirm(`${name} ENDGÜLTIG löschen?\nAudio, Transkript und Analyse werden unwiderruflich entfernt. Das lässt sich nicht rückgängig machen.`)) return;
await api.del('/api/trash/' + b.dataset.purge);
toast('Endgültig gelöscht');
refreshTrashList(listEl);
});
}
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', transcript: 'Transkript' };
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" title="Klicken: umbenennen / Stimme lernen"><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>
<div class="spkpop hidden" id="spkpop">
<input class="input" id="spkpop-name" placeholder="Name">
<label class="spk-learn-l"><input type="checkbox" id="spkpop-learn" checked> als Stimme lernen</label>
<div class="row" style="margin:8px 0 0">
<button class="btn small primary" id="spkpop-save">Speichern</button>
<button class="btn small" id="spkpop-cancel">Abbrechen</button>
</div>
</div>
</div>` : '';
// Live-typed name markers: which cluster each one was resolved to, plus the
// option to add one while listening back (same resolution path) and to re-run
// the assignment after a re-diarization.
const CONF_LBL = { high: '<span class="badge ok">sicher</span>', mid: '<span class="badge warn">wahrscheinlich</span>', low: '<span class="badge err">unsicher</span>' };
const markersHtml = `
<h3>Namensmarker <span class="muted" style="font-weight:400">(während der Aufnahme getippt)</span></h3>
${(r.markers || []).length ? `<ul class="markers">${r.markers.map(m => {
const i = spkOrder.indexOf(m.speaker);
const to = m.speaker ? spkLabel(m.speaker, i >= 0 ? i : 0) : '– kein Sprecher gefunden';
return `<li><button class="mkseek mono" data-t="${m.t_ms}" title="Dorthin springen">${fmt(m.t_ms)}</button>
<b>${escapeHtml(m.name)}</b> → <span class="${m.speaker ? (scls[m.speaker] || '') : 'muted'}">${escapeHtml(to)}</span>
${CONF_LBL[m.conf] || ''}
<button class="btn danger icon small" data-mkdel="${m.id}" title="Marker löschen">🗑</button></li>`;
}).join('')}</ul>` : '<div class="muted">Keine Marker. Tipp: während der Aufnahme Namen eintippen (Feld oben) – oder hier beim Zuhören nachtragen.</div>'}
<div class="row mkadd-row">
<input class="input" id="mkat" placeholder="Name an aktueller Abspielposition">
<button class="btn small" id="mkatbtn">🏷 Setzen</button>
${(r.markers || []).length ? '<button class="btn small" id="applymk" title="Marker erneut den Sprechern zuordnen (nach Neu-Trennung)">↻ Zuordnen</button>' : ''}
</div>`;
const mods = {
analyse: `
${r.summary ? `<p class="summary">${renderSummary(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>` : ''}
${markersHtml}`,
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>',
transcript: `
<div id="transcript-view">
${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>
<div id="table-view" class="hidden">${buildTableScene(spkOrder, scls, names, spkLabel)}</div>`,
};
// Two drop columns; every module (incl. the transcript/table panel) can be
// dragged within AND between them. Layout persists for all recordings.
const defLayout = { main: ['analyse', 'todos'], side: ['transcript'] };
let layout = null;
try { layout = JSON.parse(localStorage.getItem('aime-detail-layout') || 'null'); } catch {}
if (!layout || !Array.isArray(layout.main) || !Array.isArray(layout.side)) {
// migrate the old single-column order if present
let old = [];
try { old = JSON.parse(localStorage.getItem('aime-detail-order') || '[]'); } catch {}
layout = { main: old.filter(m => m !== 'transcript'), side: [] };
}
layout.main = layout.main.filter(m => mods[m] !== undefined);
layout.side = layout.side.filter(m => mods[m] !== undefined && !layout.main.includes(m));
for (const m of [...defLayout.main, ...defLayout.side])
if (!layout.main.includes(m) && !layout.side.includes(m))
(defLayout.side.includes(m) ? layout.side : layout.main).push(m);
const modHtml = (m) => `
<section class="module${m === 'transcript' ? ' transcript-panel' : ''}" data-mod="${m}" ${m === 'transcript' ? 'id="transcript-panel"' : ''}>
<div class="mod-head" draggable="true"><span class="mod-grip" title="Ziehen zum Umsortieren">⠿</span><h3 ${m === 'transcript' ? 'id="sidetitle"' : ''}>${MOD_TITLES[m]}</h3></div>
<div class="mod-body">${mods[m]}</div>
</section>`;
detail.innerHTML = `
<div class="card">
<h2>${escapeHtml(r.title) || 'Aufnahme #' + r.id} ${analysisBadge(r.analysis_status)}</h2>
<button class="btn icon rename-btn" id="renametitle" title="Titel umbenennen">✏️</button>
<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>` : ''}
${spkOrder.length ? `<button class="btn small icon" id="viewswitch" title="Ansicht wechseln: Konferenztisch / Transkript"></button>` : ''}
</div>
<section class="ctxbox" id="ctxbox"></section>
<div class="detail-cols">
<div class="mod-col" data-col="main">${layout.main.map(modHtml).join('')}</div>
<div class="mod-col" data-col="side">${layout.side.map(modHtml).join('')}</div>
</div>
</div>`;
// Drag & drop across BOTH columns: grab any module by its header, drop it in
// either column at any position; the layout is saved for all recordings.
const cols = [...detail.querySelectorAll('.mod-col')];
// ":empty" never matches because of template whitespace -> class-driven.
const markEmpty = () => cols.forEach(c => c.classList.toggle('empty', !c.querySelector('.module')));
markEmpty();
let dragged = null;
detail.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 save = {};
for (const c of cols) save[c.dataset.col] = [...c.querySelectorAll('.module')].map(m => m.dataset.mod);
localStorage.setItem('aime-detail-layout', JSON.stringify(save));
markEmpty();
});
});
cols.forEach(col => col.addEventListener('dragover', (e) => {
if (!dragged) return;
e.preventDefault();
const others = [...col.querySelectorAll('.module:not(.dragging)')];
const next = others.find(m => e.clientY < m.getBoundingClientRect().top + m.offsetHeight / 2);
if (next) col.insertBefore(dragged, next); else col.appendChild(dragged);
markEmpty();
}));
// ---- Kontextnotiz --------------------------------------------------------
// EIN Freitextfeld, absichtlich kein Formular mit Teilnehmer-/Begriffsfeldern:
// die Box benutzen auch Leute, die kein Formular ausfüllen; Namen und Begriffe
// holt sich die Analyse selbst aus der Prosa (llm.js contextBlock). Der
// Platzhalter führt statt Pflichtfeldern. Direkt nach dem Stop steht die Box
// offen (state.ctxPromptFor). Sie zeichnet sich SELBST neu statt über
// openRecording() — ein Neuaufbau der Detailansicht setzt den <audio>-Tag
// zurück und würde das Abspielen unterbrechen.
const ctxBox = detail.querySelector('#ctxbox');
let ctxNote = r.context_note || '';
const CTX_PH = 'Wer war dabei, worum ging es, wichtige Namen oder Begriffe? Stichworte reichen.\nz. B.: Call mit Herrn Meier von Firma Krämer wegen Angebot Halle 3, Thomas aus dem Vertrieb war auch dabei.';
const ctxInner = (open) => `
<div class="ctx-head">
<span class="ctx-title">📝 Kontext zum Gespräch</span>
${ctxNote ? '<span class="badge ok">hinterlegt</span>' : '<span class="muted small">macht die KI-Analyse deutlich besser</span>'}
<button class="btn small" id="ctxtoggle">${open ? 'Zuklappen' : ctxNote ? '✏️ Bearbeiten' : '➕ Hinzufügen'}</button>
</div>
${(!open && ctxNote) ? `<div class="ctx-text">${escapeHtml(ctxNote)}</div>` : ''}
${open ? `
<textarea class="input ctx-input" id="ctxinput" rows="4" maxlength="4000"
placeholder="${escapeHtml(CTX_PH)}">${escapeHtml(state.ctxDrafts[id] != null ? state.ctxDrafts[id] : ctxNote)}</textarea>
<div class="row ctx-actions">
<button class="btn primary small" id="ctxsave">Speichern</button>
<button class="btn small" id="ctxskip">${ctxNote ? 'Abbrechen' : 'Ohne Kontext'}</button>
<span class="muted small">Nur Hintergrundwissen für die KI — landet nie als Gesagtes in der Zusammenfassung.</span>
</div>` : ''}`;
const saveCtx = async () => {
const ta = ctxBox.querySelector('#ctxinput');
const btn = ctxBox.querySelector('#ctxsave');
if (!ta || !btn) return;
btn.disabled = true; btn.textContent = 'speichert…';
let res;
try { res = await api.patch(`/api/recordings/${id}/context`, { context_note: ta.value }); }
catch (e) { toast('Kontext speichern fehlgeschlagen: ' + e.message, true); btn.disabled = false; btn.textContent = 'Speichern'; return; }
ctxNote = res.context_note || '';
delete state.ctxDrafts[id];
if (state.ctxPromptFor === id) state.ctxPromptFor = null;
drawCtx(false);
if (!ctxNote) { toast('Kontext entfernt'); return; }
// Ehrlich sagen, ob die Notiz die laufende Verarbeitung noch erreicht
// (server.js liefert `applies` genau dafür).
if (res.applies === 'auto') { toast('Kontext gespeichert – die KI-Analyse nutzt ihn'); return; }
if (res.applies === 'running') { toast('Kontext gespeichert. Die Analyse läuft schon – danach „↻ Analyse neu“ drücken, damit er einfließt.'); return; }
if (!confirm('Kontext gespeichert.\n\nDie Analyse ist bereits fertig. Zusammenfassung und ToDos jetzt mit dem Kontext neu erstellen?\n(Die Sprecher-Trennung bleibt unverändert, dauert einige Minuten.)')) return;
try {
await api.post(`/api/recordings/${id}/reanalyze-llm`, {});
toast('Analyse läuft neu – mit Kontext');
setTimeout(() => openRecording(id), 1200);
} catch (e) { toast(e.message, true); }
};
const drawCtx = (open, focus) => {
ctxBox.classList.toggle('open', !!open);
ctxBox.classList.toggle('filled', !!ctxNote);
ctxBox.innerHTML = ctxInner(open);
ctxBox.querySelector('#ctxtoggle').onclick = () => {
if (open) { delete state.ctxDrafts[id]; if (state.ctxPromptFor === id) state.ctxPromptFor = null; }
drawCtx(!open, !open);
};
if (!open) return;
const ta = ctxBox.querySelector('#ctxinput');
// Getipptes überlebt den Neuaufbau nach Ende der Analyse (state.ctxDrafts).
ta.oninput = () => { state.ctxDrafts[id] = ta.value; };
// Strg+Enter speichert; Enter selbst macht Absätze — es ist ein Freitextfeld.
ta.onkeydown = (e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) saveCtx(); };
ctxBox.querySelector('#ctxsave').onclick = saveCtx;
ctxBox.querySelector('#ctxskip').onclick = () => {
delete state.ctxDrafts[id];
if (state.ctxPromptFor === id) state.ctxPromptFor = null;
drawCtx(false);
};
if (focus) { ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); }
};
drawCtx(state.ctxPromptFor === id || state.ctxDrafts[id] != null, state.ctxPromptFor === id);
const renBtn = detail.querySelector('#renametitle');
if (renBtn) renBtn.onclick = () => renameRecording(r.id, r.title || '');
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');
// ---- marker actions (seek / add at playhead / delete / re-assign) ----
detail.querySelectorAll('.mkseek').forEach(b => b.onclick = () => {
audio.currentTime = (+b.dataset.t) / 1000 + 0.001; audio.play();
});
const mkAt = detail.querySelector('#mkat'), mkAtBtn = detail.querySelector('#mkatbtn');
const addAt = async () => {
const name = (mkAt.value || '').trim();
if (!name) return;
try {
await api.post(`/api/recordings/${id}/markers`, { name, t_ms: Math.round(audio.currentTime * 1000) });
mkAt.value = '';
await api.post(`/api/recordings/${id}/apply-markers`, {});
toast(`🏷 ${name} gesetzt & zugeordnet`);
openRecording(id);
} catch (e) { toast(e.message, true); }
};
if (mkAtBtn) mkAtBtn.onclick = addAt;
if (mkAt) mkAt.onkeydown = (e) => { if (e.key === 'Enter') addAt(); };
detail.querySelectorAll('[data-mkdel]').forEach(b => b.onclick = async () => {
try { await api.del(`/api/recordings/${id}/markers/${b.dataset.mkdel}`); openRecording(id); }
catch (e) { toast(e.message, true); }
});
const amk = detail.querySelector('#applymk');
if (amk) amk.onclick = async () => {
amk.disabled = true; amk.textContent = 'ordne zu…';
try {
const r2 = await api.post(`/api/recordings/${id}/apply-markers`, {});
toast(`${r2.named} Sprecher benannt, ${r2.merged} zusammengeführt, ${r2.learned} Stimme(n) gelernt`);
openRecording(id);
} catch (e) { toast(e.message, true); amk.disabled = false; }
};
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;
// ---- View switch: transcript <-> conference-table scene ----
const switchBtn = detail.querySelector('#viewswitch');
const tView = detail.querySelector('#table-view');
const trView = detail.querySelector('#transcript-view');
const sideTitle = detail.querySelector('#sidetitle');
const bubble = detail.querySelector('#tbubble');
const tIdle = detail.querySelector('#tidle');
const persons = [...detail.querySelectorAll('.tperson')];
let bubbleTurnIdx = -1;
const applyViewMode = () => {
const table = localStorage.getItem('aime-side-view') === 'table';
if (tView) tView.classList.toggle('hidden', !table);
if (trView) trView.classList.toggle('hidden', table);
if (sideTitle) sideTitle.textContent = table ? 'Am Tisch' : 'Transkript';
if (switchBtn) switchBtn.innerHTML = table ? ICON_SHEET : ICON_TABLE;
};
if (switchBtn) {
switchBtn.onclick = () => {
const now = localStorage.getItem('aime-side-view') === 'table' ? 'text' : 'table';
localStorage.setItem('aime-side-view', now);
applyViewMode();
};
applyViewMode();
}
// All eyes on the speaker: shift every person's pupils toward the active seat.
const lookAt = (activeSp) => {
const target = persons.find(p => p.dataset.sp === activeSp);
for (const p of persons) {
const eyes = p.querySelector('.teyes');
if (!eyes) continue;
if (!target || p === target) { eyes.style.transform = 'translate(0, 1px)'; continue; }
const dx = (+target.dataset.x) - (+p.dataset.x);
const dy = (+target.dataset.y) - (+p.dataset.y);
const len = Math.hypot(dx, dy) || 1;
eyes.style.transform = `translate(${(dx / len * 3.2).toFixed(1)}px, ${(dy / len * 2.4).toFixed(1)}px)`;
}
};
const updateTable = (t) => {
if (!tView || tView.classList.contains('hidden')) return;
let idx = -1;
for (let i = 0; i < turns.length; i++) { if (turns[i].start <= t) idx = i; else break; }
if (idx < 0) return;
const tu = turns[idx];
if (idx !== bubbleTurnIdx) {
bubbleTurnIdx = idx;
const i = spkOrder.indexOf(tu.sp);
const seats = tableSeats(Math.min(persons.length, spkOrder.length));
const seat = seats[i >= 0 ? Math.min(i, persons.length - 1) : 0];
if (bubble && seat) {
// The bubble sits FIXED on the table; only its tail (the "Öffnung")
// wanders to point at whoever is speaking.
bubble.className = `tbubble ${scls[tu.sp] || ''} arrow-${seat.side}`;
bubble.style.left = '';
bubble.style.top = '';
// Tail x-position along the top/bottom edge = the speaker's seat mapped
// into bubble-local coordinates (bubble is centred, BUBBLE_W wide).
const ax = Math.max(8, Math.min(92, (seat.x - (50 - BUBBLE_W / 2)) / BUBBLE_W * 100));
bubble.style.setProperty('--ax', ax.toFixed(1) + '%');
bubble.querySelector('.tb-name').textContent = spkLabel(tu.sp, i >= 0 ? i : 0);
// The full turn as clickable word spans — highlighted + scrolled below.
bubble.querySelector('.tb-text').innerHTML =
tu.words.map(w => `<span class="bw" data-s="${w.s}">${escapeHtml(w.w)}</span>`).join(' ');
}
if (tIdle) tIdle.classList.add('hidden');
lookAt(tu.sp);
}
persons.forEach(p => p.classList.toggle('talking', p.dataset.sp === tu.sp && !audio.paused));
// Word highlight + follow-scroll inside the bubble (like the transcript).
if (bubble && !bubble.classList.contains('hidden')) {
const scroller = bubble.querySelector('.tb-scroll');
const bws = bubble.querySelectorAll('.bw');
let act = null;
for (const s of bws) { if (+s.dataset.s <= t) act = s; else break; }
bws.forEach(s => s.classList.toggle('active', s === act));
if (act && scroller) {
const target = act.offsetTop - scroller.clientHeight / 2 + 10;
if (Math.abs(scroller.scrollTop - target) > 24) scroller.scrollTo({ top: Math.max(0, target), behavior: 'smooth' });
}
}
};
if (bubble) bubble.addEventListener('click', (e) => {
const s = e.target.closest('.bw');
if (s) { audio.currentTime = (+s.dataset.s) / 1000 + 0.001; audio.play().catch(() => {}); }
});
// 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 inside a LANE seeks; clicks on the label column open the editor.
const seek = (e) => {
if (!e.target.closest('.lane')) return;
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);
}
// Speaker editor popover: click a lane label -> rename + optionally learn the
// voice (replaces the old separate "Sprecher" module).
const pop = detail.querySelector('#spkpop');
if (pop && lanesEl) {
let popSp = null;
const popName = pop.querySelector('#spkpop-name');
lanesEl.querySelectorAll('.lane-label').forEach(l => l.onclick = (e) => {
e.stopPropagation();
const row = l.closest('.lane-row');
popSp = row.dataset.sp;
pop.style.top = (row.offsetTop + 30) + 'px';
pop.classList.remove('hidden');
const i = spkOrder.indexOf(popSp);
popName.value = names[popSp] || '';
popName.placeholder = spkLabel(popSp, i >= 0 ? i : 0);
popName.focus();
});
pop.querySelector('#spkpop-cancel').onclick = () => pop.classList.add('hidden');
pop.querySelector('#spkpop-save').onclick = async () => {
const name = popName.value.trim();
const enroll = pop.querySelector('#spkpop-learn').checked;
const btn = pop.querySelector('#spkpop-save');
btn.disabled = true; btn.textContent = '…';
try {
const res = await api.post('/api/recordings/' + id + '/speaker', { speaker: popSp, name, enroll });
const base = res.enrolled ? `„${name}" gespeichert + Stimme gelernt` : (res.learnMsg ? 'Gespeichert (' + res.learnMsg + ')' : 'Gespeichert');
toast(res.relabeled ? base + ' · Name in Zusammenfassung übernommen' : base);
openRecording(id);
} catch (e) { toast(e.message, true); btn.disabled = false; btn.textContent = 'Speichern'; }
};
}
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'));
persons.forEach(p => p.classList.remove('talking'));
};
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 && (!trView || !trView.classList.contains('hidden'))) {
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);
updateTable(t);
};
// (Speaker renaming lives in the lane-label popover above.)
// 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])); }
// The section summary comes back as "Abschnittstitel:\nFließtext…" blocks (see
// llm.js analyzeLong). Flat, that reads as one wall of text, so the title lines
// get set in bold. Done at RENDER time on purpose, not stored in the database:
// it applies to every summary that already exists, without re-running a
// 20-minute analysis on 22 recordings.
function renderSummary(text) {
return escapeHtml(text || '').split('\n').map(line => {
const t = line.trim();
// A heading is a SHORT, STANDALONE line ending in a colon. Both conditions
// matter: prose like "…die Struktur der Projektberichterstattung: kurze
// Einordnung, aktueller Stand…" has the colon mid-line and keeps running,
// and excluding . ! ? stops a full sentence that happens to end in a colon.
return /^[^.!?]{3,90}:$/.test(t) ? `<strong class="sum-head">${t}</strong>` : line;
}).join('\n');
}
// ---- Konferenztisch-Ansicht ----
// Seat positions (person center, % of scene) per participant count, arranged
// around the rectangular table; each seat knows which bubble-arrow side points
// at it from the table-centre bubble.
const SEAT_LAYOUTS = {
1: [{ x: 50, y: 84, side: 'bottom' }],
2: [{ x: 50, y: 14, side: 'top' }, { x: 50, y: 84, side: 'bottom' }],
3: [{ x: 50, y: 13, side: 'top' }, { x: 26, y: 84, side: 'bottom' }, { x: 74, y: 84, side: 'bottom' }],
4: [{ x: 50, y: 12, side: 'top' }, { x: 90, y: 50, side: 'right' }, { x: 50, y: 86, side: 'bottom' }, { x: 10, y: 50, side: 'left' }],
5: [{ x: 32, y: 12, side: 'top' }, { x: 68, y: 12, side: 'top' }, { x: 90, y: 60, side: 'right' }, { x: 50, y: 86, side: 'bottom' }, { x: 10, y: 60, side: 'left' }],
6: [{ x: 32, y: 12, side: 'top' }, { x: 68, y: 12, side: 'top' }, { x: 90, y: 50, side: 'right' }, { x: 68, y: 86, side: 'bottom' }, { x: 32, y: 86, side: 'bottom' }, { x: 10, y: 50, side: 'left' }],
};
// Bubble width in scene-% (kept in sync with .tbubble in style.css) — needed to
// map a seat's x onto the bubble edge so the tail points AT the speaker.
const BUBBLE_W = 54;
function tableSeats(n) { return SEAT_LAYOUTS[Math.min(6, Math.max(1, n))] || SEAT_LAYOUTS[6]; }
// Toggle-button icons: three people around a table / a text sheet.
// Stecknadel als SVG statt 📌: ein Farb-Emoji bleibt immer rot, `currentColor`
// lässt sich dagegen einfärben — angeheftet = grün, sonst blass wie die
// übrigen Zeilenknöpfe.
const ICON_PIN = `<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><g fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3.5h6l-1 5 3.2 3.2H6.8L10 8.5z"/><line x1="12" y1="11.7" x2="12" y2="20.5"/></g></svg>`;
const ICON_TABLE = `<svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true"><g fill="currentColor"><rect x="6" y="10" width="12" height="6" rx="2" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="12" cy="4.6" r="2.1"/><circle cx="4" cy="19.4" r="2.1"/><circle cx="20" cy="19.4" r="2.1"/></g></svg>`;
const ICON_SHEET = `<svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true"><g fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><rect x="5" y="3" width="14" height="18" rx="2"/><line x1="8.5" y1="8" x2="15.5" y2="8"/><line x1="8.5" y1="12" x2="15.5" y2="12"/><line x1="8.5" y1="16" x2="13" y2="16"/></g></svg>`;
// The scene: rounded table, one person per speaker (their lane colour), a
// speech bubble ON the table whose arrow + border follow whoever talks.
function buildTableScene(spkOrder, scls, names, spkLabel) {
if (!spkOrder.length) return '<div class="muted">Keine Sprecherdaten.</div>';
const seats = tableSeats(spkOrder.length);
return `
<div class="tablescene" id="tablescene">
<div class="ttable"></div>
${spkOrder.slice(0, seats.length).map((sp, i) => {
const s = seats[i];
return `
<div class="tperson ${scls[sp]}" data-sp="${sp}" data-x="${s.x}" data-y="${s.y}" style="left:${s.x}%;top:${s.y}%">
<div class="thead"><div class="teyes"><span class="teye"></span><span class="teye"></span></div><div class="tmouth"></div></div>
<div class="tbody"></div>
<div class="tname">${escapeHtml(spkLabel(sp, i))}</div>
</div>`;
}).join('')}
<div class="tbubble hidden" id="tbubble"><div class="tb-scroll"><span class="tb-name"></span><span class="tb-text"></span></div></div>
<div class="tidle muted" id="tidle">▶ Abspielen — die Runde beginnt zu sprechen.</div>
</div>`;
}
// 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 for the table scene: speaker, joined text AND the single words
// (with start times) so the bubble can highlight word-by-word like the transcript.
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; last.words.push({ w: w.word, s: w.t_start_ms }); }
else turns.push({ sp: w.speaker, text: w.word, start: w.t_start_ms, end: w.t_end_ms, words: [{ w: w.word, s: w.t_start_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 '';
}
// Der Aufnahmezustand lebt im Server (recorder.js `current`), nicht im Browser.
// Ohne diesen Abgleich zeigt der Recorder nach jedem Neuladen 00:00, obwohl
// parecord weiterläuft — und der Stop-Knopf ist unerreichbar, weil das Kopfband
// dann den Start-Knopf malt.
async function rehydrateRecording() {
try {
const h = await api.get('/api/health');
if (!h || !h.recording) return;
state.recording = true;
state.recStart = h.startedAt || Date.now();
state.markers = h.markers || [];
state.mkDraft = '';
render();
if (state.tab === 'recordings') { state.lastListSig = null; refreshRecList(); }
} catch (err) { /* Server nicht erreichbar: normale Ansicht, kein Toast */ }
}
render();
rehydrateRecording();