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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0113vDKdSNtD3Dwcw4QMu9yV
This commit is contained in:
schakal00783
2026-07-17 01:10:00 +02:00
co-authored by Claude Fable 5
parent 35b4999b6c
commit 7586a707ca
3 changed files with 184 additions and 6 deletions
+117 -6
View File
@@ -440,6 +440,7 @@ async function openRecording(id) {
${(pending && !transcribing && !transcribeFailed) ? '<div class="muted">🧠 KI-Analyse läuft… (NPU)</div>' : ''}
${(errored && !transcribeFailed) ? `<div class="err-inline">KI-Analyse fehlgeschlagen. <button class="btn" id="reanalyze">Erneut versuchen</button></div>` : ''}
${(!pending && !errored && !transcribing && !transcribeFailed) ? `<button class="btn small" id="reanalyze">↻ Analyse neu</button>` : ''}
${spkOrder.length ? `<button class="btn small icon" id="viewswitch" title="Ansicht wechseln: Konferenztisch / Transkript"></button>` : ''}
</div>
<div class="detail-cols">
<div class="detail-main">
@@ -453,11 +454,14 @@ async function openRecording(id) {
</div>
<div class="detail-side">
<section class="module transcript-panel" id="transcript-panel">
<div class="mod-head"><h3>Transkript</h3></div>
<div class="mod-head"><h3 id="sidetitle">Transkript</h3></div>
<div class="mod-body">
${r.transcript_clean ? `<h3>Dialog <span class="muted" style="font-weight:400">(KI · Sprecher aus Kontext)</span></h3><div class="dialog">${renderDialog(r.transcript_clean)}</div>` : ''}
<div class="hint">Klick auf ein Wort → Audio springt dorthin. Leertaste = Play/Pause.</div>
<div id="transcript" class="transcript">${renderTurns(r.words, names, scls, spkOrder)}</div>
<div id="transcript-view">
${r.transcript_clean ? `<h3>Dialog <span class="muted" style="font-weight:400">(KI · Sprecher aus Kontext)</span></h3><div class="dialog">${renderDialog(r.transcript_clean)}</div>` : ''}
<div class="hint">Klick auf ein Wort → Audio springt dorthin. Leertaste = Play/Pause.</div>
<div id="transcript" class="transcript">${renderTurns(r.words, names, scls, spkOrder)}</div>
</div>
<div id="table-view" class="hidden">${buildTableScene(spkOrder, scls, names, spkLabel)}</div>
</div>
</section>
</div>
@@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 = `<svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true"><g fill="currentColor"><rect x="6" y="10" width="12" height="6" rx="2" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="12" cy="4.6" r="2.1"/><circle cx="4" cy="19.4" r="2.1"/><circle cx="20" cy="19.4" r="2.1"/></g></svg>`;
const ICON_SHEET = `<svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true"><g fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><rect x="5" y="3" width="14" height="18" rx="2"/><line x1="8.5" y1="8" x2="15.5" y2="8"/><line x1="8.5" y1="12" x2="15.5" y2="12"/><line x1="8.5" y1="16" x2="13" y2="16"/></g></svg>`;
// 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 '<div class="muted">Keine Sprecherdaten.</div>';
const seats = tableSeats(spkOrder.length);
return `
<div class="tablescene" id="tablescene">
<div class="ttable"></div>
${spkOrder.slice(0, seats.length).map((sp, i) => {
const s = seats[i];
return `
<div class="tperson ${scls[sp]}" data-sp="${sp}" data-x="${s.x}" data-y="${s.y}" style="left:${s.x}%;top:${s.y}%">
<div class="thead"><div class="teyes"><span class="teye"></span><span class="teye"></span></div><div class="tmouth"></div></div>
<div class="tbody"></div>
<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="tidle muted" id="tidle">▶ Abspielen — die Runde beginnt zu sprechen.</div>
</div>`;
}
// 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) {
+58
View File
@@ -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; }
}
+9
View File
@@ -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