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 |
| 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) |
| 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 ;
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 ;
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
`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
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
disparu — l'exact inverse de ce que la touche du lecteur, elle, obtient. `confirmed` reste
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.
disparu — l'exact inverse de ce que la touche du lecteur, elle, obtient.
##### 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
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;
}
/**
* 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.
*
@@ -71,6 +137,12 @@ export class FirefoxController {
private url: string | null = null;
/** Dernier verdict de plein écran ; remis à zéro par toute navigation. */
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;
/** Sérialise les préparations concurrentes : une seule instance à lancer. */
private starting: Promise<void> | null = null;
@@ -127,6 +199,9 @@ export class FirefoxController {
this.url = url;
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');
return { url, launched };
}
@@ -143,6 +218,11 @@ export class FirefoxController {
* 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
* 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> {
await this.ensureReady();
@@ -153,6 +233,35 @@ export class FirefoxController {
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', {
context: this.context,
actions: [
@@ -160,29 +269,18 @@ export class FirefoxController {
type: 'key',
id: 'stream-control-keyboard',
actions: [
{ type: 'keyDown', value: webdriverKey(settings.key) },
{ type: 'keyUp', value: webdriverKey(settings.key) },
{ type: 'keyDown', value: webdriverKey(key) },
{ type: 'keyUp', value: webdriverKey(key) },
],
},
],
}, 10_000);
await this.send('input.releaseActions', { context: this.context }, 5000).catch(() => undefined);
// Le passage en plein écran est animé : relire trop tôt renverrait faux
// alors que la touche a bien été prise en compte.
// Le passage en plein écran est animé : mesurer trop tôt reverait une
// taille intermédiaire, et ferait conclure à un échec alors que la touche a
// bien été prise en compte.
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.fullscreen = undefined;
this.baseline = null;
this.log('info', 'Lecteur déchargé (page vide) — fenêtre conservée pour OBS', 'browser.closed');
return { url: 'about:blank' };
}
@@ -583,9 +682,98 @@ export class FirefoxController {
return client.send<T>(method, params, timeoutMs);
}
private async isFullscreen(): Promise<boolean> {
const result = await this.evaluate('document.fullscreenElement !== null').catch(() => null);
return result === true;
/**
* Relève la place occupée par le lecteur, ou `null` si la page n'en montre
* 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 { currentBuildId, runningBundlePath, selfUpdate } from './updater.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 { sendHotkey } from './hotkey.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);
// 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 wait = Number(params.readyDelayMs ?? browserSettings.readyDelayMs);
@@ -473,6 +476,93 @@ async function restoreFullscreen(settings: FullscreenSettings): Promise<Fullscre
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.
*
@@ -619,11 +709,23 @@ function startStatusLoop(): void {
void pushStatus();
statusTimer = setInterval(() => void pushStatus(), statusIntervalMs);
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 {
if (statusTimer) clearInterval(statusTimer);
statusTimer = null;
if (fullscreenTimer) clearInterval(fullscreenTimer);
fullscreenTimer = null;
}
// --- Cycle de vie ------------------------------------------------------------

View File

@@ -96,6 +96,14 @@ export function AgentCard({
<p className="muted small">
{status.browser.connected ? '🦊 Firefox piloté' : '🦊 Firefox non connecté'}
{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 ? (
<span className="error"> · {status.browser.lastError}</span>
) : null}