Implement audio range requests

This commit enables serving audio files via HTTP Range requests. This is
crucial for allowing HTML5 audio players to seek to specific positions
within an audio file without re-downloading the entire file.

The changes include:
- Modifying `handle_audio` in `server/src/routes/web.rs` to parse
  `Range` headers.
- Implementing `serve_range` to handle partial content responses.
- Adding a `parse_range` helper function.
- Updating tests to verify range request functionality.
- Adding `ACCEPT_RANGES: bytes` header to indicate support for range
  requests.
- Storing recording duration in a sidecar file for faster UI rendering.
- Enhancing the HTML template to support a custom audio player with
  seeking.
This commit is contained in:
2026-04-19 22:26:04 +02:00
parent 2bcdb5436e
commit 17aa5d7200
9 changed files with 665 additions and 48 deletions
+166 -11
View File
@@ -14,11 +14,50 @@ header form { margin: 0; }
.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; }
.recording-head span { font-family: monospace; font-size: 0.9em; min-width: 20em; }
.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; min-width: 20em; }
.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; 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; }
@@ -54,10 +93,13 @@ try {
{% for rec in recordings %}
<div class="recording{% if rec.failed %} failed-row{% endif %}">
<div class="recording-head">
<span>{{ rec.filename }}</span>
<audio controls preload="none">
<source src="/web/audio/{{ slug }}/{{ case_id }}/{{ rec.filename }}" type="audio/mp4">
</audio>
<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>
@@ -82,12 +124,125 @@ try {
<script>
(() => {
const cb = document.getElementById('admin-view-toggle');
if (!cb) return;
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);
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();
});
});
})();
</script>