From 7586a707ca452f70775c78b6a76b352e704fe3b5 Mon Sep 17 00:00:00 2001 From: schakal00783 Date: Fri, 17 Jul 2026 01:10:00 +0200 Subject: [PATCH] GUI: Konferenztisch-Ansicht als Alternative zum Transkript Umschalter (3-Personen-Icon <-> Textblatt); Figuren in Sprecherfarben um abgerundeten Tisch, Sprechblase mit Live-Text zeigt zum Sprecher, alle anderen schauen hin, Mund animiert. Sitzlayouts 1-6, localStorage-persistent. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0113vDKdSNtD3Dwcw4QMu9yV --- app/web/src/main.js | 123 +++++++++++++++++++++++++++++++++++++++--- app/web/src/style.css | 58 ++++++++++++++++++++ docs/08-changelog.md | 9 ++++ 3 files changed, 184 insertions(+), 6 deletions(-) diff --git a/app/web/src/main.js b/app/web/src/main.js index f365800..90f7c87 100644 --- a/app/web/src/main.js +++ b/app/web/src/main.js @@ -440,6 +440,7 @@ async function openRecording(id) { ${(pending && !transcribing && !transcribeFailed) ? '
🧠 KI-Analyse läuft… (NPU)
' : ''} ${(errored && !transcribeFailed) ? `
KI-Analyse fehlgeschlagen.
` : ''} ${(!pending && !errored && !transcribing && !transcribeFailed) ? `` : ''} + ${spkOrder.length ? `` : ''}
@@ -453,11 +454,14 @@ async function openRecording(id) {
-

Transkript

+

Transkript

- ${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)}
+
+ ${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)}
+
+
@@ -545,6 +549,69 @@ 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; + + // ---- 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; + 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]; + if (bubble && seat) { + bubble.classList.remove('hidden'); + bubble.className = `tbubble ${scls[tu.sp] || ''} arrow-${seat.side}`; + 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; + } + 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)); + } + }; + // Lane visualizer wiring: playhead + speaking lane + live-text box. const lanesEl = detail.querySelector('#lanes'); const playheadEl = detail.querySelector('#playhead'); @@ -594,7 +661,10 @@ async function openRecording(id) { } } }; - audio.onpause = () => { if (lanesEl) lanesEl.querySelectorAll('.lane-row.speaking').forEach(r2 => r2.classList.remove('speaking')); }; + 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; @@ -602,7 +672,7 @@ async function openRecording(id) { 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) { + if (active && (!trView || !trView.classList.contains('hidden'))) { const panel = detail.querySelector('#transcript-panel'); if (panel) { const pr = panel.getBoundingClientRect(), ar = active.getBoundingClientRect(); @@ -611,6 +681,7 @@ async function openRecording(id) { } } updateViz(t); + updateTable(t); }; // Rename a speaker + (optionally) learn their voice into the DB. detail.querySelectorAll('.spk-save').forEach(btn => { @@ -649,6 +720,46 @@ async function openRecording(id) { function escapeHtml(s) { return (s || '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } +// ---- 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: 'bottomleft' }, { x: 74, y: 84, side: 'bottomright' }], + 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: 'bottomright' }, { x: 32, y: 86, side: 'bottomleft' }, { x: 10, y: 50, side: 'left' }], +}; +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. +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) { diff --git a/app/web/src/style.css b/app/web/src/style.css index 2b17e67..67a2883 100644 --- a/app/web/src/style.css +++ b/app/web/src/style.css @@ -262,6 +262,64 @@ audio { width: 100%; margin-bottom: 10px; } .topbar nav { margin-left: 0; width: 100%; order: 3; } } +/* ---- Konferenztisch-Ansicht ---- */ +.hidden { display: none !important; } +.tablescene { position: relative; width: 100%; aspect-ratio: 4 / 3; max-height: 420px; } +.ttable { + position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); + width: 58%; height: 44%; border-radius: 18px; + background: var(--surface-2); border: 2px solid var(--line); + box-shadow: inset 0 0 0 6px var(--inset); +} +.tperson { position: absolute; transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center; gap: 2px; width: 76px; } +.thead { + position: relative; width: 30px; height: 30px; border-radius: 50%; + background: var(--muted); z-index: 2; +} +.tperson.sp0 .thead, .tperson.sp0 .tbody { background: var(--sp0); } +.tperson.sp1 .thead, .tperson.sp1 .tbody { background: var(--sp1); } +.tperson.sp2 .thead, .tperson.sp2 .tbody { background: var(--sp2); } +.tperson.sp3 .thead, .tperson.sp3 .tbody { background: var(--sp3); } +.teyes { position: absolute; left: 0; right: 0; top: 9px; display: flex; justify-content: center; gap: 7px; transition: transform .25s; } +.teye { width: 5px; height: 5px; border-radius: 50%; background: #17130f; } +:root[data-theme="light"] .teye { background: #fff; } +.tmouth { + position: absolute; left: 50%; bottom: 6px; transform: translateX(-50%); + width: 9px; height: 3px; border-radius: 2px; background: rgba(0, 0, 0, .55); opacity: 0; +} +:root[data-theme="light"] .tmouth { background: rgba(255, 255, 255, .8); } +.tperson.talking .tmouth { opacity: 1; animation: talkmouth .35s ease-in-out infinite; } +@keyframes talkmouth { 0%, 100% { height: 2px; } 50% { height: 7px; } } +.tperson.talking .thead { box-shadow: 0 0 0 3px var(--accent-soft), 0 0 10px var(--accent-soft); } +.tbody { width: 46px; height: 20px; border-radius: 12px 12px 5px 5px; background: var(--muted); margin-top: -6px; opacity: .85; } +.tname { font-size: 11px; color: var(--muted); max-width: 76px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.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; + background: var(--surface); border: 2px solid var(--line); border-radius: 12px; + padding: 8px 10px; font-size: 13px; line-height: 1.4; +} +.tbubble .tb-name { display: block; font-weight: 650; font-size: 11px; margin-bottom: 2px; } +.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); } +.tbubble.sp3 { border-color: var(--sp3); } .tbubble.sp3 .tb-name { color: var(--sp3); } +.tbubble::after { + content: ''; position: absolute; width: 12px; height: 12px; + background: inherit; border: inherit; border-width: 0 2px 2px 0; + display: none; +} +.tbubble[class*="arrow-"]::after { display: block; } +.tbubble.arrow-top::after { top: -7px; left: 50%; transform: translateX(-50%) rotate(-135deg); } +.tbubble.arrow-bottom::after { bottom: -7px; left: 50%; transform: translateX(-50%) rotate(45deg); } +.tbubble.arrow-left::after { left: -7px; top: 50%; transform: translateY(-50%) rotate(135deg); } +.tbubble.arrow-right::after { right: -7px; top: 50%; transform: translateY(-50%) rotate(-45deg); } +.tbubble.arrow-bottomleft::after { bottom: -7px; left: 22%; transform: rotate(45deg); } +.tbubble.arrow-bottomright::after { bottom: -7px; right: 22%; transform: rotate(45deg); } +.tidle { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); font-size: 13px; text-align: center; } +#viewswitch svg { display: block; } + @media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } } diff --git a/docs/08-changelog.md b/docs/08-changelog.md index a7b2963..5557bcc 100644 --- a/docs/08-changelog.md +++ b/docs/08-changelog.md @@ -3,6 +3,15 @@ Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum, Was, Warum, betroffene Dateien/Backups). +## 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 +Transkript rechts durch eine animierte Szene — Sprecher als Figuren in ihren Spurfarben um einen +abgerundeten Tisch, Sprechblase AUF dem Tisch (Rand/Name in Sprecherfarbe, Pfeil zeigt zum Sprecher, +Text = aktueller Redebeitrag), alle anderen Personen drehen die Pupillen zum Sprechenden, dessen Mund +animiert beim Reden (reduced-motion respektiert). Sitzordnungen fuer 1-6 Teilnehmer vordefiniert. +Wahl bleibt in localStorage['aime-side-view']. Bundle `index-DXTNo40e.js`. + ## 2026-07-17 — GUI: Aufnahmen-Tab nutzt die volle Fensterbreite `#app.wide` war auf 1180 px gedeckelt — auf breiten Monitoren blieb rechts/links viel Leerraum und