GUI: Sprechblase am Sprecher, Wort-Highlight + internes Mitscrollen + Klick-Seek

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0113vDKdSNtD3Dwcw4QMu9yV
This commit is contained in:
schakal00783
2026-07-17 01:17:52 +02:00
co-authored by Claude Fable 5
parent 7586a707ca
commit 1975bfe696
3 changed files with 49 additions and 15 deletions
+34 -12
View File
@@ -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 => `<span class="bw" data-s="${w.s}">${escapeHtml(w.w)}</span>`).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) {
<div class="tname">${escapeHtml(spkLabel(sp, i))}</div>
</div>`;
}).join('')}
<div class="tbubble hidden" id="tbubble"><span class="tb-name"></span><span class="tb-text"></span></div>
<div class="tbubble hidden" id="tbubble"><div class="tb-scroll"><span class="tb-name"></span><span class="tb-text"></span></div></div>
<div class="tidle muted" id="tidle">▶ Abspielen — die Runde beginnt zu sprechen.</div>
</div>`;
}
@@ -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;
}
+7 -3
View File
@@ -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); }
+8
View File
@@ -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