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