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:
co-authored by
Claude Fable 5
parent
35b4999b6c
commit
7586a707ca
+117
-6
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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) {
|
||||
|
||||
Reference in New Issue
Block a user