feat: lecteur média persistant Now Playing (dock audio, mini-vidéo PiP, Media Session, mobile) #110
CI / lint (push) Successful in 2m5s
CI / security (push) Successful in 1m23s
CI / test (push) Successful in 3m57s
CI / build (push) Successful in 1m18s
CI / e2e (push) Successful in 13m18s

This commit is contained in:
2026-09-23 10:41:15 -04:00
parent e2417cb5ab
commit e3c6789776
21 changed files with 1738 additions and 211 deletions
+17 -129
View File
@@ -14,6 +14,7 @@ import { openShareDialog } from './config.js';
import { cacheViewedFile, getCachedFile } from './offline.js';
import { t } from './i18n.js';
import { onFileRender } from './plugins.js';
import { NowPlaying } from './now-playing.js';
// ── Multi-format export ────────────────────────────────────────────────────
// Downloads a file export (HTML / MD bundle / ePub) via the authenticated
@@ -782,139 +783,24 @@ export function renderImageViewer(area, data) {
try { container.focus({ preventScroll: true }); } catch (_) { /* non-fatal */ }
}
let _mediaViewerCleanup = null;
// #110 — Audio/video are handled by the global Now Playing controller
// (frontend/js/now-playing.js): a single shared media element is teleported
// between this inline view and a body-level dock, so playback survives tab,
// pane and page navigation. The inline branches below just hand over the
// surface; the legacy "stop on re-render" cleanup no longer applies.
export { formatMediaDuration, buildMediaUrl, renderFallback as renderMediaFallback } from "./now-playing.js";
/** 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.
*/
/** Audio viewer (roadmap #109-B, made persistent by #110). */
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();
NowPlaying.attachInline(area, data);
}
/**
* 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).
*/
/** Video viewer (roadmap #109-C, made persistent by #110). */
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();
NowPlaying.attachInline(area, data);
}
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
@@ -924,10 +810,10 @@ 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();
// #110 — if this render is about to replace the surface that currently hosts
// the shared media element, hand it back to the persistent dock first.
NowPlaying.handleRender(area, data);
// Handle PDF files — render in iframe with TOC sidebar
if (data.is_pdf) {
@@ -1669,6 +1555,8 @@ export function showWelcome() {
// Restore or rebuild the dashboard with tabbed sections
const area = getContentArea();
// #110 — keep playing media alive if the dashboard replaces its surface.
NowPlaying.handleRender(area, null);
const home = document.getElementById("dashboard-home");
if (area && !home) {