fix : various stuff
All checks were successful
release / build (push) Successful in 21s
release / verify-windows (push) Successful in 1m24s

This commit is contained in:
jeanotx32
2026-08-12 17:18:36 +02:00
parent 2dc3009432
commit 29645c04ca
5 changed files with 201 additions and 25 deletions

View File

@@ -266,9 +266,7 @@ export class FirefoxController {
// de contrôles du lecteur, elle, se garde visible tant qu'une souris réelle
// la survole — un comportement voulu pour ne pas la faire disparaître sous
// le curseur d'un vrai spectateur, mais qui la laisse affichée en
// permanence ici puisque rien ne bouge plus ensuite. Un coin sûr : la barre
// du haut du site (pas celle du lecteur), toujours visible d'après le
// réglage plein écran plus haut, donc jamais partie du lecteur.
// permanence ici puisque rien ne bouge plus ensuite.
await this.moveAway();
await this.send('input.releaseActions', { context: this.context }, 5000).catch(() => undefined);
@@ -653,23 +651,107 @@ export class FirefoxController {
await delay(150);
}
/** Déplace le curseur vers un coin hors du lecteur, sans cliquer. */
/**
* Déplace le curseur vers un point réellement hors du lecteur, sans cliquer.
*
* Calculé plutôt que deviné. Une version antérieure visait un coin fixe
* (haut gauche de la page), en supposant qu'une barre du site y restait
* toujours affichée au-dessus du lecteur. Rien ne garantissait que ça
* tienne — et un coin qui s'avère plutôt FAIRE PARTIE du lecteur produit
* l'inverse de l'effet recherché : la souris y reste posée, les contrôles
* restent affichés. Ici, la marge est cherchée autour de la position réelle
* du lecteur, confirmée par `elementFromPoint` plutôt que supposée.
*/
private async moveAway(): Promise<void> {
await this.send(
'input.performActions',
{
context: this.context,
actions: [
{
type: 'pointer',
id: 'stream-control-pointer',
parameters: { pointerType: 'mouse' },
actions: [{ type: 'pointerMove', x: 2, y: 2, origin: 'viewport' }],
},
],
},
5000,
).catch(() => undefined);
const point = await this.safePointOutsidePlayer();
if (point) {
await this.send(
'input.performActions',
{
context: this.context,
actions: [
{
type: 'pointer',
id: 'stream-control-pointer',
parameters: { pointerType: 'mouse' },
actions: [
{ type: 'pointerMove', x: Math.round(point.x), y: Math.round(point.y), origin: 'viewport' },
],
},
],
},
5000,
).catch(() => undefined);
return;
}
// Aucune marge trouvée : le lecteur couvre tout le viewport, sans point
// où poser le curseur en dehors de lui. Un évènement non fiable ne fera
// peut-être rien si le site vérifie `isTrusted` sur celui-ci comme il le
// fait sur le clic du bouton de qualité, mais ne coûte rien à tenter —
// mieux vaut essayer que renoncer complètement à masquer les contrôles.
await this.evaluate(`(() => {
const start = document.querySelector('video')
|| document.querySelector('.player-resolution')?.closest('[class*="player"]')
|| document.querySelector('.player-resolution');
if (!start) return;
// L'écouteur qui cache les contrôles vit le plus souvent sur le
// conteneur du lecteur (survol de la zone entière), pas sur la vidéo
// elle-même — inconnu d'ici, donc émis sur quelques niveaux de parents
// plutôt que sur le seul point de départ.
let node = start;
for (let i = 0; i < 4 && node; i++) {
node.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
node.dispatchEvent(new MouseEvent('mouseout', { bubbles: true }));
node = node.parentElement;
}
})()`).catch(() => undefined);
}
/**
* Point de viewport garanti hors du lecteur, ou `null` si aucun n'a pu être
* trouvé (lecteur couvrant tout l'écran, sans marge disponible dans aucune
* direction).
*
* L'ancre est l'élément vidéo lui-même — le repère le plus universel d'un
* lecteur, quelle que soit la façon dont le site habille ses contrôles
* autour. À défaut (site sans balise `<video>` classique), on retombe sur
* le conteneur du bouton de qualité déjà repéré plus haut.
*/
private async safePointOutsidePlayer(): Promise<{ x: number; y: number } | null> {
const raw = await this.evaluate(`JSON.stringify((() => {
const anchor = document.querySelector('video')
|| document.querySelector('.player-resolution')?.closest('[class*="player"]')
|| document.querySelector('.player-resolution');
if (!anchor) return null;
const rect = anchor.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
const margin = 20;
// Dans cet ordre : en dessous, au-dessus, à droite, à gauche — sous le
// lecteur est le plus souvent une zone de page ordinaire (chat,
// commentaires), donc le candidat le plus sûr en premier.
const candidates = [
{ x: rect.left + rect.width / 2, y: rect.bottom + margin },
{ x: rect.left + rect.width / 2, y: rect.top - margin },
{ x: rect.right + margin, y: rect.top + rect.height / 2 },
{ x: rect.left - margin, y: rect.top + rect.height / 2 },
];
for (const point of candidates) {
if (point.x < 0 || point.x > vw || point.y < 0 || point.y > vh) continue;
// Confirmé par l'élément réellement affiché à ce point, pas seulement
// par la géométrie : un habillage du site peut déborder du rectangle
// de l'ancre sans en faire partie au sens du DOM.
const el = document.elementFromPoint(point.x, point.y);
if (el && el !== anchor && !anchor.contains(el)) return point;
}
return null;
})())`).catch(() => null);
if (typeof raw !== 'string') return null;
return JSON.parse(raw) as { x: number; y: number } | null;
}
private async pressEscape(): Promise<void> {

View File

@@ -18,12 +18,25 @@ export interface FullscreenOutcome {
target?: string;
/** Vrai si confirmé, faux si l'échec est positivement établi, sinon indéterminé. */
confirmed?: boolean;
/**
* Aucune touche envoyée : un rappel très récent a déjà eu lieu. La touche
* bascule l'état du lecteur plutôt que de le forcer — un second envoi
* rapproché l'annulerait au lieu de le confirmer. Voir le garde-fou dans
* `index.ts`.
*/
skipped?: boolean;
}
/** Phrase de journal, uniforme entre les méthodes. */
export function describeFullscreen(outcome: FullscreenOutcome, key: string): string {
const where = outcome.target ? ` sur « ${outcome.target} »` : '';
if (outcome.skipped) {
return (
`Rappel du plein écran ignoré${where} : une demande a déjà été faite tout récemment — ` +
`la touche « ${key} » bascule l'affichage, un second envoi l'aurait annulé.`
);
}
if (outcome.confirmed === true) return `Plein écran confirmé${where} (${outcome.method})`;
if (outcome.confirmed === false) {
return (

View File

@@ -265,10 +265,14 @@ async function startCapture(params: Record<string, unknown>): Promise<unknown> {
// une fenêtre non maximisée que ne rien capturer du tout.
fullscreenResult = await restoreFullscreen(fullscreen)
.then((outcome) => {
// `skipped` prime sur `confirmed` : un envoi non renvoyé n'est jamais
// un échec de ce côté-ci, quel que soit le sort de celui qu'il a
// réutilisé.
const failed = !outcome.skipped && outcome.confirmed === false;
report(
outcome.confirmed === false ? 'warn' : 'info',
failed ? 'warn' : 'info',
describeFullscreen(outcome, fullscreen.key),
outcome.confirmed === false ? 'fullscreen.failed' : 'fullscreen.restored',
failed ? 'fullscreen.failed' : 'fullscreen.restored',
);
return outcome;
})
@@ -411,17 +415,62 @@ function driver(): FirefoxController | null {
return browserSettings.mode === 'bidi' ? firefox : null;
}
/**
* Au-delà de ce délai depuis le dernier envoi, un nouveau rappel est à
* nouveau autorisé. Choisi généreux face au 1200 ms que `restoreFullscreen`
* attend en interne avant de relire l'état : le but n'est pas de couvrir
* l'animation, mais la fenêtre où le flux public vient tout juste de démarrer
* et peut encore vaciller (bref retour en privé, requalification du statut).
*/
const FULLSCREEN_COOLDOWN_MS = 10_000;
/** Aucun rappel encore envoyé sur cette instance d'agent. */
let lastFullscreenAt = 0;
/** Deux appels concurrents doivent partager le même envoi, pas en déclencher deux. */
let fullscreenInFlight: Promise<FullscreenOutcome> | null = null;
/**
* Rappelle le plein écran par la voie correspondant au mode de pilotage.
*
* En BiDi, la demande part dans Firefox et son effet est relu dans la page. En
* `launch`, on en reste à une touche envoyée au serveur d'affichage, sans
* moyen de savoir ce qu'elle a produit — d'où le `confirmed` absent.
*
* Point de passage unique, et c'est voulu : deux mécanismes indépendants
* peuvent réclamer ce rappel — la séquence d'ouverture ({@link startCapture})
* et {@link StreamWatcher}, qui le redemande de lui-même après un show privé.
* Sans coordination, un flux qui vacille juste après le démarrage (un retour
* bref en privé pendant que la séquence d'ouverture patiente encore) fait
* partir les deux envois à quelques secondes d'écart. La touche du lecteur
* basculant l'affichage plutôt que le forçant, le second envoi annule le
* premier au lieu de le confirmer — d'où une fenêtre qui, au lieu de
* s'agrandir, finit par rétrécir. Le garde-fou vit ici et non chez l'un des
* deux appelants : c'est le seul endroit que les deux traversent forcément.
*/
async function restoreFullscreen(settings: FullscreenSettings): Promise<FullscreenOutcome> {
// Un envoi est déjà en cours : partager son résultat, pas en déclencher un
// second. Marqué `skipped` malgré le succès qu'il rapporte — c'est cette
// marque, et non la réussite, qui doit gouverner le message affiché à
// l'appelant (voir describeFullscreen) : sans elle, les deux appelants
// journalisent chacun « touche envoyée » pour un seul envoi réel, ce qui
// se lit comme deux rappels distincts alors qu'il n'y en a eu qu'un.
if (fullscreenInFlight) {
const outcome = await fullscreenInFlight;
return { ...outcome, skipped: true };
}
const sinceLast = Date.now() - lastFullscreenAt;
if (lastFullscreenAt > 0 && sinceLast < FULLSCREEN_COOLDOWN_MS) {
return { method: 'ignoré (rappel trop rapproché)', skipped: true };
}
const bidi = driver();
if (bidi) return bidi.restoreFullscreen(settings);
return sendHotkey({ key: settings.key, windowMatch: settings.windowMatch });
fullscreenInFlight = (
bidi ? bidi.restoreFullscreen(settings) : sendHotkey({ key: settings.key, windowMatch: settings.windowMatch })
).finally(() => {
fullscreenInFlight = null;
lastFullscreenAt = Date.now();
});
return fullscreenInFlight;
}
/**

View File

@@ -371,11 +371,15 @@ export class StreamWatcher extends EventEmitter {
// Un `confirmed: false` n'est pas un échec de commande : la demande est
// partie, la page ne l'a pas suivie. Le distinguer permet de la relire
// dans l'historique sans la confondre avec une erreur de configuration.
// `skipped` prime dessus : un envoi non renvoyé (déjà en cours ou trop
// rapproché) n'est jamais un échec ici, quel que soit le sort de celui
// qu'il a réutilisé.
const failed = !result.skipped && result.confirmed === false;
this.emit(
'log',
result.confirmed === false ? 'warn' : 'info',
failed ? 'warn' : 'info',
describeFullscreen(result, fullscreen.key),
result.confirmed === false ? 'fullscreen.failed' : 'fullscreen.restored',
failed ? 'fullscreen.failed' : 'fullscreen.restored',
);
return result;
} catch (err) {