feat: rewrite the companion interface with Vue 3 and Vite (#14)
Move the interface sources to web/ (Vue 3 single-file components, a reactive store, framework-free modules) and assemble them with Vite into fixed asset names under the gitignored src/Compagnon/Web/, embedded in the single plugin DLL. Templates are precompiled, no router or Pinia is added and no inline script is emitted, so the script-src 'self' policy still holds. Extract the delicate logic - position estimation with its freshness bounds and seeded window, history selection, help overlap, subtitle validation and ASS cleanup, saved connection parsing - into modules tested without a browser by node:test, injecting the clocks. Build the interface in scripts/package.sh before the C# compile, bump the plugin to 0.7.0, switch npm run typecheck to vue-tsc and document the Vite dev proxy flow in the README. Prove parity with the untouched end-to-end suite: 34 tests on the disposable server with and without the /jellyfin prefix, stable gap within 330 ms and seek recovery within 789 ms against the burnt-in timecode. Record the decision in docs/adr/0003 and the evidence in docs/validation/issue-14.md.
This commit is contained in:
1 parent
70135ef991
commit
d53efb4be6
32 files changed
+2895
-655
No files matched your search
@@ -3,3 +3,4 @@
|
||||
node_modules/
|
||||
.test-state/
|
||||
test-results/
|
||||
src/Compagnon/Web/
|
||||
@@ -26,8 +26,8 @@ contexte ; une session disparue est signalée sans basculer sur une autre lectur
|
||||
Version testée : **Jellyfin Server 12.0.0**, Linux x64. Les autres versions ne sont
|
||||
pas déclarées compatibles.
|
||||
|
||||
1. Télécharger [le paquet 0.6.0](dist/compagnon-0.6.0-jellyfin-12.0.0.zip)
|
||||
et sa [somme SHA-256](dist/compagnon-0.6.0-jellyfin-12.0.0.zip.sha256).
|
||||
1. Télécharger [le paquet 0.7.0](dist/compagnon-0.7.0-jellyfin-12.0.0.zip)
|
||||
et sa [somme SHA-256](dist/compagnon-0.7.0-jellyfin-12.0.0.zip.sha256).
|
||||
2. Arrêter Jellyfin. Extraire le dossier `Compagnon` de l'archive dans le dossier
|
||||
`plugins` de ses données. Avec l'image Docker officielle et son volume
|
||||
`/config`, le résultat est `/config/plugins/Compagnon/Compagnon.dll`.
|
||||
@@ -102,12 +102,20 @@ nécessaires à Chromium/Playwright. Aucun de ces outils n'est requis pour insta
|
||||
le paquet fourni.
|
||||
|
||||
```sh
|
||||
bash scripts/package.sh # compilation C# avec avertissements bloquants et archive
|
||||
bash scripts/package.sh # construit l'interface (npm ci, Vite), compile C# avec avertissements bloquants et archive
|
||||
npm ci
|
||||
npm run typecheck # JavaScript vérifié par TypeScript
|
||||
npm run typecheck # sources TypeScript et Vue vérifiées par vue-tsc
|
||||
npm run test:unit # modules purs de l'interface, testés avec node:test
|
||||
bash tests/run.sh # serveur jetable, installation du paquet et tests réels
|
||||
```
|
||||
|
||||
L'interface vit dans `web/` (Vue 3, Vite, TypeScript) ; `src/Compagnon/Web/`
|
||||
est un dossier généré, ignoré par Git et embarqué dans le DLL sous des noms
|
||||
fixes. Pour itérer sans empaqueter, `JELLYFIN_URL=http://127.0.0.1:8096 npm run dev`
|
||||
sert l'interface sur `http://127.0.0.1:5173/` et relaie l'API à ce serveur
|
||||
Jellyfin. La logique pure (estimation de position, historique, chevauchement de
|
||||
l'aide, lecture des pistes) est dans `web/src/lib/` et testée sans navigateur.
|
||||
|
||||
`tests/run.sh` utilise un port local libre et supprime son propre conteneur en
|
||||
sortie. Il crée deux comptes ordinaires, un compte administrateur de préparation
|
||||
et une vidéo originale de deux minutes avec timecode, et active Quick Connect sur
|
||||
@@ -125,6 +133,8 @@ Le [rapport du lot #7](docs/validation/issue-7.md) consolide versions, protocole
|
||||
résultats et limites de la connexion par Quick Connect, de sa conservation entre
|
||||
les visites et de la déconnexion ; le détail ticket par ticket (#8 à #12) figure
|
||||
dans le [rapport du lot #8](docs/validation/issue-8.md).
|
||||
Le [rapport de la réécriture de l'interface](docs/validation/issue-14.md) décrit
|
||||
la migration Vue 3 + Vite et la parité prouvée par la suite existante.
|
||||
|
||||
Pour relancer un seul scénario pendant le développement sur cette instance :
|
||||
|
||||
|
||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
||||
d7f980d9bb3d5c510986c0da03af1c4f8b90918b1a8988071f5556d8c58b5ac7 compagnon-0.7.0-jellyfin-12.0.0.zip
|
||||
@@ -0,0 +1,16 @@
|
||||
# Construire l'interface du compagnon avec Vue 3 et Vite
|
||||
|
||||
L'interface actuelle (`Web/app.js`, ~600 lignes de manipulation directe du DOM) mêle l'estimation de position, la machine à états et le rendu, ce qui la rend difficile à faire évoluer, et son habillage date. Nous la réécrivons en Vue 3 (composants monofichiers à gabarits précompilés) assemblée par Vite en amont, en TypeScript, avec la logique délicate extraite dans des modules purs testés unitairement. La migration se fait en un seul lot et doit laisser le comportement et la structure observée par la suite de tests de bout en bout strictement inchangés ; la refonte visuelle viendra ensuite. L'ensemble reste embarqué dans le DLL sous des noms de fichiers fixes, sans CDN ni service externe, et compatible avec la CSP `script-src 'self'` : les gabarits sont donc compilés à la construction, jamais évalués à l'exécution.
|
||||
|
||||
## Options considérées
|
||||
|
||||
- **Svelte 5** : sortie plus petite et compilée, mais écosystème et outillage moins familiers ici ; la taille du bundle est sans enjeu sur un réseau local.
|
||||
- **React / Preact** : aucun bénéfice pour une page unique, au prix d'un runtime plus lourd.
|
||||
- **Gabarits Vue compilés au chargement** : exigent `unsafe-eval`, incompatible avec la CSP du plugin.
|
||||
- **Migration progressive** : deux systèmes se partageraient le DOM d'une page unique ; la colle temporaire coûterait plus cher que la réécriture.
|
||||
- **vue-router et Pinia** : refusés — une seule page sans navigation, un seul état partagé qui tient dans un module réactif.
|
||||
|
||||
## Conséquences
|
||||
|
||||
- Les sources de l'interface vivent dans `web/` (composants, magasin réactif et modules purs) ; `scripts/package.sh` exécute leur construction (`npm ci && npm run build`) avant la compilation C#, et `src/Compagnon/Web/` devient un dossier généré, ignoré par Git.
|
||||
- `npm run typecheck` s'appuie désormais sur `vue-tsc` ; les modules purs sont couverts par des tests unitaires `node:test`.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Validation de la réécriture de l'interface — Vue 3 + Vite
|
||||
|
||||
## Protocole
|
||||
|
||||
Paquet **0.7.0**, Jellyfin Server **12.0.0** (image figée dans `tests/run.sh`),
|
||||
Jellyfin Web de cette image, Chromium **149.0.7827.55**, Playwright **1.61.1**,
|
||||
Node.js **24.18.0**, Vue **3.5.24**, Vite **7.3.6**, TypeScript **5.9.3**.
|
||||
Compagnon de **375 × 812 pixels**, hébergement sans préfixe puis suite complète
|
||||
sous `/jellyfin`.
|
||||
|
||||
```sh
|
||||
npm run typecheck
|
||||
npm run test:unit
|
||||
bash tests/run.sh
|
||||
```
|
||||
|
||||
Aucun test de la suite existante n'a été modifié : `history`, `bilingual`,
|
||||
`quick-connect`, `recovery` et `installed` sont ceux du dépôt, seule la
|
||||
référence à l'archive dans `tests/run.sh` suit la version 0.7.0.
|
||||
|
||||
## Architecture livrée
|
||||
|
||||
L'interface vit dans `web/` : composants monofichiers Vue assemblés par Vite,
|
||||
écrits en TypeScript, sans router ni Pinia. La logique délicate est extraite
|
||||
dans `web/src/lib/` — estimation de position, fraîcheur des observations,
|
||||
sélection de l'historique, chevauchement de l'aide, validation et nettoyage des
|
||||
pistes, lecture de la connexion conservée — et testée sans navigateur par
|
||||
`node:test` avec des horloges injectées. Le magasin réactif (`web/src/store.ts`)
|
||||
reste une transposition des flux de l'ancien `app.js` : mêmes gardes de
|
||||
génération, mêmes bornes, mêmes messages.
|
||||
|
||||
La construction produit des noms fixes (`index.html`, `app.js`, `style.css`)
|
||||
dans `src/Compagnon/Web/`, dossier généré et ignoré par Git, embarqué tel quel
|
||||
dans le DLL. `scripts/package.sh` construit l'interface puis compile C#.
|
||||
L'index généré ne contient aucun script en ligne, compatible avec la CSP
|
||||
`script-src 'self'` du contrôleur. Le bundle pèse 75,85 kB (29,65 kB compressés).
|
||||
|
||||
## Résultats
|
||||
|
||||
Exécution complète du **18 septembre 2026** :
|
||||
|
||||
| Vérification | Résultat |
|
||||
| --- | --- |
|
||||
| `npm run typecheck` (`vue-tsc`) | 0 erreur |
|
||||
| `npm run test:unit` | **21 tests sur 21** |
|
||||
| `npm audit` | 0 vulnérabilité |
|
||||
| Compilation C# Release | 0 erreur, 0 avertissement |
|
||||
| `bash tests/run.sh` | **34 tests sur 34**, 131,4 s |
|
||||
|
||||
Les contrôles installés passent avec et sans préfixe `/jellyfin`. La parité DOM
|
||||
est vérifiée par la suite existante : identifiants et classes (`#status`,
|
||||
`#details`, `#history .main-block`, `.help-block`, `#sessions`, `#tracks`,
|
||||
`#help-tracks`), libellés accessibles, focus après approbation, à l'expiration
|
||||
d'un code et à la déconnexion, options de pistes identiques entre les deux
|
||||
sélecteurs.
|
||||
|
||||
Les mesures de synchronisation contre le timecode incrusté restent dans les
|
||||
objectifs : écart maximal **330 ms** en lecture stable à 1× (seuil 500 ms) et
|
||||
recalage après déplacement de **789 ms** (seuil deux secondes), soit
|
||||
`stablePass` et `seekPass` vrais. Les extractions SRT, WebVTT et ASS restent du
|
||||
même ordre que le lot #5.
|
||||
|
||||
## Limites
|
||||
|
||||
- Aucun changement visuel : la refonte demandée reste un lot séparé (#15). Vite
|
||||
minifie la feuille servie (748 octets au lieu de 907), règles identiques.
|
||||
- La suite de tests de navigateur sert les fichiers générés depuis
|
||||
`src/Compagnon/Web/` : exécuter un test sans `npm run build` préalable sert un
|
||||
dossier vide.
|
||||
- Le bundle d'interface est plus lourd que le script écrit à la main (75,85 kB
|
||||
contre 26,9 kB) ; il est mis en cache par le navigateur et la page reste
|
||||
publique, sans donnée supplémentaire exposée.
|
||||
- Les navigateurs antérieurs à `AbortSignal.any` restent hors périmètre, comme
|
||||
avant la réécriture.
|
||||
Generated
+1519
-1
File diff suppressed because it is too large.
Load diff
+10
-2
@@ -3,11 +3,19 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --allowJs --checkJs --noEmit --target es2022 --module nodenext --lib es2022,dom src/Compagnon/Web/app.js",
|
||||
"build": "vite build --config web/vite.config.ts",
|
||||
"dev": "vite --config web/vite.config.ts",
|
||||
"typecheck": "vue-tsc --noEmit -p web/tsconfig.json",
|
||||
"test:unit": "node --test web/src/lib/*.test.ts",
|
||||
"test": "node --test tests/*.test.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.61.1",
|
||||
"typescript": "5.9.3"
|
||||
"@types/node": "24.10.1",
|
||||
"@vitejs/plugin-vue": "6.0.1",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "7.3.6",
|
||||
"vue": "3.5.24",
|
||||
"vue-tsc": "3.1.4"
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
npm ci --ignore-scripts
|
||||
npm run build
|
||||
docker run --rm --user "$(id -u):$(id -g)" \
|
||||
-e DOTNET_CLI_HOME=/tmp/dotnet -e NUGET_PACKAGES=/tmp/nuget \
|
||||
-v "$PWD:/work" -w /work \
|
||||
@@ -11,7 +13,7 @@ from pathlib import Path
|
||||
from zipfile import ZipFile, ZIP_DEFLATED
|
||||
import hashlib
|
||||
|
||||
archive = Path('dist/compagnon-0.6.0-jellyfin-12.0.0.zip')
|
||||
archive = Path('dist/compagnon-0.7.0-jellyfin-12.0.0.zip')
|
||||
archive.parent.mkdir(exist_ok=True)
|
||||
with ZipFile(archive, 'w', ZIP_DEFLATED) as package:
|
||||
package.write('src/Compagnon/bin/Release/net10.0/Compagnon.dll', 'Compagnon/Compagnon.dll')
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Version>0.6.0</Version>
|
||||
<Version>0.7.0</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
|
||||
@@ -1,609 +0,0 @@
|
||||
/** @typedef {{Id: string, UserId: string, DeviceName?: string, NowPlayingItem?: {Id: string, Name?: string}, PlayState?: {MediaSourceId?: string, SubtitleStreamIndex?: number, PositionTicks?: number, IsPaused?: boolean}, LastPlaybackCheckIn?: string}} Session */
|
||||
/** @typedef {{Type: string, Index: number, DisplayTitle?: string, Language?: string, Codec?: string, IsExternal?: boolean, IsTextSubtitleStream?: boolean, IsForced?: boolean, IsHearingImpaired?: boolean}} MediaStream */
|
||||
/** @typedef {{Text: string, StartPositionTicks: number, EndPositionTicks: number}} SubtitleBlock */
|
||||
/** @typedef {{token: string, userId: string, deviceId: string}} SavedConnection */
|
||||
const connectPanel = /** @type {HTMLElement} */ (document.querySelector('#connect'));
|
||||
const code = /** @type {HTMLElement} */ (document.querySelector('#code'));
|
||||
const retryButton = /** @type {HTMLButtonElement} */ (document.querySelector('#retry'));
|
||||
const following = /** @type {HTMLElement} */ (document.querySelector('#following'));
|
||||
const status = /** @type {HTMLElement} */ (document.querySelector('#status'));
|
||||
const select = /** @type {HTMLSelectElement} */ (document.querySelector('#sessions'));
|
||||
const details = /** @type {HTMLElement} */ (document.querySelector('#details'));
|
||||
const logout = /** @type {HTMLButtonElement} */ (document.querySelector('#logout'));
|
||||
const tracks = /** @type {HTMLSelectElement} */ (document.querySelector('#tracks'));
|
||||
const trackStatus = /** @type {HTMLElement} */ (document.querySelector('#track-status'));
|
||||
const helpTracks = /** @type {HTMLSelectElement} */ (document.querySelector('#help-tracks'));
|
||||
const helpStatus = /** @type {HTMLElement} */ (document.querySelector('#help-status'));
|
||||
const history = /** @type {HTMLOListElement} */ (document.querySelector('#history'));
|
||||
const base = new URL('../', location.href);
|
||||
const savedKey = 'compagnon.connexion';
|
||||
const disabledNotice = 'Quick Connect est désactivé sur ce serveur. Activez-le dans le tableau de bord Jellyfin, onglet Général, puis réessayez.';
|
||||
// Bounds on the local estimate: beyond them the display is presented as
|
||||
// uncertain and stops advancing until a fresh observation arrives.
|
||||
const observationFreshnessMs = 2500;
|
||||
const checkInFreshnessMs = 30000;
|
||||
// Jellyfin ticks are 100 ns; ticker bounds are in milliseconds.
|
||||
const ticksPerSecond = 10000000;
|
||||
const ticksPerMs = 10000;
|
||||
let deviceId = Array.from(crypto.getRandomValues(new Uint8Array(16)), byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
let token = '';
|
||||
let userId = '';
|
||||
let selected = '';
|
||||
let generation = 0;
|
||||
let timer = 0;
|
||||
let quickGeneration = 0;
|
||||
let quickTimer = 0;
|
||||
/** @type {Session[]} */
|
||||
let sessions = [];
|
||||
let context = '';
|
||||
let trackGeneration = 0;
|
||||
let trackNotice = '';
|
||||
/** @type {SubtitleBlock[] | null} */
|
||||
let blocks = null;
|
||||
let helpGeneration = 0;
|
||||
/** @type {SubtitleBlock[] | null} */
|
||||
let helpBlocks = null;
|
||||
// performance.now() marks on the local monotonic clock; Date.now() is only used
|
||||
// against the server check-in timestamp.
|
||||
let observedPerfAt = -Infinity;
|
||||
let lastTickPerfAt = 0;
|
||||
/** @type {number | null} */
|
||||
let lastRawTicks = null;
|
||||
/** @type {number | null} */
|
||||
let lastEstimateTicks = null;
|
||||
/** @type {{at: number, seeded: number}[]} */
|
||||
let seededWindow = [];
|
||||
let positionContext = '';
|
||||
/** @type {HTMLElement | null} */
|
||||
let positionValue = null;
|
||||
let historySignature = '';
|
||||
let resyncPending = false;
|
||||
/** @type {AbortController | null} */
|
||||
let sessionController = null;
|
||||
|
||||
class ApiError extends Error {
|
||||
/** @param {number} status */
|
||||
constructor(status) {
|
||||
super(status === 401 ? 'Accès refusé par le serveur.' : `Serveur indisponible ou requête refusée (${status}).`);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
function clearTrack() {
|
||||
trackGeneration++;
|
||||
blocks = null;
|
||||
historySignature = '';
|
||||
history.replaceChildren();
|
||||
}
|
||||
|
||||
// Drops the anchors of the passage being tracked. The observation clock is
|
||||
// kept: the last poll may already describe the new selection or context.
|
||||
function resetSeeded() {
|
||||
lastTickPerfAt = 0;
|
||||
lastRawTicks = null;
|
||||
lastEstimateTicks = null;
|
||||
seededWindow = [];
|
||||
}
|
||||
|
||||
function resetEstimate() {
|
||||
resetSeeded();
|
||||
observedPerfAt = -Infinity;
|
||||
positionContext = '';
|
||||
}
|
||||
|
||||
function resetSubtitles() {
|
||||
clearTrack();
|
||||
helpGeneration++;
|
||||
helpBlocks = null;
|
||||
helpTracks.replaceChildren(new Option("Choisir une piste d'aide", ''));
|
||||
helpTracks.disabled = true;
|
||||
helpStatus.textContent = '';
|
||||
context = '';
|
||||
trackNotice = '';
|
||||
tracks.replaceChildren(new Option('Choisir une piste', ''));
|
||||
tracks.disabled = true;
|
||||
trackStatus.textContent = '';
|
||||
}
|
||||
|
||||
/** @param {Session} session */
|
||||
function trackingFresh(session) {
|
||||
if (performance.now() - observedPerfAt > observationFreshnessMs) return false;
|
||||
// A paused session legitimately stops reporting; only a playing one is expected to check in.
|
||||
if (session.PlayState?.IsPaused === true) return true;
|
||||
const checkIn = Date.parse(session.LastPlaybackCheckIn ?? '');
|
||||
return !Number.isFinite(checkIn) || Date.now() - checkIn <= checkInFreshnessMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local estimate between Jellyfin observations. Jellyfin advances PositionTicks
|
||||
* in whole-second steps from the last client check-in: subtracting the whole
|
||||
* seconds already counted rebuilds the position reported at the check-in, to
|
||||
* which the elapsed time is added once. The highest rebuilt position seen
|
||||
* lately is kept so a late second tick never makes the display step backwards.
|
||||
* A tick that stalls entirely is replaced by our own clock for a bounded time.
|
||||
* @param {Session} session @returns {number | null}
|
||||
*/
|
||||
function estimateTicks(session) {
|
||||
const raw = session.PlayState?.PositionTicks;
|
||||
if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 0) return null;
|
||||
const previousRaw = lastRawTicks;
|
||||
if (raw !== previousRaw) lastTickPerfAt = performance.now();
|
||||
// A backward jump is a seek or a new report, not the same passage.
|
||||
if (previousRaw !== null && raw < previousRaw - 1.5 * ticksPerSecond) seededWindow = [];
|
||||
lastRawTicks = raw;
|
||||
if (session.PlayState?.IsPaused !== false) return raw;
|
||||
const sinceTick = performance.now() - lastTickPerfAt;
|
||||
const stalled = lastTickPerfAt && sinceTick > 1100 ? raw + Math.min(sinceTick, observationFreshnessMs) * ticksPerMs : null;
|
||||
const checkIn = Date.parse(session.LastPlaybackCheckIn ?? '');
|
||||
if (!Number.isFinite(checkIn)) return stalled ?? raw;
|
||||
const elapsed = Math.max(0, Date.now() - checkIn);
|
||||
const now = performance.now();
|
||||
seededWindow.push({ at: now, seeded: raw - Math.floor(elapsed / 1000) * ticksPerSecond });
|
||||
while (seededWindow.length && now - seededWindow[0].at > 1500) seededWindow.shift();
|
||||
const estimate = Math.max(...seededWindow.map(entry => entry.seeded)) + elapsed * ticksPerMs;
|
||||
return stalled === null ? estimate : Math.max(estimate, stalled);
|
||||
}
|
||||
|
||||
/** @param {Session} session @returns {number | null} */
|
||||
function currentEstimate(session) {
|
||||
if (!trackingFresh(session)) return null;
|
||||
const estimate = estimateTicks(session);
|
||||
if (estimate !== null) lastEstimateTicks = estimate;
|
||||
return lastEstimateTicks;
|
||||
}
|
||||
|
||||
/** @param {number | null} ticks */
|
||||
function formatPosition(ticks) {
|
||||
if (typeof ticks !== 'number' || !Number.isFinite(ticks) || ticks < 0) return 'Non communiquée';
|
||||
const seconds = ticks / ticksPerSecond;
|
||||
return `${Math.floor(seconds / 60)}:${(seconds % 60).toFixed(3).padStart(6, '0')}`;
|
||||
}
|
||||
|
||||
/** @param {Session | undefined} session */
|
||||
function renderStatus(session) {
|
||||
if (!session) return;
|
||||
if (resyncPending) {
|
||||
status.textContent = 'Reprise après suspension du navigateur — vérification de la session avant de reprendre l’affichage synchronisé.';
|
||||
return;
|
||||
}
|
||||
if (!trackingFresh(session)) {
|
||||
const age = observedPerfAt === -Infinity ? null : Math.floor((performance.now() - observedPerfAt) / 1000);
|
||||
status.textContent = `Suivi incertain — l’affichage reste figé faute d’observation récente${age === null ? '' : ` (${age} s)`} ; il reprendra après une remontée du serveur.`;
|
||||
return;
|
||||
}
|
||||
status.textContent = 'Suivi actif — position estimée, recalée sur les remontées de Jellyfin ; pas une observation directe de l’écran.';
|
||||
}
|
||||
|
||||
/** @param {number | null} [estimate] */
|
||||
function renderHistory(estimate = lastEstimateTicks) {
|
||||
if (blocks === null) return;
|
||||
const ticks = estimate ?? lastEstimateTicks;
|
||||
const recent = typeof ticks === 'number' ? blocks.filter(b => b.StartPositionTicks <= ticks).slice(-5) : [];
|
||||
const signature = `${trackGeneration}:${helpGeneration}:${recent.length}:${recent.length ? blocks.indexOf(recent[0]) : -1}`;
|
||||
if (historySignature === signature) return;
|
||||
historySignature = signature;
|
||||
history.replaceChildren();
|
||||
if (typeof ticks !== 'number') {
|
||||
trackStatus.textContent = `Position indisponible. ${trackNotice}`;
|
||||
return;
|
||||
}
|
||||
for (const block of recent) {
|
||||
const li = document.createElement('li');
|
||||
const main = document.createElement('div');
|
||||
main.className = 'main-block';
|
||||
main.textContent = block.Text;
|
||||
li.append(main);
|
||||
// At most five main blocks: a scan of the help track keeps the overlap rule explicit.
|
||||
const associated = helpBlocks?.filter(b =>
|
||||
Math.max(b.StartPositionTicks, block.StartPositionTicks) < Math.min(b.EndPositionTicks, block.EndPositionTicks));
|
||||
if (associated?.length) {
|
||||
for (const help of associated) {
|
||||
const text = document.createElement('div');
|
||||
text.className = 'help-block';
|
||||
text.textContent = help.Text;
|
||||
li.append(text);
|
||||
}
|
||||
} else {
|
||||
const absent = document.createElement('div');
|
||||
absent.textContent = helpBlocks === null ? helpStatus.textContent : 'Aucune aide associée à ce bloc.';
|
||||
li.append(absent);
|
||||
}
|
||||
history.append(li);
|
||||
}
|
||||
trackStatus.textContent = `${recent.length ? 'Historique à la position estimée.' : 'Aucun bloc commencé à cette position.'} ${trackNotice}`;
|
||||
}
|
||||
|
||||
/** @param {Session} session @param {HTMLSelectElement} selector @returns {Promise<SubtitleBlock[]>} */
|
||||
async function readTrack(session, selector) {
|
||||
const codec = selector.selectedOptions[0]?.dataset.codec;
|
||||
const path = [session.NowPlayingItem?.Id || '', session.PlayState?.MediaSourceId || '', selector.value].map(encodeURIComponent);
|
||||
// Cold extraction can outlast session polling; keep its timeout independent.
|
||||
const result = await api(`Compagnon/Subtitles/${path.join('/')}`, { signal: AbortSignal.timeout(120000) });
|
||||
if (!Array.isArray(result?.TrackEvents) || !result.TrackEvents.every((/** @type {SubtitleBlock} */ b) =>
|
||||
b && typeof b.Text === 'string' && Number.isSafeInteger(b.StartPositionTicks) && b.StartPositionTicks >= 0 &&
|
||||
Number.isSafeInteger(b.EndPositionTicks) && b.EndPositionTicks >= b.StartPositionTicks)) throw new Error('Format de piste invalide.');
|
||||
if (codec === 'ass' || codec === 'ssa') {
|
||||
// Jellyfin decodes ASS line breaks but retains override commands in JSON.
|
||||
for (const block of result.TrackEvents) block.Text = block.Text.replace(/\{\\[^}]*\}/g, '');
|
||||
}
|
||||
// Stable sort retains Jellyfin's event order for simultaneous blocks.
|
||||
return result.TrackEvents.sort((/** @type {SubtitleBlock} */ a, /** @type {SubtitleBlock} */ b) => a.StartPositionTicks - b.StartPositionTicks);
|
||||
}
|
||||
|
||||
/** @param {Session} session */
|
||||
async function loadHelpTrack(session) {
|
||||
const current = ++helpGeneration;
|
||||
helpBlocks = null;
|
||||
helpStatus.textContent = helpTracks.value ? "Chargement de la piste d'aide… Une première extraction peut prendre du temps." : "Aucune piste d'aide sélectionnée. Choisissez une piste dans la langue souhaitée si elle est disponible.";
|
||||
renderHistory();
|
||||
if (!helpTracks.value) return;
|
||||
try {
|
||||
const result = await readTrack(session, helpTracks);
|
||||
if (current !== helpGeneration) return;
|
||||
helpBlocks = result;
|
||||
helpStatus.textContent = result.length ? 'Aide associée par chevauchement temporel.' : "Piste d'aide vide : aucune aide disponible.";
|
||||
} catch {
|
||||
if (current !== helpGeneration) return;
|
||||
helpStatus.textContent = "Piste d'aide inexploitable ou accès refusé. Choisissez une piste pour réessayer.";
|
||||
}
|
||||
historySignature = '';
|
||||
renderHistory();
|
||||
}
|
||||
|
||||
/** @param {Session} session */
|
||||
async function loadTrack(session) {
|
||||
clearTrack();
|
||||
if (!tracks.value) {
|
||||
trackStatus.textContent = `Choisissez une piste principale. ${trackNotice}`;
|
||||
return;
|
||||
}
|
||||
const current = trackGeneration;
|
||||
trackStatus.textContent = `Chargement de la piste… Une première extraction peut prendre du temps. ${trackNotice}`;
|
||||
try {
|
||||
const result = await readTrack(session, tracks);
|
||||
if (current !== trackGeneration) return;
|
||||
blocks = result;
|
||||
historySignature = '';
|
||||
renderHistory();
|
||||
} catch {
|
||||
if (current === trackGeneration) trackStatus.textContent = `Piste inexploitable ou accès refusé. Choisissez une piste pour réessayer. ${trackNotice}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {Session | undefined} session */
|
||||
async function updateSubtitles(session) {
|
||||
const item = session?.NowPlayingItem?.Id;
|
||||
const source = session?.PlayState?.MediaSourceId;
|
||||
const next = session && item && source ? JSON.stringify([session.Id, item, source]) : '';
|
||||
if (!next) {
|
||||
resetSubtitles();
|
||||
if (session) trackStatus.textContent = 'Source réellement lue non communiquée : pistes indisponibles.';
|
||||
return;
|
||||
}
|
||||
if (next === context) { renderHistory(); return; }
|
||||
resetSubtitles();
|
||||
context = next;
|
||||
const current = trackGeneration;
|
||||
trackStatus.textContent = 'Chargement des pistes…';
|
||||
try {
|
||||
const result = await api(`Items/${encodeURIComponent(item || '')}/PlaybackInfo`);
|
||||
if (current !== trackGeneration) return;
|
||||
const mediaSource = result.MediaSources?.find((/** @type {{Id: string}} */ s) => s.Id === source);
|
||||
if (!mediaSource || !Array.isArray(mediaSource.MediaStreams)) throw new Error('Source indisponible.');
|
||||
const subtitles = /** @type {MediaStream[]} */ (mediaSource.MediaStreams).filter(s => s.Type === 'Subtitle');
|
||||
trackNotice = subtitles.some(s => !s.IsTextSubtitleStream) ? 'Pistes graphiques non prises en charge (sans OCR).' : '';
|
||||
const available = subtitles.filter(s => s.IsTextSubtitleStream && Number.isInteger(s.Index) && s.Index >= 0);
|
||||
for (const selector of [tracks, helpTracks]) {
|
||||
selector.append(...available.map(s => {
|
||||
const option = new Option(`#${s.Index} — ${s.DisplayTitle || s.Language || 'Langue inconnue'} — ${s.Codec || 'texte'}${s.IsForced ? ' — forcés' : ''}${s.IsHearingImpaired ? ' — malentendants' : ''}`, String(s.Index));
|
||||
option.dataset.codec = s.Codec?.toLowerCase() || '';
|
||||
return option;
|
||||
}));
|
||||
selector.disabled = !available.length;
|
||||
}
|
||||
helpStatus.textContent = available.length ? "Aucune piste d'aide sélectionnée. Choisissez une piste dans la langue souhaitée si elle est disponible." : "Aucune piste d'aide texte disponible.";
|
||||
if (!available.length) {
|
||||
trackStatus.textContent = `Aucune piste texte disponible. ${trackNotice}`;
|
||||
return;
|
||||
}
|
||||
const preferred = available.find(s => s.Index === session?.PlayState?.SubtitleStreamIndex);
|
||||
tracks.value = preferred ? String(preferred.Index) : '';
|
||||
if (session) await loadTrack(session);
|
||||
} catch {
|
||||
if (current === trackGeneration) {
|
||||
trackStatus.textContent = 'Pistes inaccessibles : source indisponible ou accès refusé.';
|
||||
helpStatus.textContent = "Pistes d'aide inaccessibles : source indisponible ou accès refusé.";
|
||||
context = ''; // Retry discovery on the next successful session observation.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {string} path @param {RequestInit} [options] */
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(new URL(path, base), {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `MediaBrowser Client="Compagnon", Device="Navigateur compagnon", DeviceId="${deviceId}", Version="0.6.0"${token ? `, Token="${token}"` : ''}`,
|
||||
},
|
||||
cache: 'no-store',
|
||||
signal: options.signal ?? AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!response.ok) throw new ApiError(response.status);
|
||||
return response.status === 204 ? null : response.json();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const options = sessions.map(s => [s.Id, `${s.DeviceName || 'Appareil inconnu'} — ${s.NowPlayingItem?.Name || 'Média inconnu'}`]);
|
||||
if (selected && !sessions.some(s => s.Id === selected)) options.unshift([selected, 'Lecture suivie indisponible']);
|
||||
options.unshift(['', 'Choisir une lecture']);
|
||||
// Preserve the native select (and keyboard focus) between observations.
|
||||
if (JSON.stringify(options) !== select.dataset.options) {
|
||||
select.replaceChildren(...options.map(([id, label]) => new Option(label, id)));
|
||||
select.dataset.options = JSON.stringify(options);
|
||||
}
|
||||
select.value = selected;
|
||||
details.replaceChildren();
|
||||
positionValue = null;
|
||||
const session = sessions.find(s => s.Id === selected);
|
||||
// A new session, media or source starts a new passage: no anchor or history
|
||||
// from the previous context may survive into its position estimate.
|
||||
const nextPosition = session ? JSON.stringify([session.Id, session.NowPlayingItem?.Id, session.PlayState?.MediaSourceId]) : '';
|
||||
if (nextPosition !== positionContext) {
|
||||
positionContext = nextPosition;
|
||||
resetSeeded();
|
||||
}
|
||||
void updateSubtitles(session);
|
||||
if (!session) {
|
||||
status.textContent = selected ? 'La lecture suivie est arrêtée ou sa session a disparu.' : sessions.length ? 'Choisissez une lecture à accompagner.' : 'Aucune lecture disponible pour ce compte.';
|
||||
return;
|
||||
}
|
||||
renderStatus(session);
|
||||
const checkIn = session.LastPlaybackCheckIn ? Date.parse(session.LastPlaybackCheckIn) : NaN;
|
||||
const fields = {
|
||||
'Appareil': session.DeviceName || 'Inconnu',
|
||||
'Session': session.Id,
|
||||
'Média': session.NowPlayingItem?.Name || 'Inconnu',
|
||||
'Source réellement lue': session.PlayState?.MediaSourceId || 'Non communiquée',
|
||||
'Position estimée': formatPosition(currentEstimate(session) ?? lastEstimateTicks),
|
||||
'État': session.PlayState?.IsPaused === true ? 'En pause' : session.PlayState?.IsPaused === false ? 'En lecture' : 'Non communiqué',
|
||||
'Dernière remontée du lecteur': Number.isFinite(checkIn) ? `Il y a ${Math.max(0, Math.floor((Date.now() - checkIn) / 1000))} s` : 'Non communiquée',
|
||||
};
|
||||
for (const [name, value] of Object.entries(fields)) {
|
||||
const dt = document.createElement('dt');
|
||||
const dd = document.createElement('dd');
|
||||
dt.textContent = name;
|
||||
dd.textContent = value;
|
||||
if (name === 'Position estimée') positionValue = dd;
|
||||
details.append(dt, dd);
|
||||
}
|
||||
renderHistory();
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
const current = generation;
|
||||
clearTimeout(timer);
|
||||
// Superseding an in-flight observation also covers a response that was
|
||||
// computed before a suspension: only the newest request may touch the display.
|
||||
sessionController?.abort();
|
||||
const controller = new AbortController();
|
||||
sessionController = controller;
|
||||
try {
|
||||
const result = /** @type {Session[]} */ (await api('Sessions', {
|
||||
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(8000)]),
|
||||
}));
|
||||
if (current !== generation || sessionController !== controller) return;
|
||||
observedPerfAt = performance.now();
|
||||
resyncPending = false;
|
||||
sessions = result.filter(s => s.UserId === userId && s.NowPlayingItem);
|
||||
select.disabled = false;
|
||||
if (!selected && sessions.length === 1) selected = sessions[0].Id;
|
||||
render();
|
||||
} catch (error) {
|
||||
if (current !== generation || sessionController !== controller) return;
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
forgetConnection('Votre connexion enregistrée n’est plus acceptée. Autorisez à nouveau le compagnon.');
|
||||
return;
|
||||
}
|
||||
select.disabled = true;
|
||||
sessions = [];
|
||||
resetSubtitles();
|
||||
details.replaceChildren();
|
||||
status.textContent = `Suivi indisponible. ${error instanceof Error ? error.message : 'Réessayez.'}`;
|
||||
}
|
||||
if (current === generation && token) timer = window.setTimeout(poll, 1000);
|
||||
}
|
||||
|
||||
/** @returns {SavedConnection | null} */
|
||||
function readSavedConnection() {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(savedKey) ?? '');
|
||||
const valid = saved && ['token', 'userId', 'deviceId'].every(key => typeof saved[key] === 'string' && saved[key].trim() !== '');
|
||||
return valid ? { token: saved.token, userId: saved.userId, deviceId: saved.deviceId } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resetConnectPanel() {
|
||||
connectPanel.hidden = true;
|
||||
retryButton.hidden = true;
|
||||
code.textContent = '';
|
||||
}
|
||||
|
||||
/** @param {string} secret @param {number} current */
|
||||
async function waitForApproval(secret, current) {
|
||||
try {
|
||||
const state = await api(`QuickConnect/Connect?secret=${encodeURIComponent(secret)}`);
|
||||
if (current !== quickGeneration) return;
|
||||
if (state?.Authenticated !== true) {
|
||||
quickTimer = window.setTimeout(() => { void waitForApproval(secret, current); }, 1000);
|
||||
return;
|
||||
}
|
||||
const authenticated = await api('Users/AuthenticateWithQuickConnect', { method: 'POST', body: JSON.stringify({ Secret: secret }) });
|
||||
if (current !== quickGeneration) return;
|
||||
if (typeof authenticated?.AccessToken !== 'string' || typeof authenticated?.User?.Id !== 'string') throw new Error('Réponse du serveur inattendue.');
|
||||
token = authenticated.AccessToken;
|
||||
userId = authenticated.User.Id;
|
||||
localStorage.setItem(savedKey, JSON.stringify({ token, userId, deviceId }));
|
||||
await startFollowing();
|
||||
} catch (error) {
|
||||
if (current !== quickGeneration) return;
|
||||
if (error instanceof ApiError && error.status === 404) failConnect('Ce code a expiré. Générez un nouveau code.', 'Générer un nouveau code');
|
||||
else failUnexpected(error);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {string} message */
|
||||
async function beginQuickConnect(message = 'Préparation de la connexion…') {
|
||||
const current = ++quickGeneration;
|
||||
generation++;
|
||||
clearTimeout(timer);
|
||||
clearTimeout(quickTimer);
|
||||
resetConnectPanel();
|
||||
status.textContent = message;
|
||||
try {
|
||||
if (await api('QuickConnect/Enabled') !== true) {
|
||||
if (current === quickGeneration) failConnect(disabledNotice, 'Réessayer');
|
||||
return;
|
||||
}
|
||||
const initiated = await api('QuickConnect/Initiate', { method: 'POST' });
|
||||
if (current !== quickGeneration) return;
|
||||
if (typeof initiated?.Secret !== 'string' || typeof initiated?.Code !== 'string') throw new Error('Réponse du serveur inattendue.');
|
||||
connectPanel.hidden = false;
|
||||
code.textContent = initiated.Code;
|
||||
status.textContent = 'Approuvez ce code dans un client Jellyfin déjà connecté.';
|
||||
await waitForApproval(initiated.Secret, current);
|
||||
} catch (error) {
|
||||
if (current !== quickGeneration) return;
|
||||
if (error instanceof ApiError && error.status === 401) failConnect(disabledNotice, 'Réessayer');
|
||||
else failUnexpected(error);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {unknown} error */
|
||||
function failUnexpected(error) {
|
||||
failConnect(`Connexion impossible. ${error instanceof Error ? error.message : 'Réessayez.'}`, 'Réessayer');
|
||||
}
|
||||
|
||||
/** @param {string} message @param {string} label */
|
||||
function failConnect(message, label) {
|
||||
resetConnectPanel();
|
||||
status.textContent = message;
|
||||
retryButton.textContent = label;
|
||||
retryButton.hidden = false;
|
||||
retryButton.focus();
|
||||
}
|
||||
|
||||
function resetSessionChooser() {
|
||||
// Drop the forgotten account's options and the cache render() keeps for them.
|
||||
select.replaceChildren(new Option('Choisir une lecture', ''));
|
||||
delete select.dataset.options;
|
||||
select.disabled = true;
|
||||
}
|
||||
|
||||
function clearConnection() {
|
||||
sessionController?.abort();
|
||||
sessionController = null;
|
||||
localStorage.removeItem(savedKey);
|
||||
token = '';
|
||||
userId = '';
|
||||
selected = '';
|
||||
sessions = [];
|
||||
details.replaceChildren();
|
||||
resetEstimate();
|
||||
resetSubtitles();
|
||||
resetSessionChooser();
|
||||
following.hidden = true;
|
||||
}
|
||||
|
||||
/** @param {string} message */
|
||||
function forgetConnection(message) {
|
||||
clearConnection();
|
||||
void beginQuickConnect(message);
|
||||
}
|
||||
|
||||
async function startFollowing() {
|
||||
const current = ++generation;
|
||||
quickGeneration++;
|
||||
clearTimeout(quickTimer);
|
||||
resetConnectPanel();
|
||||
following.hidden = false;
|
||||
select.disabled = true;
|
||||
await poll();
|
||||
if (current === generation) select.focus();
|
||||
}
|
||||
|
||||
// Called when the browser comes back to the foreground, the page is restored or
|
||||
// the network returns: the previous display was computed before the gap and
|
||||
// must not be presented as live until the chosen session is observed again.
|
||||
function resumeTracking() {
|
||||
if (!token || document.visibilityState !== 'visible') return;
|
||||
resyncPending = true;
|
||||
observedPerfAt = -Infinity;
|
||||
renderStatus(sessions.find(s => s.Id === selected));
|
||||
clearTimeout(timer);
|
||||
void poll();
|
||||
}
|
||||
|
||||
select.addEventListener('change', () => {
|
||||
selected = select.value;
|
||||
resetSeeded();
|
||||
render();
|
||||
});
|
||||
tracks.addEventListener('change', () => {
|
||||
const session = sessions.find(s => s.Id === selected);
|
||||
if (session) void loadTrack(session);
|
||||
});
|
||||
helpTracks.addEventListener('change', () => {
|
||||
const session = sessions.find(s => s.Id === selected);
|
||||
if (session) void loadHelpTrack(session);
|
||||
});
|
||||
retryButton.addEventListener('click', () => { void beginQuickConnect(); });
|
||||
logout.addEventListener('click', async () => {
|
||||
// Revoke the companion's own token; it is captured before the connection is forgotten.
|
||||
const request = api('Sessions/Logout', { method: 'POST' });
|
||||
const current = ++quickGeneration;
|
||||
generation++;
|
||||
clearTimeout(timer);
|
||||
clearTimeout(quickTimer);
|
||||
clearConnection();
|
||||
resetConnectPanel();
|
||||
status.textContent = 'Déconnecté.';
|
||||
retryButton.textContent = 'Se connecter';
|
||||
retryButton.hidden = false;
|
||||
retryButton.focus();
|
||||
try { await request; } catch {
|
||||
// A revocation that fails after a newer attempt began must not overwrite it.
|
||||
if (current === quickGeneration) status.textContent = 'Déconnecté de ce navigateur. Le serveur n’a pas confirmé la révocation du jeton.';
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') resumeTracking(); });
|
||||
window.addEventListener('pageshow', event => { if (event.persisted) resumeTracking(); });
|
||||
window.addEventListener('online', resumeTracking);
|
||||
document.addEventListener('resume', resumeTracking);
|
||||
|
||||
// A local tick keeps the display moving between observations. It stops as soon
|
||||
// as the observation goes stale, so a loss of tracking is never hidden by
|
||||
// unbounded extrapolation. The poll remains the only source of new observations.
|
||||
window.setInterval(() => {
|
||||
if (!token) return;
|
||||
const session = sessions.find(s => s.Id === selected);
|
||||
if (!session) return;
|
||||
const ticks = currentEstimate(session);
|
||||
renderStatus(session);
|
||||
if (ticks === null) return;
|
||||
if (positionValue) positionValue.textContent = formatPosition(ticks);
|
||||
renderHistory(ticks);
|
||||
}, 100);
|
||||
|
||||
const saved = readSavedConnection();
|
||||
if (saved) {
|
||||
token = saved.token;
|
||||
userId = saved.userId;
|
||||
deviceId = saved.deviceId;
|
||||
void startFollowing();
|
||||
} else {
|
||||
void beginQuickConnect();
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Compagnon de sous-titres</title>
|
||||
<link rel="stylesheet" href="./style.css">
|
||||
<script type="module" src="./app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Compagnon de sous-titres</h1>
|
||||
<p>Retrouvez votre lecture sur un second écran.</p>
|
||||
<section id="connect" hidden>
|
||||
<p>Dans un client Jellyfin déjà connecté, ouvrez <strong>Réglages → Quick Connect</strong> et approuvez ce code :</p>
|
||||
<p id="code" role="status" aria-live="polite"></p>
|
||||
</section>
|
||||
<p id="status" role="status">Préparation de la connexion…</p>
|
||||
<button id="retry" type="button" hidden>Réessayer</button>
|
||||
<section id="following" hidden>
|
||||
<label for="sessions">Lecture à accompagner</label>
|
||||
<select id="sessions"><option value="">Choisir une lecture</option></select>
|
||||
<dl id="details"></dl>
|
||||
<label for="tracks">Piste principale</label>
|
||||
<select id="tracks" disabled><option value="">Choisir une piste</option></select>
|
||||
<p id="track-status" role="status"></p>
|
||||
<label for="help-tracks">Piste d'aide</label>
|
||||
<select id="help-tracks" disabled><option value="">Choisir une piste d'aide</option></select>
|
||||
<p id="help-status" role="status"></p>
|
||||
<h2>Historique</h2>
|
||||
<ol id="history" aria-label="Cinq derniers blocs"></ol>
|
||||
<button id="logout" type="button">Se déconnecter</button>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
+1
-1
@@ -41,7 +41,7 @@ for format in srt vtt ass; do
|
||||
done
|
||||
node tests/setup.mjs
|
||||
mkdir -p .test-state/package
|
||||
python3 -m zipfile -e dist/compagnon-0.6.0-jellyfin-12.0.0.zip .test-state/package
|
||||
python3 -m zipfile -e dist/compagnon-0.7.0-jellyfin-12.0.0.zip .test-state/package
|
||||
docker cp .test-state/package/Compagnon "$container:/config/plugins/Compagnon"
|
||||
docker restart "$container" >/dev/null
|
||||
export JELLYFIN_URL="http://$(docker port "$container" 8096/tcp)"
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Compagnon de sous-titres</title>
|
||||
<script type="module" src="./src/main.ts"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import {
|
||||
changeHelpTrack,
|
||||
changeTrack,
|
||||
detailsView,
|
||||
logout,
|
||||
retry,
|
||||
selectSession,
|
||||
startCompanion,
|
||||
ui,
|
||||
} from './store.ts';
|
||||
|
||||
const selectEl = ref<HTMLSelectElement | null>(null);
|
||||
const retryEl = ref<HTMLButtonElement | null>(null);
|
||||
|
||||
onMounted(() => {
|
||||
startCompanion({ select: selectEl.value, retry: retryEl.value });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<h1>Compagnon de sous-titres</h1>
|
||||
<p>Retrouvez votre lecture sur un second écran.</p>
|
||||
<section id="connect" :hidden="ui.connectHidden">
|
||||
<p>Dans un client Jellyfin déjà connecté, ouvrez <strong>Réglages → Quick Connect</strong> et approuvez ce code :</p>
|
||||
<p id="code" role="status" aria-live="polite">{{ ui.code }}</p>
|
||||
</section>
|
||||
<p id="status" role="status">{{ ui.status }}</p>
|
||||
<button id="retry" ref="retryEl" type="button" :hidden="ui.retryHidden" @click="retry">{{ ui.retryLabel }}</button>
|
||||
<section id="following" :hidden="ui.followingHidden">
|
||||
<label for="sessions">Lecture à accompagner</label>
|
||||
<select id="sessions" ref="selectEl" :disabled="ui.selectDisabled" :value="ui.selected" @change="selectSession"><option v-for="option in ui.sessionOptions" :key="option.value" :value="option.value">{{ option.label }}</option></select>
|
||||
<dl id="details"><template v-for="field in detailsView" :key="field.name"><dt>{{ field.name }}</dt><dd>{{ field.value }}</dd></template></dl>
|
||||
<label for="tracks">Piste principale</label>
|
||||
<select id="tracks" :disabled="ui.tracksDisabled" :value="ui.tracksValue" @change="changeTrack"><option v-for="option in ui.trackOptions" :key="option.value" :value="option.value" :data-codec="option.codec">{{ option.label }}</option></select>
|
||||
<p id="track-status" role="status">{{ ui.trackStatus }}</p>
|
||||
<label for="help-tracks">Piste d'aide</label>
|
||||
<select id="help-tracks" :disabled="ui.helpTracksDisabled" :value="ui.helpTracksValue" @change="changeHelpTrack"><option v-for="option in ui.helpOptions" :key="option.value" :value="option.value" :data-codec="option.codec">{{ option.label }}</option></select>
|
||||
<p id="help-status" role="status">{{ ui.helpStatus }}</p>
|
||||
<h2>Historique</h2>
|
||||
<ol id="history" aria-label="Cinq derniers blocs"><li v-for="(entry, index) in ui.history" :key="index"><div class="main-block">{{ entry.main }}</div><div v-for="(help, helpIndex) in entry.helps" :key="helpIndex" class="help-block">{{ help }}</div><div v-if="entry.absent !== null">{{ entry.absent }}</div></li></ol>
|
||||
<button id="logout" type="button" @click="logout">Se déconnecter</button>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
export const version = '0.7.0';
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(status: number) {
|
||||
super(status === 401 ? 'Accès refusé par le serveur.' : `Serveur indisponible ou requête refusée (${status}).`);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiIdentity {
|
||||
getToken: () => string;
|
||||
getDeviceId: () => string;
|
||||
}
|
||||
|
||||
export function createApi({ getToken, getDeviceId }: ApiIdentity) {
|
||||
const base = new URL('../', location.href);
|
||||
return async function api(path: string, options: RequestInit = {}): Promise<any> {
|
||||
const response = await fetch(new URL(path, base), {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `MediaBrowser Client="Compagnon", Device="Navigateur compagnon", DeviceId="${getDeviceId()}", Version="${version}"${getToken() ? `, Token="${getToken()}"` : ''}`,
|
||||
},
|
||||
cache: 'no-store',
|
||||
signal: options.signal ?? AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!response.ok) throw new ApiError(response.status);
|
||||
return response.status === 204 ? null : response.json();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { parseSavedConnection } from './connection.ts';
|
||||
|
||||
test('a saved connection is kept only when complete', () => {
|
||||
const valid = JSON.stringify({ token: 'jeton', userId: 'alice', deviceId: 'appareil' });
|
||||
assert.deepEqual(parseSavedConnection(valid), { token: 'jeton', userId: 'alice', deviceId: 'appareil' });
|
||||
assert.deepEqual(parseSavedConnection(JSON.stringify({ token: 'jeton', userId: 'alice', deviceId: 'appareil', extra: 1 })), {
|
||||
token: 'jeton',
|
||||
userId: 'alice',
|
||||
deviceId: 'appareil',
|
||||
});
|
||||
});
|
||||
|
||||
test('unreadable or incomplete saved data is treated as no connection', () => {
|
||||
for (const raw of [
|
||||
null,
|
||||
'',
|
||||
'{oops',
|
||||
'null',
|
||||
'42',
|
||||
JSON.stringify({ token: '', userId: 'alice', deviceId: 'appareil' }),
|
||||
JSON.stringify({ token: ' ', userId: 'alice', deviceId: 'appareil' }),
|
||||
JSON.stringify({ token: 42, userId: 'alice', deviceId: 'appareil' }),
|
||||
JSON.stringify({ token: 'jeton' }),
|
||||
JSON.stringify({ token: 'jeton', userId: 'alice' }),
|
||||
]) {
|
||||
assert.equal(parseSavedConnection(raw), null, `expected no connection for ${raw}`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { SavedConnection } from './types.ts';
|
||||
|
||||
export function parseSavedConnection(raw: string | null): SavedConnection | null {
|
||||
try {
|
||||
const saved = JSON.parse(raw ?? '') as Partial<SavedConnection> | null;
|
||||
const keys: (keyof SavedConnection)[] = ['token', 'userId', 'deviceId'];
|
||||
const valid = saved && keys.every(key => typeof saved[key] === 'string' && (saved[key] as string).trim() !== '');
|
||||
return valid
|
||||
? { token: saved.token as string, userId: saved.userId as string, deviceId: saved.deviceId as string }
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { associatedHelp, historySignature, recentBlocks } from './history.ts';
|
||||
import type { SubtitleBlock } from './types.ts';
|
||||
|
||||
const block = (start: number, end: number, text: string): SubtitleBlock =>
|
||||
({ StartPositionTicks: start, EndPositionTicks: end, Text: text });
|
||||
|
||||
test('the history keeps the last five blocks started at or before the position', () => {
|
||||
const blocks = [1, 2, 3, 4, 4, 6, 9].map((second, index) => block(second * 100, second * 100 + 50, `Bloc ${index + 1}`));
|
||||
assert.deepEqual(recentBlocks(blocks, 650).map(b => b.Text), ['Bloc 2', 'Bloc 3', 'Bloc 4', 'Bloc 5', 'Bloc 6']);
|
||||
assert.deepEqual(recentBlocks(blocks, 400).map(b => b.Text), ['Bloc 1', 'Bloc 2', 'Bloc 3', 'Bloc 4', 'Bloc 5']);
|
||||
assert.deepEqual(recentBlocks(blocks, 350).map(b => b.Text), ['Bloc 1', 'Bloc 2', 'Bloc 3']);
|
||||
assert.deepEqual(recentBlocks(blocks, 0), []);
|
||||
assert.deepEqual(recentBlocks(blocks, null), []);
|
||||
});
|
||||
|
||||
test('help is associated on a positive temporal overlap only', () => {
|
||||
const main = [block(10, 20, 'One'), block(20, 30, 'Two'), block(30, 40, 'Three')];
|
||||
const help = [block(15, 25, 'Shared'), block(30, 35, 'First'), block(35, 40, 'Second'), block(45, 45, 'Zero duration')];
|
||||
assert.deepEqual(associatedHelp(help, main[0])?.map(b => b.Text), ['Shared']);
|
||||
assert.deepEqual(associatedHelp(help, main[1])?.map(b => b.Text), ['Shared']);
|
||||
assert.deepEqual(associatedHelp(help, main[2])?.map(b => b.Text), ['First', 'Second']);
|
||||
assert.deepEqual(associatedHelp(help, block(45, 46, 'X'))?.map(b => b.Text), []);
|
||||
assert.equal(associatedHelp(null, main[0]), null);
|
||||
// Adjacent frontiers do not count.
|
||||
assert.deepEqual(associatedHelp([block(0, 10, 'Before')], main[0]), []);
|
||||
});
|
||||
|
||||
test('the history signature ignores position moves inside the same recent set', () => {
|
||||
const blocks = [1, 2, 3].map(second => block(second * 100, second * 100 + 50, `Bloc ${second}`));
|
||||
assert.equal(historySignature(1, 1, blocks, recentBlocks(blocks, 250)), '1:1:2:0');
|
||||
assert.equal(historySignature(1, 1, blocks, recentBlocks(blocks, 260)), '1:1:2:0');
|
||||
assert.equal(historySignature(2, 1, blocks, recentBlocks(blocks, 260)), '2:1:2:0');
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { SubtitleBlock } from './types.ts';
|
||||
|
||||
export function recentBlocks(blocks: SubtitleBlock[], ticks: number | null): SubtitleBlock[] {
|
||||
if (typeof ticks !== 'number') return [];
|
||||
return blocks.filter(block => block.StartPositionTicks <= ticks).slice(-5);
|
||||
}
|
||||
|
||||
export function associatedHelp(
|
||||
helpBlocks: SubtitleBlock[] | null,
|
||||
block: SubtitleBlock,
|
||||
): SubtitleBlock[] | null {
|
||||
return helpBlocks?.filter(help =>
|
||||
Math.max(help.StartPositionTicks, block.StartPositionTicks) < Math.min(help.EndPositionTicks, block.EndPositionTicks),
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
export function historySignature(
|
||||
trackGeneration: number,
|
||||
helpGeneration: number,
|
||||
blocks: SubtitleBlock[],
|
||||
recent: SubtitleBlock[],
|
||||
): string {
|
||||
return `${trackGeneration}:${helpGeneration}:${recent.length}:${recent.length ? blocks.indexOf(recent[0]) : -1}`;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { followableSessions, sessionDetails, sessionOptions } from './session.ts';
|
||||
import type { Session } from './types.ts';
|
||||
|
||||
const at = Date.parse('2026-01-01T00:00:10.000Z');
|
||||
|
||||
const alice: Session = {
|
||||
Id: 'session-a',
|
||||
UserId: 'alice',
|
||||
DeviceName: 'Salon',
|
||||
NowPlayingItem: { Id: 'film', Name: 'Film' },
|
||||
PlayState: { MediaSourceId: 'source', IsPaused: false },
|
||||
LastPlaybackCheckIn: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
const bob: Session = { Id: 'session-b', UserId: 'bob', NowPlayingItem: { Id: 'other' } };
|
||||
const idle: Session = { Id: 'session-c', UserId: 'alice' };
|
||||
|
||||
test('only the connected account’s playing sessions are followed', () => {
|
||||
assert.deepEqual(followableSessions([alice, bob, idle], 'alice').map(s => s.Id), ['session-a']);
|
||||
});
|
||||
|
||||
test('session options always start with the chooser and keep a disappeared selection', () => {
|
||||
assert.deepEqual(sessionOptions([alice], ''), [
|
||||
{ value: '', label: 'Choisir une lecture' },
|
||||
{ value: 'session-a', label: 'Salon — Film' },
|
||||
]);
|
||||
assert.deepEqual(sessionOptions([alice], 'session-a')[0], { value: '', label: 'Choisir une lecture' });
|
||||
assert.deepEqual(sessionOptions([alice], 'gone')[0], { value: '', label: 'Choisir une lecture' });
|
||||
assert.deepEqual(sessionOptions([alice], 'gone')[1], { value: 'gone', label: 'Lecture suivie indisponible' });
|
||||
assert.deepEqual(sessionOptions([bob], 'gone')[2], { value: 'session-b', label: 'Appareil inconnu — Média inconnu' });
|
||||
});
|
||||
|
||||
test('session details expose the position as a dynamic field and format the check-in age', () => {
|
||||
const fields = sessionDetails(alice, at);
|
||||
assert.deepEqual(fields.map(f => f.name), [
|
||||
'Appareil', 'Session', 'Média', 'Source réellement lue', 'Position estimée',
|
||||
'État', 'Dernière remontée du lecteur',
|
||||
]);
|
||||
assert.equal(fields[4].value, null);
|
||||
assert.equal(fields[5].value, 'En lecture');
|
||||
assert.equal(fields[6].value, 'Il y a 10 s');
|
||||
const paused = { ...alice, PlayState: { ...alice.PlayState, IsPaused: true } };
|
||||
assert.equal(sessionDetails(paused, at)[5].value, 'En pause');
|
||||
const stale = { ...alice, LastPlaybackCheckIn: undefined, DeviceName: undefined, NowPlayingItem: undefined, PlayState: {} };
|
||||
const staleFields = sessionDetails(stale, at);
|
||||
assert.equal(staleFields[0].value, 'Inconnu');
|
||||
assert.equal(staleFields[2].value, 'Inconnu');
|
||||
assert.equal(staleFields[3].value, 'Non communiquée');
|
||||
assert.equal(staleFields[5].value, 'Non communiqué');
|
||||
assert.equal(staleFields[6].value, 'Non communiquée');
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Session } from './types.ts';
|
||||
|
||||
export function followableSessions(sessions: Session[], userId: string): Session[] {
|
||||
return sessions.filter(session => session.UserId === userId && session.NowPlayingItem);
|
||||
}
|
||||
|
||||
export interface SessionOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function sessionOptions(sessions: Session[], selected: string): SessionOption[] {
|
||||
const options = sessions.map(session => ({
|
||||
value: session.Id,
|
||||
label: `${session.DeviceName || 'Appareil inconnu'} — ${session.NowPlayingItem?.Name || 'Média inconnu'}`,
|
||||
}));
|
||||
if (selected && !sessions.some(session => session.Id === selected)) {
|
||||
options.unshift({ value: selected, label: 'Lecture suivie indisponible' });
|
||||
}
|
||||
options.unshift({ value: '', label: 'Choisir une lecture' });
|
||||
return options;
|
||||
}
|
||||
|
||||
export interface DetailField {
|
||||
name: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
export function sessionDetails(session: Session, nowMs: number): DetailField[] {
|
||||
const checkIn = session.LastPlaybackCheckIn ? Date.parse(session.LastPlaybackCheckIn) : NaN;
|
||||
return [
|
||||
{ name: 'Appareil', value: session.DeviceName || 'Inconnu' },
|
||||
{ name: 'Session', value: session.Id },
|
||||
{ name: 'Média', value: session.NowPlayingItem?.Name || 'Inconnu' },
|
||||
{ name: 'Source réellement lue', value: session.PlayState?.MediaSourceId || 'Non communiquée' },
|
||||
{ name: 'Position estimée', value: null },
|
||||
{
|
||||
name: 'État',
|
||||
value: session.PlayState?.IsPaused === true ? 'En pause'
|
||||
: session.PlayState?.IsPaused === false ? 'En lecture'
|
||||
: 'Non communiqué',
|
||||
},
|
||||
{
|
||||
name: 'Dernière remontée du lecteur',
|
||||
value: Number.isFinite(checkIn)
|
||||
? `Il y a ${Math.max(0, Math.floor((nowMs - checkIn) / 1000))} s`
|
||||
: 'Non communiquée',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { availableSubtitles, graphicalNotice, normalizeTrack, streamOption } from './subtitles.ts';
|
||||
import type { MediaStream } from './types.ts';
|
||||
|
||||
const stream = (overrides: Partial<MediaStream>): MediaStream => ({ Type: 'Subtitle', Index: 0, ...overrides });
|
||||
|
||||
test('an invalid track payload is refused', () => {
|
||||
const invalid = [
|
||||
null,
|
||||
{},
|
||||
{ TrackEvents: 'nope' },
|
||||
{ TrackEvents: [{ Text: 'x' }] },
|
||||
{ TrackEvents: [{ Text: 'x', StartPositionTicks: -1, EndPositionTicks: 1 }] },
|
||||
{ TrackEvents: [{ Text: 'x', StartPositionTicks: 2, EndPositionTicks: 1 }] },
|
||||
{ TrackEvents: [{ Text: 'x', StartPositionTicks: 0.5, EndPositionTicks: 1 }] },
|
||||
];
|
||||
for (const payload of invalid) {
|
||||
assert.throws(() => normalizeTrack(payload, 'srt'), /Format de piste invalide/);
|
||||
}
|
||||
assert.deepEqual(normalizeTrack({ TrackEvents: [] }, 'srt'), []);
|
||||
});
|
||||
|
||||
test('ASS override commands are removed without touching other codecs', () => {
|
||||
const ass = { TrackEvents: [{ Text: '{\\pos(10,20)}Bonjour{\\fad(1,2)}', StartPositionTicks: 0, EndPositionTicks: 1 }] };
|
||||
assert.deepEqual(normalizeTrack(ass, 'ass').map(b => b.Text), ['Bonjour']);
|
||||
const srt = { TrackEvents: [{ Text: '{\\pos(10,20)}Bonjour', StartPositionTicks: 0, EndPositionTicks: 1 }] };
|
||||
assert.deepEqual(normalizeTrack(srt, 'srt').map(b => b.Text), ['{\\pos(10,20)}Bonjour']);
|
||||
});
|
||||
|
||||
test('blocks are sorted by start while simultaneous events keep Jellyfin order', () => {
|
||||
const payload = { TrackEvents: [
|
||||
{ Text: 'late', StartPositionTicks: 20, EndPositionTicks: 30 },
|
||||
{ Text: 'first simultaneous', StartPositionTicks: 10, EndPositionTicks: 15 },
|
||||
{ Text: 'second simultaneous', StartPositionTicks: 10, EndPositionTicks: 12 },
|
||||
] };
|
||||
assert.deepEqual(normalizeTrack(payload, 'srt').map(b => b.Text), ['first simultaneous', 'second simultaneous', 'late']);
|
||||
});
|
||||
|
||||
test('only text subtitle streams with a usable index are offered', () => {
|
||||
const subtitles = [
|
||||
stream({ Index: 4, IsTextSubtitleStream: true, Language: 'eng' }),
|
||||
stream({ Index: -1, IsTextSubtitleStream: true }),
|
||||
stream({ Index: 8, IsTextSubtitleStream: false }),
|
||||
stream({ Index: 7, IsTextSubtitleStream: true, IsForced: true }),
|
||||
];
|
||||
assert.deepEqual(availableSubtitles(subtitles).map(s => s.Index), [4, 7]);
|
||||
assert.equal(graphicalNotice(subtitles), 'Pistes graphiques non prises en charge (sans OCR).');
|
||||
assert.equal(graphicalNotice([subtitles[0]]), '');
|
||||
});
|
||||
|
||||
test('stream options carry the label, codec and stable value used by the selectors', () => {
|
||||
assert.deepEqual(streamOption(stream({ Index: 4, DisplayTitle: 'Français', Codec: 'ASS', IsForced: true, IsHearingImpaired: true })), {
|
||||
value: '4',
|
||||
label: '#4 — Français — ASS — forcés — malentendants',
|
||||
codec: 'ass',
|
||||
});
|
||||
assert.deepEqual(streamOption(stream({ Index: 7 })), {
|
||||
value: '7',
|
||||
label: '#7 — Langue inconnue — texte',
|
||||
codec: '',
|
||||
});
|
||||
assert.deepEqual(streamOption(stream({ Index: 7, Language: 'fre', Codec: 'subrip' })), {
|
||||
value: '7',
|
||||
label: '#7 — fre — subrip',
|
||||
codec: 'subrip',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { MediaStream, StreamOption, SubtitleBlock } from './types.ts';
|
||||
|
||||
export function graphicalNotice(subtitles: MediaStream[]): string {
|
||||
return subtitles.some(stream => !stream.IsTextSubtitleStream)
|
||||
? 'Pistes graphiques non prises en charge (sans OCR).'
|
||||
: '';
|
||||
}
|
||||
|
||||
export function availableSubtitles(subtitles: MediaStream[]): MediaStream[] {
|
||||
return subtitles.filter(stream =>
|
||||
stream.IsTextSubtitleStream && Number.isInteger(stream.Index) && stream.Index >= 0,
|
||||
);
|
||||
}
|
||||
|
||||
export function streamOption(stream: MediaStream): StreamOption {
|
||||
const label = `#${stream.Index} — ${stream.DisplayTitle || stream.Language || 'Langue inconnue'} — ${stream.Codec || 'texte'}` +
|
||||
`${stream.IsForced ? ' — forcés' : ''}${stream.IsHearingImpaired ? ' — malentendants' : ''}`;
|
||||
return { value: String(stream.Index), label, codec: stream.Codec?.toLowerCase() || '' };
|
||||
}
|
||||
|
||||
export function normalizeTrack(result: unknown, codec: string): SubtitleBlock[] {
|
||||
const events = (result as { TrackEvents?: unknown } | null)?.TrackEvents;
|
||||
if (!Array.isArray(events) || !events.every((block: unknown) => {
|
||||
const candidate = block as Partial<SubtitleBlock> | null;
|
||||
return Boolean(candidate) && typeof candidate!.Text === 'string' &&
|
||||
Number.isSafeInteger(candidate!.StartPositionTicks) && candidate!.StartPositionTicks! >= 0 &&
|
||||
Number.isSafeInteger(candidate!.EndPositionTicks) && candidate!.EndPositionTicks! >= candidate!.StartPositionTicks!;
|
||||
})) throw new Error('Format de piste invalide.');
|
||||
const blocks = events as SubtitleBlock[];
|
||||
if (codec === 'ass' || codec === 'ssa') {
|
||||
// Jellyfin decodes ASS line breaks but retains override commands in JSON.
|
||||
for (const block of blocks) block.Text = block.Text.replace(/\{\\[^}]*\}/g, '');
|
||||
}
|
||||
// Stable sort retains Jellyfin's event order for simultaneous blocks.
|
||||
return blocks.sort((a, b) => a.StartPositionTicks - b.StartPositionTicks);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createPositionEstimator, formatPosition, ticksPerSecond } from './ticks.ts';
|
||||
import type { Session } from './types.ts';
|
||||
|
||||
function fakeClocks() {
|
||||
// performance.now() is never zero at page load; starting above zero keeps the
|
||||
// tick anchor truthy, as it is in the browser.
|
||||
let mono = 1000;
|
||||
let wall = 0;
|
||||
return {
|
||||
now: () => mono,
|
||||
wallNow: () => wall,
|
||||
advance(ms: number) {
|
||||
mono += ms;
|
||||
wall += ms;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function playingSession(positionSeconds: number): Session {
|
||||
return {
|
||||
Id: 'session',
|
||||
UserId: 'alice',
|
||||
PlayState: { MediaSourceId: 'source', IsPaused: false, PositionTicks: positionSeconds * ticksPerSecond },
|
||||
LastPlaybackCheckIn: new Date(0).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const seconds = (ticks: number | null) => (ticks === null ? null : ticks / ticksPerSecond);
|
||||
|
||||
test('the local estimate advances between observations without drifting', () => {
|
||||
const clocks = fakeClocks();
|
||||
const estimator = createPositionEstimator(clocks.now, clocks.wallNow);
|
||||
const session = playingSession(100);
|
||||
estimator.observe();
|
||||
assert.ok(Math.abs((seconds(estimator.currentEstimate(session)) ?? 0) - 100) < 0.001);
|
||||
clocks.advance(600);
|
||||
const estimate = estimator.currentEstimate(session);
|
||||
assert.ok(Math.abs((seconds(estimate) ?? 0) - 100.6) < 0.01, `expected 100.6 s, got ${seconds(estimate)}`);
|
||||
});
|
||||
|
||||
test('a fresh observation replaces the previous estimate and stale ones stop the display', () => {
|
||||
const clocks = fakeClocks();
|
||||
const estimator = createPositionEstimator(clocks.now, clocks.wallNow);
|
||||
const session = playingSession(100);
|
||||
estimator.observe();
|
||||
estimator.currentEstimate(session);
|
||||
clocks.advance(2000);
|
||||
assert.ok(Math.abs((seconds(estimator.currentEstimate(session)) ?? 0) - 102) < 0.01);
|
||||
clocks.advance(600);
|
||||
assert.equal(estimator.currentEstimate(session), null, 'beyond the freshness bound the estimate must be refused');
|
||||
assert.ok(Math.abs((seconds(estimator.lastEstimateTicks) ?? 0) - 102) < 0.01, 'the frozen estimate stays readable');
|
||||
});
|
||||
|
||||
test('a late server tick never steps the estimate backwards', () => {
|
||||
const clocks = fakeClocks();
|
||||
const estimator = createPositionEstimator(clocks.now, clocks.wallNow);
|
||||
const session = playingSession(100);
|
||||
estimator.observe();
|
||||
let previous: number | null = null;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const elapsed = clocks.wallNow();
|
||||
// Jellyfin schedules each whole-second increment 300 ms late under load.
|
||||
session.PlayState!.PositionTicks = 100 * ticksPerSecond +
|
||||
(elapsed >= 300 ? Math.floor((elapsed - 300) / 1000) + 1 : 0) * ticksPerSecond;
|
||||
const estimate = seconds(estimator.currentEstimate(session));
|
||||
assert.notEqual(estimate, null);
|
||||
if (previous !== null) assert.ok(estimate! >= previous - 0.001, `estimate stepped back: ${previous} -> ${estimate}`);
|
||||
const truth = 100 + elapsed / 1000;
|
||||
assert.ok(Math.abs(estimate! - truth) < 1.01, `estimate ${estimate} out of bounds near ${truth}`);
|
||||
previous = estimate;
|
||||
clocks.advance(200);
|
||||
}
|
||||
});
|
||||
|
||||
test('a backward jump is treated as a new passage, not an anchor', () => {
|
||||
const clocks = fakeClocks();
|
||||
const estimator = createPositionEstimator(clocks.now, clocks.wallNow);
|
||||
const session = playingSession(200);
|
||||
estimator.observe();
|
||||
estimator.currentEstimate(session);
|
||||
clocks.advance(1000);
|
||||
assert.ok((seconds(estimator.currentEstimate(session)) ?? 0) >= 200);
|
||||
session.PlayState!.PositionTicks = 50 * ticksPerSecond;
|
||||
const estimate = estimator.currentEstimate(session);
|
||||
assert.ok((seconds(estimate) ?? 0) < 55, `a backward seek must restart from 50 s, got ${seconds(estimate)}`);
|
||||
});
|
||||
|
||||
test('a stalled tick falls back to the local clock for a bounded time only', () => {
|
||||
const clocks = fakeClocks();
|
||||
const estimator = createPositionEstimator(clocks.now, clocks.wallNow);
|
||||
const session = playingSession(100);
|
||||
estimator.observe();
|
||||
estimator.currentEstimate(session);
|
||||
clocks.advance(1300);
|
||||
const stalled = seconds(estimator.currentEstimate(session));
|
||||
assert.ok(Math.abs(stalled! - 101.3) < 0.01, `expected the bounded local fallback, got ${stalled}`);
|
||||
clocks.advance(1300);
|
||||
assert.equal(estimator.currentEstimate(session), null, 'the fallback must not outlive the freshness bound');
|
||||
});
|
||||
|
||||
test('a paused session reports its raw position without extrapolation', () => {
|
||||
const clocks = fakeClocks();
|
||||
const estimator = createPositionEstimator(clocks.now, clocks.wallNow);
|
||||
const session = playingSession(100);
|
||||
session.PlayState!.IsPaused = true;
|
||||
estimator.observe();
|
||||
clocks.advance(1000);
|
||||
assert.equal(estimator.currentEstimate(session), 100 * ticksPerSecond);
|
||||
});
|
||||
|
||||
test('a context change drops the anchors of the previous passage', () => {
|
||||
const clocks = fakeClocks();
|
||||
const estimator = createPositionEstimator(clocks.now, clocks.wallNow);
|
||||
const session = playingSession(200);
|
||||
estimator.observe();
|
||||
clocks.advance(1000);
|
||||
estimator.currentEstimate(session);
|
||||
estimator.noteContext('new-source');
|
||||
session.PlayState!.PositionTicks = 10 * ticksPerSecond;
|
||||
session.LastPlaybackCheckIn = new Date(clocks.wallNow()).toISOString();
|
||||
const estimate = seconds(estimator.currentEstimate(session));
|
||||
assert.ok(Math.abs(estimate! - 10) < 0.01, `the estimate must come from the new context, got ${estimate}`);
|
||||
});
|
||||
|
||||
test('positions are formatted as m:ss.mmm', () => {
|
||||
assert.equal(formatPosition(0), '0:00.000');
|
||||
assert.equal(formatPosition(65.4321 * ticksPerSecond), '1:05.432');
|
||||
assert.equal(formatPosition(null), 'Non communiquée');
|
||||
assert.equal(formatPosition(-1), 'Non communiquée');
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { Session } from './types.ts';
|
||||
|
||||
export const observationFreshnessMs = 2500;
|
||||
export const checkInFreshnessMs = 30000;
|
||||
// Jellyfin ticks are 100 ns; ticker bounds are in milliseconds.
|
||||
export const ticksPerSecond = 10000000;
|
||||
export const ticksPerMs = 10000;
|
||||
|
||||
export type Clock = () => number;
|
||||
|
||||
export function createPositionEstimator(
|
||||
now: Clock = () => performance.now(),
|
||||
wallNow: Clock = () => Date.now(),
|
||||
) {
|
||||
let observedPerfAt = -Infinity;
|
||||
let lastTickPerfAt = 0;
|
||||
let lastRawTicks: number | null = null;
|
||||
let lastEstimateTicks: number | null = null;
|
||||
let seededWindow: { at: number; seeded: number }[] = [];
|
||||
let positionContext = '';
|
||||
|
||||
// Drops the anchors of the passage being tracked. The observation clock is
|
||||
// kept: the last poll may already describe the new selection or context.
|
||||
function resetSeeded() {
|
||||
lastTickPerfAt = 0;
|
||||
lastRawTicks = null;
|
||||
lastEstimateTicks = null;
|
||||
seededWindow = [];
|
||||
}
|
||||
|
||||
function resetEstimate() {
|
||||
resetSeeded();
|
||||
observedPerfAt = -Infinity;
|
||||
positionContext = '';
|
||||
}
|
||||
|
||||
// A changed session, media or source starts a new passage: no anchor or
|
||||
// history from the previous context may survive into its position estimate.
|
||||
function noteContext(next: string) {
|
||||
if (next !== positionContext) {
|
||||
positionContext = next;
|
||||
resetSeeded();
|
||||
}
|
||||
}
|
||||
|
||||
function observe() {
|
||||
observedPerfAt = now();
|
||||
}
|
||||
|
||||
function forgetObservation() {
|
||||
observedPerfAt = -Infinity;
|
||||
}
|
||||
|
||||
function trackingFresh(session: Session) {
|
||||
if (now() - observedPerfAt > observationFreshnessMs) return false;
|
||||
// A paused session legitimately stops reporting; only a playing one is expected to check in.
|
||||
if (session.PlayState?.IsPaused === true) return true;
|
||||
const checkIn = Date.parse(session.LastPlaybackCheckIn ?? '');
|
||||
return !Number.isFinite(checkIn) || wallNow() - checkIn <= checkInFreshnessMs;
|
||||
}
|
||||
|
||||
// Local estimate between Jellyfin observations. Jellyfin advances PositionTicks
|
||||
// in whole-second steps from the last client check-in: subtracting the whole
|
||||
// seconds already counted rebuilds the position reported at the check-in, to
|
||||
// which the elapsed time is added once. The highest rebuilt position seen
|
||||
// lately is kept so a late second tick never makes the display step backwards.
|
||||
// A tick that stalls entirely is replaced by our own clock for a bounded time.
|
||||
function estimateTicks(session: Session): number | null {
|
||||
const raw = session.PlayState?.PositionTicks;
|
||||
if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 0) return null;
|
||||
const previousRaw = lastRawTicks;
|
||||
if (raw !== previousRaw) lastTickPerfAt = now();
|
||||
// A backward jump is a seek or a new report, not the same passage.
|
||||
if (previousRaw !== null && raw < previousRaw - 1.5 * ticksPerSecond) seededWindow = [];
|
||||
lastRawTicks = raw;
|
||||
if (session.PlayState?.IsPaused !== false) return raw;
|
||||
const sinceTick = now() - lastTickPerfAt;
|
||||
const stalled = lastTickPerfAt && sinceTick > 1100
|
||||
? raw + Math.min(sinceTick, observationFreshnessMs) * ticksPerMs
|
||||
: null;
|
||||
const checkIn = Date.parse(session.LastPlaybackCheckIn ?? '');
|
||||
if (!Number.isFinite(checkIn)) return stalled ?? raw;
|
||||
const elapsed = Math.max(0, wallNow() - checkIn);
|
||||
const clockNow = now();
|
||||
seededWindow.push({ at: clockNow, seeded: raw - Math.floor(elapsed / 1000) * ticksPerSecond });
|
||||
while (seededWindow.length && clockNow - seededWindow[0].at > 1500) seededWindow.shift();
|
||||
const estimate = Math.max(...seededWindow.map(entry => entry.seeded)) + elapsed * ticksPerMs;
|
||||
return stalled === null ? estimate : Math.max(estimate, stalled);
|
||||
}
|
||||
|
||||
function currentEstimate(session: Session): number | null {
|
||||
if (!trackingFresh(session)) return null;
|
||||
const estimate = estimateTicks(session);
|
||||
if (estimate !== null) lastEstimateTicks = estimate;
|
||||
return lastEstimateTicks;
|
||||
}
|
||||
|
||||
return {
|
||||
resetSeeded,
|
||||
resetEstimate,
|
||||
noteContext,
|
||||
observe,
|
||||
forgetObservation,
|
||||
trackingFresh,
|
||||
currentEstimate,
|
||||
get observedPerfAt() {
|
||||
return observedPerfAt;
|
||||
},
|
||||
get lastEstimateTicks() {
|
||||
return lastEstimateTicks;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type PositionEstimator = ReturnType<typeof createPositionEstimator>;
|
||||
|
||||
export function formatPosition(ticks: number | null): string {
|
||||
if (typeof ticks !== 'number' || !Number.isFinite(ticks) || ticks < 0) return 'Non communiquée';
|
||||
const seconds = ticks / ticksPerSecond;
|
||||
return `${Math.floor(seconds / 60)}:${(seconds % 60).toFixed(3).padStart(6, '0')}`;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export interface Session {
|
||||
Id: string;
|
||||
UserId: string;
|
||||
DeviceName?: string;
|
||||
NowPlayingItem?: { Id: string; Name?: string };
|
||||
PlayState?: {
|
||||
MediaSourceId?: string;
|
||||
SubtitleStreamIndex?: number;
|
||||
PositionTicks?: number;
|
||||
IsPaused?: boolean;
|
||||
};
|
||||
LastPlaybackCheckIn?: string;
|
||||
}
|
||||
|
||||
export interface MediaStream {
|
||||
Type: string;
|
||||
Index: number;
|
||||
DisplayTitle?: string;
|
||||
Language?: string;
|
||||
Codec?: string;
|
||||
IsExternal?: boolean;
|
||||
IsTextSubtitleStream?: boolean;
|
||||
IsForced?: boolean;
|
||||
IsHearingImpaired?: boolean;
|
||||
}
|
||||
|
||||
export interface SubtitleBlock {
|
||||
Text: string;
|
||||
StartPositionTicks: number;
|
||||
EndPositionTicks: number;
|
||||
}
|
||||
|
||||
export interface SavedConnection {
|
||||
token: string;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
export interface StreamOption {
|
||||
value: string;
|
||||
label: string;
|
||||
codec: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import './style.css';
|
||||
|
||||
createApp(App).mount('#app');
|
||||
@@ -0,0 +1,499 @@
|
||||
import { computed, nextTick, reactive } from 'vue';
|
||||
import { ApiError, createApi } from './api.ts';
|
||||
import { parseSavedConnection } from './lib/connection.ts';
|
||||
import { associatedHelp, historySignature, recentBlocks } from './lib/history.ts';
|
||||
import { followableSessions, sessionDetails, sessionOptions } from './lib/session.ts';
|
||||
import { availableSubtitles, graphicalNotice, normalizeTrack, streamOption } from './lib/subtitles.ts';
|
||||
import { createPositionEstimator, formatPosition } from './lib/ticks.ts';
|
||||
import type { DetailField, SessionOption } from './lib/session.ts';
|
||||
import type { MediaStream, Session, StreamOption, SubtitleBlock } from './lib/types.ts';
|
||||
|
||||
const savedKey = 'compagnon.connexion';
|
||||
const disabledNotice = 'Quick Connect est désactivé sur ce serveur. Activez-le dans le tableau de bord Jellyfin, onglet Général, puis réessayez.';
|
||||
|
||||
export interface HistoryEntry {
|
||||
main: string;
|
||||
helps: string[];
|
||||
absent: string | null;
|
||||
}
|
||||
|
||||
const trackPlaceholder = (): StreamOption => ({ value: '', label: 'Choisir une piste', codec: '' });
|
||||
const helpPlaceholder = (): StreamOption => ({ value: '', label: "Choisir une piste d'aide", codec: '' });
|
||||
|
||||
export const ui = reactive({
|
||||
connectHidden: true,
|
||||
code: '',
|
||||
retryHidden: true,
|
||||
retryLabel: 'Réessayer',
|
||||
followingHidden: true,
|
||||
status: 'Préparation de la connexion…',
|
||||
selectDisabled: true,
|
||||
selected: '',
|
||||
sessionOptions: [{ value: '', label: 'Choisir une lecture' }] as SessionOption[],
|
||||
details: [] as DetailField[],
|
||||
trackOptions: [trackPlaceholder()] as StreamOption[],
|
||||
helpOptions: [helpPlaceholder()] as StreamOption[],
|
||||
tracksValue: '',
|
||||
helpTracksValue: '',
|
||||
tracksDisabled: true,
|
||||
helpTracksDisabled: true,
|
||||
trackStatus: '',
|
||||
helpStatus: '',
|
||||
history: [] as HistoryEntry[],
|
||||
displayTicks: null as number | null,
|
||||
});
|
||||
|
||||
export const detailsView = computed(() => ui.details.map(field =>
|
||||
field.name === 'Position estimée' ? { name: field.name, value: formatPosition(ui.displayTicks) } : field,
|
||||
));
|
||||
|
||||
function randomDeviceId() {
|
||||
return Array.from(crypto.getRandomValues(new Uint8Array(16)), byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
let deviceId = randomDeviceId();
|
||||
let token = '';
|
||||
let userId = '';
|
||||
let generation = 0;
|
||||
let timer = 0;
|
||||
let quickGeneration = 0;
|
||||
let quickTimer = 0;
|
||||
let sessions: Session[] = [];
|
||||
let context = '';
|
||||
let trackGeneration = 0;
|
||||
let trackNotice = '';
|
||||
let blocks: SubtitleBlock[] | null = null;
|
||||
let helpGeneration = 0;
|
||||
let helpBlocks: SubtitleBlock[] | null = null;
|
||||
let historySignatureValue = '';
|
||||
let resyncPending = false;
|
||||
let sessionController: AbortController | null = null;
|
||||
|
||||
const estimator = createPositionEstimator();
|
||||
const api = createApi({ getToken: () => token, getDeviceId: () => deviceId });
|
||||
|
||||
let selectElement: HTMLSelectElement | null = null;
|
||||
let retryElement: HTMLButtonElement | null = null;
|
||||
|
||||
function focusSelect() {
|
||||
void nextTick(() => selectElement?.focus());
|
||||
}
|
||||
|
||||
function focusRetry() {
|
||||
void nextTick(() => retryElement?.focus());
|
||||
}
|
||||
|
||||
function codecOf(options: StreamOption[], value: string) {
|
||||
return options.find(option => option.value === value)?.codec ?? '';
|
||||
}
|
||||
|
||||
function clearTrack() {
|
||||
trackGeneration++;
|
||||
blocks = null;
|
||||
historySignatureValue = '';
|
||||
ui.history = [];
|
||||
}
|
||||
|
||||
function resetSubtitles() {
|
||||
clearTrack();
|
||||
helpGeneration++;
|
||||
helpBlocks = null;
|
||||
ui.helpOptions = [helpPlaceholder()];
|
||||
ui.helpTracksValue = '';
|
||||
ui.helpTracksDisabled = true;
|
||||
ui.helpStatus = '';
|
||||
context = '';
|
||||
trackNotice = '';
|
||||
ui.trackOptions = [trackPlaceholder()];
|
||||
ui.tracksValue = '';
|
||||
ui.tracksDisabled = true;
|
||||
ui.trackStatus = '';
|
||||
}
|
||||
|
||||
function renderHistory(estimate: number | null = ui.displayTicks) {
|
||||
if (blocks === null) return;
|
||||
const ticks = estimate ?? ui.displayTicks;
|
||||
const recent = recentBlocks(blocks, ticks);
|
||||
const signature = historySignature(trackGeneration, helpGeneration, blocks, recent);
|
||||
if (historySignatureValue === signature) return;
|
||||
historySignatureValue = signature;
|
||||
if (typeof ticks !== 'number') {
|
||||
ui.history = [];
|
||||
ui.trackStatus = `Position indisponible. ${trackNotice}`;
|
||||
return;
|
||||
}
|
||||
const entries: HistoryEntry[] = [];
|
||||
for (const block of recent) {
|
||||
const associated = associatedHelp(helpBlocks, block);
|
||||
const helps = associated?.map(help => help.Text) ?? [];
|
||||
const absent = helps.length ? null : helpBlocks === null ? ui.helpStatus : 'Aucune aide associée à ce bloc.';
|
||||
entries.push({ main: block.Text, helps, absent });
|
||||
}
|
||||
ui.history = entries;
|
||||
ui.trackStatus = `${recent.length ? 'Historique à la position estimée.' : 'Aucun bloc commencé à cette position.'} ${trackNotice}`;
|
||||
}
|
||||
|
||||
async function readTrack(session: Session, value: string, codec: string): Promise<SubtitleBlock[]> {
|
||||
const path = [session.NowPlayingItem?.Id || '', session.PlayState?.MediaSourceId || '', value].map(encodeURIComponent);
|
||||
// Cold extraction can outlast session polling; keep its timeout independent.
|
||||
const result = await api(`Compagnon/Subtitles/${path.join('/')}`, { signal: AbortSignal.timeout(120000) });
|
||||
return normalizeTrack(result, codec);
|
||||
}
|
||||
|
||||
async function loadHelpTrack(session: Session) {
|
||||
const current = ++helpGeneration;
|
||||
helpBlocks = null;
|
||||
ui.helpStatus = ui.helpTracksValue
|
||||
? "Chargement de la piste d'aide… Une première extraction peut prendre du temps."
|
||||
: "Aucune piste d'aide sélectionnée. Choisissez une piste dans la langue souhaitée si elle est disponible.";
|
||||
renderHistory();
|
||||
if (!ui.helpTracksValue) return;
|
||||
try {
|
||||
const result = await readTrack(session, ui.helpTracksValue, codecOf(ui.helpOptions, ui.helpTracksValue));
|
||||
if (current !== helpGeneration) return;
|
||||
helpBlocks = result;
|
||||
ui.helpStatus = result.length ? 'Aide associée par chevauchement temporel.' : "Piste d'aide vide : aucune aide disponible.";
|
||||
} catch {
|
||||
if (current !== helpGeneration) return;
|
||||
ui.helpStatus = "Piste d'aide inexploitable ou accès refusé. Choisissez une piste pour réessayer.";
|
||||
}
|
||||
historySignatureValue = '';
|
||||
renderHistory();
|
||||
}
|
||||
|
||||
async function loadTrack(session: Session) {
|
||||
clearTrack();
|
||||
if (!ui.tracksValue) {
|
||||
ui.trackStatus = `Choisissez une piste principale. ${trackNotice}`;
|
||||
return;
|
||||
}
|
||||
const current = trackGeneration;
|
||||
ui.trackStatus = `Chargement de la piste… Une première extraction peut prendre du temps. ${trackNotice}`;
|
||||
try {
|
||||
const result = await readTrack(session, ui.tracksValue, codecOf(ui.trackOptions, ui.tracksValue));
|
||||
if (current !== trackGeneration) return;
|
||||
blocks = result;
|
||||
historySignatureValue = '';
|
||||
renderHistory();
|
||||
} catch {
|
||||
if (current === trackGeneration) {
|
||||
ui.trackStatus = `Piste inexploitable ou accès refusé. Choisissez une piste pour réessayer. ${trackNotice}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSubtitles(session: Session | undefined) {
|
||||
const item = session?.NowPlayingItem?.Id;
|
||||
const source = session?.PlayState?.MediaSourceId;
|
||||
if (!session || !item || !source) {
|
||||
resetSubtitles();
|
||||
if (session) ui.trackStatus = 'Source réellement lue non communiquée : pistes indisponibles.';
|
||||
return;
|
||||
}
|
||||
const next = JSON.stringify([session.Id, item, source]);
|
||||
if (next === context) {
|
||||
renderHistory();
|
||||
return;
|
||||
}
|
||||
resetSubtitles();
|
||||
context = next;
|
||||
const current = trackGeneration;
|
||||
ui.trackStatus = 'Chargement des pistes…';
|
||||
try {
|
||||
const result = await api(`Items/${encodeURIComponent(item || '')}/PlaybackInfo`);
|
||||
if (current !== trackGeneration) return;
|
||||
const mediaSource = result.MediaSources?.find((candidate: { Id: string }) => candidate.Id === source);
|
||||
if (!mediaSource || !Array.isArray(mediaSource.MediaStreams)) throw new Error('Source indisponible.');
|
||||
const subtitles = (mediaSource.MediaStreams as MediaStream[]).filter(stream => stream.Type === 'Subtitle');
|
||||
trackNotice = graphicalNotice(subtitles);
|
||||
const available = availableSubtitles(subtitles);
|
||||
ui.tracksValue = '';
|
||||
ui.helpTracksValue = '';
|
||||
ui.trackOptions = [trackPlaceholder(), ...available.map(streamOption)];
|
||||
ui.helpOptions = [helpPlaceholder(), ...available.map(streamOption)];
|
||||
ui.tracksDisabled = !available.length;
|
||||
ui.helpTracksDisabled = !available.length;
|
||||
ui.helpStatus = available.length
|
||||
? "Aucune piste d'aide sélectionnée. Choisissez une piste dans la langue souhaitée si elle est disponible."
|
||||
: "Aucune piste d'aide texte disponible.";
|
||||
if (!available.length) {
|
||||
ui.trackStatus = `Aucune piste texte disponible. ${trackNotice}`;
|
||||
return;
|
||||
}
|
||||
const preferred = available.find(stream => stream.Index === session?.PlayState?.SubtitleStreamIndex);
|
||||
ui.tracksValue = preferred ? String(preferred.Index) : '';
|
||||
await loadTrack(session);
|
||||
} catch {
|
||||
if (current === trackGeneration) {
|
||||
ui.trackStatus = 'Pistes inaccessibles : source indisponible ou accès refusé.';
|
||||
ui.helpStatus = "Pistes d'aide inaccessibles : source indisponible ou accès refusé.";
|
||||
context = ''; // Retry discovery on the next successful session observation.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderStatus(session: Session | undefined) {
|
||||
if (!session) return;
|
||||
if (resyncPending) {
|
||||
ui.status = 'Reprise après suspension du navigateur — vérification de la session avant de reprendre l’affichage synchronisé.';
|
||||
return;
|
||||
}
|
||||
if (!estimator.trackingFresh(session)) {
|
||||
const observed = estimator.observedPerfAt;
|
||||
const age = observed === -Infinity ? null : Math.floor((performance.now() - observed) / 1000);
|
||||
ui.status = `Suivi incertain — l’affichage reste figé faute d’observation récente${age === null ? '' : ` (${age} s)`} ; il reprendra après une remontée du serveur.`;
|
||||
return;
|
||||
}
|
||||
ui.status = 'Suivi actif — position estimée, recalée sur les remontées de Jellyfin ; pas une observation directe de l’écran.';
|
||||
}
|
||||
|
||||
function render() {
|
||||
ui.sessionOptions = sessionOptions(sessions, ui.selected);
|
||||
ui.details = [];
|
||||
const session = sessions.find(candidate => candidate.Id === ui.selected);
|
||||
// A new session, media or source starts a new passage: no anchor or history
|
||||
// from the previous context may survive into its position estimate.
|
||||
estimator.noteContext(session ? JSON.stringify([session.Id, session.NowPlayingItem?.Id, session.PlayState?.MediaSourceId]) : '');
|
||||
void updateSubtitles(session);
|
||||
if (!session) {
|
||||
ui.status = ui.selected
|
||||
? 'La lecture suivie est arrêtée ou sa session a disparu.'
|
||||
: sessions.length ? 'Choisissez une lecture à accompagner.' : 'Aucune lecture disponible pour ce compte.';
|
||||
return;
|
||||
}
|
||||
renderStatus(session);
|
||||
ui.displayTicks = estimator.currentEstimate(session) ?? estimator.lastEstimateTicks;
|
||||
ui.details = sessionDetails(session, Date.now());
|
||||
renderHistory();
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
const current = generation;
|
||||
clearTimeout(timer);
|
||||
// Superseding an in-flight observation also covers a response that was
|
||||
// computed before a suspension: only the newest request may touch the display.
|
||||
sessionController?.abort();
|
||||
const controller = new AbortController();
|
||||
sessionController = controller;
|
||||
try {
|
||||
const result = await api('Sessions', {
|
||||
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(8000)]),
|
||||
}) as Session[];
|
||||
if (current !== generation || sessionController !== controller) return;
|
||||
estimator.observe();
|
||||
resyncPending = false;
|
||||
sessions = followableSessions(result, userId);
|
||||
ui.selectDisabled = false;
|
||||
if (!ui.selected && sessions.length === 1) ui.selected = sessions[0].Id;
|
||||
render();
|
||||
} catch (error) {
|
||||
if (current !== generation || sessionController !== controller) return;
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
forgetConnection('Votre connexion enregistrée n’est plus acceptée. Autorisez à nouveau le compagnon.');
|
||||
return;
|
||||
}
|
||||
ui.selectDisabled = true;
|
||||
sessions = [];
|
||||
resetSubtitles();
|
||||
ui.details = [];
|
||||
ui.status = `Suivi indisponible. ${error instanceof Error ? error.message : 'Réessayez.'}`;
|
||||
}
|
||||
if (current === generation && token) timer = window.setTimeout(poll, 1000);
|
||||
}
|
||||
|
||||
function resetConnectPanel() {
|
||||
ui.connectHidden = true;
|
||||
ui.retryHidden = true;
|
||||
ui.code = '';
|
||||
}
|
||||
|
||||
async function waitForApproval(secret: string, current: number) {
|
||||
try {
|
||||
const state = await api(`QuickConnect/Connect?secret=${encodeURIComponent(secret)}`);
|
||||
if (current !== quickGeneration) return;
|
||||
if (state?.Authenticated !== true) {
|
||||
quickTimer = window.setTimeout(() => { void waitForApproval(secret, current); }, 1000);
|
||||
return;
|
||||
}
|
||||
const authenticated = await api('Users/AuthenticateWithQuickConnect', { method: 'POST', body: JSON.stringify({ Secret: secret }) });
|
||||
if (current !== quickGeneration) return;
|
||||
if (typeof authenticated?.AccessToken !== 'string' || typeof authenticated?.User?.Id !== 'string') throw new Error('Réponse du serveur inattendue.');
|
||||
token = authenticated.AccessToken;
|
||||
userId = authenticated.User.Id;
|
||||
localStorage.setItem(savedKey, JSON.stringify({ token, userId, deviceId }));
|
||||
await startFollowing();
|
||||
} catch (error) {
|
||||
if (current !== quickGeneration) return;
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
failConnect('Ce code a expiré. Générez un nouveau code.', 'Générer un nouveau code');
|
||||
} else {
|
||||
failUnexpected(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function beginQuickConnect(message = 'Préparation de la connexion…') {
|
||||
const current = ++quickGeneration;
|
||||
generation++;
|
||||
clearTimeout(timer);
|
||||
clearTimeout(quickTimer);
|
||||
resetConnectPanel();
|
||||
ui.status = message;
|
||||
try {
|
||||
if (await api('QuickConnect/Enabled') !== true) {
|
||||
if (current === quickGeneration) failConnect(disabledNotice, 'Réessayer');
|
||||
return;
|
||||
}
|
||||
const initiated = await api('QuickConnect/Initiate', { method: 'POST' });
|
||||
if (current !== quickGeneration) return;
|
||||
if (typeof initiated?.Secret !== 'string' || typeof initiated?.Code !== 'string') throw new Error('Réponse du serveur inattendue.');
|
||||
ui.connectHidden = false;
|
||||
ui.code = initiated.Code;
|
||||
ui.status = 'Approuvez ce code dans un client Jellyfin déjà connecté.';
|
||||
await waitForApproval(initiated.Secret, current);
|
||||
} catch (error) {
|
||||
if (current !== quickGeneration) return;
|
||||
if (error instanceof ApiError && error.status === 401) failConnect(disabledNotice, 'Réessayer');
|
||||
else failUnexpected(error);
|
||||
}
|
||||
}
|
||||
|
||||
function failUnexpected(error: unknown) {
|
||||
failConnect(`Connexion impossible. ${error instanceof Error ? error.message : 'Réessayez.'}`, 'Réessayer');
|
||||
}
|
||||
|
||||
function failConnect(message: string, label: string) {
|
||||
resetConnectPanel();
|
||||
ui.status = message;
|
||||
ui.retryLabel = label;
|
||||
ui.retryHidden = false;
|
||||
focusRetry();
|
||||
}
|
||||
|
||||
function clearConnection() {
|
||||
sessionController?.abort();
|
||||
sessionController = null;
|
||||
localStorage.removeItem(savedKey);
|
||||
token = '';
|
||||
userId = '';
|
||||
ui.selected = '';
|
||||
sessions = [];
|
||||
ui.details = [];
|
||||
estimator.resetEstimate();
|
||||
resetSubtitles();
|
||||
ui.sessionOptions = [{ value: '', label: 'Choisir une lecture' }];
|
||||
ui.selectDisabled = true;
|
||||
ui.followingHidden = true;
|
||||
}
|
||||
|
||||
function forgetConnection(message: string) {
|
||||
clearConnection();
|
||||
void beginQuickConnect(message);
|
||||
}
|
||||
|
||||
async function startFollowing() {
|
||||
const current = ++generation;
|
||||
quickGeneration++;
|
||||
clearTimeout(quickTimer);
|
||||
resetConnectPanel();
|
||||
ui.followingHidden = false;
|
||||
ui.selectDisabled = true;
|
||||
await poll();
|
||||
if (current === generation) focusSelect();
|
||||
}
|
||||
|
||||
// Called when the browser comes back to the foreground, the page is restored or
|
||||
// the network returns: the previous display was computed before the gap and
|
||||
// must not be presented as live until the chosen session is observed again.
|
||||
function resumeTracking() {
|
||||
if (!token || document.visibilityState !== 'visible') return;
|
||||
resyncPending = true;
|
||||
estimator.forgetObservation();
|
||||
renderStatus(sessions.find(candidate => candidate.Id === ui.selected));
|
||||
clearTimeout(timer);
|
||||
void poll();
|
||||
}
|
||||
|
||||
// A local tick keeps the display moving between observations. It stops as soon
|
||||
// as the observation goes stale, so a loss of tracking is never hidden by
|
||||
// unbounded extrapolation. The poll remains the only source of new observations.
|
||||
function tick() {
|
||||
if (!token) return;
|
||||
const session = sessions.find(candidate => candidate.Id === ui.selected);
|
||||
if (!session) return;
|
||||
const ticks = estimator.currentEstimate(session);
|
||||
renderStatus(session);
|
||||
if (ticks === null) return;
|
||||
ui.displayTicks = ticks;
|
||||
renderHistory(ticks);
|
||||
}
|
||||
|
||||
export function selectSession(event: Event) {
|
||||
ui.selected = (event.target as HTMLSelectElement).value;
|
||||
estimator.resetSeeded();
|
||||
render();
|
||||
}
|
||||
|
||||
export function changeTrack(event: Event) {
|
||||
ui.tracksValue = (event.target as HTMLSelectElement).value;
|
||||
const session = sessions.find(candidate => candidate.Id === ui.selected);
|
||||
if (session) void loadTrack(session);
|
||||
}
|
||||
|
||||
export function changeHelpTrack(event: Event) {
|
||||
ui.helpTracksValue = (event.target as HTMLSelectElement).value;
|
||||
const session = sessions.find(candidate => candidate.Id === ui.selected);
|
||||
if (session) void loadHelpTrack(session);
|
||||
}
|
||||
|
||||
export function retry() {
|
||||
void beginQuickConnect();
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
// Revoke the companion's own token; it is captured before the connection is forgotten.
|
||||
const request = api('Sessions/Logout', { method: 'POST' });
|
||||
const current = ++quickGeneration;
|
||||
generation++;
|
||||
clearTimeout(timer);
|
||||
clearTimeout(quickTimer);
|
||||
clearConnection();
|
||||
resetConnectPanel();
|
||||
ui.status = 'Déconnecté.';
|
||||
ui.retryLabel = 'Se connecter';
|
||||
ui.retryHidden = false;
|
||||
focusRetry();
|
||||
try {
|
||||
await request;
|
||||
} catch {
|
||||
// A revocation that fails after a newer attempt began must not overwrite it.
|
||||
if (current === quickGeneration) ui.status = 'Déconnecté de ce navigateur. Le serveur n’a pas confirmé la révocation du jeton.';
|
||||
}
|
||||
}
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (document.visibilityState === 'visible') resumeTracking();
|
||||
}
|
||||
|
||||
function onPageShow(event: PageTransitionEvent) {
|
||||
if (event.persisted) resumeTracking();
|
||||
}
|
||||
|
||||
export function startCompanion(targets: { select: HTMLSelectElement | null; retry: HTMLButtonElement | null }) {
|
||||
selectElement = targets.select;
|
||||
retryElement = targets.retry;
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
window.addEventListener('pageshow', onPageShow);
|
||||
window.addEventListener('online', resumeTracking);
|
||||
document.addEventListener('resume', resumeTracking as EventListener);
|
||||
window.setInterval(tick, 100);
|
||||
const saved = parseSavedConnection(localStorage.getItem(savedKey));
|
||||
if (saved) {
|
||||
token = saved.token;
|
||||
userId = saved.userId;
|
||||
deviceId = saved.deviceId;
|
||||
void startFollowing();
|
||||
} else {
|
||||
void beginQuickConnect();
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["node", "vite/client"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"useDefineForClassFields": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
// The plugin serves fixed asset names under /Compagnon/; keep the build output
|
||||
// stable so the controller whitelist stays trivial and the tests can serve it.
|
||||
// Development only: serve the interface with Vite and forward Jellyfin's API
|
||||
// to a running server, so `npm run dev` needs no plugin packaging loop.
|
||||
const jellyfin = process.env.JELLYFIN_URL ?? 'http://127.0.0.1:8096';
|
||||
|
||||
export default defineConfig({
|
||||
root: fileURLToPath(new URL('.', import.meta.url)),
|
||||
base: './',
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
proxy: Object.fromEntries(
|
||||
['/Sessions', '/Items', '/QuickConnect', '/Users', '/Compagnon/Subtitles']
|
||||
.map(path => [path, { target: jellyfin, changeOrigin: true }]),
|
||||
),
|
||||
},
|
||||
build: {
|
||||
outDir: fileURLToPath(new URL('../src/Compagnon/Web', import.meta.url)),
|
||||
emptyOutDir: true,
|
||||
// The controller's CSP allows 'self' only: never emit an inline script.
|
||||
modulePreload: { polyfill: false },
|
||||
rollupOptions: {
|
||||
output: {
|
||||
entryFileNames: 'app.js',
|
||||
chunkFileNames: 'app.js',
|
||||
// Only the stylesheet may take the fixed name the controller serves;
|
||||
// any other asset keeps its own name and fails loudly instead of
|
||||
// silently overwriting it.
|
||||
assetFileNames: assetInfo =>
|
||||
assetInfo.names?.some(name => name.endsWith('.css')) ? 'style.css' : '[name][extname]',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user