424330aad4
Adjust the `justify-content` of the player to align items to the right, and limit the `max-width` of the seek bar to prevent overflow and improve layout on smaller screens.
290 lines
11 KiB
HTML
290 lines
11 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Doctate — Aufnahmen Fall {{ case_id_short }}</title>
|
|
<style>
|
|
body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1em; }
|
|
header { display: flex; justify-content: space-between; align-items: center; }
|
|
header form { margin: 0; }
|
|
.breadcrumb { display: flex; gap: 0.8em; flex-wrap: wrap; }
|
|
.breadcrumb a { color: #4a90e2; text-decoration: none; }
|
|
.breadcrumb a:hover { text-decoration: underline; }
|
|
.meta { color: #999; font-family: monospace; font-size: 0.8em; margin: 0 0 1em; }
|
|
.recording { margin: 1em 0; padding: 0.7em; border: 1px solid #ddd; border-radius: 4px; }
|
|
.recording.failed-row { background: #fff7f7; border-left: 3px solid #e24a4a; }
|
|
.recording-head { display: flex; align-items: center; gap: 1em; flex-wrap: wrap; }
|
|
.rec-time { font-family: monospace; font-size: 0.95em; min-width: 11em; color: #333; }
|
|
.filename { font-family: monospace; font-size: 0.8em; color: #999; }
|
|
.transcript { margin: 0.6em 0 0; padding: 0.6em; background: #f6f6f6; border-radius: 4px; white-space: pre-wrap; font-family: serif; }
|
|
.pending { color: #888; font-style: italic; margin-top: 0.5em; }
|
|
.failed { color: #b00; font-weight: bold; margin-top: 0.5em; }
|
|
|
|
.player { display: inline-flex; align-items: center; gap: 0.5em; flex: 1; justify-content: flex-end; }
|
|
.player .play {
|
|
position: relative;
|
|
width: 2em; height: 2em;
|
|
border: 1px solid #bbb; border-radius: 50%;
|
|
background: #fff; cursor: pointer; padding: 0;
|
|
flex-shrink: 0;
|
|
}
|
|
.player .play:hover { background: #f0f0f0; }
|
|
/* Play triangle — pseudo-element via border-trick, font-independent. */
|
|
.player .play::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: 50%; left: 50%;
|
|
width: 0; height: 0;
|
|
border-style: solid;
|
|
border-width: 7px 0 7px 10px;
|
|
border-color: transparent transparent transparent #333;
|
|
transform: translate(-35%, -50%);
|
|
}
|
|
/* Pause — two bars via ::before (reused) + ::after. */
|
|
.player .play.is-pause::before {
|
|
width: 3px; height: 12px;
|
|
border: none;
|
|
background: #333;
|
|
transform: translate(-5px, -50%);
|
|
}
|
|
.player .play.is-pause::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: 50%; left: 50%;
|
|
width: 3px; height: 12px;
|
|
background: #333;
|
|
transform: translate(2px, -50%);
|
|
}
|
|
.player .seek { flex: 1; max-width: 6em; cursor: pointer; }
|
|
.player .time { font-family: monospace; font-size: 0.85em; color: #555; min-width: 5.5em; text-align: right; }
|
|
|
|
.header-right { display: flex; align-items: center; gap: 0.8em; }
|
|
.admin-toggle { font-size: 0.85em; color: #666; display: inline-flex; align-items: center; gap: 0.3em; cursor: pointer; user-select: none; }
|
|
html.admin-view-off .admin-only { display: none !important; }
|
|
</style>
|
|
<script>
|
|
try {
|
|
if (localStorage.getItem('admin-view') === 'off') {
|
|
document.documentElement.classList.add('admin-view-off');
|
|
}
|
|
} catch (_) {}
|
|
</script>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<div class="breadcrumb">
|
|
<a href="/web/cases">← Fälle</a>
|
|
{% match oneliner %}
|
|
{% when Some with (t) %}
|
|
<a href="/web/cases/{{ case_id }}">← {{ t }}</a>
|
|
{% when None %}
|
|
<a href="/web/cases/{{ case_id }}">← Fall</a>
|
|
{% endmatch %}
|
|
</div>
|
|
<div class="header-right">
|
|
{% if is_admin %}<label class="admin-toggle"><input type="checkbox" id="admin-view-toggle"> Admin view</label>{% endif %}
|
|
<form method="post" action="/web/logout"><button type="submit">Logout</button></form>
|
|
</div>
|
|
</header>
|
|
<h1>Aufnahmen</h1>
|
|
{% if is_admin %}<div class="meta admin-only">{{ case_id }}</div>{% endif %}
|
|
|
|
{% for rec in recordings %}
|
|
<div class="recording{% if rec.failed %} failed-row{% endif %}">
|
|
<div class="recording-head">
|
|
<time class="rec-time" datetime="{{ rec.recorded_at_iso }}">{{ rec.recorded_at_iso }}</time>
|
|
<div class="player" data-src="/web/audio/{{ slug }}/{{ case_id }}/{{ rec.filename }}"{% match rec.duration_seconds %}{% when Some with (d) %} data-duration="{{ d }}"{% when None %}{% endmatch %}>
|
|
<button type="button" class="play" aria-label="Abspielen"></button>
|
|
<input type="range" class="seek" min="0" max="1000" value="0" step="1" aria-label="Position">
|
|
<span class="time">0:00 / 0:00</span>
|
|
</div>
|
|
{% if is_admin %}<span class="filename admin-only">{{ rec.filename }}</span>{% endif %}
|
|
</div>
|
|
{% if rec.failed %}
|
|
<div class="failed">Transkription fehlgeschlagen — .failed-Suffix entfernen zum Erneut-Versuchen.</div>
|
|
{% else %}
|
|
{% match rec.transcript %}
|
|
{% when Some with (t) %}
|
|
{% if t.is_empty() %}
|
|
<div class="pending">(stille Aufnahme)</div>
|
|
{% else %}
|
|
<div class="transcript">{{ t }}</div>
|
|
{% endif %}
|
|
{% when None %}
|
|
{% if transcribe_busy %}
|
|
<div class="pending">Transkription läuft…</div>
|
|
{% else %}
|
|
<div class="pending">Transkription ausstehend.</div>
|
|
{% endif %}
|
|
{% endmatch %}
|
|
{% endif %}
|
|
</div>
|
|
{% endfor %}
|
|
<script>
|
|
(() => {
|
|
const cb = document.getElementById('admin-view-toggle');
|
|
if (cb) {
|
|
cb.checked = !document.documentElement.classList.contains('admin-view-off');
|
|
cb.addEventListener('change', () => {
|
|
const on = cb.checked;
|
|
try { localStorage.setItem('admin-view', on ? 'on' : 'off'); } catch (_) {}
|
|
document.documentElement.classList.toggle('admin-view-off', !on);
|
|
});
|
|
}
|
|
|
|
// Render the timestamp in the browser's local timezone. Server renders UTC
|
|
// (RFC3339) into `datetime=""`; JS formats it for display.
|
|
const timeFmt = new Intl.DateTimeFormat('de-DE', {
|
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
|
hour: '2-digit', minute: '2-digit',
|
|
});
|
|
document.querySelectorAll('.rec-time[datetime]').forEach(el => {
|
|
const iso = el.getAttribute('datetime');
|
|
if (!iso) return;
|
|
const d = new Date(iso);
|
|
if (isNaN(d.getTime())) return;
|
|
el.textContent = timeFmt.format(d);
|
|
});
|
|
|
|
const fmtDur = s => {
|
|
if (!isFinite(s) || s < 0) return '0:00';
|
|
const m = Math.floor(s / 60);
|
|
const sec = Math.floor(s % 60);
|
|
return `${m}:${sec.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
document.querySelectorAll('.player').forEach(p => {
|
|
const src = p.dataset.src;
|
|
const btn = p.querySelector('.play');
|
|
const seek = p.querySelector('.seek');
|
|
const timeEl = p.querySelector('.time');
|
|
const initialDuration = parseFloat(p.dataset.duration);
|
|
let audio = null;
|
|
let seeking = false;
|
|
// Pending seek expressed as a fraction of the full length (0..1). Used
|
|
// whenever we can't immediately apply `audio.currentTime` (metadata not
|
|
// yet loaded, or duration unknown at drop time). Cleared in `seeked`,
|
|
// so render() doesn't overwrite the slider with a stale currentTime
|
|
// reading between "set" and "applied".
|
|
let pendingSeek = null;
|
|
let duration = isFinite(initialDuration) ? initialDuration : 0;
|
|
|
|
const render = () => {
|
|
const cur = audio ? audio.currentTime : 0;
|
|
const displayTime = pendingSeek !== null ? pendingSeek * duration : cur;
|
|
timeEl.textContent = `${fmtDur(displayTime)} / ${fmtDur(duration)}`;
|
|
if (!seeking && pendingSeek === null) {
|
|
seek.value = duration > 0 ? Math.round((cur / duration) * 1000) : 0;
|
|
}
|
|
};
|
|
render();
|
|
|
|
const tryApplyPendingSeek = () => {
|
|
if (!audio || pendingSeek === null) return;
|
|
if (audio.readyState >= 1 && isFinite(audio.duration) && audio.duration > 0) {
|
|
audio.currentTime = pendingSeek * audio.duration;
|
|
// Keep pendingSeek set until `seeked` fires — the browser needs a
|
|
// moment to commit the seek, and any timeupdate in between would
|
|
// otherwise read the old currentTime.
|
|
}
|
|
};
|
|
|
|
const ensureAudio = () => {
|
|
if (audio) return audio;
|
|
audio = new Audio(src);
|
|
audio.preload = 'metadata';
|
|
audio.addEventListener('loadedmetadata', () => {
|
|
if (isFinite(audio.duration) && audio.duration > 0) duration = audio.duration;
|
|
tryApplyPendingSeek();
|
|
render();
|
|
});
|
|
audio.addEventListener('timeupdate', render);
|
|
audio.addEventListener('seeked', () => {
|
|
pendingSeek = null;
|
|
render();
|
|
});
|
|
audio.addEventListener('ended', () => {
|
|
btn.classList.remove('is-pause');
|
|
btn.setAttribute('aria-label', 'Abspielen');
|
|
audio.currentTime = 0;
|
|
render();
|
|
});
|
|
return audio;
|
|
};
|
|
|
|
btn.addEventListener('click', () => {
|
|
const a = ensureAudio();
|
|
if (a.paused) {
|
|
a.play();
|
|
btn.classList.add('is-pause');
|
|
btn.setAttribute('aria-label', 'Pause');
|
|
} else {
|
|
a.pause();
|
|
btn.classList.remove('is-pause');
|
|
btn.setAttribute('aria-label', 'Abspielen');
|
|
}
|
|
});
|
|
|
|
seek.addEventListener('input', () => {
|
|
seeking = true;
|
|
if (duration <= 0) return;
|
|
// Live time-label update while dragging (no audio change yet).
|
|
const preview = (parseFloat(seek.value) / 1000) * duration;
|
|
timeEl.textContent = `${fmtDur(preview)} / ${fmtDur(duration)}`;
|
|
});
|
|
seek.addEventListener('change', () => {
|
|
seeking = false;
|
|
const fraction = parseFloat(seek.value) / 1000;
|
|
// Ensure audio exists even if duration is still unknown — this kicks
|
|
// off metadata loading, so loadedmetadata will apply the pending seek.
|
|
ensureAudio();
|
|
pendingSeek = fraction;
|
|
tryApplyPendingSeek();
|
|
render();
|
|
});
|
|
});
|
|
})();
|
|
|
|
// Live-update bus with audio-playback protection: only events for THIS
|
|
// case trigger a reload, and if any audio is currently playing, show a
|
|
// dismissible "Updates verfügbar"-banner instead of interrupting playback.
|
|
// The `.play.is-pause` class is set by the player script above while audio
|
|
// plays, so we can probe it via DOM selector without extra wiring.
|
|
(() => {
|
|
const MY_CASE_ID = "{{ case_id }}";
|
|
let timer = null;
|
|
const anyAudioPlaying = () => !!document.querySelector('.player .play.is-pause');
|
|
const showBanner = () => {
|
|
if (document.getElementById('update-banner')) return;
|
|
const b = document.createElement('button');
|
|
b.id = 'update-banner';
|
|
b.type = 'button';
|
|
b.textContent = '\u21bb Aktualisierungen verf\u00fcgbar \u2014 neu laden';
|
|
b.style.cssText = 'position:fixed;bottom:1em;right:1em;z-index:9999;'
|
|
+ 'padding:0.6em 1em;background:#fff3cd;border:1px solid #d39e00;'
|
|
+ 'border-radius:4px;cursor:pointer;font-family:inherit;';
|
|
b.addEventListener('click', () => location.reload());
|
|
document.body.appendChild(b);
|
|
};
|
|
const scheduleReload = () => {
|
|
clearTimeout(timer);
|
|
timer = setTimeout(() => {
|
|
if (anyAudioPlaying()) showBanner();
|
|
else location.reload();
|
|
}, 300);
|
|
};
|
|
const es = new EventSource('/web/events');
|
|
// Release the socket-pool slot on navigation. Without this, Chrome
|
|
// keeps the SSE connection "draining" after unload; six rapid nav
|
|
// cycles exhaust the 6-per-origin pool and stall further requests.
|
|
window.addEventListener('pagehide', () => es.close());
|
|
es.addEventListener('case', (msg) => {
|
|
let evt;
|
|
try { evt = JSON.parse(msg.data); } catch (_) { return; }
|
|
if (evt.case_id === MY_CASE_ID) scheduleReload();
|
|
});
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|