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 fehlgeschlagen.
` : ''}
${(!pending && !errored && !transcribing && !transcribeFailed) ? `
@@ -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)}
+
+
${buildTableScene(spkOrder, scls, names, spkLabel)}
@@ -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