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
|
||||
|
||||
@@ -1839,6 +1839,8 @@
|
||||
"viewer.loaded_from_cache": "File loaded from offline cache",
|
||||
"viewer.loading": "Loading file...",
|
||||
"viewer.markdown": "Markdown",
|
||||
"viewer.media_too_large": "File too large for inline playback. Download it to watch.",
|
||||
"viewer.media_unsupported": "This format cannot be played in your browser.",
|
||||
"viewer.metadata_created": "Created",
|
||||
"viewer.metadata_modified": "Modified",
|
||||
"viewer.metadata_path": "Path",
|
||||
@@ -2127,7 +2129,7 @@
|
||||
"guide105.lib_h3_conflicts": "Sync conflicts",
|
||||
"guide105.lib_conflicts": "If you sync the vault with Syncthing, ObsiGate detects conflict files (\"sync-conflict\" copies) and offers to compare then resolve them from a dedicated page in the Options menu.",
|
||||
"guide105.lib_h3_attach": "Attachments & media",
|
||||
"guide105.lib_attach": "Inline <code>![[image.png]]</code> images, attachments and media (audio, video, embedded PDFs) are rendered in the viewer and indexed for search. Images also appear in the file tree and open in a dedicated viewer (wheel zoom, pan, navigation between images in the folder, thumbnails, metadata, lightbox); the \"Rescan attachments\" button in Configuration rebuilds the attachment index.",
|
||||
"guide105.lib_attach": "Inline <code>![[image.png]]</code> images, attachments and media (audio, video, embedded PDFs) are rendered in the viewer and indexed for search. Images also appear in the file tree and open in a dedicated viewer (wheel zoom, pan, navigation between images in the folder, thumbnails, metadata, lightbox); audio (.mp3, .wav, .flac…) and video (.mp4, .webm…) files open in a built-in HTML5 player (play, seek, speed, fullscreen), falling back to download when the format is not playable in the browser. The \"Rescan attachments\" button in Configuration rebuilds the attachment index.",
|
||||
"guide105.off_pwa": "ObsiGate is a PWA: install it (install icon in the address bar) to open it like an app. The service worker caches the UI and your recently viewed documents.",
|
||||
"guide105.off_edit": "Offline you can read cached documents and even edit them: changes are queued in IndexedDB.",
|
||||
"guide105.off_sync": "When back online the queue replays automatically (sync badge in the header). If the server version diverged meanwhile, the file is flagged as conflict and the server copy is kept as a backup.",
|
||||
|
||||
@@ -1839,6 +1839,8 @@
|
||||
"viewer.loaded_from_cache": "Fichier chargé depuis le cache hors-ligne",
|
||||
"viewer.loading": "Chargement du fichier...",
|
||||
"viewer.markdown": "Markdown",
|
||||
"viewer.media_too_large": "Fichier trop volumineux pour la lecture intégrée. Téléchargez-le pour le lire.",
|
||||
"viewer.media_unsupported": "Ce format ne peut pas être lu dans votre navigateur.",
|
||||
"viewer.metadata_created": "Créé",
|
||||
"viewer.metadata_modified": "Modifié",
|
||||
"viewer.metadata_path": "Chemin",
|
||||
@@ -2127,7 +2129,7 @@
|
||||
"guide105.lib_h3_conflicts": "Conflits de synchronisation",
|
||||
"guide105.lib_conflicts": "Si vous synchronisez le vault avec Syncthing, ObsiGate détecte les fichiers de conflit (copies « sync-conflict ») et propose de les comparer puis résoudre depuis la page dédiée du menu Options.",
|
||||
"guide105.lib_h3_attach": "Fichiers joints & médias",
|
||||
"guide105.lib_attach": "Les images <code>![[image.png]]</code>, pièces jointes et médias (audio, vidéo, PDF intégrés) dans les notes sont rendus dans le viewer et indexés pour la recherche. Les images apparaissent aussi dans l'arborescence et s'ouvrent dans une visionneuse dédiée (zoom molette, pan, navigation entre images du dossier, miniatures, métadonnées, lightbox) ; le bouton « Rescan attachments » de la configuration recrée l'index des pièces jointes.",
|
||||
"guide105.lib_attach": "Les images <code>![[image.png]]</code>, pièces jointes et médias (audio, vidéo, PDF intégrés) dans les notes sont rendus dans le viewer et indexés pour la recherche. Les images apparaissent aussi dans l'arborescence et s'ouvrent dans une visionneuse dédiée (zoom molette, pan, navigation entre images du dossier, miniatures, métadonnées, lightbox) ; les fichiers audio (.mp3, .wav, .flac…) et vidéo (.mp4, .webm…) s'ouvrent dans un lecteur HTML5 intégré (lecture, déplacement, vitesse, plein écran), avec repli sur le téléchargement si le format n'est pas lisible par le navigateur. Le bouton « Rescan attachments » de la configuration recrée l'index des pièces jointes.",
|
||||
"guide105.off_pwa": "ObsiGate est une PWA : installez-la (icône d'installation de la barre d'adresse) pour l'ouvrir comme une application. Le service worker met en cache l'interface et vos derniers documents consultés.",
|
||||
"guide105.off_edit": "Hors-ligne, vous pouvez lire les documents en cache et même les éditer : les modifications sont mises en file d'attente dans IndexedDB.",
|
||||
"guide105.off_sync": "Au retour en ligne, la file se rejoue automatiquement (badge de synchronisation dans l'en-tête). Si la version serveur a divergé entre-temps, le fichier est marqué en conflit et la version serveur est préservée en backup.",
|
||||
|
||||
@@ -9766,6 +9766,89 @@ body.desktop-mode .editor-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Audio / Video viewers (roadmap #109) ── */
|
||||
.audio-viewer-container,
|
||||
.video-viewer-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
.media-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: var(--surface1);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.media-toolbar .media-title {
|
||||
color: var(--text);
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 40%;
|
||||
}
|
||||
.media-toolbar-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.media-duration-badge {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
min-width: 46px;
|
||||
text-align: center;
|
||||
}
|
||||
.media-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.audio-stage {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
padding: 32px 24px;
|
||||
min-height: 50vh;
|
||||
background: var(--surface2);
|
||||
}
|
||||
.audio-artwork {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface1);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--accent);
|
||||
}
|
||||
.audio-player {
|
||||
width: min(560px, 100%);
|
||||
}
|
||||
.video-stage {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
min-height: 60vh;
|
||||
}
|
||||
.video-player {
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - 180px);
|
||||
object-fit: contain;
|
||||
}
|
||||
.media-fallback-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* ── Text / CSV Viewer ── */
|
||||
.text-viewer-container {
|
||||
display: flex;
|
||||
|
||||
@@ -109,6 +109,13 @@ self.addEventListener('fetch', (event) => {
|
||||
// Let the browser handle range requests (PDF/streamed media) directly.
|
||||
if (request.headers.has('range')) return;
|
||||
|
||||
// Streamed audio/video is large and range-driven — never cache it
|
||||
// (roadmap #109-E2). Image thumbnails (/api/media/{vault}/thumb) stay cached.
|
||||
if (url.pathname.startsWith('/api/media/') && !url.pathname.endsWith('/thumb')) {
|
||||
event.respondWith(fetch(request));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
event.respondWith(networkFirst(request, API_CACHE, () =>
|
||||
new Response(JSON.stringify({ error: 'Offline' }), {
|
||||
|
||||
Reference in New Issue
Block a user