GUI: Sprecher-Verlauf (farbige Spuren + Playhead + Live-Text) und Drag&Drop-Module
Detail-Ansicht modular: Zusammenfassung/Verlauf/ToDos/Sprecher/Transkript per Grip umsortierbar (localStorage). Verlauf: Spur je Sprecher, Klick=Seek, aktive Spur pulsiert, Live-Box zeigt aktuellen Redebeitrag. 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
63c7d30b5e
commit
4500e03ae4
+158
-19
@@ -383,36 +383,100 @@ async function openRecording(id) {
|
||||
const spkOrder = Object.keys(scls); // first-appearance order
|
||||
const names = r.speaker_names || {}; // rawSpeaker -> name
|
||||
const spkLabel = (sp, i) => names[sp] || ('Sprecher ' + (i + 1));
|
||||
// Speaker segments + turns for the lane visualizer and the live-text box.
|
||||
const segsBySpk = speakerSegments(r.words || []);
|
||||
const turns = wordTurns(r.words || []);
|
||||
const durMs = r.duration_ms || (r.words && r.words.length ? r.words[r.words.length - 1].t_end_ms : 0) || 1;
|
||||
|
||||
// Draggable module blocks; the saved order wins, unknown/new modules append.
|
||||
const MOD_TITLES = { analyse: 'Zusammenfassung', viz: 'Sprecher-Verlauf', todos: 'ToDos', speakers: 'Sprecher', transcript: 'Transkript' };
|
||||
const mods = {
|
||||
analyse: `
|
||||
${r.summary ? `<p class="summary">${escapeHtml(r.summary)}</p>` : '<div class="muted">Noch keine Zusammenfassung.</div>'}
|
||||
${(r.participants && r.participants.length) ? `<h3>Teilnehmer</h3><ul class="parts">${r.participants.map(p => `<li>👤 ${escapeHtml(p)}</li>`).join('')}</ul>` : ''}`,
|
||||
viz: spkOrder.length ? `
|
||||
<div class="viz-wrap">
|
||||
<div class="lanes" id="lanes">
|
||||
${spkOrder.map((sp, i) => `
|
||||
<div class="lane-row ${scls[sp]}" data-sp="${sp}">
|
||||
<span class="lane-label"><span class="spk-dot"></span>${escapeHtml(spkLabel(sp, i))}</span>
|
||||
<div class="lane">${(segsBySpk[sp] || []).map(s =>
|
||||
`<span class="seg" style="left:${(s.start / durMs * 100).toFixed(3)}%;width:${Math.max(.18, (s.end - s.start) / durMs * 100).toFixed(3)}%"></span>`).join('')}
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
<div class="playhead" id="playhead"></div>
|
||||
</div>
|
||||
<div class="livebox" id="livebox"><div class="muted">▶ Abspielen — hier erscheint, was gerade gesprochen wird.</div></div>
|
||||
</div>
|
||||
<div class="hint">Klick in die Spuren springt an die Stelle.</div>` : '<div class="muted">Keine Sprecherdaten.</div>',
|
||||
todos: (r.todos && r.todos.length) ? `<ul class="todos">${r.todos.map(t => `<li>${escapeHtml(t)}</li>`).join('')}</ul>` : '<div class="muted">Keine ToDos erkannt.</div>',
|
||||
speakers: spkOrder.length ? `
|
||||
<div class="hint">Namen vergeben → App lernt die Stimme.</div>
|
||||
<div class="spk-edit">${spkOrder.map((sp, i) => `
|
||||
<div class="spk-row ${scls[sp]}">
|
||||
<span class="spk-dot"></span>
|
||||
<input class="spk-name" data-sp="${sp}" value="${escapeHtml(names[sp] || '')}" placeholder="${escapeHtml(spkLabel(sp, i))}">
|
||||
<label class="spk-learn-l"><input type="checkbox" class="spk-learn" checked> als Stimme lernen</label>
|
||||
<button class="btn small spk-save" data-sp="${sp}">Speichern</button>
|
||||
</div>`).join('')}
|
||||
</div>` : '',
|
||||
transcript: `
|
||||
${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>`,
|
||||
};
|
||||
const defOrder = ['analyse', 'viz', 'todos', 'speakers', 'transcript'];
|
||||
let order = [];
|
||||
try { order = JSON.parse(localStorage.getItem('aime-detail-order') || '[]'); } catch {}
|
||||
order = order.filter(m => mods[m] !== undefined).concat(defOrder.filter(m => !order.includes(m)));
|
||||
|
||||
detail.innerHTML = `
|
||||
<div class="card">
|
||||
<h2>${escapeHtml(r.title) || 'Aufnahme #' + r.id} ${analysisBadge(r.analysis_status)}</h2>
|
||||
<audio id="player" controls src="/media/${r.mp3_file}" preload="metadata"></audio>
|
||||
<div class="analysis">
|
||||
<div class="status-line">
|
||||
${anaTimeSpan(r) ? `<div class="muted ana-line">⏱ Analysezeit: ${anaTimeSpan(r)}</div>` : ''}
|
||||
${transcribing ? '<div class="muted">📝 Transkription läuft… lange Aufnahmen dauern (A76-Takt gegen Überhitzung gedrosselt).</div>' : ''}
|
||||
${transcribeFailed ? `<div class="err-inline">Transkription fehlgeschlagen. <button class="btn" id="retranscribe">Neu transkribieren</button></div>` : ''}
|
||||
${(pending && !transcribing && !transcribeFailed) ? '<div class="muted">🧠 KI-Analyse läuft… (NPU, ~30-90s)</div>' : ''}
|
||||
${(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>` : ''}
|
||||
${r.summary ? `<h3>Zusammenfassung</h3><p class="summary">${escapeHtml(r.summary)}</p>` : ''}
|
||||
${(r.participants && r.participants.length) ? `<h3>Teilnehmer</h3><ul class="parts">${r.participants.map(p => `<li>👤 ${escapeHtml(p)}</li>`).join('')}</ul>` : ''}
|
||||
${(r.todos && r.todos.length) ? `<h3>ToDos</h3><ul class="todos">${r.todos.map(t => `<li>${escapeHtml(t)}</li>`).join('')}</ul>` : ''}
|
||||
${(!pending && !errored && !transcribing && !transcribeFailed) ? `<button class="btn small" id="reanalyze">↻ Analyse neu</button>` : ''}
|
||||
</div>
|
||||
${spkOrder.length ? `
|
||||
<h3>Sprecher <span class="muted" style="font-weight:400">(Namen vergeben → App lernt die Stimme)</span></h3>
|
||||
<div class="spk-edit">${spkOrder.map((sp, i) => `
|
||||
<div class="spk-row ${scls[sp]}">
|
||||
<span class="spk-dot"></span>
|
||||
<input class="spk-name" data-sp="${sp}" value="${escapeHtml(names[sp] || '')}" placeholder="${escapeHtml(spkLabel(sp, i))}">
|
||||
<label class="spk-learn-l"><input type="checkbox" class="spk-learn" checked> als Stimme lernen</label>
|
||||
<button class="btn small spk-save" data-sp="${sp}">Speichern</button>
|
||||
</div>`).join('')}
|
||||
</div>` : ''}
|
||||
${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>` : ''}
|
||||
<h3>Original-Transkript</h3>
|
||||
<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="modules">
|
||||
${order.filter(m => mods[m]).map(m => `
|
||||
<section class="module" data-mod="${m}">
|
||||
<div class="mod-head" draggable="true"><span class="mod-grip" title="Ziehen zum Umsortieren">⠿</span><h3>${MOD_TITLES[m]}</h3></div>
|
||||
<div class="mod-body">${mods[m]}</div>
|
||||
</section>`).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Drag & drop: grab a module by its header, drop it above/below the others;
|
||||
// the order is saved and applies to every recording.
|
||||
const modWrap = detail.querySelector('#modules');
|
||||
let dragged = null;
|
||||
modWrap.querySelectorAll('.mod-head').forEach(h => {
|
||||
h.addEventListener('dragstart', (e) => {
|
||||
dragged = h.closest('.module');
|
||||
dragged.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', dragged.dataset.mod); // Firefox needs data
|
||||
});
|
||||
h.addEventListener('dragend', () => {
|
||||
if (!dragged) return;
|
||||
dragged.classList.remove('dragging');
|
||||
dragged = null;
|
||||
const now = [...modWrap.querySelectorAll('.module')].map(m => m.dataset.mod);
|
||||
localStorage.setItem('aime-detail-order', JSON.stringify(now));
|
||||
});
|
||||
});
|
||||
modWrap.addEventListener('dragover', (e) => {
|
||||
if (!dragged) return;
|
||||
e.preventDefault();
|
||||
const others = [...modWrap.querySelectorAll('.module:not(.dragging)')];
|
||||
const next = others.find(m => e.clientY < m.getBoundingClientRect().top + m.offsetHeight / 2);
|
||||
if (next) modWrap.insertBefore(dragged, next); else modWrap.appendChild(dragged);
|
||||
});
|
||||
const reBtn = detail.querySelector('#reanalyze');
|
||||
if (reBtn) reBtn.onclick = async () => {
|
||||
// Re-runs speaker separation every time: empty = automatic detection (now
|
||||
@@ -468,12 +532,63 @@ 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;
|
||||
// Lane visualizer wiring: playhead + speaking lane + live-text box.
|
||||
const lanesEl = detail.querySelector('#lanes');
|
||||
const playheadEl = detail.querySelector('#playhead');
|
||||
const liveboxEl = detail.querySelector('#livebox');
|
||||
let liveTurnIdx = -1;
|
||||
if (lanesEl) {
|
||||
// Click (or drag) inside the lanes seeks; ignore the label column.
|
||||
const seek = (e) => {
|
||||
const lane = lanesEl.querySelector('.lane');
|
||||
if (!lane) return;
|
||||
const rc = lane.getBoundingClientRect();
|
||||
const frac = Math.min(1, Math.max(0, (e.clientX - rc.left) / rc.width));
|
||||
audio.currentTime = frac * durMs / 1000;
|
||||
audio.play().catch(() => {});
|
||||
};
|
||||
lanesEl.addEventListener('click', seek);
|
||||
}
|
||||
const updateViz = (t) => {
|
||||
if (!lanesEl) return;
|
||||
if (playheadEl) {
|
||||
const lane = lanesEl.querySelector('.lane');
|
||||
if (lane) {
|
||||
const left = lane.offsetLeft + Math.min(1, Math.max(0, t / durMs)) * lane.offsetWidth;
|
||||
playheadEl.style.left = left + 'px';
|
||||
playheadEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
// Which speakers talk right now → light their lane rows.
|
||||
lanesEl.querySelectorAll('.lane-row').forEach(row => {
|
||||
const sp = row.dataset.sp;
|
||||
const on = (segsBySpk[sp] || []).some(s => t >= s.start && t <= s.end);
|
||||
row.classList.toggle('speaking', on && !audio.paused);
|
||||
});
|
||||
// Live text: last turn whose start has passed (stays through gaps).
|
||||
if (liveboxEl) {
|
||||
let idx = -1;
|
||||
for (let i = 0; i < turns.length; i++) { if (turns[i].start <= t) idx = i; else break; }
|
||||
if (idx !== liveTurnIdx && idx >= 0) {
|
||||
liveTurnIdx = idx;
|
||||
const tu = turns[idx];
|
||||
const i = spkOrder.indexOf(tu.sp);
|
||||
liveboxEl.innerHTML = `
|
||||
<div class="live-turn ${scls[tu.sp] || ''}">
|
||||
<span class="spk">${escapeHtml(spkLabel(tu.sp, i >= 0 ? i : 0))}</span>
|
||||
<span class="live-text">${escapeHtml(tu.text)}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
};
|
||||
audio.onpause = () => { if (lanesEl) lanesEl.querySelectorAll('.lane-row.speaking').forEach(r2 => r2.classList.remove('speaking')); };
|
||||
audio.ontimeupdate = () => {
|
||||
const t = audio.currentTime * 1000 - SYNC_MS;
|
||||
let active = null;
|
||||
for (const s of spans) { if (+s.dataset.start <= t) active = s; else break; }
|
||||
spans.forEach(s => s.classList.toggle('active', s === active));
|
||||
if (active) { const r2 = active.getBoundingClientRect(); if (r2.top < 90 || r2.bottom > innerHeight - 40) active.scrollIntoView({ block: 'center', behavior: 'smooth' }); }
|
||||
updateViz(t);
|
||||
};
|
||||
// Rename a speaker + (optionally) learn their voice into the DB.
|
||||
detail.querySelectorAll('.spk-save').forEach(btn => {
|
||||
@@ -511,6 +626,30 @@ async function openRecording(id) {
|
||||
}
|
||||
|
||||
function escapeHtml(s) { return (s || '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
|
||||
|
||||
// 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) {
|
||||
const by = {};
|
||||
for (const w of words) {
|
||||
if (!w.speaker) continue;
|
||||
const list = by[w.speaker] || (by[w.speaker] = []);
|
||||
const last = list[list.length - 1];
|
||||
if (last && w.t_start_ms - last.end <= 1200) last.end = Math.max(last.end, w.t_end_ms);
|
||||
else list.push({ start: w.t_start_ms, end: w.t_end_ms });
|
||||
}
|
||||
return by;
|
||||
}
|
||||
// Turn list (speaker + joined text) for the live-text box.
|
||||
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 });
|
||||
}
|
||||
return turns;
|
||||
}
|
||||
// Map distinct raw speaker labels (speaker_00…) to colour classes sp0..sp3.
|
||||
function speakerClasses(words) {
|
||||
const m = {}; let i = 0; const palette = ['sp0', 'sp1', 'sp2', 'sp3'];
|
||||
|
||||
@@ -199,6 +199,58 @@ audio { width: 100%; margin-bottom: 10px; }
|
||||
.item.rec:hover { border-color: var(--accent); }
|
||||
.item.rec.sel { border-color: var(--accent); background: var(--accent-soft); }
|
||||
|
||||
/* ---- Detail-Module (Drag & Drop) ---- */
|
||||
.status-line { margin: 4px 0 10px; }
|
||||
#modules { display: flex; flex-direction: column; gap: 10px; }
|
||||
.module { background: var(--inset); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; }
|
||||
.module.dragging { opacity: .45; border-style: dashed; }
|
||||
.mod-head {
|
||||
display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: grab;
|
||||
background: var(--surface-2); user-select: none;
|
||||
}
|
||||
.mod-head:active { cursor: grabbing; }
|
||||
.mod-head h3 { margin: 0; font-size: .85rem; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
|
||||
.mod-grip { color: var(--muted); font-size: 14px; letter-spacing: -1px; }
|
||||
.mod-body { padding: 12px; }
|
||||
.mod-body h3 { margin: 10px 0 4px; font-size: .9rem; color: var(--muted); }
|
||||
|
||||
/* ---- Sprecher-Verlauf: Lanes + Live-Text ---- */
|
||||
.viz-wrap { display: flex; gap: 14px; align-items: stretch; }
|
||||
.lanes { flex: 1 1 58%; min-width: 0; position: relative; display: flex; flex-direction: column; gap: 6px; cursor: pointer; }
|
||||
.lane-row { display: flex; align-items: center; gap: 8px; }
|
||||
.lane-label {
|
||||
flex: 0 0 92px; display: flex; align-items: center; gap: 6px; font-size: 12px;
|
||||
color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.lane { position: relative; flex: 1; height: 22px; background: var(--surface-2); border-radius: 6px; overflow: hidden; }
|
||||
.seg { position: absolute; top: 3px; bottom: 3px; border-radius: 3px; opacity: .75; transition: opacity .15s; }
|
||||
.lane-row.sp0 .seg { background: var(--sp0); } .lane-row.sp1 .seg { background: var(--sp1); }
|
||||
.lane-row.sp2 .seg { background: var(--sp2); } .lane-row.sp3 .seg { background: var(--sp3); }
|
||||
.lane-row.sp0 .spk-dot { background: var(--sp0); } .lane-row.sp1 .spk-dot { background: var(--sp1); }
|
||||
.lane-row.sp2 .spk-dot { background: var(--sp2); } .lane-row.sp3 .spk-dot { background: var(--sp3); }
|
||||
.lane-row.speaking .seg { opacity: 1; }
|
||||
.lane-row.speaking .lane-label { color: var(--fg); font-weight: 600; }
|
||||
.lane-row.speaking .spk-dot { animation: pulse 1s ease-in-out infinite; }
|
||||
@keyframes pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.55); } }
|
||||
.playhead {
|
||||
display: none; position: absolute; top: 0; bottom: 0; width: 2px;
|
||||
background: var(--accent); border-radius: 1px; pointer-events: none; z-index: 2;
|
||||
transition: left .25s linear;
|
||||
}
|
||||
.livebox {
|
||||
flex: 1 1 42%; min-width: 0; background: var(--surface-2); border-radius: 10px;
|
||||
padding: 12px; max-height: 200px; overflow-y: auto; align-self: stretch;
|
||||
}
|
||||
.live-turn .spk { display: block; font-weight: 650; font-size: 12px; margin-bottom: 4px; }
|
||||
.live-turn.sp0 .spk { color: var(--sp0); } .live-turn.sp1 .spk { color: var(--sp1); }
|
||||
.live-turn.sp2 .spk { color: var(--sp2); } .live-turn.sp3 .spk { color: var(--sp3); }
|
||||
.live-text { line-height: 1.55; }
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.viz-wrap { flex-direction: column; }
|
||||
.livebox { max-height: 120px; }
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
#app.wide { max-width: 860px; }
|
||||
.rec-layout { flex-direction: column; }
|
||||
|
||||
@@ -3,6 +3,20 @@
|
||||
Chronik der Entwicklungsschritte. **Regel: jede zukünftige Änderung bekommt hier einen Eintrag** (Datum,
|
||||
Was, Warum, betroffene Dateien/Backups).
|
||||
|
||||
## 2026-07-17 — GUI: Sprecher-Verlauf-Visualisierung + Drag&Drop-Module
|
||||
|
||||
Detail-Ansicht ist jetzt modular: fester Kopf (Titel, Player, Status), darunter Blöcke
|
||||
**Zusammenfassung / Sprecher-Verlauf / ToDos / Sprecher / Transkript** — jeder Block hat einen
|
||||
Grip-Header (⠿) und lässt sich per Drag & Drop umsortieren; die Reihenfolge liegt in
|
||||
`localStorage['aime-detail-order']` und gilt für alle Aufnahmen.
|
||||
|
||||
**Sprecher-Verlauf** (neu): pro Sprecher eine farbige Spur (sp0–sp3) übereinander mit
|
||||
Redeabschnitts-Blöcken (Wörter mit ≤ 1,2 s Lücke zusammengefasst), Kupfer-Playhead wandert beim
|
||||
Abspielen mit, die Spur des gerade Sprechenden leuchtet (Label fett, Punkt pulsiert; respektiert
|
||||
`prefers-reduced-motion`), Klick in die Spuren springt an die Stelle. Rechts daneben die **Live-Box**:
|
||||
zeigt Sprecher + Text des aktuellen Redebeitrags. Per Playwright-Screenshots verifiziert
|
||||
(beide Themes, Playback mit aktiver Spur). Bundle `index-WaE171bv.js`.
|
||||
|
||||
## 2026-07-17 — GUI: „Weggezogen"-Bug gefixt + Redesign mit Dark/Light-Switch (`.bak-theme`)
|
||||
|
||||
- **Bug:** lief beim ausgewählten Eintrag eine Analyse, feuerte ein 5-s-Timer `openRecording(alteId)` als
|
||||
|
||||
Reference in New Issue
Block a user