feat: support audio & vidéo — lecteurs HTML5 intégrés #109
CI / lint (push) Successful in 1m58s
CI / security (push) Successful in 1m22s
CI / test (push) Successful in 4m32s
CI / build (push) Successful in 2m6s
CI / e2e (push) Successful in 12m57s

This commit is contained in:
2026-09-23 09:14:34 -04:00
parent eccbf7474e
commit e2417cb5ab
26 changed files with 1057 additions and 121 deletions
+20 -18
View File
@@ -213,25 +213,27 @@ const EXT_ICONS = {
".tiff": "image",
".tif": "image",
// Audio files
".mp3": "file-music",
".wav": "file-music",
".flac": "file-music",
".aac": "file-music",
".ogg": "file-music",
".m4a": "file-music",
".wma": "file-music",
// Audio files (roadmap #109-B2)
".mp3": "audio-lines",
".wav": "audio-lines",
".flac": "audio-lines",
".aac": "audio-lines",
".ogg": "audio-lines",
".oga": "audio-lines",
".opus": "audio-lines",
".m4a": "audio-lines",
".wma": "audio-lines",
// Video files
".mp4": "play",
".avi": "play",
".mov": "play",
".wmv": "play",
".flv": "play",
".webm": "play",
".mkv": "play",
".m4v": "play",
".3gp": "play",
// Video files (roadmap #109-B2)
".mp4": "video",
".avi": "video",
".mov": "video",
".wmv": "video",
".flv": "video",
".webm": "video",
".mkv": "video",
".m4v": "video",
".3gp": "video",
// Archive files
".zip": "file-archive",
+147 -1
View File
@@ -782,6 +782,139 @@ export function renderImageViewer(area, data) {
try { container.focus({ preventScroll: true }); } catch (_) { /* non-fatal */ }
}
let _mediaViewerCleanup = null;
/** Format a duration in seconds as ``m:ss`` (or ``h:mm:ss``). */
export function formatMediaDuration(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return "";
const total = Math.floor(seconds);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
const pad = (n) => String(n).padStart(2, "0");
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
/** Build the byte-range streaming URL used by the HTML5 media players (#109). */
export function buildMediaUrl(vault, path) {
return `/api/media/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`;
}
function buildDownloadUrl(vault, path) {
return `/api/file/${encodeURIComponent(vault)}/download?path=${encodeURIComponent(path)}`;
}
/**
* Fallback shown when a media codec is not playable in the browser (roadmap
* #109-C3) or when the file is too large to stream inline.
*/
export function renderMediaFallback(area, data, message) {
const fileName = (data.path || "").split("/").pop();
const sizeStr = formatBytes(data.size_bytes);
area.innerHTML = "";
const box = el("div", { class: "unsupported-file" }, [
icon("file", 48),
el("div", { class: "filename" }, [document.createTextNode(fileName)]),
el("div", {}, [document.createTextNode(message)]),
sizeStr ? el("div", { style: "font-size:0.85rem;margin-top:4px" }, [document.createTextNode(t("viewer.metadata_size") + " : " + sizeStr)]) : null,
]);
const dlBtn = el("button", { class: "btn-action", type: "button" }, [
icon("download", 14), document.createTextNode(" " + t("viewer.download")),
]);
dlBtn.addEventListener("click", () => window.open(buildDownloadUrl(data.vault, data.path), "_blank"));
const openBtn = el("button", { class: "btn-action", type: "button" }, [
icon("external-link", 14), document.createTextNode(" " + t("viewer.image_open_original")),
]);
openBtn.addEventListener("click", () => window.open(data.stream_url || buildMediaUrl(data.vault, data.path), "_blank"));
box.appendChild(el("div", { class: "media-fallback-actions" }, [dlBtn, openBtn]));
area.appendChild(box);
safeCreateIcons();
}
function mediaToolbar(data, extraButtons) {
const toolbar = el("div", { class: "media-toolbar" });
toolbar.appendChild(el("span", { class: "media-title", title: data.path }, [
document.createTextNode(data.title || (data.path || "").split("/").pop()),
]));
toolbar.appendChild(el("div", { class: "media-toolbar-spacer" }));
(extraButtons || []).forEach((b) => toolbar.appendChild(b));
const openBtn = el("button", { class: "btn-action", type: "button", title: t("viewer.image_open_original"), "aria-label": t("viewer.image_open_original") }, [icon("external-link", 14)]);
openBtn.addEventListener("click", () => window.open(data.stream_url || buildMediaUrl(data.vault, data.path), "_blank"));
const downloadBtn = el("button", { class: "btn-action", type: "button", title: t("viewer.download"), "aria-label": t("viewer.download") }, [icon("download", 14)]);
downloadBtn.addEventListener("click", () => window.open(buildDownloadUrl(data.vault, data.path), "_blank"));
toolbar.appendChild(openBtn);
toolbar.appendChild(downloadBtn);
return toolbar;
}
/**
* Render an audio player (roadmap #109-B): full-width native ``<audio>``,
* artwork placeholder, duration read from ``loadedmetadata`` and a toolbar
* with the vault, size, open-original and download actions.
*/
export function renderAudioViewer(area, data) {
const streamUrl = data.stream_url || buildMediaUrl(data.vault, data.path);
area.innerHTML = "";
const container = el("div", { class: "audio-viewer-container" });
const badge = el("span", { class: "media-duration-badge" }, [document.createTextNode("--:--")]);
const vaultLabel = el("span", { class: "media-meta" }, [document.createTextNode(`${data.vault} · ${formatBytes(data.size_bytes)}`)]);
container.appendChild(mediaToolbar(data, [vaultLabel, badge]));
const stage = el("div", { class: "audio-stage" });
const artwork = el("div", { class: "audio-artwork" }, [icon("audio-lines", 64)]);
const audio = el("audio", { class: "audio-player", controls: "", preload: "metadata", src: streamUrl });
stage.appendChild(artwork);
stage.appendChild(audio);
container.appendChild(stage);
audio.addEventListener("loadedmetadata", () => {
badge.textContent = formatMediaDuration(audio.duration) || "--:--";
});
audio.addEventListener("error", () => {
renderMediaFallback(area, data, t("viewer.media_unsupported"));
});
_mediaViewerCleanup = () => { try { audio.pause(); audio.removeAttribute("src"); } catch (_) { /* detached */ } };
area.appendChild(container);
safeCreateIcons();
}
/**
* Render a video player (roadmap #109-C): centered native ``<video>`` with a
* black letterboxed stage and a toolbar. Playing is stopped when the content
* area is re-rendered (tab switch / navigation).
*/
export function renderVideoViewer(area, data) {
const streamUrl = data.stream_url || buildMediaUrl(data.vault, data.path);
area.innerHTML = "";
const container = el("div", { class: "video-viewer-container" });
const badge = el("span", { class: "media-duration-badge" }, [document.createTextNode("--:--")]);
const vaultLabel = el("span", { class: "media-meta" }, [document.createTextNode(`${data.vault} · ${formatBytes(data.size_bytes)}`)]);
container.appendChild(mediaToolbar(data, [vaultLabel, badge]));
const stage = el("div", { class: "video-stage" });
const video = el("video", { class: "video-player", controls: "", playsinline: "", preload: "metadata", src: streamUrl });
stage.appendChild(video);
container.appendChild(stage);
video.addEventListener("loadedmetadata", () => {
const parts = [formatMediaDuration(video.duration) || "--:--"];
if (video.videoWidth && video.videoHeight) parts.push(`${video.videoWidth} × ${video.videoHeight}`);
badge.textContent = parts.join(" · ");
});
video.addEventListener("error", () => {
renderMediaFallback(area, data, t("viewer.media_unsupported"));
});
_mediaViewerCleanup = () => { try { video.pause(); video.removeAttribute("src"); } catch (_) { /* detached */ } };
area.appendChild(container);
safeCreateIcons();
}
export function renderFile(data) {
// #93 — An inline edition session (#editor-container mounted in the content
// area) is destroyed by this very re-render: release it first so the editor
@@ -791,6 +924,9 @@ export function renderFile(data) {
// #108 — release the image viewer's document-level shortcuts before swapping
// the content area (otherwise they leak on every re-render).
if (_imageViewerCleanup) { _imageViewerCleanup(); _imageViewerCleanup = null; }
// #109 — stop any playing audio/video when the file view is replaced
// (tab switch, navigation): no persistent playback in v1.
if (_mediaViewerCleanup) { _mediaViewerCleanup(); _mediaViewerCleanup = null; }
const area = getContentArea();
// Handle PDF files — render in iframe with TOC sidebar
@@ -847,6 +983,16 @@ export function renderFile(data) {
return;
}
// Handle audio / video — native HTML5 players (roadmap #109)
if (data.is_audio) {
renderAudioViewer(area, data);
return;
}
if (data.is_video) {
renderVideoViewer(area, data);
return;
}
// Handle unsupported (binary) files
if (data.unsupported) {
const sizeStr = data.size_bytes
@@ -858,7 +1004,7 @@ export function renderFile(data) {
<div class="unsupported-file">
<i data-lucide="file" style="width:48px;height:48px"></i>
<div class="filename">${escapeHtml(data.path.split("/").pop())}</div>
<div>Ce fichier est binaire et ne peut pas être affiché.</div>
<div>${data.media_too_large ? escapeHtml(t("viewer.media_too_large")) : "Ce fichier est binaire et ne peut pas être affiché."}</div>
${sizeStr ? `<div style="font-size:0.85rem;margin-top:4px">Taille : ${sizeStr}</div>` : ""}
<button class="btn-action" id="unsupported-download-btn">
<i data-lucide="download" style="width:14px;height:14px"></i> Télécharger