188 lines
9.5 KiB
Markdown
188 lines
9.5 KiB
Markdown
# #109 — Support audio & vidéo (lecteurs HTML5 intégrés)
|
||
|
||
> **Version livrée :** 2.18.0 · **Statut :** ✅ · **Impact :** 🟡
|
||
> **Zone :** backend (`main`, `indexer`, `media_types`, `bookslm`) + frontend
|
||
> (`viewer.js`, `utils.js`, `style.css`, `sw.js`).
|
||
|
||
## Contexte
|
||
|
||
Le socle média de #108 (`backend/media_types.py`) exposait déjà
|
||
`AUDIO_EXTENSIONS` / `VIDEO_EXTENSIONS`, mais ces fichiers n'étaient ni indexés
|
||
ni affichables : ils tombaient dans le chemin binaire « Ce fichier est binaire
|
||
et ne peut pas être affiché » + bouton download.
|
||
|
||
## Ce qui a été livré
|
||
|
||
### A. Backend
|
||
|
||
- **Indexation** : `AUDIO_EXTENSIONS` et `VIDEO_EXTENSIONS` sont intégrées à
|
||
`SUPPORTED_EXTENSIONS` (`indexer.py`). La branche `is_media(ext)` existante
|
||
indexe **nom / taille / mtime** sans jamais lire les octets (`content: ""`),
|
||
donc le TF-IDF et les logs restent propres. Le watcher et le filtre `ext:`
|
||
suivent automatiquement.
|
||
- **Streaming** : le helper Range de `pdf/stream` a été extrait en
|
||
`_stream_file_with_range(file_path, request, media_type)` (206 +
|
||
`Content-Range` + `Accept-Ranges`, `416` sur plage invalide, `FileResponse`
|
||
simple sinon, lectures offloadées via `asyncio.to_thread`). `pdf/stream`
|
||
l'utilise désormais aussi (comportement inchangé, tests de régression).
|
||
- **Nouvel endpoint `GET /api/media/{vault}?path=…`** : sert audio/vidéo avec le
|
||
MIME `media_types.media_mime_type` (surcharges `.m4a→audio/mp4`,
|
||
`.opus/.oga→audio/ogg`, `.mov→video/quicktime`, `.m4v→video/mp4`).
|
||
- **Gardes-fous** : `_resolve_safe_path`, `check_vault_access`, et
|
||
`OBSIGATE_MEDIA_MAX_INLINE_MB` (défaut **500 Mo**) — au-delà, l'endpoint
|
||
renvoie `413` et la vue fichier bascule sur l'UI de téléchargement.
|
||
- **`api_file_view()`** renvoie, avant tout `read_text()`, `is_audio` /
|
||
`is_video` / `stream_url` / `media_mime` / `size_bytes`. Au-delà de la limite :
|
||
`unsupported: true` + `media_too_large: true`.
|
||
|
||
### B/C. Frontend — lecteurs
|
||
|
||
- `viewer.js` dispatche `data.is_audio` → `renderAudioViewer()` et
|
||
`data.is_video` → `renderVideoViewer()`.
|
||
- **Audio** : `<audio controls preload="metadata">` pleine largeur, artwork
|
||
placeholder (icône Lucide `audio-lines`), durée lue via `loadedmetadata`,
|
||
toolbar (titre, voûte + taille, badge durée, ouvrir l'original, télécharger).
|
||
- **Vidéo** : `<video controls playsinline preload="metadata">` centrée sur une
|
||
scène noire letterboxée (`max-height: calc(100vh - 180px)`), même toolbar
|
||
avec durée + résolution.
|
||
- `renderMediaFallback()` : sur l'événement `error` de l'élément média (codec
|
||
hors web-natif : `.mkv`, `.avi`, HEVC…) ou si le fichier est trop volumineux,
|
||
remplace le lecteur par l'UI binaire + message
|
||
`viewer.media_unsupported` / `viewer.media_too_large` + **Télécharger** /
|
||
**Ouvrir dans un nouvel onglet**.
|
||
- **Pause** au changement de vue : `_mediaViewerCleanup` met en pause et détache
|
||
la source quand `renderFile()` re-rend la zone (changement d'onglet, navigation)
|
||
— pas de lecture persistante en v1 (cohérence #75).
|
||
- `EXT_ICONS` (`utils.js`) : audio → `audio-lines`, vidéo → `video`.
|
||
- i18n FR/EN (`viewer.media_unsupported`, `viewer.media_too_large`).
|
||
|
||
### D. Recherche & intégrations
|
||
|
||
- Filtres `ext:mp3`, `ext:mp4`, etc. opérationnels (les médias entrent dans
|
||
l'index).
|
||
- Récents / dashboards : previews vides (aucun texte extrait) — comportement
|
||
naturel de la branche binaire.
|
||
- **BooksLM** : `_file_entry()` ignore désormais tout média (`is_media`) — les
|
||
octets ne sont jamais envoyés au modèle ; les images restent gérées à part via
|
||
`load_vault_image_data_url` (vision).
|
||
- **Hors scope v1** (porte notée) : transcription audio via Whisper.
|
||
|
||
### E. Mobile & PWA
|
||
|
||
- Le service worker ne met **jamais** en cache le flux média
|
||
(`/api/media/{vault}`), tout en conservant le cache des miniatures
|
||
(`/api/media/{vault}/thumb`) et la stratégie Network First pour le reste. Les
|
||
requêtes `Range` étaient déjà exclues.
|
||
|
||
### F. Tests
|
||
|
||
- `tests/test_media_stream.py` : 206 + `Content-Range` + 1024 octets, `416` hors
|
||
borne, `200` + `Accept-Ranges` sans Range, suffix range, `413` au-delà de la
|
||
limite, `403` path traversal, `403` vault sans accès, `400` non-média, MIME
|
||
`.mov`, régression `pdf/stream`.
|
||
- `tests/test_media_indexing.py` : `.mp3`/`.mp4`/`.flac` dans l'arborescence et
|
||
l'index, `content == ""`, watcher pertinent, filtre `ext:mp3`.
|
||
- `tests/frontend/media-viewer.test.mjs` : helpers purs (`formatMediaDuration`,
|
||
`buildMediaUrl`) + vérifications statiques (dispatch, `<audio>`/`<video>`,
|
||
fallback, CSS, icônes, i18n, service worker, endpoints backend).
|
||
- `tests/e2e/media-viewer.spec.js` : lecture `<audio>` et `<video>` via
|
||
`/api/media` + réponse `206` sur requête `Range`. Fixtures :
|
||
`test_vault/sample-audio.mp3` (sine 1 s) et `test_vault/sample-video.webm`
|
||
(VP8 64×64).
|
||
|
||
## Limitations connues
|
||
|
||
- Formats hors web-natifs (`.mkv`, `.avi`, HEVC, AC-4) : non lisibles sans
|
||
transcodage (ffmpeg hors scope) → repli téléchargement / lecteur de l'OS.
|
||
- 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`).
|
||
|