Fix : quality selection
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user