feat: lecteur média persistant Now Playing (dock audio, mini-vidéo PiP, Media Session, mobile) #110
This commit is contained in:
+17
-129
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user