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.
1925 lines
103 KiB
JavaScript
1925 lines
103 KiB
JavaScript
import './style.css';
|
||
|
||
const api = {
|
||
async get(u) { const r = await fetch(u); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); },
|
||
async post(u, b) { const r = await fetch(u, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b || {}) }); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); },
|
||
async del(u) { const r = await fetch(u, { method: 'DELETE' }); return r.json(); },
|
||
async put(u, b) { const r = await fetch(u, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b || {}) }); if (!r.ok) throw new Error((await r.json()).error || r.status); return r.json(); },
|
||
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 & 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 & 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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();
|