Feat : detection de fullscreen
This commit is contained in:
53
README.md
53
README.md
@@ -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
|
||||||
|
|||||||
@@ -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 relèverait 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 ------------------------------------------------------------
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user