Fix : overlay showing some times
This commit is contained in:
@@ -79,6 +79,50 @@ export function describeMetrics(
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ce qu'a pu faire {@link FirefoxDriver.parkPointer} du curseur.
|
||||
*
|
||||
* Relevé plutôt que supposé : c'est la seule façon de savoir, après coup et
|
||||
* depuis le journal, pourquoi une incrustation est restée à l'écran sur un
|
||||
* enregistrement — le curseur posé sur un bouton, ou pas déplacé du tout.
|
||||
*/
|
||||
/** Curseur effectivement déplacé, avec le point retenu. */
|
||||
export interface ParkPoint {
|
||||
where: 'outside' | 'inside';
|
||||
x: number;
|
||||
y: number;
|
||||
over: string;
|
||||
}
|
||||
|
||||
export type ParkOutcome =
|
||||
/**
|
||||
* Curseur posé quelque part : `outside`, hors du lecteur — le cas idéal, le
|
||||
* survol cesse aussitôt ; `inside`, dans le lecteur mais à l'écart de ses
|
||||
* commandes (le cas du plein écran, où il n'existe aucun dehors), la minuterie
|
||||
* d'auto-masquage du lecteur faisant le reste. `over` nomme l'élément
|
||||
* réellement sous le curseur à l'arrivée.
|
||||
*/
|
||||
| ParkPoint
|
||||
/**
|
||||
* Aucun point utilisable : sortie de survol simulée en dernier ressort
|
||||
* (`events`), ou rien à faire faute de lecteur dans la page (`none`).
|
||||
*/
|
||||
| { where: 'events' | 'none' };
|
||||
|
||||
/** Résume un éloignement de curseur pour le journal. */
|
||||
export function describePark(outcome: ParkOutcome): string {
|
||||
switch (outcome.where) {
|
||||
case 'outside':
|
||||
return `curseur écarté hors du lecteur (sur ${outcome.over})`;
|
||||
case 'inside':
|
||||
return `lecteur plein cadre : curseur posé à l'écart des commandes (sur ${outcome.over})`;
|
||||
case 'events':
|
||||
return 'aucun point hors des commandes : sortie de survol simulée';
|
||||
default:
|
||||
return 'curseur non déplacé : aucun lecteur repéré dans la page';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantané de la lecture, tel que le lecteur le rapporte.
|
||||
*
|
||||
@@ -274,6 +318,20 @@ export class FirefoxController {
|
||||
}
|
||||
|
||||
this.fullscreen = state;
|
||||
|
||||
// Après la mesure, jamais avant : déplacer le curseur réaffiche les
|
||||
// contrôles du lecteur le temps de sa minuterie, et le relevé porterait
|
||||
// alors sur une page en train de changer.
|
||||
//
|
||||
// C'est ici que se jouait l'incrustation restée en travers de certaines
|
||||
// reprises. Une capture qui démarre passe par la sélection de qualité, qui
|
||||
// se terminait déjà par cet écart ; un simple rappel de plein écran — le
|
||||
// chemin de toute reprise après un show privé ou un passage hors-ligne —
|
||||
// n'y passait pas, et laissait le curseur exactement là où la dernière
|
||||
// interaction l'avait posé.
|
||||
const parked = await this.parkPointer();
|
||||
this.log('info', describePark(parked));
|
||||
|
||||
return {
|
||||
method: attempts > 1 ? 'webdriver (touche, 2 essais)' : 'webdriver (touche)',
|
||||
target: this.url ?? undefined,
|
||||
@@ -351,8 +409,15 @@ export class FirefoxController {
|
||||
await delay(150);
|
||||
}
|
||||
|
||||
if (!tooltip?.found) return { ok: false, reason: 'menu de qualité non ouvert' };
|
||||
// Un abandon laisse le curseur sur le bouton de qualité, donc les contrôles
|
||||
// épinglés à l'écran : la qualité n'aura pas été réglée, mais au moins
|
||||
// l'incrustation ne sera pas enregistrée par-dessus le flux.
|
||||
if (!tooltip?.found) {
|
||||
await this.parkPointer();
|
||||
return { ok: false, reason: 'menu de qualité non ouvert' };
|
||||
}
|
||||
if (tooltip.options.length === 0) {
|
||||
await this.parkPointer();
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'menu ouvert mais aucune option de résolution trouvée',
|
||||
@@ -388,12 +453,10 @@ export class FirefoxController {
|
||||
await this.click(buttonPoint.x, buttonPoint.y);
|
||||
}
|
||||
|
||||
// Le curseur reste sinon posé sur le bouton ou l'option cliquée : la barre
|
||||
// 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.
|
||||
await this.moveAway();
|
||||
// Le curseur reste sinon posé sur le bouton ou l'option cliquée, ce qui
|
||||
// maintient la barre de contrôles affichée (voir {@link parkPointer}).
|
||||
const parked = await this.parkPointer();
|
||||
this.log('info', describePark(parked));
|
||||
|
||||
await this.send('input.releaseActions', { context: this.context }, 5000).catch(() => undefined);
|
||||
|
||||
@@ -895,20 +958,34 @@ export class FirefoxController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Déplace le curseur vers un point réellement hors du lecteur, sans cliquer.
|
||||
* Écarte le curseur de tout ce qui maintient les contrôles du lecteur
|
||||
* affichés, sans cliquer. À appeler à la fin de chaque interaction.
|
||||
*
|
||||
* 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.
|
||||
* Un lecteur vidéo garde sa barre de contrôles visible tant qu'une souris la
|
||||
* survole — comportement voulu pour un vrai spectateur, mais qui la fige à
|
||||
* l'écran ici, puisque plus rien ne bouge ensuite. Et cette barre est
|
||||
* enregistrée avec le flux : c'est l'incrustation que l'on voit en travers de
|
||||
* certains replays.
|
||||
*
|
||||
* Trois recours, du plus fiable au moins :
|
||||
*
|
||||
* 1. **Hors du lecteur.** Calculé plutôt que deviné : une version antérieure
|
||||
* visait un coin fixe de la page, en supposant qu'une barre du site y
|
||||
* restait affichée. Un coin qui s'avère FAIRE PARTIE du lecteur produit
|
||||
* exactement l'inverse de l'effet recherché.
|
||||
* 2. **Dans le lecteur, à l'écart des contrôles.** Le cas du plein écran :
|
||||
* le lecteur couvre tout, il n'existe aucun « dehors ». Un point posé sur
|
||||
* la surface vidéo nue relance quand même la minuterie d'auto-masquage du
|
||||
* lecteur, qui fait alors disparaître la barre toute seule — ce qui
|
||||
* n'arrive jamais tant que le curseur reste sur un bouton.
|
||||
* 3. **Évènements de sortie simulés**, en dernier ressort. Un site qui
|
||||
* vérifie `isTrusted` les ignorera, mais ils ne coûtent rien à tenter.
|
||||
*/
|
||||
private async moveAway(): Promise<void> {
|
||||
const point = await this.safePointOutsidePlayer();
|
||||
if (point) {
|
||||
await this.send(
|
||||
async parkPointer(): Promise<ParkOutcome> {
|
||||
const target = await this.pointerParkTarget();
|
||||
|
||||
if (target) {
|
||||
const moved = await this.send(
|
||||
'input.performActions',
|
||||
{
|
||||
context: this.context,
|
||||
@@ -918,26 +995,24 @@ export class FirefoxController {
|
||||
id: 'stream-control-pointer',
|
||||
parameters: { pointerType: 'mouse' },
|
||||
actions: [
|
||||
{ type: 'pointerMove', x: Math.round(point.x), y: Math.round(point.y), origin: 'viewport' },
|
||||
{ type: 'pointerMove', x: Math.round(target.x), y: Math.round(target.y), origin: 'viewport' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
5000,
|
||||
).catch(() => undefined);
|
||||
return;
|
||||
).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
if (moved) return target;
|
||||
}
|
||||
|
||||
// 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 dispatched = await this.evaluate(`(() => {
|
||||
const start = document.querySelector('video')
|
||||
|| document.querySelector('.player-resolution')?.closest('[class*="player"]')
|
||||
|| document.querySelector('.player-resolution');
|
||||
if (!start) return;
|
||||
if (!start) return false;
|
||||
// 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
|
||||
@@ -948,20 +1023,21 @@ export class FirefoxController {
|
||||
node.dispatchEvent(new MouseEvent('mouseout', { bubbles: true }));
|
||||
node = node.parentElement;
|
||||
}
|
||||
})()`).catch(() => undefined);
|
||||
return true;
|
||||
})()`).catch(() => false);
|
||||
|
||||
return { where: dispatched === true ? 'events' : 'none' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Point de viewport où poser le curseur, ou `null` si la page n'offre rien.
|
||||
*
|
||||
* 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> {
|
||||
private async pointerParkTarget(): Promise<ParkPoint | null> {
|
||||
const raw = await this.evaluate(`JSON.stringify((() => {
|
||||
const anchor = document.querySelector('video')
|
||||
|| document.querySelector('.player-resolution')?.closest('[class*="player"]')
|
||||
@@ -972,29 +1048,67 @@ export class FirefoxController {
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const margin = 20;
|
||||
const inside = (p) => p.x >= 0 && p.x <= vw && p.y >= 0 && p.y <= vh;
|
||||
const name = (el) => {
|
||||
if (!el) return '?';
|
||||
const cls = typeof el.className === 'string' ? el.className.trim().split(/\\s+/)[0] : '';
|
||||
return el.tagName.toLowerCase() + (cls ? '.' + cls : '');
|
||||
};
|
||||
|
||||
// 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 = [
|
||||
// 1. Hors du lecteur. 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 outside = [
|
||||
{ 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;
|
||||
for (const point of outside) {
|
||||
if (!inside(point)) 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;
|
||||
if (el && el !== anchor && !anchor.contains(el)) {
|
||||
return { where: 'outside', x: point.x, y: point.y, over: name(el) };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Dans le lecteur, à l'écart de ses commandes. Balayage par bandes du
|
||||
// haut vers le bas : la barre de contrôles occupe le bas chez tous les
|
||||
// lecteurs, et le sommet de l'image est ce qui a le moins de chances de
|
||||
// porter un bouton.
|
||||
const CONTROLS = 'button,[role="button"],a,input,select,textarea,'
|
||||
+ '[class*="control"],[class*="tooltip"],[class*="menu"],[class*="btn"],[class*="bar"]';
|
||||
const points = [];
|
||||
for (const fy of [0.18, 0.3, 0.1, 0.42]) {
|
||||
for (const fx of [0.5, 0.25, 0.75]) {
|
||||
const point = { x: rect.left + rect.width * fx, y: rect.top + rect.height * fy };
|
||||
if (inside(point)) points.push(point);
|
||||
}
|
||||
}
|
||||
|
||||
// Deux passes, et pas une seule avec exclusion : la surface vidéo nue est
|
||||
// le seul endroit dont on sache avec certitude qu'il n'épingle rien. Un
|
||||
// habillage sans nom parlant (un voile cliquable, un dégradé) fait un
|
||||
// repli acceptable — les lecteurs masquent leurs contrôles après
|
||||
// inactivité même curseur posé sur l'image — mais il ne doit pas être
|
||||
// préféré à la vidéo elle-même quand celle-ci est atteignable.
|
||||
for (const point of points) {
|
||||
if (document.elementFromPoint(point.x, point.y) === anchor) {
|
||||
return { where: 'inside', x: point.x, y: point.y, over: name(anchor) };
|
||||
}
|
||||
}
|
||||
for (const point of points) {
|
||||
const el = document.elementFromPoint(point.x, point.y);
|
||||
if (!el || el.closest(CONTROLS)) continue;
|
||||
return { where: 'inside', x: point.x, y: point.y, over: name(el) };
|
||||
}
|
||||
return null;
|
||||
})())`).catch(() => null);
|
||||
if (typeof raw !== 'string') return null;
|
||||
return JSON.parse(raw) as { x: number; y: number } | null;
|
||||
return JSON.parse(raw) as ParkPoint | null;
|
||||
}
|
||||
|
||||
private async evaluate(expression: string, userActivation = false): Promise<unknown> {
|
||||
|
||||
@@ -36,6 +36,7 @@ import { blankPage, closeWindow, delay as sleep, openUrl } from './browser.ts';
|
||||
import {
|
||||
FirefoxController,
|
||||
describeMetrics,
|
||||
describePark,
|
||||
type PlaybackSample,
|
||||
type StreamQualityOutcome,
|
||||
} from './firefox.ts';
|
||||
@@ -324,9 +325,35 @@ async function preparePlayer(waitMs: number): Promise<unknown> {
|
||||
// l'éloignement du curseur reste la dernière chose à toucher la page avant
|
||||
// l'enregistrement.
|
||||
await selectMaxStreamQuality();
|
||||
|
||||
// Les deux étapes ci-dessus se terminent chacune en écartant le curseur du
|
||||
// lecteur. Toutes deux désactivées, plus rien ne le fait : la capture
|
||||
// démarrerait avec les contrôles éventuellement figés à l'écran, hérités du
|
||||
// dernier geste — la souris n'a aucune raison d'avoir bougé depuis.
|
||||
if (!fullscreen.enabled && !watcher.snapshotSettings.streamQuality.enabled) {
|
||||
await parkPointer();
|
||||
}
|
||||
|
||||
return fullscreenResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Écarte le curseur de la barre de contrôles du lecteur.
|
||||
*
|
||||
* Sans effet hors mode BiDi : déplacer une souris dans une page suppose de
|
||||
* parler au navigateur. Un échec n'est jamais bloquant — au pire, une
|
||||
* incrustation reste visible sur l'enregistrement.
|
||||
*/
|
||||
async function parkPointer(): Promise<void> {
|
||||
const bidi = driver();
|
||||
if (!bidi) return;
|
||||
const outcome = await bidi.parkPointer().catch((err: Error) => {
|
||||
report('warn', `Curseur non écarté du lecteur : ${err.message}`);
|
||||
return null;
|
||||
});
|
||||
if (outcome) report('info', describePark(outcome));
|
||||
}
|
||||
|
||||
/** Ouvre une page par la voie correspondant au mode de pilotage. */
|
||||
async function openPage(url: string): Promise<unknown> {
|
||||
const bidi = driver();
|
||||
|
||||
Reference in New Issue
Block a user