fix : various stuff
This commit is contained in:
28
README.md
28
README.md
@@ -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 à
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user