discovery-media-player 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRAT.md +515 -0
- package/LICENSE +661 -0
- package/LICENSE-MIT +21 -0
- package/README.md +152 -0
- package/bin/__tests__/serve.test.js +84 -0
- package/bin/serve.js +115 -0
- package/context/__tests__/storage.test.js +99 -0
- package/context/standalone.js +224 -0
- package/context/storage.js +230 -0
- package/package.json +72 -0
- package/server/brands.js +44 -0
- package/server/browser.generated.js +7 -0
- package/server/handler.js +2657 -0
- package/server/presentations.js +319 -0
- package/server/shared.generated.js +93 -0
- package/server/shares.js +275 -0
- package/src/__tests__/bridge.test.ts +108 -0
- package/src/__tests__/chat.test.ts +138 -0
- package/src/__tests__/live.test.ts +211 -0
- package/src/__tests__/presentation-content.test.ts +132 -0
- package/src/__tests__/presentation-state.test.ts +81 -0
- package/src/__tests__/tracking.test.ts +217 -0
- package/src/__tests__/viewer.test.ts +133 -0
- package/src/bridge.ts +141 -0
- package/src/chat.ts +103 -0
- package/src/index.ts +14 -0
- package/src/live.ts +225 -0
- package/src/presentation-content.ts +109 -0
- package/src/presentation-state.ts +93 -0
- package/src/tracking.ts +250 -0
- package/src/viewer.ts +109 -0
- package/supabase/init.sql +242 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// CONTRAT DE CONTENU d'une présentation en direct : ce que le présentateur diffuse quand il quitte
|
|
2
|
+
// le document pour une carte ou une vue Street View.
|
|
3
|
+
//
|
|
4
|
+
// Ce contrat traverse trois frontières — le navigateur du présentateur le produit, le serveur le
|
|
5
|
+
// valide et le persiste, le navigateur de chaque spectateur l'applique. Il vivait en deux
|
|
6
|
+
// exemplaires (une production côté client, un `sanitizeContent` côté serveur sans aucun test) :
|
|
7
|
+
// toute divergence se traduisait par une audience qui ne voit pas ce que montre le présentateur,
|
|
8
|
+
// sans erreur nulle part.
|
|
9
|
+
//
|
|
10
|
+
// Ce module est le seul exemplaire. Il est bâti pour les deux côtés : sans DOM, sans Node, sans
|
|
11
|
+
// dépendance — il part dans le bundle navigateur ET dans un module CommonJS pour les fonctions
|
|
12
|
+
// serverless (cf. player/build/bundle.mjs).
|
|
13
|
+
|
|
14
|
+
export type MapType = "roadmap" | "satellite" | "hybrid";
|
|
15
|
+
|
|
16
|
+
/** Ordre de rotation du bouton de fond de carte. */
|
|
17
|
+
export const MAP_TYPES: MapType[] = ["roadmap", "satellite", "hybrid"];
|
|
18
|
+
|
|
19
|
+
/** Centre de repli : la France entière, quand on ne sait pas encore où regarder. */
|
|
20
|
+
export const DEFAULT_CENTER: [number, number] = [46.6, 2.5];
|
|
21
|
+
export const DEFAULT_ZOOM = 6;
|
|
22
|
+
|
|
23
|
+
export interface MapContent {
|
|
24
|
+
kind: "map";
|
|
25
|
+
center: [number, number];
|
|
26
|
+
zoom: number;
|
|
27
|
+
marker: [number, number] | null;
|
|
28
|
+
mapType: MapType | null;
|
|
29
|
+
label: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface StreetViewContent {
|
|
33
|
+
kind: "streetview";
|
|
34
|
+
position: [number, number];
|
|
35
|
+
pov: { heading: number; pitch: number };
|
|
36
|
+
zoom: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type PresentationContent = MapContent | StreetViewContent;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Conversion numérique tolérante.
|
|
43
|
+
* ⚠️ Comportement HISTORIQUE conservé : `+null`, `+""` et `+[]` valent 0 et sont finis, donc ces
|
|
44
|
+
* valeurs donnent 0 et non `null`. Le durcir ferait diverger le client du serveur — la seule
|
|
45
|
+
* chose que ce module existe pour empêcher. À changer un jour, des deux côtés à la fois.
|
|
46
|
+
*/
|
|
47
|
+
function num(value: unknown): number | null {
|
|
48
|
+
const n = Number(value);
|
|
49
|
+
return Number.isFinite(n) ? n : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function pair(value: unknown): [number, number] | null {
|
|
53
|
+
if (!Array.isArray(value)) return null;
|
|
54
|
+
const a = num(value[0]);
|
|
55
|
+
const b = num(value[1]);
|
|
56
|
+
return a != null && b != null ? [a, b] : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v));
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Valide un contenu reçu de l'extérieur. Refus par défaut : ce qui n'est pas reconnu vaut `null`,
|
|
63
|
+
* c'est-à-dire « on présente le document » — le repli sûr, jamais un écran vide.
|
|
64
|
+
*/
|
|
65
|
+
export function sanitizeContent(input: unknown): PresentationContent | null {
|
|
66
|
+
if (!input || typeof input !== "object") return null;
|
|
67
|
+
const c = input as Record<string, unknown>;
|
|
68
|
+
|
|
69
|
+
// `pdf` et l'absence de genre signifient tous deux « retour au document ».
|
|
70
|
+
if (c.kind === "pdf" || c.kind == null) return null;
|
|
71
|
+
|
|
72
|
+
if (c.kind === "streetview") {
|
|
73
|
+
const position = pair(c.position);
|
|
74
|
+
if (!position) return null; // une vue Street View sans position n'a aucun sens
|
|
75
|
+
const rawPov = c.pov && typeof c.pov === "object" ? (c.pov as Record<string, unknown>) : null;
|
|
76
|
+
const pov = rawPov
|
|
77
|
+
? { heading: num(rawPov.heading) || 0, pitch: clamp(num(rawPov.pitch) || 0, -90, 90) }
|
|
78
|
+
: { heading: 0, pitch: 0 };
|
|
79
|
+
return { kind: "streetview", position, pov, zoom: clamp(num(c.zoom) || 1, 0, 5) };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (c.kind !== "map") return null;
|
|
83
|
+
return {
|
|
84
|
+
kind: "map",
|
|
85
|
+
center: pair(c.center) || [...DEFAULT_CENTER] as [number, number],
|
|
86
|
+
zoom: clamp(Math.trunc(num(c.zoom) || DEFAULT_ZOOM), 1, 21),
|
|
87
|
+
marker: pair(c.marker),
|
|
88
|
+
mapType: MAP_TYPES.includes(c.mapType as MapType) ? (c.mapType as MapType) : null,
|
|
89
|
+
label: String(c.label || "").slice(0, 160) || null,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Fond de carte suivant dans la rotation du bouton. */
|
|
94
|
+
export function cycleMapType(current: string | null | undefined): MapType {
|
|
95
|
+
const index = MAP_TYPES.indexOf(current as MapType);
|
|
96
|
+
return MAP_TYPES[(index + 1) % MAP_TYPES.length];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Libellé du bouton : il annonce ce vers quoi on va, pas ce qu'on regarde. */
|
|
100
|
+
export function mapTypeLabel(current: string | null | undefined): string {
|
|
101
|
+
if (current === "roadmap") return "🛰 Satellite";
|
|
102
|
+
if (current === "satellite") return "🗺 Hybride";
|
|
103
|
+
return "🗺 Plan";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Contenu de départ quand le présentateur bascule sur une carte. */
|
|
107
|
+
export function initialMapContent(): MapContent {
|
|
108
|
+
return { kind: "map", center: [...DEFAULT_CENTER] as [number, number], zoom: DEFAULT_ZOOM, marker: null, mapType: null, label: null };
|
|
109
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Ce que l'audience fait d'un état de présentation reçu.
|
|
2
|
+
//
|
|
3
|
+
// Cette décision n'existait qu'à un seul endroit : le gestionnaire d'un `postgres_changes`. Elle
|
|
4
|
+
// doit maintenant servir TROIS sources — la table (existant), une diffusion `broadcast` par le
|
|
5
|
+
// présentateur, et une relecture d'état à la (re)connexion — parce qu'on veut se passer de la
|
|
6
|
+
// lecture anonyme des tables, qui rend aujourd'hui toutes les présentations énumérables.
|
|
7
|
+
//
|
|
8
|
+
// Une même règle, trois portes d'entrée : elle vit ici, testée, plutôt que recopiée trois fois.
|
|
9
|
+
//
|
|
10
|
+
// ⚠️ L'ORDRE EST UNE RÈGLE, pas un hasard :
|
|
11
|
+
// 1. une présentation terminée l'emporte sur tout ;
|
|
12
|
+
// 2. une carte ou une vue Street View SUSPEND le suivi du document — le présentateur montre
|
|
13
|
+
// autre chose, l'audience ne doit pas revenir au PDF ni suivre ses pages ;
|
|
14
|
+
// 3. un changement de document précède un changement de page (la page 3 de l'ancien document
|
|
15
|
+
// n'est pas la page 3 du nouveau) ;
|
|
16
|
+
// 4. la page en dernier.
|
|
17
|
+
|
|
18
|
+
import { sanitizeContent, type PresentationContent } from "./presentation-content";
|
|
19
|
+
|
|
20
|
+
/** L'état d'une présentation, tel qu'il arrive — de la table, d'une diffusion, ou d'une relecture. */
|
|
21
|
+
export interface PresentationRow {
|
|
22
|
+
active?: boolean | null;
|
|
23
|
+
content?: unknown;
|
|
24
|
+
file_url?: string | null;
|
|
25
|
+
file_name?: string | null;
|
|
26
|
+
doc_title?: string | null;
|
|
27
|
+
current_page?: number | null;
|
|
28
|
+
updated_at?: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Ce que l'audience regarde en ce moment. */
|
|
32
|
+
export interface AudienceView {
|
|
33
|
+
docUrl?: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type PresentationAction =
|
|
37
|
+
| { kind: "ended" }
|
|
38
|
+
| { kind: "show-map"; content: PresentationContent }
|
|
39
|
+
| { kind: "leave-map" }
|
|
40
|
+
| { kind: "switch-doc"; url: string; name?: string; title?: string; updatedAt?: string }
|
|
41
|
+
| { kind: "show-page"; page: number }
|
|
42
|
+
| { kind: "nothing" };
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Décide ce que l'audience doit faire d'un état reçu.
|
|
46
|
+
*
|
|
47
|
+
* ⚠️ Le contenu est **re-validé ici**. Sur la voie `postgres_changes` il venait de notre base,
|
|
48
|
+
* donc déjà assaini ; sur la voie `broadcast` il vient du NAVIGATEUR du présentateur. Faire
|
|
49
|
+
* confiance à un message diffusé par un client reviendrait à laisser n'importe quel participant
|
|
50
|
+
* imposer une vue à toute l'audience.
|
|
51
|
+
*/
|
|
52
|
+
export function presentationTransition(
|
|
53
|
+
row: PresentationRow | null | undefined,
|
|
54
|
+
view: AudienceView = {},
|
|
55
|
+
): PresentationAction[] {
|
|
56
|
+
if (!row) return [{ kind: "nothing" }];
|
|
57
|
+
|
|
58
|
+
// Une présentation terminée l'emporte : plus rien d'autre n'a de sens.
|
|
59
|
+
if (row.active === false) return [{ kind: "ended" }];
|
|
60
|
+
|
|
61
|
+
const content = sanitizeContent(row.content);
|
|
62
|
+
if (content) return [{ kind: "show-map", content }];
|
|
63
|
+
|
|
64
|
+
const actions: PresentationAction[] = [{ kind: "leave-map" }];
|
|
65
|
+
|
|
66
|
+
// Changement de document AVANT changement de page : la page 3 de l'ancien document n'est pas
|
|
67
|
+
// la page 3 du nouveau.
|
|
68
|
+
if (row.file_url && row.file_url !== view.docUrl) {
|
|
69
|
+
actions.push({
|
|
70
|
+
kind: "switch-doc",
|
|
71
|
+
url: row.file_url,
|
|
72
|
+
name: row.file_name || undefined,
|
|
73
|
+
title: row.doc_title || undefined,
|
|
74
|
+
updatedAt: row.updated_at || undefined,
|
|
75
|
+
});
|
|
76
|
+
return actions;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (row.current_page) actions.push({ kind: "show-page", page: row.current_page });
|
|
80
|
+
return actions;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* URL de rechargement d'un document changé en cours de présentation.
|
|
85
|
+
*
|
|
86
|
+
* ⚠️ **Le cache-buster n'est pas décoratif.** L'audience recharge le PDF par la même URL de proxy
|
|
87
|
+
* (`?present=<slug>&file=1`) ; sans un paramètre qui change, pdf.js ressert son cache et l'audience
|
|
88
|
+
* continue de voir l'ANCIEN document pendant que le présentateur commente le nouveau.
|
|
89
|
+
*/
|
|
90
|
+
export function switchDocUrl(base: string, updatedAt?: string): string {
|
|
91
|
+
const jeton = encodeURIComponent(updatedAt || String(Date.now()));
|
|
92
|
+
return `${base}${base.includes("?") ? "&" : "?"}v=${jeton}`;
|
|
93
|
+
}
|
package/src/tracking.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// Suivi de lecture d'un document : temps réellement passé PAR PAGE, page la plus loin atteinte,
|
|
2
|
+
// durée totale. C'est la matière première de tout ce que l'application affiche ensuite (entonnoir
|
|
3
|
+
// de lecture, donut de progression, graphe temps/page, classement des lecteurs).
|
|
4
|
+
//
|
|
5
|
+
// La règle qui fait la valeur de la mesure : on ne compte QUE le temps « réel à l'écran ».
|
|
6
|
+
// L'onglet doit être visible, la fenêtre avoir le focus, et l'utilisateur ne pas être inactif.
|
|
7
|
+
// Sans ces trois conditions, un document laissé ouvert dans un onglet oublié accumulerait des
|
|
8
|
+
// heures de lecture et rendrait toutes les statistiques mensongères.
|
|
9
|
+
//
|
|
10
|
+
// DEUX POPULATIONS, JAMAIS MÉLANGÉES :
|
|
11
|
+
// - lien tracé public (`slug`) → événements `open`/`page` + session prospect
|
|
12
|
+
// - aperçu interne équipe (`internal`) → session interne SEULEMENT, aucun `open`/`page`
|
|
13
|
+
// Un aperçu interne qui journaliserait un `open` gonflerait l'entonnoir du prospect et ferait
|
|
14
|
+
// croire à une lecture qui n'a pas eu lieu. Cette séparation est testée.
|
|
15
|
+
|
|
16
|
+
/** Transport d'un événement. Injectable pour les tests ; par défaut `sendBeacon`, repli `fetch`. */
|
|
17
|
+
export type TrackerTransport = (payload: Record<string, unknown>) => void;
|
|
18
|
+
|
|
19
|
+
export interface TrackerOptions {
|
|
20
|
+
/** Lien tracé public. Absent en aperçu interne. */
|
|
21
|
+
slug?: string | null;
|
|
22
|
+
/** Aperçu interne équipe. Exclusif du `slug`. */
|
|
23
|
+
internal?: { docId?: string; email?: string; name?: string } | null;
|
|
24
|
+
/** Route d'ingestion des événements. */
|
|
25
|
+
endpoint?: string;
|
|
26
|
+
/** Sans interaction pendant ce délai, le chrono se met en pause. */
|
|
27
|
+
idleMs?: number;
|
|
28
|
+
/** Cadence de persistance de la session (filet si l'onglet meurt sans prévenir). */
|
|
29
|
+
sessionEveryMs?: number;
|
|
30
|
+
/** Surface de défilement du document — son scroll compte comme une interaction. */
|
|
31
|
+
scrollElement?: { addEventListener: EventTarget["addEventListener"]; removeEventListener: EventTarget["removeEventListener"] } | null;
|
|
32
|
+
now?: () => number;
|
|
33
|
+
doc?: Document;
|
|
34
|
+
win?: Window;
|
|
35
|
+
send?: TrackerTransport;
|
|
36
|
+
sessionId?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface Tracker {
|
|
40
|
+
/** Journalise l'ouverture et met le suivi en marche. */
|
|
41
|
+
start(): void;
|
|
42
|
+
/** La page à l'écran a changé. */
|
|
43
|
+
setPage(page: number): void;
|
|
44
|
+
/** Le document a fini de charger : nombre total de pages. */
|
|
45
|
+
setPageCount(count: number): void;
|
|
46
|
+
/** Une interaction a eu lieu sur une surface que le suivi n'observe pas lui-même. */
|
|
47
|
+
noteActivity(): void;
|
|
48
|
+
/** Persiste la session immédiatement. */
|
|
49
|
+
flush(): void;
|
|
50
|
+
/** Retire tous les écouteurs et minuteries. */
|
|
51
|
+
stop(): void;
|
|
52
|
+
readonly sessionId: string;
|
|
53
|
+
totalSeconds(): number;
|
|
54
|
+
maxPage(): number;
|
|
55
|
+
/** Temps par page, en secondes entières — la forme envoyée au serveur. */
|
|
56
|
+
pageTimes(): Record<string, number>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const ACTIVITY_EVENTS = ["mousemove", "mousedown", "keydown", "wheel", "touchstart", "pointerdown"] as const;
|
|
60
|
+
|
|
61
|
+
function defaultSessionId(): string {
|
|
62
|
+
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `sendBeacon` d'abord : c'est le seul transport qui survive de façon fiable à la fermeture d'un onglet. */
|
|
66
|
+
function defaultTransport(endpoint: string, win: Window): TrackerTransport {
|
|
67
|
+
return (payload) => {
|
|
68
|
+
try {
|
|
69
|
+
const body = JSON.stringify(payload);
|
|
70
|
+
const nav = win.navigator;
|
|
71
|
+
if (nav && typeof nav.sendBeacon === "function") {
|
|
72
|
+
nav.sendBeacon(endpoint, new Blob([body], { type: "application/json" }));
|
|
73
|
+
} else {
|
|
74
|
+
win.fetch(endpoint, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: { "Content-Type": "application/json" },
|
|
77
|
+
body,
|
|
78
|
+
keepalive: true,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
} catch { /* le suivi ne doit jamais empêcher de lire le document */ }
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createTracker(options: TrackerOptions = {}): Tracker {
|
|
86
|
+
const win = options.win || (typeof window !== "undefined" ? window : (null as unknown as Window));
|
|
87
|
+
const doc = options.doc || (win && win.document);
|
|
88
|
+
const now = options.now || (() => Date.now());
|
|
89
|
+
const endpoint = options.endpoint || "/api/doc";
|
|
90
|
+
const idleMs = options.idleMs ?? 60000;
|
|
91
|
+
const sessionEveryMs = options.sessionEveryMs ?? 12000;
|
|
92
|
+
const slug = options.slug || "";
|
|
93
|
+
const internal = options.internal || null;
|
|
94
|
+
const sessionId = options.sessionId || defaultSessionId();
|
|
95
|
+
const send = options.send || defaultTransport(endpoint, win);
|
|
96
|
+
|
|
97
|
+
const pageTimes: Record<number, number> = {};
|
|
98
|
+
let activePage = 0;
|
|
99
|
+
let current = 0;
|
|
100
|
+
let furthest = 0;
|
|
101
|
+
let pageCount = 0;
|
|
102
|
+
// `activeSince === null` ⇒ chrono en pause.
|
|
103
|
+
let activeSince: number | null = null;
|
|
104
|
+
let lastActivity = now();
|
|
105
|
+
let idle = false;
|
|
106
|
+
let started = false;
|
|
107
|
+
|
|
108
|
+
const cleanups: Array<() => void> = [];
|
|
109
|
+
|
|
110
|
+
// Ni slug ni contexte interne : rien à rattacher, on n'envoie rien (aperçu d'un membre non identifié).
|
|
111
|
+
const canReport = () => !!slug || !!internal;
|
|
112
|
+
|
|
113
|
+
const post = (payload: Record<string, unknown>) => {
|
|
114
|
+
if (!canReport()) return;
|
|
115
|
+
// Le suivi ne doit JAMAIS empêcher de lire le document : réseau coupé, `sendBeacon` refusé,
|
|
116
|
+
// transport tiers qui lève — on perd la mesure, jamais la lecture.
|
|
117
|
+
try { send(payload); } catch { /* best-effort */ }
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const viewable = () =>
|
|
121
|
+
!!doc &&
|
|
122
|
+
doc.visibilityState === "visible" &&
|
|
123
|
+
(typeof doc.hasFocus === "function" ? doc.hasFocus() : true) &&
|
|
124
|
+
!idle;
|
|
125
|
+
|
|
126
|
+
const commit = () => {
|
|
127
|
+
if (activeSince != null && activePage > 0) {
|
|
128
|
+
pageTimes[activePage] = (pageTimes[activePage] || 0) + (now() - activeSince) / 1000;
|
|
129
|
+
}
|
|
130
|
+
activeSince = viewable() ? now() : null;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const pause = () => { commit(); activeSince = null; };
|
|
134
|
+
const resume = () => { if (activeSince == null && viewable()) activeSince = now(); };
|
|
135
|
+
|
|
136
|
+
const totalSeconds = () => {
|
|
137
|
+
let s = 0;
|
|
138
|
+
for (const k in pageTimes) s += pageTimes[k];
|
|
139
|
+
if (activeSince != null) s += (now() - activeSince) / 1000;
|
|
140
|
+
return Math.round(s);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const roundedPageTimes = () => {
|
|
144
|
+
const out: Record<string, number> = {};
|
|
145
|
+
for (const k in pageTimes) out[k] = Math.round(pageTimes[k]);
|
|
146
|
+
return out;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const noteActivity = () => {
|
|
150
|
+
lastActivity = now();
|
|
151
|
+
if (idle) { idle = false; resume(); }
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/** Événement léger (`open`/`page`) — prospects uniquement. */
|
|
155
|
+
const track = (event: string, page?: number) => {
|
|
156
|
+
if (internal) return;
|
|
157
|
+
post({ slug, event, page: page || current, maxPage: furthest, seconds: totalSeconds(), sessionId });
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const flush = () => {
|
|
161
|
+
commit();
|
|
162
|
+
const payload: Record<string, unknown> = {
|
|
163
|
+
event: "session",
|
|
164
|
+
sessionId,
|
|
165
|
+
numPages: pageCount,
|
|
166
|
+
maxPage: furthest,
|
|
167
|
+
totalSeconds: totalSeconds(),
|
|
168
|
+
pagesTime: roundedPageTimes(),
|
|
169
|
+
};
|
|
170
|
+
if (internal) {
|
|
171
|
+
payload.internal = true;
|
|
172
|
+
payload.docId = internal.docId;
|
|
173
|
+
payload.email = internal.email;
|
|
174
|
+
payload.name = internal.name;
|
|
175
|
+
} else {
|
|
176
|
+
payload.slug = slug;
|
|
177
|
+
}
|
|
178
|
+
post(payload);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const on = (
|
|
182
|
+
target: { addEventListener: (...a: never[]) => void; removeEventListener: (...a: never[]) => void } | null | undefined,
|
|
183
|
+
type: string,
|
|
184
|
+
handler: () => void,
|
|
185
|
+
opts?: AddEventListenerOptions,
|
|
186
|
+
) => {
|
|
187
|
+
// Environnement partiel (surface absente, contexte de test, navigateur bridé) : on se prive de
|
|
188
|
+
// ce signal, on ne casse pas le reste. Même règle que partout ici — la mesure cède, pas la lecture.
|
|
189
|
+
if (!target || typeof target.addEventListener !== "function") return;
|
|
190
|
+
(target.addEventListener as unknown as (t: string, h: () => void, o?: AddEventListenerOptions) => void)(type, handler, opts);
|
|
191
|
+
cleanups.push(() => {
|
|
192
|
+
(target.removeEventListener as unknown as (t: string, h: () => void, o?: AddEventListenerOptions) => void)(type, handler, opts);
|
|
193
|
+
});
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
sessionId,
|
|
198
|
+
totalSeconds,
|
|
199
|
+
maxPage: () => furthest,
|
|
200
|
+
pageTimes: roundedPageTimes,
|
|
201
|
+
|
|
202
|
+
start() {
|
|
203
|
+
if (started) return;
|
|
204
|
+
started = true;
|
|
205
|
+
track("open", 1);
|
|
206
|
+
activePage = 1;
|
|
207
|
+
activeSince = now();
|
|
208
|
+
lastActivity = now();
|
|
209
|
+
|
|
210
|
+
// Onglet caché → on met en pause ET on persiste : un onglet caché peut ne jamais revenir.
|
|
211
|
+
on(doc, "visibilitychange", () => {
|
|
212
|
+
if (doc.visibilityState === "hidden") { pause(); flush(); }
|
|
213
|
+
else { noteActivity(); resume(); }
|
|
214
|
+
});
|
|
215
|
+
on(win, "blur", pause);
|
|
216
|
+
on(win, "focus", () => { noteActivity(); resume(); });
|
|
217
|
+
for (const ev of ACTIVITY_EVENTS) on(win, ev, noteActivity, { passive: true });
|
|
218
|
+
on(options.scrollElement, "scroll", noteActivity, { passive: true });
|
|
219
|
+
on(win, "beforeunload", flush);
|
|
220
|
+
|
|
221
|
+
const idleTimer = win.setInterval(() => {
|
|
222
|
+
if (!idle && now() - lastActivity > idleMs) { idle = true; pause(); }
|
|
223
|
+
}, 5000);
|
|
224
|
+
const sessionTimer = win.setInterval(flush, sessionEveryMs);
|
|
225
|
+
cleanups.push(() => { win.clearInterval(idleTimer); win.clearInterval(sessionTimer); });
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
setPage(page: number) {
|
|
229
|
+
if (!page || page === current) return;
|
|
230
|
+
commit();
|
|
231
|
+
activePage = page;
|
|
232
|
+
current = page;
|
|
233
|
+
// Un `page` n'est journalisé qu'à la PREMIÈRE visite : l'entonnoir mesure la progression
|
|
234
|
+
// dans le document, pas les allers-retours.
|
|
235
|
+
if (page > furthest) { furthest = page; track("page", page); }
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
setPageCount(count: number) { pageCount = count || 0; },
|
|
239
|
+
noteActivity,
|
|
240
|
+
flush,
|
|
241
|
+
|
|
242
|
+
stop() {
|
|
243
|
+
while (cleanups.length) {
|
|
244
|
+
const off = cleanups.pop();
|
|
245
|
+
try { off?.(); } catch { /* cible déjà détruite */ }
|
|
246
|
+
}
|
|
247
|
+
started = false;
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
}
|
package/src/viewer.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Géométrie de la visionneuse : zoom, largeur de page, pagination, progression.
|
|
2
|
+
//
|
|
3
|
+
// Ce sont des calculs, pas du dessin — mais ils vivaient au milieu du DOM, mêlés à des
|
|
4
|
+
// `getBoundingClientRect()` et des `classList`, donc invérifiables. Chacune de ces règles a un
|
|
5
|
+
// motif précis, et certaines réparent un bug constaté : le décalage d'une page entre ce que
|
|
6
|
+
// montre le présentateur et ce que voit l'audience venait du choix de la bande de détection.
|
|
7
|
+
//
|
|
8
|
+
// Ici on ne mesure rien : les mesures sont fournies par l'appelant, qui seul connaît le DOM.
|
|
9
|
+
|
|
10
|
+
/** Bornes du zoom manuel. Au-delà, pdf.js rend des pages inutilisables (trop lourdes ou illisibles). */
|
|
11
|
+
export const MIN_ZOOM = 0.5;
|
|
12
|
+
export const MAX_ZOOM = 3;
|
|
13
|
+
|
|
14
|
+
/** Proportion de repli d'une page quand le document n'a pas encore été mesuré (~A4 portrait). */
|
|
15
|
+
export const DEFAULT_ASPECT = 1.35;
|
|
16
|
+
|
|
17
|
+
/** Largeur minimale d'une page : en dessous, le texte n'est plus lisible sur téléphone. */
|
|
18
|
+
export const MIN_PAGE_WIDTH = 280;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Réglages des deux observateurs de défilement. Ils ont des rôles DIFFÉRENTS, et les confondre
|
|
22
|
+
* était le bug :
|
|
23
|
+
* - `PRERENDER` déclenche le rendu un peu avant que la page arrive (marge large) → défilement fluide ;
|
|
24
|
+
* - `CURRENT_PAGE` détermine la page RÉELLEMENT regardée, via une bande fine au centre du cadre.
|
|
25
|
+
* Utiliser la marge de pré-rendu pour la page courante faisait considérer comme « courante » une
|
|
26
|
+
* page encore à 500 px sous le pli — d'où une audience en retard d'une page sur le présentateur.
|
|
27
|
+
*/
|
|
28
|
+
export const PRERENDER_MARGIN = "500px 0px";
|
|
29
|
+
export const CURRENT_PAGE_MARGIN = "-48% 0px -48% 0px";
|
|
30
|
+
|
|
31
|
+
/** Zoom borné et arrondi au dixième — sans arrondi, les clics successifs dérivent (0.7999…). */
|
|
32
|
+
export function clampZoom(zoom: number): number {
|
|
33
|
+
const rounded = Math.round((Number(zoom) || 0) * 10) / 10;
|
|
34
|
+
return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, rounded));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Numéro de page ramené dans les bornes du document. Tolère `undefined`, `0`, une chaîne. */
|
|
38
|
+
export function clampPage(page: unknown, pageCount: number): number {
|
|
39
|
+
const total = pageCount || 1;
|
|
40
|
+
return Math.max(1, Math.min(total, Number(page) || 1));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Progression de lecture, en pourcentage entier. Sans document mesuré : 0. */
|
|
44
|
+
export function progressPercent(page: number, pageCount: number): number {
|
|
45
|
+
if (!pageCount) return 0;
|
|
46
|
+
return Math.round((clampPage(page, pageCount) / pageCount) * 100);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** État des flèches du mode « une seule page ». */
|
|
50
|
+
export function arrowState(page: number, pageCount: number): { prevDisabled: boolean; nextDisabled: boolean } {
|
|
51
|
+
return {
|
|
52
|
+
prevDisabled: page <= 1,
|
|
53
|
+
nextDisabled: !!pageCount && page >= pageCount,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface FitInput {
|
|
58
|
+
/** Largeur utile du cadre de défilement, hors marges. */
|
|
59
|
+
containerWidth: number;
|
|
60
|
+
/** Hauteur utile du cadre. Ignorée hors du mode « une seule page ». */
|
|
61
|
+
containerHeight?: number;
|
|
62
|
+
zoom?: number;
|
|
63
|
+
/** Mode « une seule page » : la page doit tenir ENTIÈREMENT dans le cadre visible. */
|
|
64
|
+
onePage?: boolean;
|
|
65
|
+
/** Proportion hauteur/largeur de la première page. */
|
|
66
|
+
aspect?: number;
|
|
67
|
+
/** Hauteur occultée par un panneau qui recouvre le bas du document (feuille mobile). */
|
|
68
|
+
overlap?: number;
|
|
69
|
+
/** Hauteur réservée à un bandeau (mode barre, lecteur guidé…). */
|
|
70
|
+
reserve?: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Respiration conservée sous et au-dessus de la page en mode « une seule page ». */
|
|
74
|
+
const BREATHING = 28;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Largeur à donner à une page.
|
|
78
|
+
*
|
|
79
|
+
* En défilement vertical, c'est simplement la largeur du cadre fois le zoom. En mode « une seule
|
|
80
|
+
* page », la page doit tenir dans la HAUTEUR disponible : on la borne donc par ce que la hauteur
|
|
81
|
+
* autorise, une fois retirés le panneau qui la recouvre et le bandeau réservé. Sans cette borne,
|
|
82
|
+
* la page débordait sous la feuille de conversation et le lecteur ne voyait plus le bas.
|
|
83
|
+
*/
|
|
84
|
+
export function fitWidth(input: FitInput): number {
|
|
85
|
+
const zoom = input.zoom ?? 1;
|
|
86
|
+
const width = Math.max(MIN_PAGE_WIDTH, input.containerWidth || 900) * zoom;
|
|
87
|
+
if (!input.onePage) return width;
|
|
88
|
+
|
|
89
|
+
const aspect = input.aspect || DEFAULT_ASPECT;
|
|
90
|
+
const available = (input.containerHeight || 600) - BREATHING - (input.overlap || 0) - (input.reserve || 0);
|
|
91
|
+
const maxByHeight = available / aspect;
|
|
92
|
+
// Sous ce seuil la contrainte de hauteur produirait une page minuscule (panneau presque
|
|
93
|
+
// plein écran) : on préfère une page trop haute qu'un timbre-poste illisible.
|
|
94
|
+
return maxByHeight > 120 ? Math.min(width, maxByHeight) : width;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Couleur moyenne d'une bande de pixels RGBA — sert à prolonger la page dans le fond de l'écran. */
|
|
98
|
+
export function averageColor(pixels: ArrayLike<number>): string {
|
|
99
|
+
const count = pixels.length / 4;
|
|
100
|
+
if (!count) return "rgb(0,0,0)";
|
|
101
|
+
let r = 0, g = 0, b = 0;
|
|
102
|
+
for (let i = 0; i < pixels.length; i += 4) { r += pixels[i]; g += pixels[i + 1]; b += pixels[i + 2]; }
|
|
103
|
+
return `rgb(${Math.round(r / count)},${Math.round(g / count)},${Math.round(b / count)})`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Le nom du fichier fait foi : le type MIME n'est pas toujours renvoyé par le stockage. */
|
|
107
|
+
export function isImageDocument(fileName?: string | null, fileUrl?: string | null): boolean {
|
|
108
|
+
return /[.](png|jpe?g|webp|gif|avif)($|[?])/i.test(String(fileName || fileUrl || ""));
|
|
109
|
+
}
|