Fix : quality selection
All checks were successful
release / build (push) Successful in 26s
release / verify-windows (push) Successful in 1m28s

This commit is contained in:
jeanotx32
2026-08-12 14:19:02 +02:00
parent 399f54f290
commit 043823600b
3 changed files with 183 additions and 67 deletions

View File

@@ -23,6 +23,23 @@ export interface StreamQualityOutcome {
already?: boolean;
/** Raison de l'échec, présente seulement si `!ok`. */
reason?: string;
/** Extrait de diagnostic (menu trouvé mais vide, p. ex.) — pour le journal, pas pour l'utilisateur. */
debug?: string;
}
/** Une option de résolution repérée dans le menu, avec ses coordonnées écran. */
interface TooltipOption {
label: string;
x: number;
y: number;
active: boolean;
}
/** État du menu de qualité au moment de la lecture. */
interface TooltipState {
found: boolean;
options: TooltipOption[];
debug?: string;
}
/**
@@ -173,11 +190,16 @@ export class FirefoxController {
*
* Stripchat ne démarre pas toujours sur son maximum : la valeur active au
* chargement varie, et chaque nouvelle page (donc chaque nouveau stream
* ouvert) repart du choix du site, pas du précédent. Le tout se joue en un
* seul aller-retour BiDi — un script auto-suffisant qui ouvre le menu,
* attend son montage, et clique la meilleure option — plutôt qu'une suite
* d'appels synchronisés par des délais fixes, plus fragile face à la
* variabilité du temps de rendu.
* ouvert) repart du choix du site, pas du précédent.
*
* Le clic part par `input.performActions` (un vrai évènement souris, comme
* la touche plein écran plus haut), pas par un `.click()` scripté dans la
* page : un premier essai au `.click()` DOM laissait le menu fermé — le
* bouton existait bien, mais l'ouverture ne se déclenchait pas depuis un
* évènement non fiable (`isTrusted: false`). D'où l'aller-retour en
* plusieurs étapes BiDi (repérer le bouton, cliquer réellement, attendre le
* menu, repérer puis cliquer la meilleure option) plutôt qu'un unique
* script auto-suffisant.
*
* Couplé à la structure actuelle du lecteur (`.player-resolution`,
* `.player-resolution-tooltip__button--resolution`) : sans effet, mais sans
@@ -187,63 +209,61 @@ export class FirefoxController {
async selectMaxStreamQuality(): Promise<StreamQualityOutcome> {
await this.ensureReady();
const expression = `(async () => {
const button = document.querySelector('.player-resolution');
if (!button) return JSON.stringify({ ok: false, reason: 'bouton qualité introuvable' });
button.click();
const deadline = Date.now() + 3000;
let options = [];
while (Date.now() < deadline) {
options = [...document.querySelectorAll(
'.player-resolution-tooltip__resolutions .player-resolution-tooltip__button--resolution'
)];
if (options.length > 0) break;
await new Promise((resolve) => setTimeout(resolve, 100));
}
if (options.length === 0) return JSON.stringify({ ok: false, reason: 'menu de qualité non ouvert' });
// « (Source) » est le signal le plus sûr — c'est le site qui désigne le
// flux non dégradé, quelle que soit sa valeur numérique. À défaut, on
// compare les hauteurs réelles plutôt que de supposer un ordre de liste
// décroissant : une échelle tronquée (rien au-dessus de 480p) ou une
// valeur atypique (souvent le cas des flux verticaux, pas calée sur
// l'échelle 240/480/720/1080) ne doit pas tromper le choix.
const height = (el) => {
// Antislash doublé à dessein : cette expression vit dans un template
// literal (le code de la page, pas du TypeScript exécuté ici). Un
// seul antislash y est une séquence d'échappement inconnue, que le
// moteur JS avale silencieusement — «/(\\d+)/» redevient «/(d+)/»,
// qui ne trouve jamais rien, et le classement retombe alors sur
// l'ordre du DOM au lieu de la valeur réelle.
const match = /(\\d+)/.exec(el.getAttribute('data-resolution') || el.textContent);
return match ? parseInt(match[1], 10) : -1;
};
const best =
options.find((el) => /source/i.test(el.textContent)) ||
options.reduce((a, b) => (height(b) > height(a) ? b : a));
const already = best.classList.contains('player-resolution-tooltip__button--active');
const label = best.getAttribute('data-resolution') || best.textContent.trim();
if (!already) best.click();
// Referme le menu comme le ferait un clic ailleurs sur la page.
document.body.click();
return JSON.stringify({ ok: true, label, already });
})()`;
// BiDi sérialise un objet renvoyé par la page sous forme de paires
// [clé, valeur], pas comme un objet JS ordinaire : la chaîne JSON évite
// d'avoir à décoder ce format, une chaîne restant une chaîne.
const raw = await this.evaluate(expression).catch((err: Error) =>
JSON.stringify({ ok: false, reason: err.message }),
// Sans activation de l'onglet, le clic partirait vers une page en
// arrière-plan, dont le lecteur ignore les évènements souris.
await this.send('browsingContext.activate', { context: this.context }, 5000).catch(() =>
undefined,
);
try {
return JSON.parse(typeof raw === 'string' ? raw : String(raw)) as StreamQualityOutcome;
} catch {
return { ok: false, reason: 'réponse illisible' };
const buttonPoint = await this.locate("document.querySelector('.player-resolution')").catch(
() => null,
);
if (!buttonPoint) return { ok: false, reason: 'bouton qualité introuvable' };
await this.click(buttonPoint.x, buttonPoint.y);
// Le menu s'anime à l'ouverture — quelques essais espacés plutôt qu'une
// seule lecture immédiate.
const deadline = Date.now() + 3000;
let tooltip: TooltipState | null = null;
while (Date.now() < deadline) {
tooltip = await this.readTooltip().catch(() => null);
if (tooltip?.found && tooltip.options.length > 0) break;
await delay(150);
}
if (!tooltip?.found) return { ok: false, reason: 'menu de qualité non ouvert' };
if (tooltip.options.length === 0) {
return {
ok: false,
reason: 'menu ouvert mais aucune option de résolution trouvée',
debug: tooltip.debug,
};
}
// « (Source) » est le signal le plus sûr — c'est le site qui désigne le
// flux non dégradé, quelle que soit sa valeur numérique. À défaut, on
// compare les hauteurs réelles plutôt que de supposer un ordre de liste
// décroissant : une échelle tronquée (rien au-dessus de 480p) ou une
// valeur atypique (souvent le cas des flux verticaux, pas calée sur
// l'échelle 240/480/720/1080) ne doit pas tromper le choix.
const height = (label: string) => {
const match = /(\d+)/.exec(label);
return match?.[1] ? parseInt(match[1], 10) : -1;
};
const best =
tooltip.options.find((option) => /source/i.test(option.label)) ||
tooltip.options.reduce((a, b) => (height(b.label) > height(a.label) ? b : a));
if (!best.active) {
await this.click(best.x, best.y);
} else {
// Referme le menu sans rien changer.
await this.pressEscape();
}
await this.send('input.releaseActions', { context: this.context }, 5000).catch(() => undefined);
return { ok: true, label: best.label, already: best.active };
}
/** Décharge le lecteur sans détruire la fenêtre — donc sans casser la source OBS. */
@@ -548,6 +568,96 @@ export class FirefoxController {
return result === true;
}
/**
* Coordonnées écran du premier élément que `expression` désigne, ou `null`
* s'il est absent. `expression` est une IIFE renvoyant l'élément (pas ses
* coordonnées) — le calcul de rectangle est fait ici, une fois pour toutes.
*/
private async locate(expression: string): Promise<{ x: number; y: number } | null> {
const raw = await this.evaluate(`JSON.stringify((() => {
const el = (${expression});
if (!el) return null;
el.scrollIntoView({ block: 'center', inline: 'center' });
const rect = el.getBoundingClientRect();
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
})())`);
if (typeof raw !== 'string') return null;
return JSON.parse(raw) as { x: number; y: number } | null;
}
/** État actuel du menu de qualité — ouvert ou non, options et leurs coordonnées. */
private async readTooltip(): Promise<TooltipState> {
// BiDi sérialise un objet renvoyé par la page sous forme de paires
// [clé, valeur], pas comme un objet JS ordinaire : la chaîne JSON évite
// d'avoir à décoder ce format, une chaîne restant une chaîne.
const raw = await this.evaluate(`JSON.stringify((() => {
const tooltip = document.querySelector('.player-resolution-tooltip');
if (!tooltip) return { found: false, options: [] };
const options = [...tooltip.querySelectorAll(
'.player-resolution-tooltip__button--resolution'
)].map((el) => {
const rect = el.getBoundingClientRect();
return {
label: el.getAttribute('data-resolution') || el.textContent.trim(),
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
active: el.classList.contains('player-resolution-tooltip__button--active'),
};
});
// Le menu est ouvert (found) mais peut-être avec un autre balisage que
// celui attendu : ce fragment aide à le repérer sans nouvelle capture
// manuelle depuis les DevTools.
return { found: true, options, debug: options.length === 0 ? tooltip.outerHTML.slice(0, 800) : undefined };
})())`);
if (typeof raw !== 'string') return { found: false, options: [] };
return JSON.parse(raw) as TooltipState;
}
/** Clic souris réel (pas un `.click()` scripté) à des coordonnées de viewport. */
private async click(x: number, y: number): Promise<void> {
await this.send(
'input.performActions',
{
context: this.context,
actions: [
{
type: 'pointer',
id: 'stream-control-pointer',
parameters: { pointerType: 'mouse' },
actions: [
{ type: 'pointerMove', x: Math.round(x), y: Math.round(y), origin: 'viewport' },
{ type: 'pointerDown', button: 0 },
{ type: 'pointerUp', button: 0 },
],
},
],
},
5000,
);
// Laisse la page réagir (ouverture animée du menu, p. ex.) avant la suite.
await delay(150);
}
private async pressEscape(): Promise<void> {
await this.send(
'input.performActions',
{
context: this.context,
actions: [
{
type: 'key',
id: 'stream-control-keyboard',
actions: [
{ type: 'keyDown', value: webdriverKey('escape') },
{ type: 'keyUp', value: webdriverKey('escape') },
],
},
],
},
5000,
).catch(() => undefined);
}
private async evaluate(expression: string, userActivation = false): Promise<unknown> {
const response = await this.send<{
type: string;

View File

@@ -432,7 +432,10 @@ async function selectMaxStreamQuality(): Promise<void> {
.catch((err: Error) => ({ ok: false, reason: err.message }));
if (!result.ok) {
report('warn', `Qualité du flux non réglée : ${result.reason}`, 'quality.failed');
// Le diagnostic (menu ouvert mais balisage inattendu) part dans le même
// message : plus vite exploitable que de redemander une capture DevTools.
const suffix = result.debug ? `${result.debug}` : '';
report('warn', `Qualité du flux non réglée : ${result.reason}${suffix}`, 'quality.failed');
return;
}
report(