${sysmonMarkup()}
`;
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 =>
``).join('');
function sysmonMarkup() {
return `
–
Speicher für Aufnahmen
Aufnahmen–
Noch frei–
Gesamt für Aufnahmen–
100 % = freier Platz + alle vorhandenen Aufnahmen (also der Platz eines frisch aufgesetzten Systems)
Partition gesamt–
Systemlast
CPU–
RAM–
Temperatur–
`;
}
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 'noch keine Namen markiert';
return state.markers.map((m, i) =>
`${fmt(m.t_ms)} ${escapeHtml(m.name)}`).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 = `
Lade…
`;
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 = '
Keine Geräte. „Suchen“ drücken (Headset in Pairing-Modus).
');
}
// ---------- Voices (enrollment) ----------
async function viewVoices() {
const v = document.querySelector('#view');
v.innerHTML = `
Stimme anlernen
Person spricht ~15-20s frei (Headset). Danach wird ihr Stimmprofil gespeichert und in Aufnahmen automatisch erkannt.
00:00
Personen
Eine Person darf mehrere Namen haben („Sascha“ und „Sascha Wolf“ = ein Mensch) und
mehrere Stimmproben aus verschiedenen Aufnahmen. Erkannt wird über die beste Probe.
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.
Glossar
Lade…
`;
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 => `
'; }
}
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 = `
';
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 = `
Antwort
${escapeHtml(e.message)}
`;
} 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'), '$1');
}
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 = `
Keine Treffer für „${escapeHtml(state.searchQ)}".
${range ? `Eingeschränkt auf ${escapeHtml(range.label)} — vielleicht liegt es daran. ` : ''}
Andere Formulierung oder weniger Wörter versuchen.
` : ''}`;
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 = `
${title}
${bodyHtml}
${okText ? `` : ''}
`;
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 = `
Projekte
Ordner bündeln Aufnahmen zu einem Projekt. In jedem Ordner lassen sich Unterreiter anlegen und auf beiden Ebenen Aufnahmen anpinnen.
';
let p;
try { p = await api.get('/api/projects/' + state.projectId); }
catch (e) { v.innerHTML = '
' + e.message + '
'; 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 = `
📁 ${escapeHtml(p.name)}
${p.tabs.map(t => ``).join('')}
${escapeHtml(levelName)}${pins.length} angeheftete Aufnahme${pins.length === 1 ? '' : 'n'}${p.tabs.length && pins.length ? ' · auf einen Reiter ziehen zum Verschieben' : ''}
${cur ? '' : ''}
` : '';
const m = openModal(`„${escapeHtml(title)}" anheften`, `
${pinsHtml()}
`, {
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 = ''; return; }
const p = await api.get('/api/projects/' + pp.value);
pt.innerHTML = `` +
p.tabs.map(t => ``).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', ``);
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 only; callers add the ⏱.
function anaTimeSpan(r) {
if (r.analysis_ms != null) return `${fmt(r.analysis_ms)}`;
if (r.analysis_started_at && (r.analysis_status === 'pending' || r.status === 'transcribing'))
return `${fmt(Date.now() - r.analysis_started_at)}`;
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 = `
← Aufnahme wählen
`;
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 = '
' + e.message + '
'; 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 = '
Noch keine Aufnahmen.
'; 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 ? `
`;
}).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 = '
← Aufnahme wählen
'; }
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 = '
' + e.message + '
'; }
}
// 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 = '
`).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 = '
Lade…
';
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 ? `
${spkOrder.map((sp, i) => `
${escapeHtml(spkLabel(sp, i))}
${(segsBySpk[sp] || []).map(s =>
``).join('')}
`).join('')}
` : '';
// 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: 'sicher', mid: 'wahrscheinlich', low: 'unsicher' };
const markersHtml = `
Namensmarker (während der Aufnahme getippt)
${(r.markers || []).length ? `
${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 `
`;
// 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