feat: support audio & vidéo — lecteurs HTML5 intégrés #109
This commit is contained in:
+20
-18
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user