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
+23 -1
View File
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.18.0**.
> [Unreleased](#unreleased). La dernière version livrée est **2.19.0**.
---
@@ -14,6 +14,28 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.19.0] — 2026-09-23
### Ajouté
- **#110 — Lecteur média persistant « Now Playing »** : les fichiers audio et
vidéo continuent de jouer pendant la navigation grâce à un contrôleur global
`frontend/js/now-playing.js` qui possède **un seul** élément `<audio>`/`<video>`
téléporté entre la vue inline (onglet/panneau) et un dock flottant
(`<body>`), sans interruption de lecture. Dock desktop : pilule verre dépoli
(artwork, titre, voûte, play/pause, précédent/suivant, barre de progression,
volume, retour au média, agrandir, fermer). Mini-fenêtre vidéo flottante
déplaçable/redimensionnable avec aimantation aux coins et bouton
Picture-in-Picture natif. Mode mobile : mini-player fixé au-dessus de la barre
d'outils (safe-area `viewport-fit=cover`). Intégration **Media Session** (écran
verrouillé, touches matérielles, Windows SMTC), panneau étendu façon « Now
Playing », lecture auto du média suivant/précédent du dossier, reprise après
rechargement, notification quand l'onglet est fermé pendant la lecture. Fiche :
[docs/features/media-viewers-109.md](docs/features/media-viewers-109.md) (§ Now
Playing, #110).
---
## [2.18.0] — 2026-09-23
### Ajouté
+4 -4
View File
@@ -4,7 +4,7 @@
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
[![Version](https://img.shields.io/badge/Version-2.18.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.19.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -83,7 +83,7 @@ Les **guides d'utilisation** pas à pas se trouvent dans [`docs/GUIDES/`](docs/G
- **🏷️ Tag cloud** : Filtrage par tags extraits des frontmatters YAML
- **🔗 Wikilinks** : Les `[[liens internes]]` Obsidian sont cliquables
- **🖼️ Images Obsidian** : Support complet des syntaxes d'images Obsidian avec résolution intelligente
- **🎬 Audio & vidéo** : Lecteurs HTML5 intégrés (`.mp3 .wav .flac .mp4 .webm`…) avec streaming HTTP Range (lecture, déplacement, plein écran), repli téléchargement si le format n'est pas lisible par le navigateur
- **🎬 Audio & vidéo** : Lecteurs HTML5 intégrés (`.mp3 .wav .flac .mp4 .webm`…) avec streaming HTTP Range (lecture, déplacement, plein écran) et **lecture persistante** (mini-lecteur flottant / mini-fenêtre vidéo, retour au média ou arrêt à tout moment, contrôles écran verrouillé via Media Session), repli téléchargement si le format n'est pas lisible par le navigateur
- **🎨 Diagrammes Excalidraw** : Visualiseur/éditeur natif des fichiers `.excalidraw` et `.excalidraw.md` (iframe sandboxée, auto-save, thème clair/sombre, texte des diagrammes indexé pour la recherche)
- **🎨 Syntax highlight** : Coloration syntaxique des blocs de code
- **🌓 Thème clair/sombre** : Toggle persisté en localStorage
@@ -966,8 +966,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.18.0).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.19.0).
---
*Projet : ObsiGate | Version : 2.18.0 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.19.0 | Dernière mise à jour : Septembre 2026*
+4 -4
View File
@@ -2,7 +2,7 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
[![Version](https://img.shields.io/badge/Version-2.18.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.19.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -82,7 +82,7 @@ Step-by-step **user guides** live in [`docs/GUIDES/`](docs/GUIDES/):
- **🏷️ Tag Cloud** : Filtering by tags extracted from YAML frontmatters
- **🔗 Wikilinks** : `[[internal links]]` from Obsidian are clickable
- **🖼️ Obsidian Images** : Full support for all Obsidian image syntaxes with intelligent resolution
- **🎬 Audio & video** : Built-in HTML5 players (`.mp3 .wav .flac .mp4 .webm`…) with HTTP Range streaming (play, seek, fullscreen), falling back to download when the format is not playable in the browser
- **🎬 Audio & video** : Built-in HTML5 players (`.mp3 .wav .flac .mp4 .webm`…) with HTTP Range streaming (play, seek, fullscreen) and **persistent playback** (floating mini-player / mini video window, return to media or stop anytime, lock-screen controls via Media Session), falling back to download when the format is not playable in the browser
- **🎨 Excalidraw Diagrams** : Native viewer/editor for `.excalidraw` and `.excalidraw.md` files (sandboxed iframe, autosave, dark/light theme, diagram text indexed for search)
- **🎨 Syntax Highlight** : Syntax highlighting for code blocks
- **🌓 Light/Dark Theme** : Toggle persisted in localStorage
@@ -1142,8 +1142,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.18.0).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.19.0).
---
*Project: ObsiGate | Version: 2.18.0 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.19.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.18.0
2.19.0
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.18.0"
version = "2.19.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.18.0"
version = "2.19.0"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.18.0",
"version": "2.19.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+3 -2
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.18.0 | **Dernière mise à jour :** 2026-09-23
> **Version :** 2.19.0 | **Dernière mise à jour :** 2026-09-23
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
@@ -184,6 +184,7 @@
| 86 | Optimisation globale des performances (phase 3) — scan différentiel, excalidraw différé, garde-fou `replace` (inverted index / PDF lazy / caps regex déjà livrés via BUG-033/040/025) | 2.16.0 | [features/perf-phase3-86.md](./features/perf-phase3-86.md) |
| 108 | Support complet des images — arborescence, visionneuse (zoom/pan/navigation/miniatures), indexation nom+métadonnées, `media_types.py`, filtre `ext:`, SVG sandbox | 2.17.0 | [features/image-support.md](./features/image-support.md) |
| 109 | Support audio & vidéo — lecteurs HTML5 intégrés, streaming HTTP Range (`/api/media`), fallback codec/taille | 2.18.0 | [features/media-viewers-109.md](./features/media-viewers-109.md) |
| 110 | Lecteur média persistant « Now Playing » — élément partagé téléporté (inline ⇄ dock), Media Session, mini-vidéo PiP, mobile, reprise | 2.19.0 | [features/media-viewers-109.md](./features/media-viewers-109.md) |
---
@@ -191,7 +192,7 @@
| Priorité | Items | Effort total estimé |
|---|---|---|
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #86, #88–93, #94–100, #102–109, #92 | ~125 jours réalisés |
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #86, #88–93, #94–100, #102–110, #92 | ~130 jours réalisés |
| 🔵 P2 restant | #77 Desktop : signature de code (non retenue), 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) | ~0,5-1 jour |
| ⚪ P4 restant | #73 Sync (6-8j) | 6-8 jours |
| ⚪ P0/P1 restant | #85, #87 Refonte architecturale, CI/CD (BUG-035 → BUG-040 corrigés, #86 livré) | ~11-17 jours |
+85
View File
@@ -97,3 +97,88 @@ et ne peut pas être affiché » + bouton download.
- HLS, sous-titres `<track src=".vtt">` et vignettes vidéo : hors scope v1.
- Gros médias (> 500 Mo par défaut) : pas de lecture intégrée (protège le worker
uvicorn unique) ; ajustable via `OBSIGATE_MEDIA_MAX_INLINE_MB`.
---
## #110 — Lecteur média persistant « Now Playing »
> **Version livrée :** 2.19.0 · **Statut :** ✅ · **Impact :** 🟡
> **Zone :** frontend (`now-playing.js`, `viewer.js`, `ui.js`, `pane-manager.js`,
> `app.js`, `style.css`, `index.html`, locales).
### Principe : un seul média, téléporté
`frontend/js/now-playing.js` est un contrôleur singleton qui possède **l'unique
élément `<audio>`/`<video>`** de l'application. Il est déplacé par `appendChild`
(sans recréation, donc sans couper la lecture) entre :
- la **vue inline** — l'onglet/panneau du média, via la surface
`NowPlaying.attachInline(area, data)` (appelée par `renderAudioViewer` /
`renderVideoViewer` de `viewer.js`) ; et
- le **dock global** — un enfant direct de `<body>` (`#now-playing-host`), monté
hors de `.content-wrapper` pour survivre à `renderFile()`, aux onglets, aux
panneaux et à la reconstruction de la grille split.
`renderFile()` appelle `NowPlaying.handleRender(area, data)` : si la zone qui va
être réécrite contient l'élément média, celui-ci est renvoyé au dock. Les autres
points qui vident le contenu (dashboard `_showDashboard`, `showWelcome`,
`PaneManager._buildGrid` / `_collapseToSingle`) appellent le même hook via le
global `window.NowPlaying`.
### Surfaces et ergonomie
- **Dock audio (desktop)** : pilule flottante verre dépoli centrée en bas
(`.np-dock--audio`) — artwork, titre, voûte, durée, play/pause,
précédent/suivant, barre de progression (seek), volume, **revenir au média**,
agrandir, fermer.
- **Panneau étendu** : carte centrale (bottom-sheet sur mobile) avec artwork,
scrub large, volume, vitesse 0,5–2×, précédent/suivant et actions
ouvrir/télécharger/fermer.
- **Mini-vidéo flottante** (`.np-dock--video`) : déplaçable (aimantation au coin
le plus proche) et redimensionnable, géométrie persistée ; sur mobile elle se
fixe au-dessus de la barre d'outils.
- **Mobile** : mini-player au-dessus de la barre 64 px
(`bottom: calc(64px + env(safe-area-inset-bottom))`), `viewport-fit=cover`
ajouté, et `body.np-active` ajoute le décalage du contenu.
### Comportements
- Naviguer (onglet, panneau, dashboard, split) **ne coupe pas** la lecture ; le
dock apparaît.
- **Revenir au média** : `NowPlaying.focus()` rouvre/focalise l'onglet
`vault::path` (`window.getActiveTabManager().open`).
- **Fermer** : `NowPlaying.stop()` met en pause, libère l'élément et masque le
dock.
- **Fermer l'onglet** du média en cours : la lecture continue et un toast
`player.continues` le signale.
- **Media Session** : métadonnées (`MediaMetadata`) + actions
play/pause/stop/seek/nexttrack/previoustrack → écran verrouillé, casque
Bluetooth, touches média, **Windows SMTC** (WebView2).
- **Picture-in-Picture** natif pour la vidéo (`requestPictureInPicture`), bouton
masqué si non supporté.
- **Reprise après rechargement** : état (fichier, position, pause, préférences
volume/vitesse) persisté en `localStorage` (`obsigate-now-playing`,
`obsigate-player-prefs`, `obsigate-player-pos`).
### Fichiers modifiés / ajoutés
- Nouveau : `frontend/js/now-playing.js` (contrôleur, dock, session, PiP,
persistance), `tests/e2e/media-viewer.spec.js` (dock/retour/fermeture/mini-vidéo).
- Modifiés : `viewer.js` (délégation inline + `handleRender`), `ui.js`
(dashboard + toast de fermeture d'onglet), `pane-manager.js` (grille/collapse),
`app.js` (`initNowPlaying`), `style.css` (dock/étendu/vidéo/mobile, et
correction des variables `--surface1`/`--text-dim` non définies),
`index.html` (`viewport-fit=cover`), locales FR/EN (`player.*`).
### Correctifs annexes
- Les variables CSS `--surface1` et `--text-dim`, utilisées mais **jamais
définies** depuis #108/#109, sont remplacées par `--surface` et
`--text-secondary` (+ `--text-dim` dans toute la feuille).
### Hors scope (porte notée)
- Fenêtre vidéo détachée **native** Tauri (`WebviewWindowBuilder` +
`always_on_top`) : non implémentée, à faire dans une itération dédiée (Rust,
capabilities, route `/player`).
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title data-i18n="header.logo">ObsiGate</title>
<!-- PWA Meta Tags -->
+2
View File
@@ -6,6 +6,7 @@ import * as UI from './ui.js';
import * as Utils from './utils.js';
import { initI18n, t } from './i18n.js';
import { initAIFab } from './ai-fab.js';
import { initNowPlaying } from './now-playing.js';
// Wire up AI toolbar toast (avoids circular import in utils.js)
window._obsigateShowToast = UI.showToast;
@@ -113,6 +114,7 @@ async function init() {
setupFocusMode();
Utils.safeCreateIcons();
initAIFab();
initNowPlaying();
}
document.addEventListener("DOMContentLoaded", async () => {
+980
View File
@@ -0,0 +1,980 @@
/* ObsiGate — Global "Now Playing" media controller (roadmap #110).
*
* Owns a SINGLE <audio>/<video> element for the whole application and teleports
* it between two surfaces:
* - inline: inside the media's own tab/pane view (native controls);
* - dock: a body-level floating surface (audio pill / video mini-window)
* that keeps the media playing while the user navigates.
*
* Playback is therefore never interrupted by tab/pane re-renders. The module
* also wires the Media Session API (lock screen / hardware keys / OS overlay),
* native Picture-in-Picture for video, folder prev/next, and resumable state
* across reloads.
*
* The module is deliberately self-contained: it imports only i18n + low-level
* utils and reaches the tab system through the `window.*` globals exposed by
* ui.js / pane-manager.js (no import cycle).
*/
import { t } from './i18n.js';
import { safeCreateIcons } from './utils.js';
const LS_KEY = 'obsigate-now-playing';
const LS_PREFS = 'obsigate-player-prefs';
const LS_POS = 'obsigate-player-pos';
const AUDIO_EXTS = new Set(['.mp3', '.m4a', '.aac', '.wav', '.ogg', '.oga', '.opus', '.flac']);
const VIDEO_EXTS = new Set(['.mp4', '.webm', '.mov', '.m4v']);
/** 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 shared media element. */
export function buildMediaUrl(vault, path) {
return `/api/media/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`;
}
function extOf(p) {
const lower = (p || '').toLowerCase();
const dot = lower.lastIndexOf('.');
return dot === -1 ? '' : lower.slice(dot);
}
/** True when *p* is a supported audio path. */
export function isAudioPath(p) {
return AUDIO_EXTS.has(extOf(p));
}
/** True when *p* is a supported video path. */
export function isVideoPath(p) {
return VIDEO_EXTS.has(extOf(p));
}
function downloadUrl(vault, path) {
return `/api/file/${encodeURIComponent(vault)}/download?path=${encodeURIComponent(path)}`;
}
// ── Tiny DOM helpers (no dependency on viewer.js, which imports this module) ──
function npEl(tag, attrs, children) {
const e = document.createElement(tag);
if (attrs) {
Object.entries(attrs).forEach(([k, v]) => {
if (v === false || v == null) return;
if (k === 'class') e.className = v;
else e.setAttribute(k, v);
});
}
if (children) children.forEach((c) => { if (c) e.appendChild(c); });
return e;
}
function npIcon(name, size) {
const i = document.createElement('i');
i.setAttribute('data-lucide', name);
i.style.width = size + 'px';
i.style.height = size + 'px';
i.classList.add('icon');
return i;
}
function setIcon(btn, name) {
if (!btn) return;
const i = btn.querySelector('i');
if (i) i.setAttribute('data-lucide', name);
}
function notify(message, type = 'info') {
if (typeof window._obsigateShowToast === 'function') window._obsigateShowToast(message, type);
}
function isMobile() {
try { return window.matchMedia('(max-width: 768px)').matches; } catch (_) { return false; }
}
// ── Persistent prefs (volume / rate) ──────────────────────────────────────────
function readPrefs() {
try { return JSON.parse(localStorage.getItem(LS_PREFS) || '{}') || {}; } catch (_) { return {}; }
}
function writePrefs(patch) {
try { localStorage.setItem(LS_PREFS, JSON.stringify({ ...readPrefs(), ...patch })); } catch (_) { /* quota */ }
}
function readPosition() {
try { return JSON.parse(localStorage.getItem(LS_POS) || 'null'); } catch (_) { return null; }
}
function writePosition(pos) {
try { localStorage.setItem(LS_POS, JSON.stringify(pos)); } catch (_) { /* quota */ }
}
// ── Controller state ──────────────────────────────────────────────────────────
const S = {
active: false,
vault: null,
path: null,
title: '',
kind: null, // 'audio' | 'video'
streamUrl: '',
mime: '',
sizeBytes: 0,
media: null,
inline: false,
expanded: false,
siblings: [],
index: -1,
host: null,
dock: null,
dockKind: null,
expandedEl: null,
mediaSlotInline: null,
_saveTimer: null,
_drag: null,
};
// ── Media element lifecycle ───────────────────────────────────────────────────
function applyPrefsToMedia(media) {
const p = readPrefs();
if (typeof p.volume === 'number') media.volume = Math.min(1, Math.max(0, p.volume));
if (typeof p.muted === 'boolean') media.muted = p.muted;
if (typeof p.rate === 'number') media.playbackRate = p.rate;
}
function ensureMedia(kind) {
if (S.media && S.media.dataset.npKind === kind) return S.media;
if (S.media) destroyMedia();
const media = document.createElement(kind);
media.className = `np-media np-media--${kind}`;
media.dataset.npKind = kind;
media.setAttribute('controls', '');
media.setAttribute('preload', 'metadata');
media.setAttribute('playsinline', '');
media.setAttribute('aria-label', t('player.now_playing'));
attachMediaListeners(media);
applyPrefsToMedia(media);
S.media = media;
return media;
}
function destroyMedia() {
const m = S.media;
if (!m) return;
try { m.pause(); } catch (_) { /* detached */ }
m.removeAttribute('src');
try { m.load(); } catch (_) { /* ignore */ }
m.remove();
S.media = null;
}
function attachMediaListeners(media) {
media.addEventListener('loadedmetadata', () => {
if (Number.isFinite(media.duration)) updateAll();
});
media.addEventListener('timeupdate', () => {
updateProgress();
schedulePersist();
});
media.addEventListener('play', () => {
setIcon(S.dock && S.dock.querySelector('[data-np="play"]'), 'pause');
syncSessionPlayback();
persist();
});
media.addEventListener('pause', () => {
setIcon(S.dock && S.dock.querySelector('[data-np="play"]'), 'play');
syncSessionPlayback();
persist();
});
media.addEventListener('ended', () => {
if (!goSibling(1, { fromEnded: true })) { persist(); }
});
media.addEventListener('volumechange', () => {
writePrefs({ volume: media.volume, muted: media.muted });
updateVolumeInputs();
});
media.addEventListener('ratechange', () => {
writePrefs({ rate: media.playbackRate });
updateRateInputs();
});
media.addEventListener('error', () => onMediaError());
media.addEventListener('enterpictureinpicture', () => updatePipButton(true));
media.addEventListener('leavepictureinpicture', () => updatePipButton(false));
}
function onMediaError() {
if (!S.active) return;
const data = { vault: S.vault, path: S.path, title: S.title, stream_url: S.streamUrl, size_bytes: S.sizeBytes };
const message = t('viewer.media_unsupported');
if (S.inline && S.mediaSlotInline && S.mediaSlotInline.isConnected) {
const area = S.mediaSlotInline.closest('.audio-viewer-container, .video-viewer-container')?.parentElement;
renderFallback(area || S.mediaSlotInline.parentElement, data, message);
S.inline = false;
}
// Keep the dock but show a compact error state.
S.active = false;
clearPersisted();
if (S.dock) renderDockError(message);
}
/** Standalone fallback (unsupported codec / too large) used inline + in the dock. */
export function renderFallback(container, data, message) {
if (!container) return;
const sizeStr = formatBytes(data.size_bytes);
container.innerHTML = '';
const box = npEl('div', { class: 'unsupported-file' }, [
npIcon('file', 48),
npEl('div', { class: 'filename' }, [document.createTextNode((data.path || '').split('/').pop())]),
npEl('div', {}, [document.createTextNode(message)]),
sizeStr ? npEl('div', { style: 'font-size:0.85rem;margin-top:4px' }, [document.createTextNode(`${t('viewer.metadata_size')} : ${sizeStr}`)]) : null,
]);
const dl = npEl('button', { class: 'btn-action', type: 'button' }, [npIcon('download', 14), document.createTextNode(' ' + t('viewer.download'))]);
dl.addEventListener('click', () => window.open(downloadUrl(data.vault, data.path), '_blank'));
const open = npEl('button', { class: 'btn-action', type: 'button' }, [npIcon('external-link', 14), document.createTextNode(' ' + t('viewer.image_open_original'))]);
open.addEventListener('click', () => window.open(data.stream_url || buildMediaUrl(data.vault, data.path), '_blank'));
box.appendChild(npEl('div', { class: 'media-fallback-actions' }, [dl, open]));
container.appendChild(box);
safeCreateIcons();
}
function formatBytes(n) {
if (!n && n !== 0) return '';
if (n < 1024) return `${n} o`;
if (n < 1048576) return `${(n / 1024).toFixed(1)} Ko`;
return `${(n / 1048576).toFixed(1)} Mo`;
}
// ── Loading a media into the shared element ───────────────────────────────────
function load(data, { autoplay = true } = {}) {
const kind = data.is_audio ? 'audio' : 'video';
const sameKey = S.media && S.media.dataset.npKey === `${data.vault}::${data.path}`;
S.vault = data.vault;
S.path = data.path;
S.title = data.title || (data.path || '').split('/').pop();
S.kind = kind;
S.streamUrl = data.stream_url || buildMediaUrl(data.vault, data.path);
S.mime = data.media_mime || '';
S.sizeBytes = data.size_bytes || 0;
const media = ensureMedia(kind);
if (!sameKey) {
media.dataset.npKey = `${data.vault}::${data.path}`;
media.src = S.streamUrl;
}
S.active = true;
loadSiblings();
updateSessionMetadata();
if (autoplay && media.paused) {
const p = media.play();
if (p && p.catch) p.catch(() => { /* autoplay blocked — user presses play */ });
}
updateAll();
persist();
return media;
}
// ── Inline surface (the media's own tab/pane) ────────────────────────────────
function buildInline(data) {
const kind = S.kind;
const badge = npEl('span', { class: 'media-duration-badge' }, [document.createTextNode('--:--')]);
const vaultLabel = npEl('span', { class: 'media-meta' }, [document.createTextNode(`${data.vault} · ${formatBytes(S.sizeBytes)}`)]);
const toolbar = buildToolbar(data, [vaultLabel, badge]);
const container = npEl('div', { class: kind === 'audio' ? 'audio-viewer-container' : 'video-viewer-container' });
container.appendChild(toolbar);
let stage;
if (kind === 'audio') {
stage = npEl('div', { class: 'audio-stage' });
stage.appendChild(npEl('div', { class: 'audio-artwork' }, [npIcon('audio-lines', 64)]));
} else {
stage = npEl('div', { class: 'video-stage' });
}
const slot = npEl('div', { class: 'np-inline-slot' });
stage.appendChild(slot);
container.appendChild(stage);
return { container, slot, badge };
}
function buildToolbar(data, extra) {
const toolbar = npEl('div', { class: 'media-toolbar' });
toolbar.appendChild(npEl('span', { class: 'media-title', title: data.path }, [document.createTextNode(S.title)]));
toolbar.appendChild(npEl('div', { class: 'media-toolbar-spacer' }));
(extra || []).forEach((b) => toolbar.appendChild(b));
const open = npEl('button', { class: 'btn-action', type: 'button', title: t('viewer.image_open_original'), 'aria-label': t('viewer.image_open_original') }, [npIcon('external-link', 14)]);
open.addEventListener('click', () => window.open(S.streamUrl, '_blank'));
const dl = npEl('button', { class: 'btn-action', type: 'button', title: t('viewer.download'), 'aria-label': t('viewer.download') }, [npIcon('download', 14)]);
dl.addEventListener('click', () => window.open(downloadUrl(S.vault, S.path), '_blank'));
toolbar.appendChild(open);
toolbar.appendChild(dl);
return toolbar;
}
function attachInline(area, data) {
if (!area) return;
load(data, { autoplay: true });
const media = S.media;
const { container, slot, badge } = buildInline(data);
// Move the shared element into the (fresh) inline slot — no restart.
media.setAttribute('controls', '');
slot.appendChild(media);
area.innerHTML = '';
area.appendChild(container);
S.inline = true;
S.mediaSlotInline = slot;
hideDock();
if (Number.isFinite(media.duration)) badge.textContent = formatMediaDuration(media.duration);
media.addEventListener('loadedmetadata', function onMeta() {
if (badge && badge.isConnected) badge.textContent = formatMediaDuration(media.duration);
media.removeEventListener('loadedmetadata', onMeta);
});
updateAll();
safeCreateIcons();
}
/** If the shared media element lives inside *area*, move it out to the dock. */
function handleRender(area, data) {
if (!S.active || !S.media) return;
if (data && S.vault === data.vault && S.path === data.path) return; // own view attaches inline
if (S.inline && area && area.contains(S.media)) { showDock(); return; }
if (!S.media.isConnected) { showDock(); }
}
// ── Dock surface ──────────────────────────────────────────────────────────────
function ensureHost() {
if (S.host) return S.host;
const host = npEl('div', { id: 'now-playing-host', class: 'np-host' });
host.hidden = true;
document.body.appendChild(host);
S.host = host;
return host;
}
function stopEvent(e) { e.stopPropagation(); }
function ctrlButton(name, iconName, label, handler) {
const b = npEl('button', { class: 'np-ctrl', type: 'button', 'data-np': name, title: label, 'aria-label': label }, [npIcon(iconName, 16)]);
b.addEventListener('click', (e) => { stopEvent(e); handler(); });
return b;
}
function buildAudioDock() {
const dock = npEl('div', { class: 'np-dock np-dock--audio', role: 'region', 'aria-label': t('player.now_playing') });
const art = npEl('button', { class: 'np-dock-art', type: 'button', title: t('player.expand'), 'aria-label': t('player.expand') }, [npIcon('audio-lines', 22)]);
art.addEventListener('click', (e) => { stopEvent(e); toggleExpanded(); });
dock.appendChild(art);
const info = npEl('div', { class: 'np-dock-info' });
info.appendChild(npEl('span', { class: 'np-dock-title' }));
info.appendChild(npEl('span', { class: 'np-dock-sub' }));
dock.appendChild(info);
const controls = npEl('div', { class: 'np-dock-controls' });
controls.appendChild(ctrlButton('prev', 'skip-back', t('player.previous'), () => goSibling(-1)));
controls.appendChild(ctrlButton('play', 'play', t('player.play'), togglePlay));
controls.appendChild(ctrlButton('next', 'skip-forward', t('player.next'), () => goSibling(1)));
dock.appendChild(controls);
const progress = npEl('div', { class: 'np-dock-progress' });
const cur = npEl('span', { class: 'np-time np-time-cur' }, [document.createTextNode('0:00')]);
const seek = npEl('input', { class: 'np-seek', type: 'range', min: '0', max: '1000', value: '0', 'aria-label': t('player.seek'), 'data-np': 'seek' });
bindSeek(seek);
const dur = npEl('span', { class: 'np-time np-time-dur' }, [document.createTextNode('0:00')]);
progress.appendChild(cur);
progress.appendChild(seek);
progress.appendChild(dur);
dock.appendChild(progress);
const actions = npEl('div', { class: 'np-dock-actions' });
const volWrap = npEl('div', { class: 'np-volume' });
volWrap.appendChild(npIcon('volume-2', 16));
const vol = npEl('input', { class: 'np-volume-range', type: 'range', min: '0', max: '1', step: '0.05', value: '1', 'aria-label': t('player.volume'), 'data-np': 'volume' });
vol.addEventListener('input', (e) => { stopEvent(e); if (S.media) { S.media.volume = Number(vol.value); S.media.muted = false; } });
volWrap.appendChild(vol);
actions.appendChild(volWrap);
actions.appendChild(ctrlButton('reopen', 'maximize-2', t('player.reopen_tab'), focus));
actions.appendChild(ctrlButton('expand', 'chevron-up', t('player.expand'), toggleExpanded));
actions.appendChild(ctrlButton('close', 'x', t('player.close'), () => stop()));
dock.appendChild(actions);
return dock;
}
function buildVideoDock() {
const dock = npEl('div', { class: 'np-dock np-dock--video', role: 'region', 'aria-label': t('player.now_playing') });
const frame = npEl('div', { class: 'np-dock-video-frame' });
const slot = npEl('div', { class: 'np-dock-video-slot', 'data-np': 'slot' });
const bar = npEl('div', { class: 'np-dock-video-bar' });
const grip = npEl('span', { class: 'np-video-grip', title: t('player.move') }, [npIcon('grip-horizontal', 14)]);
bar.appendChild(grip);
bar.appendChild(ctrlButton('play', 'play', t('player.play'), togglePlay));
const seek = npEl('input', { class: 'np-seek', type: 'range', min: '0', max: '1000', value: '0', 'aria-label': t('player.seek'), 'data-np': 'seek' });
bindSeek(seek);
bar.appendChild(seek);
const time = npEl('span', { class: 'np-time np-time-cur' }, [document.createTextNode('0:00')]);
bar.appendChild(time);
bar.appendChild(ctrlButton('pip', 'picture-in-picture-2', t('player.pip'), togglePip));
bar.appendChild(ctrlButton('expand', 'maximize-2', t('player.reopen_tab'), focus));
bar.appendChild(ctrlButton('close', 'x', t('player.close'), () => stop()));
frame.appendChild(slot);
frame.appendChild(bar);
dock.appendChild(frame);
const resize = npEl('div', { class: 'np-video-resize', title: t('player.resize'), 'aria-hidden': 'true' });
dock.appendChild(resize);
bindVideoDrag(dock, grip);
bindVideoResize(dock, resize);
return dock;
}
function buildDock() {
ensureHost();
const kind = S.kind;
if (S.dock && S.dockKind === kind) return S.dock;
if (S.dock) S.dock.remove();
S.dock = kind === 'audio' ? buildAudioDock() : buildVideoDock();
S.dockKind = kind;
S.host.appendChild(S.dock);
applyVideoGeometry();
bindDelegatedControls();
return S.dock;
}
function bindDelegatedControls() {
if (!S.dock || S.dock._npBound) return;
S.dock._npBound = true;
}
function showDock() {
if (!S.active || !S.media) return;
const dock = buildDock();
const slot = dock.querySelector('[data-np="slot"]');
if (S.kind === 'video' && slot) {
S.media.setAttribute('controls', '');
slot.appendChild(S.media);
} else {
// Audio: element stays hidden (wrapper), custom pill drives it.
dock.appendChild(S.media);
}
S.inline = false;
S.mediaSlotInline = null;
updateAll();
if (S.host) S.host.hidden = false;
dock.hidden = false;
document.body.classList.add('np-active');
safeCreateIcons();
}
function hideDock() {
if (S.dock) S.dock.hidden = true;
if (S.host) S.host.hidden = true;
document.body.classList.remove('np-active');
if (S.expanded) closeExpanded();
}
function renderDockError(message) {
if (!S.dock) return;
const dock = S.dock;
dock.classList.add('np-dock--error');
const slot = dock.querySelector('[data-np="slot"]');
if (slot) {
slot.innerHTML = '';
slot.appendChild(npEl('div', { class: 'np-dock-error-msg' }, [document.createTextNode(message)]));
}
}
// ── Progress / seek ───────────────────────────────────────────────────────────
function bindSeek(input) {
let scrubbing = false;
input.addEventListener('pointerdown', () => { scrubbing = true; });
input.addEventListener('pointerup', () => { scrubbing = false; });
input.addEventListener('input', (e) => {
stopEvent(e);
if (!S.media) return;
const d = S.media.duration;
if (Number.isFinite(d) && d > 0) {
S.media.currentTime = (Number(input.value) / 1000) * d;
updateProgress();
}
});
input.addEventListener('change', () => { scrubbing = false; });
void scrubbing;
}
function updateProgress() {
const media = S.media;
if (!media) return;
const d = Number.isFinite(media.duration) ? media.duration : 0;
const cur = media.currentTime || 0;
const pct = d > 0 ? Math.round((cur / d) * 1000) : 0;
const curText = formatMediaDuration(cur) || '0:00';
const durText = formatMediaDuration(d) || '0:00';
document.querySelectorAll('.np-seek').forEach((el) => {
if (el === document.activeElement) return;
el.value = String(pct);
});
document.querySelectorAll('.np-time-cur').forEach((el) => { el.textContent = curText; });
document.querySelectorAll('.np-time-dur').forEach((el) => { el.textContent = durText; });
}
function updateVolumeInputs() {
if (!S.media) return;
document.querySelectorAll('.np-volume-range').forEach((el) => { el.value = String(S.media.muted ? 0 : S.media.volume); });
}
function updateRateInputs() {
if (!S.media) return;
document.querySelectorAll('.np-rate').forEach((el) => { el.value = String(S.media.playbackRate); });
}
function selfDurationLabel() {
return S.media && Number.isFinite(S.media.duration) ? formatMediaDuration(S.media.duration) : '';
}
function updateAll() {
const dock = S.dock;
if (dock) {
const title = dock.querySelector('.np-dock-title');
if (title) title.textContent = S.title;
const sub = dock.querySelector('.np-dock-sub');
if (sub) sub.textContent = S.kind === 'audio' ? `${S.vault}` : `${S.vault} · ${selfDurationLabel()}`.trim();
setIcon(dock.querySelector('[data-np="play"]'), S.media && !S.media.paused ? 'pause' : 'play');
const prev = dock.querySelector('[data-np="prev"]');
const next = dock.querySelector('[data-np="next"]');
const multi = S.siblings.length > 1;
if (prev) prev.disabled = !multi;
if (next) next.disabled = !multi;
const pip = dock.querySelector('[data-np="pip"]');
if (pip) pip.hidden = !supportsPip();
}
updateProgress();
updateVolumeInputs();
updateRateInputs();
updatePipButton(!!(document.pictureInPictureElement));
updateExpanded();
}
function togglePlay() {
if (!S.media) return;
if (S.media.paused) {
const p = S.media.play();
if (p && p.catch) p.catch(() => { /* blocked */ });
} else {
S.media.pause();
}
}
// ── Prev / next within the media folder ───────────────────────────────────────
async function loadSiblings() {
try {
const dir = S.path.includes('/') ? S.path.slice(0, S.path.lastIndexOf('/')) : '';
const res = await fetch(`/api/browse/${encodeURIComponent(S.vault)}?path=${encodeURIComponent(dir)}`, { credentials: 'include' });
if (!res.ok) return;
const data = await res.json();
const want = S.kind;
S.siblings = (data.items || [])
.filter((it) => it.type === 'file')
.filter((it) => (want === 'audio' ? isAudioPath(it.path) : isVideoPath(it.path)));
S.index = S.siblings.findIndex((it) => it.path === S.path);
updateAll();
} catch (_) { /* best effort */ }
}
function goSibling(delta) {
if (S.siblings.length < 2 || S.index < 0) return false;
const next = (S.index + delta + S.siblings.length) % S.siblings.length;
openInApp(S.vault, S.siblings[next].path);
return true;
}
function openInApp(vault, path) {
const tm = (window.getActiveTabManager && window.getActiveTabManager()) || window.TabManager;
if (tm && typeof tm.open === 'function') {
Promise.resolve(tm.open(vault, path)).catch(() => { /* ignore */ });
return true;
}
if (window.Viewer && typeof window.Viewer.openFile === 'function') {
window.Viewer.openFile(vault, path);
return true;
}
return false;
}
// ── Public actions ────────────────────────────────────────────────────────────
export function focusNowPlaying() {
if (!S.active) return;
openInApp(S.vault, S.path);
}
const focus = focusNowPlaying;
/** Notify (once) that the media's tab was closed while playback continues. */
function notifyTabClosed(vault, path) {
if (S.active && S.vault === vault && S.path === path) notify(t('player.continues'), 'info');
}
export function stopNowPlaying() {
if (S.media) { try { S.media.pause(); } catch (_) { /* detached */ } }
S.active = false;
S.inline = false;
S.mediaSlotInline = null;
hideDock();
clearPersisted();
destroyMedia();
S.vault = S.path = null;
S.title = '';
S.kind = null;
S.siblings = [];
S.index = -1;
clearSession();
}
const stop = stopNowPlaying;
function toggleExpanded() {
if (S.expanded) closeExpanded();
else openExpanded();
}
function buildExpanded() {
const root = npEl('div', { class: 'np-expanded', role: 'dialog', 'aria-label': t('player.now_playing') });
const backdrop = npEl('div', { class: 'np-expanded-backdrop' });
backdrop.addEventListener('click', closeExpanded);
const card = npEl('div', { class: 'np-exp-card' });
card.appendChild(npEl('div', { class: 'np-exp-art' }, [npIcon('audio-lines', 56)]));
card.appendChild(npEl('div', { class: 'np-exp-title' }));
card.appendChild(npEl('div', { class: 'np-exp-sub' }));
const seek = npEl('input', { class: 'np-seek np-exp-seek', type: 'range', min: '0', max: '1000', value: '0', 'aria-label': t('player.seek') });
bindSeek(seek);
card.appendChild(seek);
const times = npEl('div', { class: 'np-exp-times' }, [
npEl('span', { class: 'np-time-cur' }, [document.createTextNode('0:00')]),
npEl('span', { class: 'np-time-dur' }, [document.createTextNode('0:00')]),
]);
card.appendChild(times);
const controls = npEl('div', { class: 'np-exp-controls' });
controls.appendChild(ctrlButton('prev', 'skip-back', t('player.previous'), () => goSibling(-1)));
const play = ctrlButton('play', 'play', t('player.play'), togglePlay);
play.classList.add('np-exp-play');
controls.appendChild(play);
controls.appendChild(ctrlButton('next', 'skip-forward', t('player.next'), () => goSibling(1)));
card.appendChild(controls);
const extra = npEl('div', { class: 'np-exp-extra' });
const vol = npEl('input', { class: 'np-volume-range', type: 'range', min: '0', max: '1', step: '0.05', value: '1', 'aria-label': t('player.volume') });
vol.addEventListener('input', () => { if (S.media) { S.media.volume = Number(vol.value); S.media.muted = false; } });
const rate = npEl('select', { class: 'np-rate', 'aria-label': t('player.speed') });
['0.5', '0.75', '1', '1.25', '1.5', '2'].forEach((v) => rate.appendChild(npEl('option', { value: v }, [document.createTextNode(v + '×')])));
rate.addEventListener('change', () => { if (S.media) S.media.playbackRate = Number(rate.value); });
extra.appendChild(npEl('span', { class: 'np-vol-icon' }, [npIcon('volume-2', 16)]));
extra.appendChild(vol);
extra.appendChild(rate);
extra.appendChild(ctrlButton('open', 'external-link', t('viewer.image_open_original'), () => window.open(S.streamUrl, '_blank')));
extra.appendChild(ctrlButton('download', 'download', t('viewer.download'), () => window.open(downloadUrl(S.vault, S.path), '_blank')));
extra.appendChild(ctrlButton('close', 'x', t('player.close'), () => stop()));
card.appendChild(extra);
root.appendChild(backdrop);
root.appendChild(card);
return root;
}
function openExpanded() {
ensureHost();
if (!S.expandedEl) {
S.expandedEl = buildExpanded();
S.host.appendChild(S.expandedEl);
}
S.expandedEl.hidden = false;
S.expanded = true;
updateExpanded();
safeCreateIcons();
}
function closeExpanded() {
if (S.expandedEl) S.expandedEl.hidden = true;
S.expanded = false;
}
function updateExpanded() {
const root = S.expandedEl;
if (!root || root.hidden) return;
const title = root.querySelector('.np-exp-title');
if (title) title.textContent = S.title;
const sub = root.querySelector('.np-exp-sub');
if (sub) sub.textContent = S.vault || '';
const play = root.querySelector('.np-exp-play');
setIcon(play, S.media && !S.media.paused ? 'pause' : 'play');
const multi = S.siblings.length > 1;
const prev = root.querySelector('[data-np="prev"]');
const next = root.querySelector('[data-np="next"]');
if (prev) prev.disabled = !multi;
if (next) next.disabled = !multi;
}
// ── Picture-in-Picture ────────────────────────────────────────────────────────
function supportsPip() {
return !!(document.pictureInPictureEnabled) && !!S.media && S.media.tagName === 'VIDEO';
}
function togglePip() {
if (!supportsPip()) return;
if (document.pictureInPictureElement) {
document.exitPictureInPicture().catch(() => { /* ignore */ });
} else {
S.media.requestPictureInPicture().catch(() => { /* ignore */ });
}
}
function updatePipButton(active) {
const btn = S.dock && S.dock.querySelector('[data-np="pip"]');
if (btn) btn.classList.toggle('active', !!active);
}
// ── Media Session API (OS / lock screen / hardware keys) ──────────────────────
function updateSessionMetadata() {
if (!('mediaSession' in navigator) || typeof MediaMetadata === 'undefined') return;
try {
navigator.mediaSession.metadata = new MediaMetadata({
title: S.title || '',
artist: S.vault || '',
album: 'ObsiGate',
artwork: [
{ src: '/static/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/static/icon-512.png', sizes: '512x512', type: 'image/png' },
],
});
navigator.mediaSession.setActionHandler('play', () => togglePlay());
navigator.mediaSession.setActionHandler('pause', () => togglePlay());
navigator.mediaSession.setActionHandler('stop', () => stop());
navigator.mediaSession.setActionHandler('seekbackward', (d) => seekBy(-(d?.seekOffset || 10)));
navigator.mediaSession.setActionHandler('seekforward', (d) => seekBy(d?.seekOffset || 10));
navigator.mediaSession.setActionHandler('seekto', (d) => { if (S.media && d && typeof d.seekTime === 'number') S.media.currentTime = d.seekTime; });
navigator.mediaSession.setActionHandler('previoustrack', S.siblings.length > 1 ? () => goSibling(-1) : null);
navigator.mediaSession.setActionHandler('nexttrack', S.siblings.length > 1 ? () => goSibling(1) : null);
} catch (_) { /* unsupported action */ }
}
function syncSessionPlayback() {
if (!('mediaSession' in navigator)) return;
try {
navigator.mediaSession.playbackState = S.media && !S.media.paused ? 'playing' : 'paused';
if (S.media && 'setPositionState' in navigator.mediaSession && Number.isFinite(S.media.duration) && S.media.duration > 0) {
navigator.mediaSession.setPositionState({
duration: S.media.duration,
playbackRate: S.media.playbackRate || 1,
position: Math.min(S.media.currentTime || 0, S.media.duration),
});
}
} catch (_) { /* ignore */ }
}
function seekBy(delta) {
if (!S.media) return;
const d = S.media.duration;
const target = (S.media.currentTime || 0) + delta;
S.media.currentTime = Number.isFinite(d) ? Math.max(0, Math.min(d, target)) : Math.max(0, target);
}
function clearSession() {
if (!('mediaSession' in navigator)) return;
try {
navigator.mediaSession.metadata = null;
navigator.mediaSession.playbackState = 'none';
} catch (_) { /* ignore */ }
}
// ── Persistence (resume across reloads) ───────────────────────────────────────
function persist() {
if (!S.active || !S.media) return;
try {
localStorage.setItem(LS_KEY, JSON.stringify({
vault: S.vault, path: S.path, title: S.title, kind: S.kind,
streamUrl: S.streamUrl, mime: S.mime, sizeBytes: S.sizeBytes,
time: S.media.currentTime || 0, paused: !!S.media.paused,
}));
} catch (_) { /* quota / private mode */ }
}
function schedulePersist() {
if (S._saveTimer) return;
S._saveTimer = setTimeout(() => { S._saveTimer = null; persist(); }, 2000);
}
function clearPersisted() {
try { localStorage.removeItem(LS_KEY); } catch (_) { /* ignore */ }
}
function restore() {
let saved = null;
try { saved = JSON.parse(localStorage.getItem(LS_KEY) || 'null'); } catch (_) { saved = null; }
if (!saved || !saved.path) return;
S.vault = saved.vault; S.path = saved.path; S.title = saved.title || saved.path.split('/').pop();
S.kind = saved.kind || (isVideoPath(saved.path) ? 'video' : 'audio');
S.streamUrl = saved.streamUrl || buildMediaUrl(saved.vault, saved.path);
S.mime = saved.mime || ''; S.sizeBytes = saved.sizeBytes || 0;
const media = ensureMedia(S.kind);
media.dataset.npKey = `${S.vault}::${S.path}`;
media.src = S.streamUrl;
const time = Number(saved.time) || 0;
if (time > 1) {
media.addEventListener('loadedmetadata', function once() {
try { media.currentTime = time; } catch (_) { /* ignore */ }
media.removeEventListener('loadedmetadata', once);
});
}
S.active = true;
loadSiblings();
updateSessionMetadata();
showDock();
notify(t('player.resume'), 'info');
}
// ── Video dock drag & resize ──────────────────────────────────────────────────
const VIDEO_MIN = { w: 240, h: 150 };
function applyVideoGeometry() {
if (!S.dock || S.dockKind !== 'video') return;
const pos = readPosition();
const mobile = isMobile();
if (mobile) {
S.dock.style.removeProperty('left');
S.dock.style.removeProperty('top');
S.dock.style.removeProperty('width');
S.dock.style.removeProperty('height');
return;
}
const w = pos && pos.w ? pos.w : 360;
const h = pos && pos.h ? pos.h : 220;
S.dock.style.width = w + 'px';
S.dock.style.height = h + 'px';
const vw = window.innerWidth, vh = window.innerHeight;
const margin = 16;
const corner = (pos && pos.corner) || 'br';
let left, top;
if (corner.includes('l')) left = margin; else left = vw - w - margin;
if (corner.includes('t')) top = margin + 56; else top = vh - h - 24;
S.dock.style.left = Math.max(margin, left) + 'px';
S.dock.style.top = Math.max(margin, top) + 'px';
}
function nearestCorner(left, top, w, h) {
const midX = left + w / 2, midY = top + h / 2;
const vw = window.innerWidth, vh = window.innerHeight;
return `${midY < vh / 2 ? 't' : 'b'}${midX < vw / 2 ? 'l' : 'r'}`;
}
function bindVideoDrag(dock, grip) {
if (!grip) return;
grip.style.cursor = 'grab';
grip.addEventListener('pointerdown', (e) => {
if (isMobile()) return;
e.preventDefault();
const rect = dock.getBoundingClientRect();
S._drag = { dx: e.clientX - rect.left, dy: e.clientY - rect.top, w: rect.width, h: rect.height };
grip.setPointerCapture?.(e.pointerId);
grip.style.cursor = 'grabbing';
document.addEventListener('pointermove', onDragMove);
document.addEventListener('pointerup', onDragEnd);
});
}
function onDragMove(e) {
if (!S._drag || !S.dock) return;
const margin = 8;
const w = S.dock.offsetWidth, h = S.dock.offsetHeight;
let left = e.clientX - S._drag.dx;
let top = e.clientY - S._drag.dy;
left = Math.max(margin, Math.min(window.innerWidth - w - margin, left));
top = Math.max(margin, Math.min(window.innerHeight - h - margin, top));
S.dock.style.left = left + 'px';
S.dock.style.top = top + 'px';
}
function onDragEnd(e) {
document.removeEventListener('pointermove', onDragMove);
document.removeEventListener('pointerup', onDragEnd);
if (!S.dock) return;
const r = S.dock.getBoundingClientRect();
const corner = nearestCorner(r.left, r.top, r.width, r.height);
writePosition({ corner, w: Math.round(r.width), h: Math.round(r.height) });
applyVideoGeometry();
void e;
S._drag = null;
}
function bindVideoResize(dock, handle) {
if (!handle) return;
handle.addEventListener('pointerdown', (e) => {
if (isMobile()) return;
e.preventDefault();
e.stopPropagation();
const rect = dock.getBoundingClientRect();
S._drag = { resize: true, startX: e.clientX, startY: e.clientY, w: rect.width, h: rect.height };
handle.setPointerCapture?.(e.pointerId);
document.addEventListener('pointermove', onResizeMove);
document.addEventListener('pointerup', onResizeEnd);
});
}
function onResizeMove(e) {
if (!S._drag || !S._drag.resize || !S.dock) return;
const w = Math.max(VIDEO_MIN.w, S._drag.w + (e.clientX - S._drag.startX));
const h = Math.max(VIDEO_MIN.h, S._drag.h + (e.clientY - S._drag.startY));
S.dock.style.width = w + 'px';
S.dock.style.height = h + 'px';
}
function onResizeEnd() {
document.removeEventListener('pointermove', onResizeMove);
document.removeEventListener('pointerup', onResizeEnd);
if (S.dock) {
const r = S.dock.getBoundingClientRect();
const pos = readPosition() || {};
writePosition({ ...pos, w: Math.round(r.width), h: Math.round(r.height) });
applyVideoGeometry();
}
S._drag = null;
}
// ── Keyboard ──────────────────────────────────────────────────────────────────
function onKeydown(e) {
if (!S.active) return;
const host = S.host;
if (!host || (!host.contains(document.activeElement) && !S.expanded)) return;
if (e.key === 'Escape') {
if (S.expanded) { closeExpanded(); }
e.preventDefault();
} else if (e.key === ' ' || e.key === 'k') {
e.preventDefault();
togglePlay();
}
}
// ── Init ──────────────────────────────────────────────────────────────────────
export function initNowPlaying() {
window.NowPlaying = NowPlaying;
ensureHost();
if (!S._keyBound) {
document.addEventListener('keydown', onKeydown);
window.addEventListener('resize', () => applyVideoGeometry());
S._keyBound = true;
}
restore();
}
export const NowPlaying = {
isActive: () => S.active,
matches: (vault, path) => S.active && S.vault === vault && S.path === path,
current: () => (S.active ? { vault: S.vault, path: S.path, kind: S.kind, paused: S.media ? S.media.paused : true } : null),
attachInline,
handleRender,
showDock,
focus: focusNowPlaying,
stop: stopNowPlaying,
notifyTabClosed,
};
export default initNowPlaying;
+8
View File
@@ -197,9 +197,13 @@ function createPaneTabManager(paneId) {
close(tabId) {
const idx = this._tabs.findIndex(t => t.id === tabId);
if (idx === -1) return;
const closingTab = this._tabs[idx];
this._tabs.splice(idx, 1);
delete this._tabCache[tabId];
this._dirtyTabs.delete(tabId);
if (window.NowPlaying && closingTab) {
window.NowPlaying.notifyTabClosed(closingTab.vault, closingTab.path);
}
if (this._tabs.length === 0) {
this._activeTabId = null;
// If this pane has no more tabs and isn't the last pane, close it
@@ -826,6 +830,8 @@ const PaneManager = {
grid.className = 'pane-grid';
this._applyGridTemplate(grid, n);
// #110 — keep playing media alive across a pane-grid rebuild.
if (window.NowPlaying) window.NowPlaying.handleRender(wrapper, null);
wrapper.innerHTML = '';
wrapper.appendChild(grid);
this.panes = [];
@@ -1216,6 +1222,8 @@ const PaneManager = {
if (!grid) return;
const wrapper = grid.parentElement;
const pane0 = this.panes[0];
// #110 — keep playing media alive across a pane collapse.
if (window.NowPlaying) window.NowPlaying.handleRender(wrapper, null);
wrapper.innerHTML = '';
let tabBar = null, content = null;
if (pane0 && pane0.element) {
+7
View File
@@ -2080,11 +2080,16 @@ export const TabManager = {
}
const idx = this._tabs.findIndex(t => t.id === tabId);
if (idx === -1) return;
const closingTab = this._tabs[idx];
this._tabs.splice(idx, 1);
delete this._tabCache[tabId];
this._dirtyTabs.delete(tabId);
if (window.NowPlaying && closingTab) {
window.NowPlaying.notifyTabClosed(closingTab.vault, closingTab.path);
}
if (this._tabs.length === 0) {
this._activeTabId = null;
this._showDashboard();
@@ -2207,6 +2212,8 @@ export const TabManager = {
_showDashboard() {
const area = document.getElementById("content-area");
// #110 — move any playing media to the persistent dock before wiping.
if (window.NowPlaying && area) window.NowPlaying.handleRender(area, null);
// Save dashboard DOM before clearing (it may have been removed from DOM by renderFile)
let dashboard = document.getElementById("dashboard-home");
if (!dashboard) {
+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) {
+18 -1
View File
@@ -2028,6 +2028,23 @@
"mfa.webauthn_btn": "Verify with my key",
"mfa.webauthn_cancelled": "WebAuthn ceremony cancelled.",
"mfa.webauthn_no_key": "No security key registered for this account.",
"player.now_playing": "Now playing",
"player.play": "Play",
"player.pause": "Pause",
"player.previous": "Previous",
"player.next": "Next",
"player.seek": "Seek",
"player.volume": "Volume",
"player.speed": "Playback speed",
"player.expand": "Expand player",
"player.minimize": "Minimize player",
"player.close": "Stop and close player",
"player.continues": "Playback continues — use the player to stop it.",
"player.reopen_tab": "Return to media",
"player.pip": "Picture in picture",
"player.move": "Move",
"player.resize": "Resize",
"player.resume": "Resuming last playback",
"plugins.title": "🧩 Plugins",
"plugins.description": "Extend ObsiGate with custom renderers, search filters, and editor actions.",
"plugins.install": "Install Plugin",
@@ -2129,7 +2146,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); 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.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; playback continues while you navigate thanks to a floating mini-player (audio) or a mini video window, letting you return to the media or stop it at any time. 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.",
+18 -1
View File
@@ -2028,6 +2028,23 @@
"mfa.webauthn_btn": "Valider avec ma clé",
"mfa.webauthn_cancelled": "Cérémonie WebAuthn annulée.",
"mfa.webauthn_no_key": "Aucune clé de sécurité enregistrée pour ce compte.",
"player.now_playing": "Lecture en cours",
"player.play": "Lecture",
"player.pause": "Pause",
"player.previous": "Précédent",
"player.next": "Suivant",
"player.seek": "Position de lecture",
"player.volume": "Volume",
"player.speed": "Vitesse de lecture",
"player.expand": "Agrandir le lecteur",
"player.minimize": "Réduire le lecteur",
"player.close": "Arrêter et fermer le lecteur",
"player.continues": "Lecture en cours — utilisez le lecteur pour l'arrêter.",
"player.reopen_tab": "Revenir au média",
"player.pip": "Image dans l'image",
"player.move": "Déplacer",
"player.resize": "Redimensionner",
"player.resume": "Reprise de la dernière lecture",
"plugins.title": "🧩 Plugins",
"plugins.description": "Étendez ObsiGate avec des renderers personnalisés, filtres de recherche et actions d'éditeur.",
"plugins.install": "Installer le plugin",
@@ -2129,7 +2146,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) ; 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.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 ; la lecture continue pendant la navigation grâce à un mini-lecteur flottant (audio) ou une mini-fenêtre vidéo, qui permet à tout moment de revenir au média ou de l'arrêter. 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.",
+423 -21
View File
@@ -9569,14 +9569,14 @@ body.desktop-mode .editor-container {
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--surface1);
background: var(--surface);
border-bottom: 1px solid var(--border);
font-size: 0.85rem;
flex-shrink: 0;
}
.pdf-info {
flex: 1;
color: var(--text-dim);
color: var(--text-secondary);
}
.pdf-iframe {
flex: 1;
@@ -9601,7 +9601,7 @@ body.desktop-mode .editor-container {
.pdf-toc h3 {
margin: 0 0 8px 0;
font-size: 0.9rem;
color: var(--text-dim);
color: var(--text-secondary);
}
.pdf-toc ul {
list-style: none;
@@ -9620,7 +9620,7 @@ body.desktop-mode .editor-container {
text-decoration: underline;
}
.toc-page {
color: var(--text-dim);
color: var(--text-secondary);
font-size: 0.75rem;
margin-left: 4px;
}
@@ -9631,7 +9631,7 @@ body.desktop-mode .editor-container {
align-items: center;
justify-content: center;
min-height: 200px;
background: var(--surface1);
background: var(--surface);
padding: 20px;
}
.image-viewer-body img {
@@ -9651,7 +9651,7 @@ body.desktop-mode .editor-container {
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--surface1);
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
@@ -9669,7 +9669,7 @@ body.desktop-mode .editor-container {
.image-zoom-badge {
font-variant-numeric: tabular-nums;
font-size: 0.75rem;
color: var(--text-dim);
color: var(--text-secondary);
min-width: 46px;
text-align: center;
}
@@ -9698,7 +9698,7 @@ body.desktop-mode .editor-container {
will-change: transform;
}
.image-error {
color: var(--text-dim);
color: var(--text-secondary);
font-size: 0.9rem;
}
.image-nav-strip {
@@ -9706,7 +9706,7 @@ body.desktop-mode .editor-container {
gap: 6px;
padding: 8px;
overflow-x: auto;
background: var(--surface1);
background: var(--surface);
border-top: 1px solid var(--border);
flex-shrink: 0;
}
@@ -9732,10 +9732,10 @@ body.desktop-mode .editor-container {
}
.image-meta-panel {
padding: 10px 16px;
background: var(--surface1);
background: var(--surface);
border-top: 1px solid var(--border);
font-size: 0.8rem;
color: var(--text-dim);
color: var(--text-secondary);
flex-shrink: 0;
}
.image-meta-panel[hidden] {
@@ -9748,7 +9748,7 @@ body.desktop-mode .editor-container {
margin: 0;
}
.image-meta-panel dt {
color: var(--text-dim);
color: var(--text-secondary);
}
.image-meta-panel dd {
margin: 0;
@@ -9778,7 +9778,7 @@ body.desktop-mode .editor-container {
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--surface1);
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
@@ -9796,13 +9796,13 @@ body.desktop-mode .editor-container {
.media-duration-badge {
font-variant-numeric: tabular-nums;
font-size: 0.75rem;
color: var(--text-dim);
color: var(--text-secondary);
min-width: 46px;
text-align: center;
}
.media-meta {
font-size: 0.75rem;
color: var(--text-dim);
color: var(--text-secondary);
}
.audio-stage {
flex: 1;
@@ -9822,7 +9822,7 @@ body.desktop-mode .editor-container {
width: 140px;
height: 140px;
border-radius: 12px;
background: var(--surface1);
background: var(--surface);
border: 1px solid var(--border);
color: var(--accent);
}
@@ -9849,6 +9849,408 @@ body.desktop-mode .editor-container {
margin-top: 12px;
}
/* ── Now Playing — persistent media dock (roadmap #110) ── */
.np-inline-slot {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.np-inline-slot .np-media--audio {
width: min(560px, 100%);
}
.np-media--video {
max-width: 100%;
max-height: calc(100vh - 180px);
}
.np-host {
position: fixed;
inset: 0;
z-index: 950;
pointer-events: none;
}
.np-dock {
pointer-events: auto;
}
.np-ctrl {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--text);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.np-ctrl:hover {
background: color-mix(in srgb, var(--accent) 18%, transparent);
color: var(--accent);
}
.np-ctrl:disabled {
opacity: 0.35;
cursor: default;
}
.np-ctrl:disabled:hover {
background: transparent;
color: var(--text);
}
.np-dock--audio {
position: fixed;
left: 50%;
bottom: 20px;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 10px;
width: min(720px, calc(100vw - 32px));
padding: 8px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
backdrop-filter: blur(12px);
}
.np-dock--audio .np-media {
display: none;
}
.np-dock-art {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface2);
color: var(--accent);
cursor: pointer;
}
.np-dock-info {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.np-dock-title {
color: var(--text);
font-size: 0.85rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.np-dock-sub {
color: var(--text-secondary);
font-size: 0.72rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.np-dock-controls {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 2px;
}
.np-dock-progress {
flex: 1 1 180px;
min-width: 120px;
display: flex;
align-items: center;
gap: 8px;
}
.np-time {
color: var(--text-secondary);
font-size: 0.7rem;
font-variant-numeric: tabular-nums;
min-width: 34px;
text-align: center;
}
.np-seek {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 4px;
border-radius: 2px;
background: var(--border);
cursor: pointer;
}
.np-seek::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--accent);
}
.np-seek::-moz-range-thumb {
width: 12px;
height: 12px;
border: none;
border-radius: 50%;
background: var(--accent);
}
.np-dock-actions {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 2px;
}
.np-volume {
display: flex;
align-items: center;
gap: 4px;
color: var(--text-secondary);
padding: 0 4px;
}
.np-volume-range {
width: 70px;
-webkit-appearance: none;
appearance: none;
height: 4px;
border-radius: 2px;
background: var(--border);
cursor: pointer;
}
.np-volume-range::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 11px;
height: 11px;
border-radius: 50%;
background: var(--accent);
}
.np-volume-range::-moz-range-thumb {
width: 11px;
height: 11px;
border: none;
border-radius: 50%;
background: var(--accent);
}
/* Video dock — floating, draggable, resizable mini-window */
.np-dock--video {
position: fixed;
background: #000;
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.5);
}
.np-dock-video-frame {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
}
.np-dock-video-slot {
flex: 1;
display: flex;
min-height: 0;
background: #000;
overflow: hidden;
}
.np-dock-video-slot .np-media--video {
width: 100%;
height: 100%;
object-fit: contain;
max-height: none;
}
.np-dock-video-bar {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 6px;
background: rgba(0, 0, 0, 0.62);
}
.np-dock-video-bar .np-ctrl,
.np-dock-video-bar .np-time {
color: #f5f5f5;
}
.np-dock-video-bar .np-seek {
background: rgba(255, 255, 255, 0.28);
}
.np-dock-video-bar .np-ctrl:hover {
background: rgba(255, 255, 255, 0.16);
color: #fff;
}
.np-video-grip {
display: inline-flex;
align-items: center;
color: rgba(255, 255, 255, 0.7);
cursor: grab;
touch-action: none;
}
.np-video-grip:active {
cursor: grabbing;
}
.np-video-resize {
position: absolute;
right: 0;
bottom: 0;
width: 18px;
height: 18px;
cursor: nwse-resize;
touch-action: none;
}
.np-video-resize::after {
content: "";
position: absolute;
right: 3px;
bottom: 3px;
width: 9px;
height: 9px;
border-right: 2px solid rgba(255, 255, 255, 0.55);
border-bottom: 2px solid rgba(255, 255, 255, 0.55);
}
.np-dock--error .np-dock-error-msg {
color: #fff;
font-size: 0.8rem;
padding: 24px 16px;
text-align: center;
}
/* Expanded audio panel (bottom sheet on mobile) */
.np-expanded {
position: fixed;
inset: 0;
z-index: 960;
pointer-events: auto;
}
.np-expanded[hidden] {
display: none;
}
.np-expanded-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.35);
}
.np-exp-card {
position: absolute;
left: 50%;
bottom: 96px;
transform: translateX(-50%);
width: min(460px, calc(100vw - 32px));
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 22px 20px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 18px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.5);
}
.np-exp-art {
display: flex;
align-items: center;
justify-content: center;
width: 120px;
height: 120px;
border-radius: 14px;
background: var(--surface2);
border: 1px solid var(--border);
color: var(--accent);
}
.np-exp-title {
color: var(--text);
font-size: 1rem;
text-align: center;
overflow-wrap: anywhere;
}
.np-exp-sub {
color: var(--text-secondary);
font-size: 0.78rem;
}
.np-exp-seek {
width: 100%;
}
.np-exp-times {
width: 100%;
display: flex;
justify-content: space-between;
}
.np-exp-controls {
display: flex;
align-items: center;
gap: 14px;
}
.np-exp-controls .np-ctrl {
width: 44px;
height: 44px;
}
.np-exp-play {
background: var(--accent);
color: #fff;
}
.np-exp-play:hover {
background: var(--accent);
color: #fff;
filter: brightness(1.08);
}
.np-exp-extra {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
.np-rate {
background: var(--surface2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 4px 6px;
font-size: 0.75rem;
}
@media (max-width: 768px) {
.np-dock--audio {
left: 8px;
right: 8px;
bottom: calc(64px + env(safe-area-inset-bottom, 0px) + 8px);
transform: none;
width: auto;
}
.np-dock-progress {
flex-basis: 90px;
}
.np-volume {
display: none;
}
.np-dock--video {
left: 8px !important;
right: 8px;
top: auto !important;
bottom: calc(64px + env(safe-area-inset-bottom, 0px) + 8px) !important;
width: auto !important;
height: 200px !important;
border-radius: 12px;
}
.np-video-resize {
display: none;
}
.np-exp-card {
bottom: calc(64px + env(safe-area-inset-bottom, 0px) + 12px);
}
body.np-active .content-area {
padding-bottom: calc(64px + env(safe-area-inset-bottom, 0px) + 76px);
}
}
@media (prefers-reduced-motion: reduce) {
.np-ctrl,
.np-dock,
.np-exp-card {
transition: none;
}
}
/* ── Text / CSV Viewer ── */
.text-viewer-container {
display: flex;
@@ -9860,14 +10262,14 @@ body.desktop-mode .editor-container {
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--surface1);
background: var(--surface);
border-bottom: 1px solid var(--border);
font-size: 0.85rem;
flex-shrink: 0;
}
.file-info {
flex: 1;
color: var(--text-dim);
color: var(--text-secondary);
}
.text-viewer-body {
flex: 1;
@@ -9897,8 +10299,8 @@ body.desktop-mode .editor-container {
font-size: 0.85rem;
}
.csv-table th {
background: var(--surface1);
color: var(--text-dim);
background: var(--surface);
color: var(--text-secondary);
font-weight: 600;
padding: 8px 12px;
text-align: left;
@@ -9911,7 +10313,7 @@ body.desktop-mode .editor-container {
border-bottom: 1px solid var(--border-light, var(--border));
}
.csv-table tr:hover td {
background: var(--surface1);
background: var(--surface);
}
/* ── JSON Viewer ── */
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.18.0",
"version": "2.19.0",
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
"main": "patch.js",
"directories": {
+68 -6
View File
@@ -1,8 +1,7 @@
/**
* E2E tests for the ObsiGate audio/video viewers (roadmap #109).
* E2E tests for the ObsiGate media viewers & persistent player (roadmap #109/#110).
*
* Fixtures : `test_vault/sample-audio.mp3` (1 s sine, ~5 ko) +
* `test_vault/sample-video.webm` (64x64 VP8, ~1 ko).
* Fixtures : `test_vault/sample-audio.mp3` (1 s sine) + `test_vault/sample-video.webm`.
*
* Run (local):
* BASE_URL=http://localhost:2029 npx playwright test tests/e2e/media-viewer.spec.js
@@ -44,11 +43,10 @@ test.describe('Media viewers — HTML5 audio/video (#109)', () => {
await login(page);
await openFile(page, 'TestVault', 'sample-audio.mp3');
const audio = page.locator('#content-area .audio-viewer-container audio.audio-player');
const audio = page.locator('#content-area .audio-viewer-container audio.np-media--audio');
await expect(audio).toBeVisible({ timeout: 10000 });
await expect(audio).toHaveAttribute('src', /\/api\/media\/TestVault\?path=/);
await expect(audio).toHaveAttribute('controls', '');
// La durée est lue côté client via loadedmetadata.
await expect(page.locator('#content-area .media-duration-badge')).not.toHaveText('--:--', { timeout: 10000 });
});
@@ -56,7 +54,7 @@ test.describe('Media viewers — HTML5 audio/video (#109)', () => {
await login(page);
await openFile(page, 'TestVault', 'sample-video.webm');
const video = page.locator('#content-area .video-viewer-container video.video-player');
const video = page.locator('#content-area .video-viewer-container video.np-media--video');
await expect(video).toBeVisible({ timeout: 10000 });
await expect(video).toHaveAttribute('src', /\/api\/media\/TestVault\?path=/);
await expect(video).toHaveAttribute('playsinline', '');
@@ -73,3 +71,67 @@ test.describe('Media viewers — HTML5 audio/video (#109)', () => {
expect(resp.headers()['accept-ranges']).toBe('bytes');
});
});
test.describe('Now Playing — lecture persistante (#110)', () => {
test('affiche le dock et continue la lecture quand on navigue ailleurs', async ({ page }) => {
await login(page);
await openFile(page, 'TestVault', 'sample-audio.mp3');
await expect(page.locator('#content-area audio.np-media--audio')).toBeVisible({ timeout: 10000 });
// Naviguer vers une note : le média doit passer dans le dock.
await openFile(page, 'TestVault', 'note1.md');
const dock = page.locator('#now-playing-host .np-dock--audio');
await expect(dock).toBeVisible({ timeout: 10000 });
await expect(dock.locator('.np-dock-title')).toHaveText('sample-audio.mp3');
// S'assurer de la lecture (l'autoplay peut être bloqué sans geste).
const media = page.locator('#now-playing-host audio.np-media--audio');
if (await media.evaluate((el) => el.paused)) {
await dock.locator('[data-np="play"]').click();
}
await expect.poll(() => media.evaluate((el) => !el.paused), { timeout: 5000 }).toBe(true);
// Naviguer encore : toujours en lecture.
await openFile(page, 'TestVault', 'Accueil.md');
await expect(dock).toBeVisible();
expect(await media.evaluate((el) => !el.paused)).toBe(true);
});
test('revient sur le média depuis le dock', async ({ page }) => {
await login(page);
await openFile(page, 'TestVault', 'sample-audio.mp3');
await expect(page.locator('#content-area audio.np-media--audio')).toBeVisible({ timeout: 10000 });
await openFile(page, 'TestVault', 'note1.md');
const dock = page.locator('#now-playing-host .np-dock--audio');
await expect(dock).toBeVisible({ timeout: 10000 });
await dock.locator('[data-np="reopen"]').click();
await expect(page.locator('#content-area .audio-viewer-container audio.np-media--audio')).toBeVisible({ timeout: 10000 });
await expect(dock).toBeHidden();
});
test('ferme la lecture depuis le dock', async ({ page }) => {
await login(page);
await openFile(page, 'TestVault', 'sample-audio.mp3');
await expect(page.locator('#content-area audio.np-media--audio')).toBeVisible({ timeout: 10000 });
await openFile(page, 'TestVault', 'note1.md');
const dock = page.locator('#now-playing-host .np-dock--audio');
await expect(dock).toBeVisible({ timeout: 10000 });
await dock.locator('[data-np="close"]').click();
await expect(page.locator('#now-playing-host .np-dock--audio')).toBeHidden();
});
test('le mini-player vidéo flotte et reste visible en naviguant', async ({ page }) => {
await login(page);
await openFile(page, 'TestVault', 'sample-video.webm');
await expect(page.locator('#content-area video.np-media--video')).toBeVisible({ timeout: 10000 });
await openFile(page, 'TestVault', 'note1.md');
const mini = page.locator('#now-playing-host .np-dock--video');
await expect(mini).toBeVisible({ timeout: 10000 });
await expect(mini.locator('video.np-media--video')).toBeVisible();
});
});
+72 -36
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env node
/**
* ObsiGate — Audio/video viewer tests (roadmap #109).
* ObsiGate — Media viewers tests (roadmap #109 + #110).
*
* Pure helpers (formatMediaDuration / buildMediaUrl) are evaluated in a VM
* sandbox to avoid importing the full browser-only viewer chain. The rest are
* static checks on the source, CSS and backend wiring.
* Pure helpers are evaluated in a VM sandbox to avoid importing the
* browser-only module chain. The rest are static checks on the source, CSS,
* i18n and backend wiring.
*
* Usage: node tests/frontend/media-viewer.test.mjs
*/
@@ -19,6 +19,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, "..", "..");
const viewer = readFileSync(path.join(ROOT, "frontend", "js", "viewer.js"), "utf8");
const np = readFileSync(path.join(ROOT, "frontend", "js", "now-playing.js"), "utf8");
const utils = readFileSync(path.join(ROOT, "frontend", "js", "utils.js"), "utf8");
const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8");
const sw = readFileSync(path.join(ROOT, "frontend", "sw.js"), "utf8");
@@ -37,16 +38,18 @@ function test(label, fn) {
}
// ── Pure helpers (VM sandbox) ──────────────────────────────────────────────
const begin = viewer.indexOf("export function formatMediaDuration");
const end = viewer.indexOf("function buildDownloadUrl");
assert.ok(begin !== -1 && end > begin, "media viewer helper block not found");
const begin = np.indexOf("const AUDIO_EXTS");
const end = np.indexOf("function downloadUrl");
assert.ok(begin !== -1 && end > begin, "now-playing helper block not found");
const sandbox = {};
vm.createContext(sandbox);
vm.runInContext(
`${viewer.slice(begin, end).replace(/\bexport /g, "")}\nglobalThis.formatMediaDuration = formatMediaDuration; globalThis.buildMediaUrl = buildMediaUrl;`,
`${np.slice(begin, end).replace(/\bexport /g, "")}\n` +
`globalThis.formatMediaDuration = formatMediaDuration; globalThis.buildMediaUrl = buildMediaUrl;` +
`globalThis.isAudioPath = isAudioPath; globalThis.isVideoPath = isVideoPath;`,
sandbox,
);
const { formatMediaDuration, buildMediaUrl } = sandbox;
const { formatMediaDuration, buildMediaUrl, isAudioPath, isVideoPath } = sandbox;
test("formatMediaDuration renders m:ss and h:mm:ss", () => {
assert.equal(formatMediaDuration(0), "0:00");
@@ -61,49 +64,76 @@ test("buildMediaUrl encodes vault and path", () => {
assert.equal(url, "/api/media/My%20Vault?path=caf%C3%A9%20clip.mp4");
});
// ── Static checks: viewer wiring ───────────────────────────────────────────
test("isAudioPath / isVideoPath recognise media extensions", () => {
for (const p of ["a.mp3", "a.M4A", "dir/b.flac", "c.opus", "d.oga"]) assert.equal(isAudioPath(p), true, p);
for (const p of ["a.mp4", "a.WEBM", "dir/b.mov", "c.m4v"]) assert.equal(isVideoPath(p), true, p);
for (const p of ["a.md", "a.png", "noext", ""]) {
assert.equal(isAudioPath(p), false, p);
assert.equal(isVideoPath(p), false, p);
}
});
// ── Static checks: viewer delegates to the persistent controller ──────────
test("viewer.js dispatches is_audio / is_video to the media viewers", () => {
assert.match(viewer, /if \(data\.is_audio\) \{\s*renderAudioViewer\(area, data\);/);
assert.match(viewer, /if \(data\.is_video\) \{\s*renderVideoViewer\(area, data\);/);
});
test("audio viewer uses a native <audio> element and stream URL", () => {
test("viewer delegates inline rendering to NowPlaying.attachInline", () => {
const start = viewer.indexOf("export function renderAudioViewer");
const stop = viewer.indexOf("export function renderVideoViewer");
const stop = viewer.indexOf("export function renderFile");
assert.ok(start !== -1 && stop > start, "renderAudioViewer block not found");
const block = viewer.slice(start, stop);
assert.match(block, /el\("audio"/);
assert.match(block, /controls: ""/);
assert.match(block, /data\.stream_url \|\| buildMediaUrl/);
assert.match(block, /NowPlaying\.attachInline\(area, data\)/);
});
test("video viewer uses a native <video> element with playsinline", () => {
const start = viewer.indexOf("export function renderVideoViewer");
const stop = viewer.indexOf("export function renderFile");
assert.ok(start !== -1 && stop > start, "renderVideoViewer block not found");
const block = viewer.slice(start, stop);
assert.match(block, /el\("video"/);
assert.match(block, /playsinline: ""/);
test("renderFile hands the shared element to the dock before a re-render", () => {
assert.match(viewer, /NowPlaying\.handleRender\(area, data\)/);
assert.doesNotMatch(viewer, /_mediaViewerCleanup/);
});
test("unsupported codec triggers the binary fallback UI", () => {
assert.match(viewer, /audio\.addEventListener\("error",\s*\(\)\s*=>\s*\{\s*renderMediaFallback/);
assert.match(viewer, /video\.addEventListener\("error",\s*\(\)\s*=>\s*\{\s*renderMediaFallback/);
assert.match(viewer, /export function renderMediaFallback/);
// ── Static checks: global Now Playing controller (#110) ───────────────────
test("now-playing owns ONE media element reused across surfaces", () => {
assert.match(np, /media\.dataset\.npKey === `\$\{data\.vault\}::\$\{data\.path\}`/);
assert.match(np, /function ensureMedia\(kind\)/);
assert.match(np, /slot\.appendChild\(media\)/);
assert.match(np, /dock\.appendChild\(S\.media\)/);
});
test("playback is stopped when the content area is re-rendered", () => {
assert.match(viewer, /_mediaViewerCleanup/);
assert.match(viewer, /_mediaViewerCleanup\(\); _mediaViewerCleanup = null;/);
test("now-playing shows an unsupported fallback on media error", () => {
assert.match(np, /media\.addEventListener\('error',\s*\(\)\s*=>\s*onMediaError\(\)\)/);
assert.match(np, /export function renderFallback/);
});
test("now-playing wires Media Session, PiP and persistence", () => {
assert.match(np, /navigator\.mediaSession\.metadata/);
assert.match(np, /setActionHandler\('nexttrack'/);
assert.match(np, /requestPictureInPicture/);
assert.match(np, /const LS_KEY = 'obsigate-now-playing'/);
assert.match(np, /function restore\(\)/);
});
test("now-playing exposes the public controller API and is initialised at boot", () => {
assert.match(np, /export const NowPlaying = \{/);
assert.match(np, /attachInline/);
assert.match(np, /notifyTabClosed/);
const app = readFileSync(path.join(ROOT, "frontend", "js", "app.js"), "utf8");
assert.match(app, /initNowPlaying\(\)/);
});
// ── Static checks: CSS ─────────────────────────────────────────────────────
test("style.css defines the audio/video viewer layout", () => {
test("style.css defines the media viewers and the Now Playing dock", () => {
assert.match(css, /\.audio-viewer-container/);
assert.match(css, /\.audio-stage\s*\{/);
assert.match(css, /\.audio-artwork\s*\{/);
assert.match(css, /\.video-stage\s*\{/);
assert.match(css, /\.video-player\s*\{/);
assert.match(css, /\.np-dock--audio\s*\{/);
assert.match(css, /\.np-dock--video\s*\{/);
assert.match(css, /\.np-expanded\s*\{/);
assert.match(css, /body\.np-active \.content-area/);
});
test("style.css no longer references undefined --surface1 / --text-dim vars", () => {
assert.doesNotMatch(css, /var\(--surface1\)/);
assert.doesNotMatch(css, /var\(--text-dim\)/);
});
// ── Static checks: icons, i18n, service worker ─────────────────────────────
@@ -114,8 +144,15 @@ test("utils.js maps media extensions to audio-lines / video icons", () => {
assert.match(utils, /"\.webm": "video"/);
});
test("media viewer strings exist in FR and EN", () => {
for (const key of ["viewer.media_unsupported", "viewer.media_too_large"]) {
test("player/viewer media strings exist in FR and EN", () => {
for (const key of [
"viewer.media_unsupported",
"viewer.media_too_large",
"player.now_playing",
"player.close",
"player.reopen_tab",
"player.continues",
]) {
assert.match(fr, new RegExp(`"${key}"`));
assert.match(en, new RegExp(`"${key}"`));
}
@@ -130,7 +167,6 @@ test("service worker never caches streamed media", () => {
test("backend exposes /api/media and the shared Range helper", () => {
assert.match(main, /@app\.get\("\/api\/media\/\{vault_name\}"/);
assert.match(main, /def _stream_file_with_range\(/);
assert.match(main, /AUDIO_EXTENSIONS|is_audio/);
assert.match(main, /is_audio\(ext\) or is_video\(ext\)/);
});