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

@@ -204,6 +204,23 @@ disparu — l'exact inverse de ce que la touche du lecteur, elle, obtient. `conf
donc indéterminé (`undefined`, pas `false`) quand l'API native ne signale rien : ce n'est pas 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. un échec, juste un mode que ce champ ne peut pas observer.
**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
la surveillance de l'agent, qui le redemande de lui-même après un show privé. Sans
coordination, un flux qui vacille juste après le démarrage (bref retour en privé pendant que
la séquence d'ouverture patiente encore) fait partir les deux presque en même temps — et la
touche du lecteur *basculant* l'affichage plutôt que le forçant, le second envoi annule le
premier au lieu de le confirmer : la fenêtre, au lieu de s'agrandir, finit par rétrécir. Les
deux mécanismes convergent vers un même point de passage côté agent, qui partage le résultat
d'un envoi déjà en cours plutôt que d'en déclencher un second, et ignore toute nouvelle
demande dans les 10 s suivant la précédente.
**La qualité vidéo se règle après le plein écran, jamais avant.** Le rappel de plein écran
envoie une touche au lecteur, et la plupart des lecteurs vidéo traitent n'importe quelle
touche — pas seulement la souris — comme une activité qui réaffiche leurs contrôles. Réglée
avant, la sélection de qualité (qui termine en écartant le curseur pour laisser les
contrôles disparaître — voir plus bas) se ferait aussitôt annuler par cette touche.
**Un profil Firefox dédié est obligatoire**, pas cosmétique : le port de pilotage ne **Un profil Firefox dédié est obligatoire**, pas cosmétique : le port de pilotage ne
s'ouvre qu'au démarrage du processus, et deux instances ne peuvent pas partager un profil. s'ouvre qu'au démarrage du processus, et deux instances ne peuvent pas partager un profil.
L'agent en gère un et y réécrit un `user.js` à chaque lancement — chaque préférence y supprime quelque chose qui finirait dans le fichier L'agent en gère un et y réécrit un `user.js` à chaque lancement — chaque préférence y supprime quelque chose qui finirait dans le fichier
@@ -325,6 +342,17 @@ inspectant la page en direct. Sans effet, mais sans erreur non plus (juste un av
dans l'historique), si Stripchat change ce balisage — l'avertissement inclut alors un extrait dans l'historique), si Stripchat change ce balisage — l'avertissement inclut alors un extrait
du menu tel qu'ouvert, pour diagnostiquer sans repasser par les DevTools. du menu tel qu'ouvert, pour diagnostiquer sans repasser par les DevTools.
**Le curseur est écarté du lecteur une fois la qualité réglée**, sans quoi la barre de
contrôles du lecteur — qui se garde affichée tant qu'une souris réelle la survole — resterait
visible en permanence, faute de mouvement ultérieur. Le point de sortie est **calculé, pas
deviné** : une marge est cherchée autour de la position réelle de l'élément vidéo (en
dessous en priorité, sinon au-dessus, à droite ou à gauche), confirmée par
`elementFromPoint` plutôt que supposée à un endroit fixe de la page. Si le lecteur couvre
tout le viewport sans la moindre marge, un évènement `mouseleave` non fiable est émis en
dernier recours sur l'élément vidéo et ses parents proches — sans garantie si le site vérifie
`isTrusted` dessus comme il le fait sur le clic du bouton de qualité, mais sans risque non
plus à tenter.
#### Lancement simple #### Lancement simple
Conservé comme défaut pour ne pas changer le comportement d'un agent existant à la mise à Conservé comme défaut pour ne pas changer le comportement d'un agent existant à la mise à

View File

@@ -266,9 +266,7 @@ export class FirefoxController {
// de contrôles du lecteur, elle, se garde visible tant qu'une souris réelle // 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 // 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 // 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 // permanence ici puisque rien ne bouge plus ensuite.
// 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.
await this.moveAway(); await this.moveAway();
await this.send('input.releaseActions', { context: this.context }, 5000).catch(() => undefined); await this.send('input.releaseActions', { context: this.context }, 5000).catch(() => undefined);
@@ -653,8 +651,20 @@ export class FirefoxController {
await delay(150); 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> { private async moveAway(): Promise<void> {
const point = await this.safePointOutsidePlayer();
if (point) {
await this.send( await this.send(
'input.performActions', 'input.performActions',
{ {
@@ -664,12 +674,84 @@ export class FirefoxController {
type: 'pointer', type: 'pointer',
id: 'stream-control-pointer', id: 'stream-control-pointer',
parameters: { pointerType: 'mouse' }, parameters: { pointerType: 'mouse' },
actions: [{ type: 'pointerMove', x: 2, y: 2, origin: 'viewport' }], actions: [
{ type: 'pointerMove', x: Math.round(point.x), y: Math.round(point.y), origin: 'viewport' },
],
}, },
], ],
}, },
5000, 5000,
).catch(() => undefined); ).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> { private async pressEscape(): Promise<void> {

View File

@@ -18,12 +18,25 @@ export interface FullscreenOutcome {
target?: string; target?: string;
/** Vrai si confirmé, faux si l'échec est positivement établi, sinon indéterminé. */ /** Vrai si confirmé, faux si l'échec est positivement établi, sinon indéterminé. */
confirmed?: boolean; 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. */ /** Phrase de journal, uniforme entre les méthodes. */
export function describeFullscreen(outcome: FullscreenOutcome, key: string): string { export function describeFullscreen(outcome: FullscreenOutcome, key: string): string {
const where = outcome.target ? ` sur « ${outcome.target} »` : ''; 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 === true) return `Plein écran confirmé${where} (${outcome.method})`;
if (outcome.confirmed === false) { if (outcome.confirmed === false) {
return ( 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. // une fenêtre non maximisée que ne rien capturer du tout.
fullscreenResult = await restoreFullscreen(fullscreen) fullscreenResult = await restoreFullscreen(fullscreen)
.then((outcome) => { .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( report(
outcome.confirmed === false ? 'warn' : 'info', failed ? 'warn' : 'info',
describeFullscreen(outcome, fullscreen.key), describeFullscreen(outcome, fullscreen.key),
outcome.confirmed === false ? 'fullscreen.failed' : 'fullscreen.restored', failed ? 'fullscreen.failed' : 'fullscreen.restored',
); );
return outcome; return outcome;
}) })
@@ -411,17 +415,62 @@ function driver(): FirefoxController | null {
return browserSettings.mode === 'bidi' ? firefox : 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. * 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 * 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 * `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. * 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> { 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(); const bidi = driver();
if (bidi) return bidi.restoreFullscreen(settings); fullscreenInFlight = (
return sendHotkey({ key: settings.key, windowMatch: settings.windowMatch }); 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 // 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 // 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. // 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( this.emit(
'log', 'log',
result.confirmed === false ? 'warn' : 'info', failed ? 'warn' : 'info',
describeFullscreen(result, fullscreen.key), describeFullscreen(result, fullscreen.key),
result.confirmed === false ? 'fullscreen.failed' : 'fullscreen.restored', failed ? 'fullscreen.failed' : 'fullscreen.restored',
); );
return result; return result;
} catch (err) { } catch (err) {