fix(file-viewer): make previewed video seekable and stop it on close

Two bugs in the File Viewer's media player, both reproduced in a real
browser against an 18MB mp4 before and after the fix.

1. Closing the preview left the video playing. closeFilePreview() only
   dropped the overlay's `visible` class, which is display:none and
   nothing else, so the audio kept going with no visible player to pause.
   Detaching the element is not a fix either: a detached HTMLMediaElement
   plays on until it is garbage collected. _stopFilePreviewMedia() now
   pauses, drops src and load()s every media element (also on re-open,
   where overwriting innerHTML had the same effect), which additionally
   aborts the in-flight download.

2. The scrub bar was inert. file-raw read the whole file and answered
   200 with no Accept-Ranges, so Chrome reported video.seekable as
   [0, 0] and silently reverted `currentTime = x`; Safari refuses to
   start such media at all. Raw bodies are now streamed and range-aware:
   Accept-Ranges: bytes on every response, 206 + Content-Range for a
   Range request, 416 for one past EOF, and a malformed spec ignored
   (200) per RFC 9110. Parsing is pure in src/web/http-range.ts.

Measured on tmp/codeman-crt-v5-66s.mp4 (18MB, 66.6s):
  before  seekable [0, 0]     seek to 56.6s reverted to 3.9s   close: still playing
  after   seekable [0, 66.56] seek to 56.6s landed at 60.2s    close: paused, NETWORK_EMPTY

Range slices are byte-identical to `dd`, the full-file path is
byte-identical to the file, and the SVG octet-stream/attachment
hardening and the 50MB cap are unchanged (the cap is still checked
before the range).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-14 00:14:21 +02:00
parent 4bbe2b7ff6
commit d68cba9432
9 changed files with 681 additions and 25 deletions
+35 -2
View File
@@ -3246,6 +3246,9 @@ Object.assign(CodemanApp.prototype, {
// Edit mode: reset any prior editor state whenever a preview (re)loads.
this._resetFilePreviewEdit();
// Stop whatever the previous preview was playing. Overwriting innerHTML
// only DETACHES a <video>/<audio>; a detached media element keeps playing.
this._stopFilePreviewMedia();
// Show overlay with loading state
overlay.classList.add('visible');
@@ -3330,10 +3333,13 @@ Object.assign(CodemanApp.prototype, {
bodyEl.innerHTML = `<img src="${data.url}" alt="${escapeHtml(filePath)}">`;
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
} else if (data.type === 'video') {
bodyEl.innerHTML = `<video src="${data.url}" controls autoplay></video>`;
// playsinline: iOS otherwise hijacks playback into its fullscreen
// player, which leaves the overlay behind it and its own close button
// as the only way back.
bodyEl.innerHTML = `<video src="${escapeHtml(data.url)}" controls autoplay playsinline preload="metadata"></video>`;
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
} else if (data.type === 'audio') {
bodyEl.innerHTML = `<audio src="${data.url}" controls autoplay></audio>`;
bodyEl.innerHTML = `<audio src="${escapeHtml(data.url)}" controls autoplay preload="metadata"></audio>`;
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
} else if (data.type === 'binary') {
const downloadHref = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}&download=true`;
@@ -3366,9 +3372,36 @@ Object.assign(CodemanApp.prototype, {
if (overlay) {
overlay.classList.remove('visible');
}
// The overlay is hidden with display:none, which stops it being PAINTED and
// nothing else: a <video>/<audio> inside it keeps playing, keeps its audio
// audible and keeps streaming from the server. Closing has to stop it.
this._stopFilePreviewMedia();
this.filePreviewContent = '';
},
/**
* Pause and unload every media element in the preview body, then empty it.
*
* Removing the element from the DOM is NOT enough — a detached HTMLMediaElement
* plays on until it is garbage collected, which is why the X button used to
* leave a video audible. pause() stops playback, dropping src + load() aborts
* the in-flight network fetch and puts the element back in NETWORK_EMPTY.
*/
_stopFilePreviewMedia() {
const bodyEl = this.$('filePreviewBody');
if (!bodyEl) return;
for (const media of bodyEl.querySelectorAll('video, audio')) {
try {
media.pause();
media.removeAttribute('src');
media.load();
} catch (err) {
console.warn('Failed to stop preview media:', err);
}
}
bodyEl.innerHTML = '';
},
// ═══════════════════════════════════════════════════════════════
// File Viewer edit mode (issue #212 — docs/file-viewer-edit-plan.md)
// ═══════════════════════════════════════════════════════════════