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
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
|
||||
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
|
||||
// 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,8 +651,20 @@ 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> {
|
||||
const point = await this.safePointOutsidePlayer();
|
||||
if (point) {
|
||||
await this.send(
|
||||
'input.performActions',
|
||||
{
|
||||
@@ -664,12 +674,84 @@ export class FirefoxController {
|
||||
type: 'pointer',
|
||||
id: 'stream-control-pointer',
|
||||
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,
|
||||
).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> {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user