From 1975bfe696dec7874cacd7bc165fa2c8da8e8967 Mon Sep 17 00:00:00 2001 From: schakal00783 Date: Fri, 17 Jul 2026 01:17:52 +0200 Subject: [PATCH] GUI: Sprechblase am Sprecher, Wort-Highlight + internes Mitscrollen + Klick-Seek Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0113vDKdSNtD3Dwcw4QMu9yV --- app/web/src/main.js | 46 ++++++++++++++++++++++++++++++++----------- app/web/src/style.css | 10 +++++++--- docs/08-changelog.md | 8 ++++++++ 3 files changed, 49 insertions(+), 15 deletions(-) diff --git a/app/web/src/main.js b/app/web/src/main.js index 90f7c87..35ebc66 100644 --- a/app/web/src/main.js +++ b/app/web/src/main.js @@ -592,25 +592,46 @@ async function openRecording(id) { 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 tu = turns[idx]; const i = spkOrder.indexOf(tu.sp); - const seat = tableSeats(Math.min(persons.length, spkOrder.length))[i >= 0 ? Math.min(i, persons.length - 1) : 0]; + const seats = tableSeats(Math.min(persons.length, spkOrder.length)); + const seat = seats[i >= 0 ? Math.min(i, persons.length - 1) : 0]; if (bubble && seat) { - bubble.classList.remove('hidden'); + // Classic speech bubble AT the speaker: hang it between their mouth and + // the table centre, tail (arrow) pointing back at them. + const bx = seat.x + (50 - seat.x) * 0.52; + const by = seat.y + (50 - seat.y) * 0.60; bubble.className = `tbubble ${scls[tu.sp] || ''} arrow-${seat.side}`; + bubble.style.left = bx + '%'; + bubble.style.top = by + '%'; bubble.querySelector('.tb-name').textContent = spkLabel(tu.sp, i >= 0 ? i : 0); - bubble.querySelector('.tb-text').textContent = tu.text.length > 220 ? tu.text.slice(0, 220) + '…' : tu.text; + // 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'); - persons.forEach(p => p.classList.toggle('talking', p.dataset.sp === tu.sp && !audio.paused)); lookAt(tu.sp); - } else { - // Same turn: keep the mouth in sync with play/pause. - persons.forEach(p => p.classList.toggle('talking', p.dataset.sp === turns[idx].sp && !audio.paused)); + } + 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'); @@ -755,7 +776,7 @@ function buildTableScene(spkOrder, scls, names, spkLabel) {
${escapeHtml(spkLabel(sp, i))}
`; }).join('')} - +
▶ Abspielen — die Runde beginnt zu sprechen.
`; } @@ -773,13 +794,14 @@ function speakerSegments(words) { } return by; } -// Turn list (speaker + joined text) for the live-text box. +// 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; } - else turns.push({ sp: w.speaker, text: w.word, start: w.t_start_ms, end: w.t_end_ms }); + 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; } diff --git a/app/web/src/style.css b/app/web/src/style.css index 67a2883..a4c6241 100644 --- a/app/web/src/style.css +++ b/app/web/src/style.css @@ -296,11 +296,15 @@ audio { width: 100%; margin-bottom: 10px; } .tperson.talking .tname { color: var(--fg); font-weight: 650; } .tbubble { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); - width: 52%; max-height: 38%; overflow: hidden; z-index: 3; + width: 46%; z-index: 3; background: var(--surface); border: 2px solid var(--line); border-radius: 12px; - padding: 8px 10px; font-size: 13px; line-height: 1.4; + padding: 8px 10px; font-size: 13px; line-height: 1.55; } -.tbubble .tb-name { display: block; font-weight: 650; font-size: 11px; margin-bottom: 2px; } +.tb-scroll { max-height: 132px; overflow-y: auto; } +.tbubble .tb-name { display: block; font-weight: 650; font-size: 11px; margin-bottom: 2px; position: sticky; top: 0; background: var(--surface); padding: 2px 0; z-index: 1; } +.tbubble .bw { cursor: pointer; padding: 1px 2px; border-radius: 4px; } +.tbubble .bw:hover { background: var(--surface-2); } +.tbubble .bw.active { background: var(--accent); color: var(--accent-fg); } .tbubble.sp0 { border-color: var(--sp0); } .tbubble.sp0 .tb-name { color: var(--sp0); } .tbubble.sp1 { border-color: var(--sp1); } .tbubble.sp1 .tb-name { color: var(--sp1); } .tbubble.sp2 { border-color: var(--sp2); } .tbubble.sp2 .tb-name { color: var(--sp2); } diff --git a/docs/08-changelog.md b/docs/08-changelog.md index 5557bcc..1dcf3d5 100644 --- a/docs/08-changelog.md +++ b/docs/08-changelog.md @@ -3,6 +3,14 @@ Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum, Was, Warum, betroffene Dateien/Backups). +## 2026-07-17 — GUI: Sprechblase am Sprecher mit Wort-Highlight + Mitscrollen + +Tisch-Ansicht verfeinert: Die Blase haengt jetzt klassisch am MUND des Sprechers (dynamisch zwischen +Sitz und Tischmitte positioniert, Schwanz zeigt zum Kopf) statt zentral auf dem Tisch. Inhalt = der +komplette Redebeitrag als einzelne Wort-Spans: aktives Wort wird synchron zum Audio gehighlightet +(wie im Transkript), die Blase scrollt intern mit (innere .tb-scroll-Huelle, damit der Pfeil nicht +mitscrollt), Klick auf ein Wort springt im Audio dorthin. Bundle `index-BK0gpRp9.js`. + ## 2026-07-17 — GUI: Konferenztisch-Ansicht (Umschalter neben "Analyse neu") Neuer Umschalt-Button (Icon: drei Personen am Tisch <-> Textblatt) in der Status-Zeile: ersetzt das