diff --git a/app/web/src/main.js b/app/web/src/main.js
index f9525e8..73f7db6 100644
--- a/app/web/src/main.js
+++ b/app/web/src/main.js
@@ -383,36 +383,100 @@ async function openRecording(id) {
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.
+ const MOD_TITLES = { analyse: 'Zusammenfassung', viz: 'Sprecher-Verlauf', todos: 'ToDos', speakers: 'Sprecher', transcript: 'Transkript' };
+ const mods = {
+ analyse: `
+ ${r.summary ? `
Noch keine Zusammenfassung.
'}
+ ${(r.participants && r.participants.length) ? `Klick in die Spuren springt an die Stelle.
` : 'Keine Sprecherdaten.
',
+ todos: (r.todos && r.todos.length) ? `Keine ToDos erkannt.
',
+ speakers: spkOrder.length ? `
+ Namen vergeben → App lernt die Stimme.
+ Klick auf ein Wort → Audio springt dorthin. Leertaste = Play/Pause.
+
${escapeHtml(r.title) || 'Aufnahme #' + r.id} ${analysisBadge(r.analysis_status)}
-
+
${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, ~30-90s)
' : ''}
+ ${(pending && !transcribing && !transcribeFailed) ? '
🧠KI-Analyse läuft… (NPU)
' : ''}
${(errored && !transcribeFailed) ? `
KI-Analyse fehlgeschlagen.
` : ''}
- ${r.summary ? `
Zusammenfassung
${escapeHtml(r.summary)}
` : ''}
- ${(r.participants && r.participants.length) ? `
Teilnehmer
${r.participants.map(p => `- 👤 ${escapeHtml(p)}
`).join('')}
` : ''}
- ${(r.todos && r.todos.length) ? `
ToDos
${r.todos.map(t => `- ${escapeHtml(t)}
`).join('')}
` : ''}
${(!pending && !errored && !transcribing && !transcribeFailed) ? `
` : ''}
- ${spkOrder.length ? `
-
Sprecher (Namen vergeben → App lernt die Stimme)
-
` : ''}
- ${r.transcript_clean ? `
Dialog (KI · Sprecher aus Kontext)
${renderDialog(r.transcript_clean)}
` : ''}
-
Original-Transkript
-
Klick auf ein Wort → Audio springt dorthin. Leertaste = Play/Pause.
-
${renderTurns(r.words, names, scls, spkOrder)}
+
+ ${order.filter(m => mods[m]).map(m => `
+
+ â ż
${MOD_TITLES[m]}
+ ${mods[m]}
+ `).join('')}
+
`;
+
+ // Drag & drop: grab a module by its header, drop it above/below the others;
+ // the order is saved and applies to every recording.
+ const modWrap = detail.querySelector('#modules');
+ let dragged = null;
+ modWrap.querySelectorAll('.mod-head').forEach(h => {
+ h.addEventListener('dragstart', (e) => {
+ dragged = h.closest('.module');
+ dragged.classList.add('dragging');
+ e.dataTransfer.effectAllowed = 'move';
+ e.dataTransfer.setData('text/plain', dragged.dataset.mod); // Firefox needs data
+ });
+ h.addEventListener('dragend', () => {
+ if (!dragged) return;
+ dragged.classList.remove('dragging');
+ dragged = null;
+ const now = [...modWrap.querySelectorAll('.module')].map(m => m.dataset.mod);
+ localStorage.setItem('aime-detail-order', JSON.stringify(now));
+ });
+ });
+ modWrap.addEventListener('dragover', (e) => {
+ if (!dragged) return;
+ e.preventDefault();
+ const others = [...modWrap.querySelectorAll('.module:not(.dragging)')];
+ const next = others.find(m => e.clientY < m.getBoundingClientRect().top + m.offsetHeight / 2);
+ if (next) modWrap.insertBefore(dragged, next); else modWrap.appendChild(dragged);
+ });
const reBtn = detail.querySelector('#reanalyze');
if (reBtn) reBtn.onclick = async () => {
// Re-runs speaker separation every time: empty = automatic detection (now
@@ -468,12 +532,63 @@ async function openRecording(id) {
// start has passed (stays lit through inter-word gaps → no flicker).
// Tune SYNC_MS if the marker still leads (raise) or lags (lower).
const SYNC_MS = 250;
+ // Lane visualizer wiring: playhead + speaking lane + live-text box.
+ const lanesEl = detail.querySelector('#lanes');
+ const playheadEl = detail.querySelector('#playhead');
+ const liveboxEl = detail.querySelector('#livebox');
+ let liveTurnIdx = -1;
+ if (lanesEl) {
+ // Click (or drag) inside the lanes seeks; ignore the label column.
+ const seek = (e) => {
+ const lane = lanesEl.querySelector('.lane');
+ if (!lane) return;
+ const rc = lane.getBoundingClientRect();
+ const frac = Math.min(1, Math.max(0, (e.clientX - rc.left) / rc.width));
+ audio.currentTime = frac * durMs / 1000;
+ audio.play().catch(() => {});
+ };
+ lanesEl.addEventListener('click', seek);
+ }
+ const updateViz = (t) => {
+ if (!lanesEl) return;
+ if (playheadEl) {
+ const lane = lanesEl.querySelector('.lane');
+ if (lane) {
+ const left = lane.offsetLeft + Math.min(1, Math.max(0, t / durMs)) * lane.offsetWidth;
+ playheadEl.style.left = left + 'px';
+ playheadEl.style.display = 'block';
+ }
+ }
+ // Which speakers talk right now → light their lane rows.
+ lanesEl.querySelectorAll('.lane-row').forEach(row => {
+ const sp = row.dataset.sp;
+ const on = (segsBySpk[sp] || []).some(s => t >= s.start && t <= s.end);
+ row.classList.toggle('speaking', on && !audio.paused);
+ });
+ // Live text: last turn whose start has passed (stays through gaps).
+ if (liveboxEl) {
+ let idx = -1;
+ for (let i = 0; i < turns.length; i++) { if (turns[i].start <= t) idx = i; else break; }
+ if (idx !== liveTurnIdx && idx >= 0) {
+ liveTurnIdx = idx;
+ const tu = turns[idx];
+ const i = spkOrder.indexOf(tu.sp);
+ liveboxEl.innerHTML = `
+
+ ${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')); };
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));
if (active) { const r2 = active.getBoundingClientRect(); if (r2.top < 90 || r2.bottom > innerHeight - 40) active.scrollIntoView({ block: 'center', behavior: 'smooth' }); }
+ updateViz(t);
};
// Rename a speaker + (optionally) learn their voice into the DB.
detail.querySelectorAll('.spk-save').forEach(btn => {
@@ -511,6 +626,30 @@ async function openRecording(id) {
}
function escapeHtml(s) { return (s || '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
+
+// Per-speaker talk segments for the lane visualizer: consecutive words of the
+// same speaker, gaps ≤ 1.2 s bridged (word-level gaps would render as dust).
+function speakerSegments(words) {
+ const by = {};
+ for (const w of words) {
+ if (!w.speaker) continue;
+ const list = by[w.speaker] || (by[w.speaker] = []);
+ const last = list[list.length - 1];
+ if (last && w.t_start_ms - last.end <= 1200) last.end = Math.max(last.end, w.t_end_ms);
+ else list.push({ start: w.t_start_ms, end: w.t_end_ms });
+ }
+ return by;
+}
+// Turn list (speaker + joined text) for the live-text box.
+function wordTurns(words) {
+ const turns = [];
+ for (const w of words) {
+ const last = turns[turns.length - 1];
+ if (last && last.sp === w.speaker) { last.text += ' ' + w.word; last.end = w.t_end_ms; }
+ else turns.push({ sp: w.speaker, text: w.word, start: w.t_start_ms, end: w.t_end_ms });
+ }
+ return turns;
+}
// Map distinct raw speaker labels (speaker_00…) to colour classes sp0..sp3.
function speakerClasses(words) {
const m = {}; let i = 0; const palette = ['sp0', 'sp1', 'sp2', 'sp3'];
diff --git a/app/web/src/style.css b/app/web/src/style.css
index 02c0243..e75bd2a 100644
--- a/app/web/src/style.css
+++ b/app/web/src/style.css
@@ -199,6 +199,58 @@ audio { width: 100%; margin-bottom: 10px; }
.item.rec:hover { border-color: var(--accent); }
.item.rec.sel { border-color: var(--accent); background: var(--accent-soft); }
+/* ---- Detail-Module (Drag & Drop) ---- */
+.status-line { margin: 4px 0 10px; }
+#modules { display: flex; flex-direction: column; gap: 10px; }
+.module { background: var(--inset); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; }
+.module.dragging { opacity: .45; border-style: dashed; }
+.mod-head {
+ display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: grab;
+ background: var(--surface-2); user-select: none;
+}
+.mod-head:active { cursor: grabbing; }
+.mod-head h3 { margin: 0; font-size: .85rem; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
+.mod-grip { color: var(--muted); font-size: 14px; letter-spacing: -1px; }
+.mod-body { padding: 12px; }
+.mod-body h3 { margin: 10px 0 4px; font-size: .9rem; color: var(--muted); }
+
+/* ---- Sprecher-Verlauf: Lanes + Live-Text ---- */
+.viz-wrap { display: flex; gap: 14px; align-items: stretch; }
+.lanes { flex: 1 1 58%; min-width: 0; position: relative; display: flex; flex-direction: column; gap: 6px; cursor: pointer; }
+.lane-row { display: flex; align-items: center; gap: 8px; }
+.lane-label {
+ flex: 0 0 92px; display: flex; align-items: center; gap: 6px; font-size: 12px;
+ color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+}
+.lane { position: relative; flex: 1; height: 22px; background: var(--surface-2); border-radius: 6px; overflow: hidden; }
+.seg { position: absolute; top: 3px; bottom: 3px; border-radius: 3px; opacity: .75; transition: opacity .15s; }
+.lane-row.sp0 .seg { background: var(--sp0); } .lane-row.sp1 .seg { background: var(--sp1); }
+.lane-row.sp2 .seg { background: var(--sp2); } .lane-row.sp3 .seg { background: var(--sp3); }
+.lane-row.sp0 .spk-dot { background: var(--sp0); } .lane-row.sp1 .spk-dot { background: var(--sp1); }
+.lane-row.sp2 .spk-dot { background: var(--sp2); } .lane-row.sp3 .spk-dot { background: var(--sp3); }
+.lane-row.speaking .seg { opacity: 1; }
+.lane-row.speaking .lane-label { color: var(--fg); font-weight: 600; }
+.lane-row.speaking .spk-dot { animation: pulse 1s ease-in-out infinite; }
+@keyframes pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.55); } }
+.playhead {
+ display: none; position: absolute; top: 0; bottom: 0; width: 2px;
+ background: var(--accent); border-radius: 1px; pointer-events: none; z-index: 2;
+ transition: left .25s linear;
+}
+.livebox {
+ flex: 1 1 42%; min-width: 0; background: var(--surface-2); border-radius: 10px;
+ padding: 12px; max-height: 200px; overflow-y: auto; align-self: stretch;
+}
+.live-turn .spk { display: block; font-weight: 650; font-size: 12px; margin-bottom: 4px; }
+.live-turn.sp0 .spk { color: var(--sp0); } .live-turn.sp1 .spk { color: var(--sp1); }
+.live-turn.sp2 .spk { color: var(--sp2); } .live-turn.sp3 .spk { color: var(--sp3); }
+.live-text { line-height: 1.55; }
+
+@media (max-width: 800px) {
+ .viz-wrap { flex-direction: column; }
+ .livebox { max-height: 120px; }
+}
+
@media (max-width: 800px) {
#app.wide { max-width: 860px; }
.rec-layout { flex-direction: column; }
diff --git a/docs/08-changelog.md b/docs/08-changelog.md
index 7d0cd24..37e4c95 100644
--- a/docs/08-changelog.md
+++ b/docs/08-changelog.md
@@ -3,6 +3,20 @@
Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum,
Was, Warum, betroffene Dateien/Backups).
+## 2026-07-17 — GUI: Sprecher-Verlauf-Visualisierung + Drag&Drop-Module
+
+Detail-Ansicht ist jetzt modular: fester Kopf (Titel, Player, Status), darunter Blöcke
+**Zusammenfassung / Sprecher-Verlauf / ToDos / Sprecher / Transkript** — jeder Block hat einen
+Grip-Header (⠿) und lässt sich per Drag & Drop umsortieren; die Reihenfolge liegt in
+`localStorage['aime-detail-order']` und gilt fĂĽr alle Aufnahmen.
+
+**Sprecher-Verlauf** (neu): pro Sprecher eine farbige Spur (sp0–sp3) übereinander mit
+Redeabschnitts-Blöcken (Wörter mit ≤ 1,2 s Lücke zusammengefasst), Kupfer-Playhead wandert beim
+Abspielen mit, die Spur des gerade Sprechenden leuchtet (Label fett, Punkt pulsiert; respektiert
+`prefers-reduced-motion`), Klick in die Spuren springt an die Stelle. Rechts daneben die **Live-Box**:
+zeigt Sprecher + Text des aktuellen Redebeitrags. Per Playwright-Screenshots verifiziert
+(beide Themes, Playback mit aktiver Spur). Bundle `index-WaE171bv.js`.
+
## 2026-07-17 — GUI: „Weggezogen"-Bug gefixt + Redesign mit Dark/Light-Switch (`.bak-theme`)
- **Bug:** lief beim ausgewählten Eintrag eine Analyse, feuerte ein 5-s-Timer `openRecording(alteId)` als