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 }; 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 , 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 = `

Aime Anruf-Assistent

${state.recording ? fmt(Date.now() - state.recStart) : '00:00'} ${state.recording ? ` 🏷 ${state.markers.length}` : ''}
`; 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; bindMarkerInput(); if (state.recording) startTimerUI(); ({ bluetooth: viewBluetooth, recordings: viewRecordings, projects: viewProjects, search: viewSearch, voices: viewVoices, glossary: viewGlossary })[state.tab](); } // 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…'); 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).
'; return; } listEl.innerHTML = d.map(dev => `
${dev.name || '(unbenannt)'} ${dev.audio ? '🎧' : ''}
${dev.mac}
${dev.connected ? 'verbunden' : dev.paired ? 'gepaart' : 'neu'} ${dev.connected ? `` : dev.paired ? `` : ``} ${dev.audio ? `` : ''}
`).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 = '
' + e.message + '
'); } // ---------- 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.
Lade…
`; const listEl = v.querySelector('#vplist'); async function load() { try { const persons = await api.get('/api/persons'); const opts = persons.map(p => ``).join(''); listEl.innerHTML = persons.length ? persons.map(p => `
👤 ${escapeHtml(p.display_name)}
${p.names.map(n => `${escapeHtml(n)}${p.names.length > 1 ? `` : ''}`).join('')}
${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' : ''}
${persons.length > 1 ? `` : ''} ${p.voiceprints.length ? `` : ''}
`).join('') : '
Noch keine Personen. Oben eine Stimme anlernen oder in einer Aufnahme einen Sprecher benennen.
'; 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 = '
' + e.message + '
'; } } 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 = `

Begriff hinzufügen

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 => `
📖 ${escapeHtml(g.term)} ${g.soundalikes ? `
↔ ${escapeHtml(g.soundalikes)}
` : ''}
`).join('') : '
Noch keine Begriffe. Oben hinzufügen.
'; 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 = '
' + e.message + '
'; } } 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 = `
${hasResults ? '' : `
Durchsucht alle Mitschriften. Beispiele:
${SEARCH_EXAMPLES.map(e => ``).join('')}
`}
`; 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 = '
Suche…
'; 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 = '
' + escapeHtml(e.message) + '
'; 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.
`; return; } out.innerHTML = `
Antwort …
${total} Fundstelle${total === 1 ? '' : 'n'}${range ? ` · Zeitraum: ${escapeHtml(range.label)}` : ''} · ${semantic ? 'Volltext + semantisch' : 'nur Volltext'}
    ${hits.map(h => `
  1. ${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) : ''}
    ${escapeHtml(h.title) || 'Aufnahme #' + h.recording_id} bei ${fmt(h.t_start_ms)}
    ${highlight(h.text, terms)}
  2. `).join('')}
`; 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 = `
Antwort
${escapeHtml(answer)}
${sources && sources.length ? `
${sources.map(s => ``).join('')}
` : ''}`; 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 = ` `; 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.
Lade…
`; 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 = '
' + e.message + '
'; return; } if (!ps.length) { listEl.innerHTML = '
Noch keine Projekte. Oben einen Ordner anlegen.
'; return; } listEl.innerHTML = ps.map(p => `
📁
${escapeHtml(p.name)}
${p.pins} Aufnahme${p.pins === 1 ? '' : 'n'} · ${p.tabs} Unterreiter
`).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 = '
Lade…
'; 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'} ${cur ? '' : ''}
${pins.length ? pins.map(r => `
${escapeHtml(r.title) || 'Aufnahme #' + r.id} ${analysisBadge(r.analysis_status)}
${new Date(r.created_at).toLocaleDateString('de-DE')} · ${fmt(r.duration_ms || 0)}
${r.summary ? `
${escapeHtml(r.summary)}
` : ''}
`).join('') : '
Hier ist noch nichts angeheftet. Rechts oben eine Aufnahme anheften.
'}
`; 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); }); } // 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 => ``; openModal('Aufnahme anheften', ` `, { 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 ? `
${pins.map(p => `
📁 ${escapeHtml(p.project_name)}${p.tab_name ? ' › ' + escapeHtml(p.tab_name) : ''}
`).join('')}
` : ''; 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 ? `
Aufnahme läuft…
wird nach Stop transkribiert & analysiert
${markerChipsHtml()}
${fmt(Date.now() - state.recStart)}
` : ''; listEl.innerHTML = liveHtml + groups.map(day => { const collapsed = state.collapsedDates.has(day); const items = byDay[day].map(r => { const at = anaTimeSpan(r); return `
${escapeHtml(r.title) || 'Aufnahme #' + r.id} ${analysisBadge(r.analysis_status)}
${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 : ''}
${r.summary ? `
${escapeHtml(r.summary)}
` : ''}
`; }).join(''); return `
▾ ${escapeHtml(day)} ${byDay[day].length}
${items}
`; }).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 = '
' + e.message + '
'; return; } const items = data.items || []; if (!items.length) { listEl.innerHTML = `
Papierkorb ist leer.
Gelöschte Aufnahmen bleiben ${data.days} Tage hier.
`; return; } listEl.innerHTML = `
Wird nach ${data.days} Tagen automatisch endgültig gelöscht.
` + items.map(r => `
${escapeHtml(r.title) || 'Aufnahme #' + r.id}
${new Date(r.created_at).toLocaleDateString('de-DE')} · ${fmt(r.duration_ms || 0)} · noch ${r.days_left} Tag${r.days_left === 1 ? '' : 'e'}
`).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 ? `` : '
Keine Marker. Tipp: während der Aufnahme Namen eintippen (Feld oben) – oder hier beim Zuhören nachtragen.
'}
${(r.markers || []).length ? '' : ''}
`; const mods = { analyse: ` ${r.summary ? `

${renderSummary(r.summary)}

` : '
Noch keine Zusammenfassung.
'} ${(r.participants && r.participants.length) ? `

Teilnehmer

` : ''} ${markersHtml}`, todos: (r.todos && r.todos.length) ? `` : '
Keine ToDos erkannt.
', transcript: `
${r.transcript_clean ? `

Dialog (KI · Sprecher aus Kontext)

${renderDialog(r.transcript_clean)}
` : ''}
Klick auf ein Wort → Audio springt dorthin. Leertaste = Play/Pause.
${renderTurns(r.words, names, scls, spkOrder)}
`, }; // 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) => `
⠿

${MOD_TITLES[m]}

${mods[m]}
`; detail.innerHTML = `

${escapeHtml(r.title) || 'Aufnahme #' + r.id} ${analysisBadge(r.analysis_status)}

${vizHtml}
${anaTimeSpan(r) ? `
⏱ Analysezeit: ${anaTimeSpan(r)}
` : ''} ${transcribing ? '
📝 Transkription läuft… lange Aufnahmen dauern (A76-Takt gegen Überhitzung gedrosselt).
' : ''} ${transcribeFailed ? `
Transkription fehlgeschlagen.
` : ''} ${(pending && !transcribing && !transcribeFailed) ? '
🧠 KI-Analyse läuft… (NPU)
' : ''} ${(errored && !transcribeFailed) ? `
KI-Analyse fehlgeschlagen.
` : ''} ${(!pending && !errored && !transcribing && !transcribeFailed) ? `` : ''} ${spkOrder.length ? `` : ''}
${layout.main.map(modHtml).join('')}
${layout.side.map(modHtml).join('')}
`; // 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(); })); 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 => `${escapeHtml(w.w)}`).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 = `
${escapeHtml(spkLabel(tu.sp, i >= 0 ? i : 0))} ${escapeHtml(tu.text)}
`; } } }; 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) ? `${t}` : 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 = ``; const ICON_TABLE = ``; const ICON_SHEET = ``; // 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 '
Keine Sprecherdaten.
'; const seats = tableSeats(spkOrder.length); return `
${spkOrder.slice(0, seats.length).map((sp, i) => { const s = seats[i]; return `
${escapeHtml(spkLabel(sp, i))}
`; }).join('')}
▶ Abspielen — die Runde beginnt zu sprechen.
`; } // 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 'Kein Transkript.'; 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 += `
${nm ? `${escapeHtml(nm)} ` : ''}${buf.join(' ')}
`; buf = []; }; for (const w of words) { const sp = w.speaker || null; if (sp !== cur) { flush(); cur = sp; } buf.push(`${escapeHtml(w.word)}`); } 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 `
${escapeHtml(l)}
`; const spk = m[1].trim(); if (!(spk in colors)) { colors[spk] = palette[ci % palette.length]; ci++; } return `
${escapeHtml(spk)} ${escapeHtml(m[2])}
`; }).join(''); } function analysisBadge(st) { if (st === 'pending') return '🧠 Analyse…'; if (st === 'error') return 'KI-Fehler'; if (st === 'done') return 'KI ✓'; return ''; } render();