Feat : detection de fullscreen
All checks were successful
release / build (push) Successful in 30s
release / verify-windows (push) Successful in 2m57s

This commit is contained in:
jeanotx32
2026-08-13 14:05:49 -04:00
parent a1295003fd
commit 00535d5e19
4 changed files with 366 additions and 25 deletions

View File

@@ -180,7 +180,7 @@ Le rappel du plein écran dépend entièrement de ce choix, qui se règle par ag
| --- | --- | --- | | --- | --- | --- |
| Ouverture d'une page | commande dans l'instance pilotée | un processus lancé par page | | Ouverture d'une page | commande dans l'instance pilotée | un processus lancé par page |
| Plein écran | commande WebDriver adressée à Firefox | touche envoyée au serveur d'affichage | | Plein écran | commande WebDriver adressée à Firefox | touche envoyée au serveur d'affichage |
| Effet vérifié ? | oui — `document.fullscreenElement` est relu | non | | Effet vérifié ? | oui — la place occupée par le lecteur est mesurée | non |
| Session Wayland | **fonctionne** | impossible (voir plus bas) | | Session Wayland | **fonctionne** | impossible (voir plus bas) |
| Dépendances | Firefox | xdotool + session X11 | | Dépendances | Firefox | xdotool + session X11 |
@@ -199,16 +199,59 @@ Séquence du rappel de plein écran :
1. `browsingContext.activate` — sans quoi la touche partirait vers un onglet d'arrière-plan ; 1. `browsingContext.activate` — sans quoi la touche partirait vers un onglet d'arrière-plan ;
2. `input.performActions` — la touche configurée (`f` par défaut), délivrée à la page comme 2. `input.performActions` — la touche configurée (`f` par défaut), délivrée à la page comme
un vrai évènement (`isTrusted: true`), donc traitée par le lecteur du site ; un vrai évènement (`isTrusted: true`), donc traitée par le lecteur du site ;
3. relecture de `document.fullscreenElement`, et verdict rapporté dans l'historique de la VM. 3. **mesure** de la place réellement occupée par le lecteur, et verdict rapporté dans
l'historique de la VM.
Une seule voie, volontairement, sans repli. Une version antérieure appelait Une seule voie, volontairement, sans repli. Une version antérieure appelait
`requestFullscreen()` sur l'élément `<video>` brut quand `document.fullscreenElement` restait `requestFullscreen()` sur l'élément `<video>` brut quand `document.fullscreenElement` restait
vide après la touche — mais ce champ ne dit rien du mode « théâtre » de Stripchat, qui garde vide après la touche — mais ce champ ne dit rien du mode « théâtre » de Stripchat, qui garde
la barre du haut visible et n'a le plus souvent rien à voir avec l'API plein écran native du la barre du haut visible et n'a le plus souvent rien à voir avec l'API plein écran native du
navigateur. Le repli déclenchait alors un vrai plein écran natif — vidéo seule, tout le reste navigateur. Le repli déclenchait alors un vrai plein écran natif — vidéo seule, tout le reste
disparu — l'exact inverse de ce que la touche du lecteur, elle, obtient. `confirmed` reste disparu — l'exact inverse de ce que la touche du lecteur, elle, obtient.
donc indéterminé (`undefined`, pas `false`) quand l'API native ne signale rien : ce n'est pas
un échec, juste un mode que ce champ ne peut pas observer. ##### Mesurer plutôt que demander
`document.fullscreenElement` ne connaît que l'API native : il ignore le mode théâtre, donc il
ne renseignait presque jamais rien. L'agent **mesure** désormais ce qui compte vraiment — la
part de la fenêtre que le lecteur occupe, relevée par `getBoundingClientRect()` sur l'élément
`<video>` et ramenée au cadre visible (ce qui déborde n'est pas capturé par OBS).
**C'est la hauteur qui tranche, pas la surface.** Un lecteur passé en théâtre remplit toujours
la fenêtre verticalement, alors que sa largeur dépend de la forme du flux. Un flux vertical —
courant sur cette plateforme — reste cerné de bandes noires même en plein écran : mesuré en
surface, il passerait pour un échec permanent. Vérifié en conditions réelles contre un Firefox
piloté : un lecteur vertical en théâtre ne couvre que **27 %** de la fenêtre (contre 12 %
fenêtré), mais en occupe bien toute la hauteur.
**Un repère plutôt qu'un seuil absolu.** La hauteur du lecteur *fenêtré* est relevée une fois
par page, juste avant le premier envoi de touche ; le plein écran est reconnu quand la hauteur
dépasse ce repère d'un quart. Rien ne garantit qu'un site laisse au lecteur la même part
d'écran d'une mise en page à l'autre, et un seuil fixe se serait trompé sur la première
refonte venue. Le repère n'est jamais réécrit ensuite — un second relevé, pris alors que le
plein écran est déjà en place, en ferait la nouvelle référence et rendrait toute mesure
ultérieure aveugle. Il est en revanche oublié à chaque changement de page.
Il en découle **trois verdicts et non deux**. `false` n'est rendu que là où l'échec est
réellement établi ; sans repère fenêtré auquel se comparer (agent redémarré au milieu d'une
capture), on retombe sur un substitut — hauteur ≥ 80 % — dont le négatif reste *indéterminé*
plutôt qu'annoncé comme un échec. L'état apparaît sur la fiche de la VM (⛶ plein écran / hors
plein écran), et rien n'est affiché tant qu'il est indéterminé : faire passer un doute pour un
constat serait pire que se taire.
**Une seconde tentative, jamais plus, et seulement sur un échec établi.** La touche *bascule*
l'affichage : réappuyer sur une simple présomption ferait *sortir* du plein écran une page qui
y était déjà. C'est précisément la mesure fiable qui rend ce second essai possible.
**Le plein écran est relu toutes les 30 s pendant une capture.** Il peut se perdre sans que le
statut du stream bouge — un clic malheureux, un ré-affichage du lecteur, une publicité qui
reprend la main — et rien ne le signalait jusqu'ici : la capture en sortait sans qu'on sache
ni quand ni pourquoi. La relecture est espacée à dessein (c'est une évaluation dans la page,
pas au rythme du statut), et n'a lieu que pendant un enregistrement **actif et non suspendu** :
hors capture il n'y a rien à cadrer, et pendant une pause de show privé l'overlay du site
occupe le lecteur. Le constat part au journal **avec les mesures qui l'ont motivé**
(« hauteur 55 %, surface 27 %, repère fenêtré 55 % »), pour que la perte soit diagnosticable
après coup. Après trois rappels sans effet, la surveillance se suspend jusqu'au prochain
enregistrement plutôt que d'insister toutes les 30 s.
**Un seul envoi à la fois, et pas plus d'un toutes les 10 s.** Deux mécanismes indépendants **Un seul envoi à la fois, et pas plus d'un toutes les 10 s.** Deux mécanismes indépendants
peuvent réclamer ce rappel : la séquence d'ouverture (une fois, à l'arrivée sur la page) et peuvent réclamer ce rappel : la séquence d'ouverture (une fois, à l'arrivée sur la page) et

View File

@@ -42,6 +42,72 @@ interface TooltipState {
debug?: string; debug?: string;
} }
/**
* Place réellement occupée par le lecteur dans la fenêtre.
*
* C'est la seule mesure qui décrive ce qu'OBS capture — bien plus que
* `document.fullscreenElement`, qui ne connaît que l'API plein écran native du
* navigateur et ignore le mode « théâtre » que la plupart des lecteurs, celui de
* Stripchat compris, implémentent en CSS.
*
* Toutes les parts sont ramenées à la fenêtre visible, et non au document : ce
* qui déborde du cadre n'est pas capturé, donc ne compte pas.
*/
export interface PlayerMetrics {
/** L'API plein écran native est engagée : verdict sans appel. */
native: boolean;
/** Surface de la fenêtre couverte par la vidéo, de 0 à 1. */
coverage: number;
/** Parts de la largeur et de la hauteur de la fenêtre, de 0 à 1. */
width: number;
height: number;
}
/**
* Résume une mesure pour le journal : c'est elle qui doit répondre au « pourquoi
* le plein écran a-t-il été jugé perdu » plutôt que de laisser le constat nu.
*/
export function describeMetrics(
metrics: PlayerMetrics | null,
baseline: PlayerMetrics | null,
): string {
if (!metrics) return 'aucun lecteur mesurable dans la page';
const pct = (value: number) => `${Math.round(value * 100)} %`;
const parts = [`hauteur ${pct(metrics.height)}`, `surface ${pct(metrics.coverage)}`];
if (baseline) parts.push(`repère fenêtré ${pct(baseline.height)}`);
if (metrics.native) parts.push('plein écran natif');
return parts.join(', ');
}
/**
* Croissance minimale de la hauteur occupée pour conclure au plein écran.
*
* La hauteur, et non la surface : un lecteur en théâtre remplit toujours la
* fenêtre verticalement, tandis que sa largeur dépend de la forme du flux. Un
* flux vertical — courant sur cette plateforme — reste cerné de bandes noires
* même en plein écran et ne couvrira jamais qu'une fraction de la fenêtre ; jugé
* à la surface, il passerait pour un échec permanent. Vérifié en conditions
* réelles : un lecteur vertical passé en théâtre ne couvre que 12 % de la
* fenêtre, mais en occupe bien toute la hauteur.
*
* Comparaison à un repère plutôt que seuil absolu : rien ne garantit qu'un site
* laisse au lecteur fenêtré la même part d'écran d'une mise en page à l'autre.
* 1,25 laisse de la marge sous le gain réel (de l'ordre de 1,7) sans se laisser
* abuser par un simple remaniement de la page.
*/
const FULLSCREEN_GROWTH = 1.25;
/**
* Hauteur occupée à partir de laquelle on conclut au plein écran, faute de
* repère.
*
* Le repère fenêtré manque quand l'agent redémarre au milieu d'une capture : il
* hérite alors d'une page déjà en cours dont il n'a jamais vu l'état de départ.
* Moins sûr qu'une comparaison, d'où un verdict négatif rendu « indéterminé »
* plutôt qu'« échec ».
*/
const FULLSCREEN_FALLBACK_HEIGHT = 0.8;
/** /**
* Attente maximale de l'ouverture du port de pilotage. * Attente maximale de l'ouverture du port de pilotage.
* *
@@ -71,6 +137,12 @@ export class FirefoxController {
private url: string | null = null; private url: string | null = null;
/** Dernier verdict de plein écran ; remis à zéro par toute navigation. */ /** Dernier verdict de plein écran ; remis à zéro par toute navigation. */
private fullscreen: boolean | undefined; private fullscreen: boolean | undefined;
/**
* Place occupée par le lecteur en mode fenêtré, relevée sur cette page avant
* tout rappel de plein écran. C'est l'étalon auquel les mesures suivantes se
* comparent — voir {@link FULLSCREEN_GROWTH}.
*/
private baseline: PlayerMetrics | null = null;
private lastError: string | undefined; private lastError: string | undefined;
/** Sérialise les préparations concurrentes : une seule instance à lancer. */ /** Sérialise les préparations concurrentes : une seule instance à lancer. */
private starting: Promise<void> | null = null; private starting: Promise<void> | null = null;
@@ -127,6 +199,9 @@ export class FirefoxController {
this.url = url; this.url = url;
this.fullscreen = undefined; this.fullscreen = undefined;
// Le repère appartient à la page qui l'a fourni : une nouvelle mise en page
// le rend caduc, et le conserver ferait comparer deux lecteurs différents.
this.baseline = null;
this.log('info', `Page ${url} ouverte dans Firefox piloté`, 'browser.opened'); this.log('info', `Page ${url} ouverte dans Firefox piloté`, 'browser.opened');
return { url, launched }; return { url, launched };
} }
@@ -143,6 +218,11 @@ export class FirefoxController {
* alors un vrai plein écran natif — vidéo seule, tout le reste disparu — * alors un vrai plein écran natif — vidéo seule, tout le reste disparu —
* précisément l'effet que la touche du lecteur, elle, évite. Mieux vaut * précisément l'effet que la touche du lecteur, elle, évite. Mieux vaut
* envoyer le geste attendu par le site et ne pas le corriger à l'aveugle. * envoyer le geste attendu par le site et ne pas le corriger à l'aveugle.
*
* L'effet est ensuite *mesuré* — voir {@link PlayerMetrics} — et non plus
* seulement demandé à l'API native. C'est ce qui autorise la seconde tentative
* ci-dessous : sans mesure fiable, réappuyer sur une touche qui *bascule*
* l'affichage risquait d'annuler un plein écran déjà en place.
*/ */
async restoreFullscreen(settings: FullscreenSettings): Promise<FullscreenOutcome> { async restoreFullscreen(settings: FullscreenSettings): Promise<FullscreenOutcome> {
await this.ensureReady(); await this.ensureReady();
@@ -153,6 +233,35 @@ export class FirefoxController {
undefined, undefined,
); );
// Avant la première touche, donc sur une page encore fenêtrée : c'est tout
// l'intérêt du relevé.
await this.captureBaseline();
await this.pressFullscreenKey(settings.key);
let state = await this.fullscreenState();
// Une seule seconde tentative, et seulement sur un échec établi : sans
// repère, `fullscreenState` rend « indéterminé » plutôt que `false`, et on
// n'y touche pas — réappuyer sur une simple présomption ferait ressortir du
// plein écran une page qui y était déjà.
let attempts = 1;
if (state === false) {
this.log('info', 'Plein écran non pris : seconde tentative');
await this.pressFullscreenKey(settings.key);
state = await this.fullscreenState();
attempts = 2;
}
this.fullscreen = state;
return {
method: attempts > 1 ? 'webdriver (touche, 2 essais)' : 'webdriver (touche)',
target: this.url ?? undefined,
confirmed: state,
};
}
/** Envoie la touche du lecteur, puis laisse l'animation se terminer. */
private async pressFullscreenKey(key: string): Promise<void> {
await this.send('input.performActions', { await this.send('input.performActions', {
context: this.context, context: this.context,
actions: [ actions: [
@@ -160,29 +269,18 @@ export class FirefoxController {
type: 'key', type: 'key',
id: 'stream-control-keyboard', id: 'stream-control-keyboard',
actions: [ actions: [
{ type: 'keyDown', value: webdriverKey(settings.key) }, { type: 'keyDown', value: webdriverKey(key) },
{ type: 'keyUp', value: webdriverKey(settings.key) }, { type: 'keyUp', value: webdriverKey(key) },
], ],
}, },
], ],
}, 10_000); }, 10_000);
await this.send('input.releaseActions', { context: this.context }, 5000).catch(() => undefined); await this.send('input.releaseActions', { context: this.context }, 5000).catch(() => undefined);
// Le passage en plein écran est animé : relire trop tôt renverrait faux // Le passage en plein écran est animé : mesurer trop tôt reverait une
// alors que la touche a bien été prise en compte. // taille intermédiaire, et ferait conclure à un échec alors que la touche a
// bien été prise en compte.
await delay(1200); await delay(1200);
const confirmed = await this.isFullscreen();
this.fullscreen = confirmed;
// `confirmed` reste `undefined`, pas `false`, quand l'API native ne signale
// rien : c'est le cas attendu pour un mode « théâtre » propre au site, pas
// un échec. Le distinguer évite de journaliser en avertissement une touche
// qui a très bien pu faire son effet.
return {
method: 'webdriver (touche)',
target: this.url ?? undefined,
confirmed: confirmed ? true : undefined,
};
} }
/** /**
@@ -297,6 +395,7 @@ export class FirefoxController {
}); });
this.url = 'about:blank'; this.url = 'about:blank';
this.fullscreen = undefined; this.fullscreen = undefined;
this.baseline = null;
this.log('info', 'Lecteur déchargé (page vide) — fenêtre conservée pour OBS', 'browser.closed'); this.log('info', 'Lecteur déchargé (page vide) — fenêtre conservée pour OBS', 'browser.closed');
return { url: 'about:blank' }; return { url: 'about:blank' };
} }
@@ -583,9 +682,98 @@ export class FirefoxController {
return client.send<T>(method, params, timeoutMs); return client.send<T>(method, params, timeoutMs);
} }
private async isFullscreen(): Promise<boolean> { /**
const result = await this.evaluate('document.fullscreenElement !== null').catch(() => null); * Relève la place occupée par le lecteur, ou `null` si la page n'en montre
return result === true; * aucun (page vide, lecteur pas encore posé).
*/
private async measure(): Promise<PlayerMetrics | null> {
const raw = await this.evaluate(`JSON.stringify((() => {
const video = document.querySelector('video');
if (!video) return null;
const rect = video.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
// Un lecteur pas encore dimensionné ne dit rien : mieux vaut ne rien
// renvoyer que de rapporter une surface nulle, qu'on lirait comme un
// plein écran perdu.
if (!vw || !vh || rect.width < 1 || rect.height < 1) return null;
// Bornée au cadre visible : ce qui déborde n'est pas capturé par OBS.
const w = Math.max(0, Math.min(rect.right, vw) - Math.max(rect.left, 0));
const h = Math.max(0, Math.min(rect.bottom, vh) - Math.max(rect.top, 0));
return {
native: document.fullscreenElement !== null,
coverage: (w * h) / (vw * vh),
width: w / vw,
height: h / vh,
};
})())`).catch(() => null);
if (typeof raw !== 'string') return null;
try {
return JSON.parse(raw) as PlayerMetrics | null;
} catch {
return null;
}
}
/**
* Le lecteur occupe-t-il la fenêtre ? `undefined` quand rien ne permet de
* trancher.
*
* Trois verdicts et non deux : `false` n'a de sens que là où l'échec est
* réellement établi. Un négatif obtenu par le seul substitut de hauteur, sans
* repère fenêtré auquel se comparer, reste indéterminé — l'annoncer comme un
* échec ferait crier au loup à chaque flux vertical.
*/
private verdict(now: PlayerMetrics | null): boolean | undefined {
if (!now) return undefined;
if (now.native) return true;
if (this.baseline) return now.height >= this.baseline.height * FULLSCREEN_GROWTH;
return now.height >= FULLSCREEN_FALLBACK_HEIGHT ? true : undefined;
}
private async fullscreenState(): Promise<boolean | undefined> {
return this.verdict(await this.measure());
}
/**
* Relève l'état courant sans rien envoyer à la page, mesures à l'appui.
*
* Destinée à la surveillance périodique : elle ne réveille jamais Firefox — un
* navigateur fermé n'a pas d'état de plein écran à défendre, et le rouvrir
* pour le constater serait exactement l'effet de bord à éviter.
*
* Les mesures repartent avec le verdict : c'est ce qui permet de journaliser
* *pourquoi* le plein écran est jugé perdu, plutôt que de le décréter.
*/
async checkFullscreen(): Promise<{
fullscreen: boolean | undefined;
metrics: PlayerMetrics | null;
baseline: PlayerMetrics | null;
}> {
if (this.client?.isOpen !== true || !this.context) {
return { fullscreen: undefined, metrics: null, baseline: this.baseline };
}
const metrics = await this.measure().catch(() => null);
const fullscreen = this.verdict(metrics);
this.fullscreen = fullscreen;
return { fullscreen, metrics, baseline: this.baseline };
}
/**
* Fixe le repère du mode fenêtré, une fois par page.
*
* Appelée juste avant le tout premier envoi de touche, donc sur une page
* encore fenêtrée. Jamais réécrite ensuite : un second relevé, pris alors que
* le plein écran est déjà en place, ferait de celui-ci la nouvelle référence
* et rendrait toute mesure ultérieure aveugle.
*/
private async captureBaseline(): Promise<void> {
if (this.baseline) return;
const metrics = await this.measure();
if (metrics && !metrics.native) this.baseline = metrics;
} }
/** /**

View File

@@ -33,7 +33,7 @@ import { ObsController } from './obs.ts';
import { StreamWatcher } from './watcher.ts'; import { StreamWatcher } from './watcher.ts';
import { currentBuildId, runningBundlePath, selfUpdate } from './updater.ts'; import { currentBuildId, runningBundlePath, selfUpdate } from './updater.ts';
import { blankPage, closeWindow, delay as sleep, openUrl } from './browser.ts'; import { blankPage, closeWindow, delay as sleep, openUrl } from './browser.ts';
import { FirefoxController, type StreamQualityOutcome } from './firefox.ts'; import { FirefoxController, describeMetrics, type StreamQualityOutcome } from './firefox.ts';
import { describeFullscreen, type FullscreenOutcome } from './fullscreen.ts'; import { describeFullscreen, type FullscreenOutcome } from './fullscreen.ts';
import { sendHotkey } from './hotkey.ts'; import { sendHotkey } from './hotkey.ts';
import { cpuUsagePercent, diskUsage, memoryUsage } from './system.ts'; import { cpuUsagePercent, diskUsage, memoryUsage } from './system.ts';
@@ -252,6 +252,9 @@ async function startCapture(params: Record<string, unknown>): Promise<unknown> {
} }
const url = requireUrl(params); const url = requireUrl(params);
// Une nouvelle capture repart avec ses propres chances : un abandon décidé sur
// la précédente ne doit pas laisser celle-ci sans surveillance.
fullscreenMisses = 0;
const opened = await openPage(url); const opened = await openPage(url);
const wait = Number(params.readyDelayMs ?? browserSettings.readyDelayMs); const wait = Number(params.readyDelayMs ?? browserSettings.readyDelayMs);
@@ -473,6 +476,93 @@ async function restoreFullscreen(settings: FullscreenSettings): Promise<Fullscre
return fullscreenInFlight; return fullscreenInFlight;
} }
// --- Surveillance du plein écran ---------------------------------------------
/**
* Fréquence de relecture du plein écran pendant une capture.
*
* Espacée : la mesure est une évaluation dans la page, et rien ne justifie de
* la refaire au rythme du statut (2 s). Une perte de plein écran gâche au pire
* cette demi-minute de vidéo, là où une relecture serrée pèserait en continu.
*/
const FULLSCREEN_WATCH_MS = 30_000;
/**
* Rappels de surveillance restés sans effet, à la suite. Au-delà, on cesse
* d'insister : réappuyer toutes les 30 s sur une touche qui ne produit rien
* noierait l'historique sans rien corriger. Le compteur repart à chaque
* nouvelle capture, et à chaque rappel qui aboutit.
*/
let fullscreenMisses = 0;
const MAX_FULLSCREEN_MISSES = 3;
let fullscreenTimer: NodeJS.Timeout | null = null;
/**
* Vérifie que le lecteur occupe toujours la fenêtre, et le rétablit sinon.
*
* C'est le pendant du rappel programmé par {@link StreamWatcher} : celui-ci ne
* réagit qu'aux interruptions du flux, alors que le plein écran peut se perdre
* sans que le statut bouge — un clic malheureux, un ré-affichage du lecteur, une
* publicité qui reprend la main. Rien ne le signalait jusqu'ici, d'où des
* captures qui en sortaient sans qu'on sache ni quand ni pourquoi ; la mesure
* est maintenant relue régulièrement, et l'écart journalisé.
*/
async function watchFullscreen(): Promise<void> {
const bidi = driver();
const settings = watcher.snapshotSettings.fullscreen;
if (!bidi || !settings.enabled || fullscreenMisses >= MAX_FULLSCREEN_MISSES) return;
// Uniquement pendant une capture réellement en cours. Hors capture il n'y a
// rien à cadrer ; en pause (show privé), l'overlay du site occupe le lecteur
// et insister entrerait en conflit avec le rappel que la surveillance
// programme déjà pour le retour du flux public.
const record = await obs.recordState().catch(() => null);
if (!record?.active || record.paused) {
fullscreenMisses = 0;
return;
}
// `undefined` couvre aussi bien « pas de lecteur dans la page » que « rien ne
// permet de trancher » : dans les deux cas, agir se ferait à l'aveugle.
const { fullscreen, metrics, baseline } = await bidi.checkFullscreen();
if (fullscreen !== false) {
fullscreenMisses = 0;
return;
}
fullscreenMisses += 1;
// La mesure part avec le constat : c'est elle qui dit ce que le lecteur occupe
// réellement au moment où on le déclare sorti du plein écran.
report(
'warn',
`Plein écran perdu en cours de capture (${describeMetrics(metrics, baseline)}) — rappel envoyé`,
'fullscreen.failed',
);
const outcome = await restoreFullscreen(settings).catch((err: Error) => {
report('warn', `Rappel du plein écran impossible : ${err.message}`, 'fullscreen.failed');
return null;
});
if (!outcome) return;
// Un envoi ignoré (rappel trop rapproché) ne prouve rien : il ne compte ni
// comme réussite ni comme échec, et la relecture suivante tranchera.
if (outcome.skipped) return;
if (outcome.confirmed === true) {
fullscreenMisses = 0;
report('info', describeFullscreen(outcome, settings.key), 'fullscreen.restored');
return;
}
if (fullscreenMisses >= MAX_FULLSCREEN_MISSES) {
report(
'warn',
`Plein écran non rétabli après ${MAX_FULLSCREEN_MISSES} tentatives : surveillance ` +
'suspendue jusqu\'au prochain enregistrement.',
'fullscreen.failed',
);
}
}
/** /**
* Sélectionne la meilleure qualité offerte par le lecteur Stripchat. * Sélectionne la meilleure qualité offerte par le lecteur Stripchat.
* *
@@ -619,11 +709,23 @@ function startStatusLoop(): void {
void pushStatus(); void pushStatus();
statusTimer = setInterval(() => void pushStatus(), statusIntervalMs); statusTimer = setInterval(() => void pushStatus(), statusIntervalMs);
statusTimer.unref?.(); statusTimer.unref?.();
// Horloge distincte de celle du statut : la relecture du plein écran est bien
// plus espacée, et la caler sur un cycle de 2 s reviendrait à évaluer du code
// dans la page en permanence.
fullscreenTimer = setInterval(() => {
void watchFullscreen().catch((err: unknown) => {
console.error('Surveillance du plein écran en échec :', err);
});
}, FULLSCREEN_WATCH_MS);
fullscreenTimer.unref?.();
} }
function stopStatusLoop(): void { function stopStatusLoop(): void {
if (statusTimer) clearInterval(statusTimer); if (statusTimer) clearInterval(statusTimer);
statusTimer = null; statusTimer = null;
if (fullscreenTimer) clearInterval(fullscreenTimer);
fullscreenTimer = null;
} }
// --- Cycle de vie ------------------------------------------------------------ // --- Cycle de vie ------------------------------------------------------------

View File

@@ -96,6 +96,14 @@ export function AgentCard({
<p className="muted small"> <p className="muted small">
{status.browser.connected ? '🦊 Firefox piloté' : '🦊 Firefox non connecté'} {status.browser.connected ? '🦊 Firefox piloté' : '🦊 Firefox non connecté'}
{status.browser.url ? ` · ${status.browser.url}` : ''} {status.browser.url ? ` · ${status.browser.url}` : ''}
{/* Rien quand l'état est indéterminé : afficher « hors plein écran »
sur une mesure que l'agent lui-même juge non concluante ferait
passer un doute pour un constat. */}
{status.browser.fullscreen === true ? (
<span> · plein écran</span>
) : status.browser.fullscreen === false ? (
<span className="warn-text"> · hors plein écran</span>
) : null}
{status.browser.lastError ? ( {status.browser.lastError ? (
<span className="error"> · {status.browser.lastError}</span> <span className="error"> · {status.browser.lastError}</span>
) : null} ) : null}