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 ? `

${escapeHtml(r.summary)}

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

Teilnehmer

` : ''}`, + viz: spkOrder.length ? ` +
+
+ ${spkOrder.map((sp, i) => ` +
+ ${escapeHtml(spkLabel(sp, i))} +
${(segsBySpk[sp] || []).map(s => + ``).join('')} +
+
`).join('')} +
+
+
▶ Abspielen — hier erscheint, was gerade gesprochen wird.
+
+
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.
+
${spkOrder.map((sp, i) => ` +
+ + + + +
`).join('')} +
` : '', + 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)}
`, + }; + const defOrder = ['analyse', 'viz', 'todos', 'speakers', 'transcript']; + let order = []; + try { order = JSON.parse(localStorage.getItem('aime-detail-order') || '[]'); } catch {} + order = order.filter(m => mods[m] !== undefined).concat(defOrder.filter(m => !order.includes(m))); + detail.innerHTML = `

${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)

-
${spkOrder.map((sp, i) => ` -
- - - - -
`).join('')} -
` : ''} - ${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