Compare commits

...
3 Commits
Author SHA1 Message Date
bruno 8264e7ffae fix: conserver plein ecran et panneau metadonnees dans la visionneuse d'images (sidebar droite) + lanceur E2E Windows BUG-072
CI / lint (push) Successful in 2m1s
CI / security (push) Successful in 1m23s
CI / test (push) Successful in 4m27s
CI / build (push) Successful in 1m19s
CI / e2e (push) Successful in 17m45s
2026-09-23 13:43:47 -04:00
bruno 80852374a8 fix: positionnement lecteur media (mobile/desktop) et drag libre de la mini-video #110
CI / lint (push) Successful in 2m0s
CI / security (push) Successful in 1m22s
CI / test (push) Successful in 4m36s
CI / build (push) Successful in 1m18s
CI / e2e (push) Successful in 13m7s
2026-09-23 11:42:23 -04:00
bruno e3c6789776 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
2026-09-23 10:41:15 -04:00
26 changed files with 2137 additions and 234 deletions
+5 -1
View File
@@ -44,9 +44,13 @@ node tests/frontend/unit.test.mjs
# Tests JSDOM : node_modules dans tests/frontend/ (npm install là-bas si absent), ex :
node tests/frontend/pane-manager.test.mjs
# E2E (si UI touchée, ~5 min) : reproduit le job CI e2e (port 2029, auth désactivée)
# E2E (si UI touchée, ~10 min) : reproduit le job CI e2e (port 2029, auth désactivée)
npm run test:e2e # prérequis : uv, Node >= 20, npx playwright install chromium
bash scripts/run-e2e-local.sh -g "nom du test" # filtre / --headed
# Windows sans bash exploitable (WSL HS, git-bash bloqué par App Control) :
npm run test:e2e:ps # équivalent PowerShell, mêmes conditions que le CI
pwsh -File scripts/run-e2e-local.ps1 -PlaywrightArgs @('-g','nom du test')
```
- Un seul test backend : `.\.venv\Scripts\python.exe -m pytest tests/test_search.py -q`.
+62 -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.2**.
---
@@ -14,6 +14,67 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.19.2] — 2026-09-23
### Ajouté
- **Lanceur E2E Windows/PowerShell** : `scripts/run-e2e-local.ps1` (+ script npm
`test:e2e:ps`) reproduit localement le job CI `e2e` (uvicorn natif, auth
désactivée, fixtures TestVault/TestDir, port 2029, projet `chromium-desktop`)
sans dépendre de `bash` — indispensable sur les postes Windows où WSL ne
démarre pas et où git-bash est bloqué par une politique de contrôle
d'application. Le script `bash` reste la référence pour la CI/Linux.
### Corrigé
- **BUG-072 — visionneuse d'images** : le plein écran (lightbox) et le panneau
« Métadonnées » sont désormais **conservés lors de la navigation** entre images
(flèches ←/→ et clic sur la pellicule) ; auparavant chaque changement d'image
recréait la visionneuse et perdait ces états. Le panneau de métadonnées s'affiche
maintenant en **barre latérale à droite de l'image** (au lieu d'une bande sous la
pellicule) et reste visible en plein écran.
---
## [2.19.1] — 2026-09-23
### Corrigé
- **#110 — lisibilité et positionnement du lecteur média** : en mode mobile, la
barre audio passe en grille (barre de progression pleine largeur sur sa propre
ligne) et les actions secondaires (précédent/suivant/agrandir/volume) sont
masquées pour éviter tout chevauchement ; le volume est aussi masqué sur les
écrans intermédiaires en desktop, et `overflow: hidden` empêche le débordement
hors de la pilule. La mini-fenêtre vidéo n'est plus ré-aimantée sur les bords :
elle se déplace et se redimensionne **librement** (position absolue mémorisée,
centre autorisé), le glisser fonctionnant désormais depuis n'importe quel point
de la fenêtre (boutons/curseurs/poignée exclus, seuil de 4 px pour préserver
les contrôles natifs de la vidéo).
---
## [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é
+14 -5
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.2-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
@@ -872,7 +872,7 @@ Configurables via l'interface (Settings) ou l'API `/api/config`.
| Validation des imports frontend | `node tests/frontend/validate-imports.mjs` | `lint` |
| Tests unitaires frontend | `node tests/frontend/unit.test.mjs` | `lint` |
| Tests backend | `pytest tests/ -q` | `test` |
| **E2E Playwright** | `npm run test:e2e` (~5 min) | `e2e` |
| **E2E Playwright** | `npm run test:e2e` (~10 min) | `e2e` |
#### Tests E2E locaux (`npm run test:e2e`)
@@ -895,6 +895,15 @@ bash scripts/run-e2e-local.sh --headed # navigateur visible
bash scripts/run-e2e-local.sh -g "reset panes" # filtre sur un test
```
Sous Windows, si `bash` n'est pas exploitable (WSL indisponible, git-bash
bloqué par une politique de contrôle d'application), utiliser le lanceur
PowerShell équivalent :
```powershell
npm run test:e2e:ps
pwsh -File scripts/run-e2e-local.ps1 -PlaywrightArgs @('-g','reset panes')
```
La suite doit se terminer sur **tous les tests passant** (60 actuellement),
sans échec ni dépendance aux retries. En cas d'échec : corriger et relancer
localement jusqu'à 100 %, puis seulement commiter.
@@ -966,8 +975,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.2).
---
*Projet : ObsiGate | Version : 2.18.0 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.19.2 | Dernière mise à jour : Septembre 2026*
+13 -5
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.2-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
@@ -1042,7 +1042,7 @@ These parameters are configurable via the interface (Settings) or the `/api/conf
| Frontend import validation | `node tests/frontend/validate-imports.mjs` | `lint` |
| Frontend unit tests | `node tests/frontend/unit.test.mjs` | `lint` |
| Backend tests | `pytest tests/ -q` | `test` |
| **E2E Playwright** | `npm run test:e2e` (~5 min) | `e2e` |
| **E2E Playwright** | `npm run test:e2e` (~10 min) | `e2e` |
#### Local E2E Tests (`npm run test:e2e`)
@@ -1065,6 +1065,14 @@ bash scripts/run-e2e-local.sh --headed # visible browser
bash scripts/run-e2e-local.sh -g "reset panes" # filter on a test
```
On Windows, when `bash` is unusable (WSL unavailable, git-bash blocked by an
Application Control policy), use the equivalent PowerShell launcher:
```powershell
npm run test:e2e:ps
pwsh -File scripts/run-e2e-local.ps1 -PlaywrightArgs @('-g','reset panes')
```
The suite must end with **all tests passing** (60 currently), with no failure
or reliance on retries. In case of failure: fix and re-run locally until 100 %,
then only commit.
@@ -1142,8 +1150,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.2).
---
*Project: ObsiGate | Version: 2.18.0 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.19.2 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.18.0
2.19.2
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.18.0"
version = "2.19.2"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.18.0"
version = "2.19.2"
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.2",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+2
View File
@@ -180,6 +180,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *BUG-069* | Login 2FA bloqué sans erreur : après user+pwd corrects, la page de login reste affichée et le challenge MFA n'apparaît jamais | 🟢 corrigé | P0 | 📱 frontend | IA | `frontend/js/auth.js`, `frontend/index.html` | Activer 2FA → logout → login (bon user+pwd) | `frontend/js/auth.js` (`showMfaChallenge` → `.login-card` + erreur `mfa.challenge_unavailable` si montage impossible), locales FR/EN ; tests `tests/frontend/mfa-settings.test.mjs` (+2) | Reproduit au navigateur avant correctif (challenge jamais affiché), vérifié après : challenge affiché, code erroné → erreur, code valide (200) → app ; frontend mfa-settings 11/11, unit + validate-imports verts |
| *BUG-070* | Activation clé physique WebAuthn impossible : « Validation du credential WebAuthn échouée » à chaque tentative | 🟢 corrigé | P0 | ⚙️ backend | IA | `backend/auth/webauthn_mfa.py`, `backend/auth/router.py` | Config → Sécurité → Ajouter une clé → cérémonie navigateur → 400 | `resolve_relying_party()` (rp_id/origines dérivés de la requête, config explicite prioritaire, forwarded si TRUST_PROXY) sur les 4 endpoints ; challenges multiples (5 derniers) acceptés ; `.env.example` ; tests `tests/test_webauthn.py` (+8) | Logs : origin `http://localhost:2020` rejetée + challenge mismatch au retry. Vérifié navigateur (authentificateur virtuel CDP) : register 200 + clé listée, clé de test retirée (admin de nouveau TOTP seul) ; pytest 1249 passed, ruff/mypy 0 |
| *BUG-071* | Configuration « Configurations » inutilisable en mode mobile : sommaire masqué sans bouton d'accès, navigation par ancre sans JS, grilles 2 colonnes et rangées d'ajout qui débordent (≤768px) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/index.html`, `frontend/js/config.js`, `frontend/js/i18n.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json` | Mobile (≤768px) : ouvrir Configurations → aucun sommaire ni moyen d'atteindre une section ; champs « Clés IA » / jetons / webhooks débordent | `index.html` (+`#config-hamburger` `.help-hamburger`, `config.toc_toggle` FR/EN) ; `config.js` (toggle, scroll doux + actif + repli auto mobile, reset à l'ouverture) ; `i18n.js` (`data-i18n-attr` multi-paires `;`) ; `style.css` (bloc mobile `#config-modal` : sommaire haut 46vh, grilles 1fr, add-rows wrap + `!important`, items wrap, 44px) ; tests `tests/frontend/config-mobile.test.mjs` (nouveau, 11) + CI ; E2E `tests/e2e/config-mobile.spec.js` (nouveau, 3/3 projet chromium-mobile, ignoré en desktop) | pytest 1249 passed / 6 skipped, ruff 0, mypy 0, validate-imports 39 modules, unit 10/10, JSDOM ai 93/93 + sidebar 6/6 + mobile 35/35 + ai-keys 7/7 |
| *BUG-072* | Visionneuse d'images : le plein écran et le panneau « Métadonnées » ne sont pas conservés lors de la navigation ←/→, et le panneau s'affiche sous la pellicule au lieu d'une barre latérale | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/viewer.js`, `frontend/style.css` | Ouvrir une image, activer le plein écran (ou Métadonnées), puis naviguer avec les flèches précédent/suivant | État persistant `_imageViewerState { lightbox, meta }` + drapeau `_imageViewerNavPending` posé par `go()`/pellicule : `renderFile` ne réinitialise que hors navigation image→image. Panneau reconstruit dans `.image-viewer-body` (sidebar droite, `border-left`, `width:280px; max-width:40%`) ; la règle lightbox ne masque plus que la pellicule. Boutons `image-btn-lightbox`/`image-btn-metadata` (+ `aria-pressed`), `Escape` resynchronisé. Tests : `tests/frontend/image-viewer.test.mjs` (+2), E2E `tests/e2e/image-viewer.spec.js` (+1). | Navigation → `openFile` → `renderImageViewer` recréait le conteneur : les états `lightbox`/`metaPanel` étaient perdus. Le panneau était rendu en bas (colonne) au lieu d'une sidebar droite |
| | | | | | | | | | | |
### TODOs techniques (améliorations / nouvelles tâches)
@@ -258,6 +259,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-09-23 | BUG-070 | Correction | `backend/auth/webauthn_mfa.py`, `backend/auth/router.py`, `.env.example`, `tests/test_webauthn.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-070** : activation WebAuthn rejetée en 400. (1) Défauts `localhost` sans port → `resolve_relying_party()` dérive rp_id/origines de la requête (config explicite prioritaire, forwarded sous TRUST_PROXY), appliqué aux endpoints register + login. (2) Challenge single-use → 5 derniers conservés, vérification contre le challenge de la cérémonie en cours. **Vérifié au navigateur** (authentificateur virtuel CDP, instance Docker) : register 200, clé listée, clé de test retirée. Tests : `test_webauthn.py` 19/19 (+8), suite complète 1249 passed / 6 skipped, ruff/mypy 0. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-23 | BUG-071 | Correction | `frontend/index.html`, `frontend/js/config.js`, `frontend/js/i18n.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/config-mobile.test.mjs` (nouveau), `.gitea/workflows/ci.yml`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-071** : page « Configurations » inutilisable en mobile. (1) `#config-nav` masquée sous 768px sans toggle → hamburger `#config-hamburger` ajouté à l'en-tête (`.help-hamburger`, libellé `config.toc_toggle` FR/EN). (2) Ancres brutes sans JS → interception en `config.js` (scroll doux, lien actif, repli auto mobile, reset à l'ouverture). (3) Débordements 360px → bloc CSS mobile `#config-modal` (sommaire haut 46vh, grilles 1fr, add-rows wrap + largeurs inline neutralisées, items wrap, cibles 44px). `data-i18n-attr` multi-paires (`;`). Vérifié : `config-mobile.test.mjs` 11/11 (nouveau, au CI), pytest 1249 passed / 6 skipped, ruff/mypy 0, validate-imports 39 modules, unit 10/10, JSDOM ai 93/93 + ai-sidebar 6/6 + sidebar-filters 8/8 + mobile-editor 35/35 + config-ai-keys 7/7. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-23 | BUG-071 (complément E2E) | Test | `tests/e2e/config-mobile.spec.js` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-071 (complément E2E)** : spec Playwright mobile (convention `mobile-editor.spec.js` : `test.skip` hors viewport ≤768px, donc inactive sur le projet `chromium-desktop` du CI). Vérifié en local sur l'instance de test (port 2029, auth désactivée) : hamburger → sommaire, sélection → scroll + actif + repli, 0 débordement horizontal à 393px (3/3 `chromium-mobile`, 3 ignorés en desktop) ; suite `mobile-editor.spec.js` intacte (3/3). | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-23 | BUG-072 | Correction | `frontend/js/viewer.js`, `frontend/style.css`, `tests/frontend/image-viewer.test.mjs`, `tests/e2e/image-viewer.spec.js`, `scripts/run-e2e-local.ps1` (nouveau), `package.json`, `AGENTS.md`, `README.md`, `README.fr.md`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-072** : dans la visionneuse d'images (#108-D), le plein écran (lightbox) et le panneau « Métadonnées » étaient perdus dès qu'on changeait d'image avec ←/→ (ou la pellicule), car `openFile` → `renderFile` recrée entièrement `renderImageViewer`. (1) **Persistance** : état module `_imageViewerState { lightbox, meta }` restauré à chaque rendu ; un drapeau `_imageViewerNavPending` posé par `go()` et le clic de vignette indique à `renderFile` que le rendu suivant est une navigation image→image (pas de réinitialisation) — toute autre ouverture repart à zéro. (2) **Panneau latéral** : `.image-meta-panel` déplacé dans un nouveau `.image-viewer-body` en flex row, à droite de `.image-stage` (`border-left`, `width:280px; max-width:40%`, défilement vertical) au lieu d'une bande sous la pellicule ; la règle lightbox ne masque plus que la pellicule. Boutons stables `image-btn-lightbox`/`image-btn-metadata` + `aria-pressed`, `Escape` resynchronise l'état. Tests statiques `image-viewer.test.mjs` (+2) et E2E Playwright (+1). **Diagnostic E2E** : `npm run test:e2e` bloquait car `bash` résout vers WSL (HS, Ubuntu `Stopped`, `HCS_E_CONNECTION_TIMEOUT`) et git-bash est bloqué par App Control → lanceur PowerShell ajouté. Vérifié : `image-viewer.spec.js` 4/4, **suite `chromium-desktop` complète 103 passed / 6 skipped (10,3 min)** via `scripts/run-e2e-local.ps1`, `image-viewer.test.mjs` 12/12, unit 10/10, validate-imports 40 modules. | 🟢 corrigé (en attente vérif utilisateur) |
---
+3 -2
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.18.0 | **Dernière mise à jour :** 2026-09-23
> **Version :** 2.19.2 | **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 |
+88
View File
@@ -97,3 +97,91 @@ 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 **librement** depuis
n'importe quel point de la fenêtre (position absolue mémorisée, centre autorisé
— aucune aimantation aux bords) 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. La barre audio passe
en grille (progression sur sa propre ligne) et masque les actions secondaires
pour éviter tout chevauchement.
### 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 () => {
File diff suppressed because it is too large Load Diff
+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) {
+65 -144
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
@@ -547,6 +548,13 @@ const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".
const IMAGE_ZOOM_MIN = 0.1;
const IMAGE_ZOOM_MAX = 8;
let _imageViewerCleanup = null;
// BUG-072 — the lightbox and metadata panel states must survive the re-render
// triggered by ←/→ navigation (openFile → renderFile → renderImageViewer).
// Reset as soon as a non-image file is rendered.
const _imageViewerState = { lightbox: false, meta: false };
// Set right before an image→image navigation (←/→ / filmstrip click) so
// renderFile knows the state above must be carried over instead of reset.
let _imageViewerNavPending = false;
/** Clamp a zoom factor into the supported [0.1, 8] range. */
export function clampImageZoom(value) {
@@ -613,27 +621,29 @@ export function renderImageViewer(area, data) {
const nextBtn = mkBtn("chevron-right", t("viewer.image_next"));
const originalBtn = mkBtn("external-link", t("viewer.image_open_original"));
const downloadBtn = mkBtn("download", t("viewer.download"));
const metaBtn = mkBtn("info", t("viewer.image_metadata"));
const lightboxBtn = mkBtn("maximize", t("viewer.image_fullscreen"));
const metaBtn = mkBtn("info", t("viewer.image_metadata"), "image-btn-metadata");
const lightboxBtn = mkBtn("maximize", t("viewer.image_fullscreen"), "image-btn-lightbox");
[prevBtn, nextBtn, zoomOutBtn, zoomInBtn, zoomResetBtn, metaBtn, originalBtn, downloadBtn, lightboxBtn]
.forEach((b) => toolbar.appendChild(b));
container.appendChild(toolbar);
// ── Stage (image + pan/zoom) ───────────────────────────────────────────
// ── Body: image stage + metadata sidebar (right) ───────────────────────
const body = el("div", { class: "image-viewer-body" });
const stage = el("div", { class: "image-stage" });
const img = el("img", { class: "image-main", src: imgUrl, alt: data.title || fileName, draggable: "false" });
stage.appendChild(img);
container.appendChild(stage);
body.appendChild(stage);
// ── Metadata sidebar (kept open across ←/→ navigation) ─────────────────
const metaPanel = el("div", { class: "image-meta-panel" });
metaPanel.hidden = !_imageViewerState.meta;
body.appendChild(metaPanel);
container.appendChild(body);
// ── Thumbnail filmstrip (navigation) ───────────────────────────────────
const strip = el("div", { class: "image-nav-strip", hidden: true });
container.appendChild(strip);
// ── Metadata panel ─────────────────────────────────────────────────────
const metaPanel = el("div", { class: "image-meta-panel" });
metaPanel.hidden = true;
container.appendChild(metaPanel);
let scale = 1;
let tx = 0;
let ty = 0;
@@ -673,6 +683,7 @@ export function renderImageViewer(area, data) {
const go = (delta) => {
if (siblings.length < 2 || currentIndex < 0) return;
const next = (currentIndex + delta + siblings.length) % siblings.length;
_imageViewerNavPending = true;
openFile(vault, siblings[next].path);
};
@@ -688,7 +699,11 @@ export function renderImageViewer(area, data) {
title: s.name,
loading: "lazy",
});
thumb.addEventListener("click", () => { if (s.path !== path) openFile(vault, s.path); });
thumb.addEventListener("click", () => {
if (s.path === path) return;
_imageViewerNavPending = true;
openFile(vault, s.path);
});
strip.appendChild(thumb);
});
};
@@ -750,13 +765,21 @@ export function renderImageViewer(area, data) {
const dlUrl = `/api/file/${encodeURIComponent(vault)}/download?path=${encodeURIComponent(path)}`;
window.open(dlUrl, "_blank");
});
metaBtn.setAttribute("aria-pressed", _imageViewerState.meta ? "true" : "false");
metaBtn.addEventListener("click", () => {
metaPanel.hidden = !metaPanel.hidden;
if (!metaPanel.hidden) renderMeta();
_imageViewerState.meta = !_imageViewerState.meta;
metaPanel.hidden = !_imageViewerState.meta;
metaBtn.setAttribute("aria-pressed", _imageViewerState.meta ? "true" : "false");
if (_imageViewerState.meta) renderMeta();
});
const syncLightbox = () => {
container.classList.toggle("lightbox", _imageViewerState.lightbox);
lightboxBtn.setAttribute("aria-pressed", _imageViewerState.lightbox ? "true" : "false");
};
syncLightbox();
lightboxBtn.addEventListener("click", () => {
container.classList.toggle("lightbox");
lightboxBtn.setAttribute("aria-pressed", container.classList.contains("lightbox") ? "true" : "false");
_imageViewerState.lightbox = !_imageViewerState.lightbox;
syncLightbox();
});
img.addEventListener("load", () => { if (!metaPanel.hidden) renderMeta(); });
@@ -771,7 +794,10 @@ export function renderImageViewer(area, data) {
else if (e.key === "+" || e.key === "=") { e.preventDefault(); setZoom(scale * 1.25); }
else if (e.key === "-") { e.preventDefault(); setZoom(scale / 1.25); }
else if (e.key === "0") { e.preventDefault(); resetView(); }
else if (e.key === "Escape") { container.classList.remove("lightbox"); }
else if (e.key === "Escape") {
_imageViewerState.lightbox = false;
syncLightbox();
}
};
document.addEventListener("keydown", onKey);
_imageViewerCleanup = () => document.removeEventListener("keydown", onKey);
@@ -779,142 +805,28 @@ export function renderImageViewer(area, data) {
area.appendChild(container);
safeCreateIcons();
applyTransform();
if (_imageViewerState.meta) renderMeta();
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 +836,17 @@ 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; }
// BUG-072 — carry the lightbox / metadata-panel state over an image→image
// navigation; any other render starts from a clean viewer.
if (!data.is_image || !_imageViewerNavPending) {
_imageViewerState.lightbox = false;
_imageViewerState.meta = false;
}
_imageViewerNavPending = false;
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 +1588,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.",
+471 -25
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,10 +9669,15 @@ 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;
}
.image-viewer-body {
flex: 1;
display: flex;
min-height: 0;
}
.image-stage {
flex: 1;
position: relative;
@@ -9680,7 +9685,7 @@ body.desktop-mode .editor-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
min-width: 0;
background: var(--surface2);
cursor: grab;
touch-action: none;
@@ -9698,7 +9703,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 +9711,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;
}
@@ -9731,11 +9736,14 @@ body.desktop-mode .editor-container {
opacity: 1;
}
.image-meta-panel {
width: 280px;
max-width: 40%;
overflow-y: auto;
padding: 10px 16px;
background: var(--surface1);
border-top: 1px solid var(--border);
background: var(--surface);
border-left: 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 +9756,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;
@@ -9761,8 +9769,7 @@ body.desktop-mode .editor-container {
z-index: 2000;
background: rgba(0, 0, 0, 0.9);
}
.image-viewer-container.lightbox .image-nav-strip,
.image-viewer-container.lightbox .image-meta-panel {
.image-viewer-container.lightbox .image-nav-strip {
display: none;
}
@@ -9778,7 +9785,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 +9803,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 +9829,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 +9856,445 @@ 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);
overflow: hidden;
}
.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 120px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
cursor: pointer;
}
.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 160px;
min-width: 0;
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;
}
/* Mid-size screens: drop the volume slider before the row gets cramped. */
@media (max-width: 1100px) {
.np-dock--audio .np-volume {
display: none;
}
}
@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;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
grid-template-areas:
"progress progress progress progress"
"art info controls actions";
align-items: center;
gap: 6px 8px;
padding: 8px 10px;
border-radius: 12px;
}
.np-dock--audio .np-dock-art {
grid-area: art;
width: 38px;
height: 38px;
}
.np-dock--audio .np-dock-info {
grid-area: info;
}
.np-dock--audio .np-dock-controls {
grid-area: controls;
}
.np-dock--audio .np-dock-progress {
grid-area: progress;
width: 100%;
min-width: 0;
}
.np-dock--audio .np-dock-actions {
grid-area: actions;
}
/* Compact mobile: keep play / return / close; expand via the artwork. */
.np-dock--audio [data-np="prev"],
.np-dock--audio [data-np="next"],
.np-dock--audio [data-np="expand"] {
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 +10306,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 +10343,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 +10357,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 ── */
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.18.0",
"version": "2.19.2",
"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": {
@@ -9,7 +9,8 @@
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"test:e2e": "bash scripts/run-e2e-local.sh"
"test:e2e": "bash scripts/run-e2e-local.sh",
"test:e2e:ps": "pwsh -NoProfile -File scripts/run-e2e-local.ps1"
},
"repository": {
"type": "git",
+120
View File
@@ -0,0 +1,120 @@
<#
.SYNOPSIS
ObsiGate — E2E locaux (Playwright) sous Windows/PowerShell.
.DESCRIPTION
Équivalent PowerShell de `scripts/run-e2e-local.sh`, pour les postes Windows
où `bash` n'est pas utilisable (WSL indisponible, git-bash bloqué par une
politique de contrôle d'application). Démarre le backend nativement via
uvicorn (auth désactivée, fixtures TestVault/TestDir, port 2029 — mêmes
conditions que le job CI `e2e`), lance la suite Playwright puis nettoie.
.PARAMETER PlaywrightArgs
Arguments transmis à `npx playwright test`, ex. `-g "image viewer"`,
`--headed`.
.EXAMPLE
./scripts/run-e2e-local.ps1
./scripts/run-e2e-local.ps1 -g "BUG-072"
./scripts/run-e2e-local.ps1 --headed
#>
[CmdletBinding()]
param(
[Parameter(ValueFromRemainingArguments = $true)]
[string[]]$PlaywrightArgs
)
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $Root
$Port = if ($env:E2E_PORT) { $env:E2E_PORT } else { "2029" }
$BaseUrl = "http://127.0.0.1:$Port"
$ServerLog = "data/e2e-server.log"
$ServerErrLog = "data/e2e-server.err.log"
function Assert-Command([string]$Name, [string]$Hint) {
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
throw "[ERR] $Name introuvable. $Hint"
}
}
Assert-Command "uv" "Installez-le : https://docs.astral.sh/uv/"
Assert-Command "npx" "Installez Node.js (>= 20)."
# ----- Venv Python 3.11 (créé une seule fois) -----
$Python = ".venv-e2e/Scripts/python.exe"
if (-not (Test-Path -LiteralPath $Python)) {
Write-Host "[INFO] Création du venv .venv-e2e (Python 3.11)..."
uv venv .venv-e2e --python 3.11
uv pip install --python $Python -r backend/requirements.txt
}
# ----- Port déjà occupé ? -----
try {
Invoke-WebRequest -Uri "$BaseUrl/api/health" -TimeoutSec 2 -UseBasicParsing | Out-Null
Write-Host "[ERR] Quelque chose répond déjà sur $BaseUrl."
Write-Host " Arrêtez-le ou choisissez un autre port : `$env:E2E_PORT=2030; ./scripts/run-e2e-local.ps1"
exit 1
} catch {
# port libre
}
# ----- Démarrage du serveur (mêmes conditions que le CI e2e) -----
Write-Host "[INFO] Démarrage d'ObsiGate sur $BaseUrl (auth désactivée)..."
New-Item -ItemType Directory -Force -Path "data" | Out-Null
$env:OBSIGATE_AUTH_ENABLED = "false"
$env:VAULT_1_NAME = "TestVault"
$env:VAULT_1_PATH = (Resolve-Path -LiteralPath "test_vault").Path
$env:DIR_1_NAME = "TestDir"
$env:DIR_1_PATH = (Resolve-Path -LiteralPath "test_dir").Path
$server = Start-Process -FilePath $Python `
-ArgumentList "-m", "uvicorn", "backend.main:app", "--host", "127.0.0.1", "--port", $Port `
-RedirectStandardOutput $ServerLog -RedirectStandardError $ServerErrLog `
-PassThru -WindowStyle Hidden
$exitCode = 1
try {
# ----- Attente du health check (30 s max, comme le CI) -----
$ready = $false
for ($i = 0; $i -lt 30; $i++) {
try {
Invoke-WebRequest -Uri "$BaseUrl/api/health" -TimeoutSec 2 -UseBasicParsing | Out-Null
$ready = $true
break
} catch {
if ($server.HasExited) {
Write-Host "[ERR] Le serveur a quitté prématurément. Log :"
Get-Content -LiteralPath $ServerErrLog -Tail 30 -ErrorAction SilentlyContinue
exit 1
}
Start-Sleep -Seconds 1
}
}
if (-not $ready) {
Write-Host "[ERR] Serveur injoignable sur $BaseUrl. Log :"
Get-Content -LiteralPath $ServerErrLog -Tail 30 -ErrorAction SilentlyContinue
exit 1
}
Write-Host "[OK] Serveur prêt."
# ----- Browsers Playwright (no-op s'ils sont déjà installés) -----
npx playwright install chromium
# ----- Exécution de la suite (projet CI : chromium-desktop) -----
Write-Host "[INFO] BASE_URL=$BaseUrl npx playwright test --project=chromium-desktop $($PlaywrightArgs -join ' ')"
$env:BASE_URL = $BaseUrl
& npx playwright test --project=chromium-desktop @PlaywrightArgs
$exitCode = $LASTEXITCODE
} finally {
Write-Host "[INFO] Arrêt du serveur (PID $($server.Id))..."
if (-not $server.HasExited) { Stop-Process -Id $server.Id -Force -ErrorAction SilentlyContinue }
# uvicorn (via uv) peut lancer un interpréteur enfant : tuer le groupe resté sur le port.
Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue |
ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }
}
exit $exitCode
+28
View File
@@ -95,4 +95,32 @@ test.describe('Image viewer — zoom / pan / navigation (#108)', () => {
await page.locator('#content-area .image-nav-strip img.image-thumb').first().click();
await expect(page.locator('#content-area .image-title')).toBeVisible();
});
test('conserve le plein écran et le panneau métadonnées à la navigation (#BUG-072)', async ({ page }) => {
await login(page);
await openFile(page, 'TestVault', 'sample-image.png');
const container = page.locator('#content-area .image-viewer-container');
const metaPanel = page.locator('#content-area .image-meta-panel');
const title = page.locator('#content-area .image-title');
await expect(container).toBeVisible({ timeout: 10000 });
// Métadonnées : barre latérale à droite de l'image (pas sous la pellicule).
await page.locator('#content-area .image-btn-metadata').click();
await expect(metaPanel).toBeVisible();
const stageBox = await page.locator('#content-area .image-stage').boundingBox();
const metaBox = await metaPanel.boundingBox();
expect(metaBox.x).toBeGreaterThanOrEqual(stageBox.x + stageBox.width - 1);
// Plein écran activé.
await page.locator('#content-area .image-btn-lightbox').click();
await expect(container).toHaveClass(/lightbox/);
// Naviguer (flèche droite) : les deux états doivent survivre au re-render.
const titleBefore = await title.innerText();
await page.keyboard.press('ArrowRight');
await expect(container).toHaveClass(/lightbox/);
await expect(metaPanel).toBeVisible();
await expect(title).not.toHaveText(titleBefore);
});
});
+94 -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,93 @@ 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();
});
test('la mini-fenêtre vidéo peut être déplacée librement (centre)', 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 });
const before = await mini.evaluate((el) => ({ left: parseFloat(el.style.left), top: parseFloat(el.style.top) }));
const box = await mini.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + 12);
await page.mouse.down();
await page.mouse.move(640, 360, { steps: 12 });
await page.mouse.up();
const after = await mini.evaluate((el) => ({ left: parseFloat(el.style.left), top: parseFloat(el.style.top) }));
expect(after.left).not.toBe(before.left);
expect(after.top).not.toBe(before.top);
// Doit pouvoir rester au centre (pas de re-aimantation sur les bords).
expect(after.left).toBeGreaterThan(50);
expect(after.left).toBeLessThan(900);
expect(after.top).toBeGreaterThan(20);
expect(after.top).toBeLessThan(600);
});
});
+27
View File
@@ -93,6 +93,20 @@ test("image viewer exposes thumbnail navigation via /api/media thumb", () => {
assert.match(viewer, /siblings = \(res\.items \|\| \[\]\)/);
});
// BUG-072 — lightbox + metadata panel must survive ←/→ (and filmstrip) navigation.
test("image viewer persists lightbox + metadata state across navigation", () => {
assert.match(viewer, /const _imageViewerState = \{ lightbox: false, meta: false \}/);
assert.match(viewer, /let _imageViewerNavPending = false/);
// Both navigation paths flag the next render as an image→image navigation.
assert.match(viewer, /_imageViewerNavPending = true;\s*\n\s*openFile\(vault, siblings\[next\]\.path\)/);
assert.match(viewer, /_imageViewerNavPending = true;\s*\n\s*openFile\(vault, s\.path\)/);
// renderFile resets the state only when the next render is not a carried-over image.
assert.match(viewer, /if \(!data\.is_image \|\| !_imageViewerNavPending\)/);
// The persisted state drives the container class and the panel visibility.
assert.match(viewer, /classList\.toggle\("lightbox", _imageViewerState\.lightbox\)/);
assert.match(viewer, /metaPanel\.hidden = !_imageViewerState\.meta/);
});
// ── Static checks: CSS ─────────────────────────────────────────────────────
test("style.css defines the image viewer layout + lightbox", () => {
assert.match(css, /\.image-stage\s*\{/);
@@ -101,6 +115,19 @@ test("style.css defines the image viewer layout + lightbox", () => {
assert.match(css, /\.image-viewer-container\.lightbox\s*\{/);
});
// BUG-072 — metadata is a right-hand sidebar, not a strip under the filmstrip,
// and it stays visible in lightbox mode.
test("style.css lays the metadata panel out as a right sidebar", () => {
assert.match(css, /\.image-viewer-body\s*\{[\s\S]*?display:\s*flex/);
const meta = /\.image-meta-panel\s*\{([^}]*)\}/.exec(css);
assert.ok(meta, ".image-meta-panel rule not found");
assert.match(meta[1], /border-left:/);
assert.doesNotMatch(meta[1], /border-top:/);
// The lightbox hide rule targets the filmstrip only — not the metadata sidebar.
assert.match(css, /\.image-viewer-container\.lightbox \.image-nav-strip\s*\{/);
assert.doesNotMatch(css, /\.image-viewer-container\.lightbox[^{]*\.image-meta-panel/);
});
// ── Static checks: icon mapping + backend ──────────────────────────────────
test("utils.js maps image extensions to the Lucide 'image' icon", () => {
assert.match(utils, /"\.png": "image"/);
+84 -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,88 @@ 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\(\)/);
});
test("video mini-window is freely positioned (no corner snapping)", () => {
assert.doesNotMatch(np, /nearestCorner/);
assert.match(np, /function clampToViewport/);
assert.match(np, /left: Math\.round\(r\.left\),\s*\n\s*top: Math\.round\(r\.top\)/);
assert.match(np, /bindVideoDrag\(dock\);/);
});
// ── 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("mobile dock uses a grid so actions never overlap the progress bar", () => {
assert.match(css, /grid-template-areas:\s*\n\s*"progress progress progress progress"/);
assert.match(css, /\.np-dock--audio \[data-np="expand"\]/);
});
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 +156,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 +179,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\)/);
});