Feat : Change quality
This commit is contained in:
@@ -7,12 +7,24 @@ import type {
|
||||
BrowserState,
|
||||
FullscreenSettings,
|
||||
SessionImportResult,
|
||||
StreamQualitySettings,
|
||||
} from '@stream-control/shared';
|
||||
import { BidiClient } from './bidi.ts';
|
||||
import { assertWebUrl, type BrowserLog } from './browser.ts';
|
||||
import type { FullscreenOutcome } from './fullscreen.ts';
|
||||
import { sessionEnv } from './x11.ts';
|
||||
|
||||
/** Compte rendu de {@link FirefoxController.selectMaxStreamQuality}. */
|
||||
export interface StreamQualityOutcome {
|
||||
ok: boolean;
|
||||
/** Valeur choisie (`1080p`…), présente seulement si `ok`. */
|
||||
label?: string;
|
||||
/** Déjà sur ce choix avant l'appel — rien n'a donc été cliqué. */
|
||||
already?: boolean;
|
||||
/** Raison de l'échec, présente seulement si `!ok`. */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attente maximale de l'ouverture du port de pilotage.
|
||||
*
|
||||
@@ -156,6 +168,84 @@ export class FirefoxController {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sélectionne la meilleure qualité offerte par le lecteur Stripchat.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Couplé à la structure actuelle du lecteur (`.player-resolution`,
|
||||
* `.player-resolution-tooltip__button--resolution`) : sans effet, mais sans
|
||||
* erreur non plus, si Stripchat change son balisage — signalé dans le
|
||||
* compte rendu plutôt que dans une exception.
|
||||
*/
|
||||
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 }),
|
||||
);
|
||||
|
||||
try {
|
||||
return JSON.parse(typeof raw === 'string' ? raw : String(raw)) as StreamQualityOutcome;
|
||||
} catch {
|
||||
return { ok: false, reason: 'réponse illisible' };
|
||||
}
|
||||
}
|
||||
|
||||
/** Décharge le lecteur sans détruire la fenêtre — donc sans casser la source OBS. */
|
||||
async blank(): Promise<{ url: string }> {
|
||||
if (!this.client?.isOpen) {
|
||||
|
||||
@@ -33,7 +33,7 @@ import { ObsController } from './obs.ts';
|
||||
import { StreamWatcher } from './watcher.ts';
|
||||
import { currentBuildId, runningBundlePath, selfUpdate } from './updater.ts';
|
||||
import { blankPage, closeWindow, delay as sleep, openUrl } from './browser.ts';
|
||||
import { FirefoxController } from './firefox.ts';
|
||||
import { FirefoxController, type StreamQualityOutcome } from './firefox.ts';
|
||||
import { describeFullscreen, type FullscreenOutcome } from './fullscreen.ts';
|
||||
import { sendHotkey } from './hotkey.ts';
|
||||
import { cpuUsagePercent, diskUsage, memoryUsage } from './system.ts';
|
||||
@@ -256,6 +256,8 @@ async function startCapture(params: Record<string, unknown>): Promise<unknown> {
|
||||
report('info', `Attente de ${Math.round(wait / 1000)} s avant le plein écran`);
|
||||
await sleep(wait);
|
||||
|
||||
await selectMaxStreamQuality();
|
||||
|
||||
const fullscreen = watcher.snapshotSettings.fullscreen;
|
||||
let fullscreenResult: unknown = 'ignoré';
|
||||
if (fullscreen.enabled) {
|
||||
@@ -412,6 +414,36 @@ async function restoreFullscreen(settings: FullscreenSettings): Promise<Fullscre
|
||||
return sendHotkey({ key: settings.key, windowMatch: settings.windowMatch });
|
||||
}
|
||||
|
||||
/**
|
||||
* Sélectionne la meilleure qualité offerte par le lecteur Stripchat.
|
||||
*
|
||||
* Sans effet hors mode BiDi : il n'existe aucun moyen d'interagir avec le
|
||||
* contenu de la page sans lui. Un échec ne bloque jamais l'enregistrement —
|
||||
* mieux vaut capturer une qualité non maximale que ne rien capturer du tout.
|
||||
*/
|
||||
async function selectMaxStreamQuality(): Promise<void> {
|
||||
const bidi = driver();
|
||||
const settings = watcher.snapshotSettings.streamQuality;
|
||||
if (!bidi || !settings.enabled) return;
|
||||
|
||||
await sleep(settings.delayMs);
|
||||
const result: StreamQualityOutcome = await bidi
|
||||
.selectMaxStreamQuality()
|
||||
.catch((err: Error) => ({ ok: false, reason: err.message }));
|
||||
|
||||
if (!result.ok) {
|
||||
report('warn', `Qualité du flux non réglée : ${result.reason}`, 'quality.failed');
|
||||
return;
|
||||
}
|
||||
report(
|
||||
'info',
|
||||
result.already
|
||||
? `Qualité du flux déjà au maximum (${result.label})`
|
||||
: `Qualité du flux réglée sur ${result.label}`,
|
||||
'quality.selected',
|
||||
);
|
||||
}
|
||||
|
||||
// --- Presets d'enregistrement ------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -336,6 +336,25 @@ export interface FullscreenSettings {
|
||||
delayMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sélection automatique de la meilleure qualité offerte par le lecteur Stripchat.
|
||||
*
|
||||
* Stripchat ne démarre pas toujours sur sa meilleure qualité disponible — la
|
||||
* valeur active par défaut varie, et rien ne garantit qu'elle corresponde au
|
||||
* maximum réellement proposé pour ce streamer. Chaque nouvelle page (donc
|
||||
* chaque nouveau stream ouvert) repart de ce choix du site, pas du précédent.
|
||||
*
|
||||
* Couplé à la structure actuelle du lecteur Stripchat (`.player-resolution`,
|
||||
* `.player-resolution-tooltip__button--resolution`) : n'a d'effet qu'en mode
|
||||
* de pilotage WebDriver BiDi, et se limite à ne rien faire si le site change
|
||||
* son balisage.
|
||||
*/
|
||||
export interface StreamQualitySettings {
|
||||
enabled: boolean;
|
||||
/** Délai après le chargement de la page, le temps que le lecteur s'initialise. */
|
||||
delayMs: number;
|
||||
}
|
||||
|
||||
export interface WatchSettings {
|
||||
enabled: boolean;
|
||||
provider: WatchProvider;
|
||||
@@ -375,6 +394,7 @@ export interface WatchSettings {
|
||||
*/
|
||||
offlineStopDelayMs: number;
|
||||
fullscreen: FullscreenSettings;
|
||||
streamQuality: StreamQualitySettings;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -540,6 +560,10 @@ export const DEFAULT_WATCH_SETTINGS: WatchSettings = {
|
||||
windowMatch: 'Stripchat',
|
||||
delayMs: 4000,
|
||||
},
|
||||
streamQuality: {
|
||||
enabled: true,
|
||||
delayMs: 3000,
|
||||
},
|
||||
};
|
||||
|
||||
/** État courant de la surveillance, remonté avec le statut de l'agent. */
|
||||
@@ -705,6 +729,8 @@ export const AGENT_EVENTS = [
|
||||
'watch.failed',
|
||||
'fullscreen.restored',
|
||||
'fullscreen.failed',
|
||||
'quality.selected',
|
||||
'quality.failed',
|
||||
'browser.opened',
|
||||
'browser.closed',
|
||||
'browser.imported',
|
||||
@@ -917,6 +943,7 @@ export function normalizeWatchSettings(raw: unknown): WatchSettings {
|
||||
const input = (raw ?? {}) as Partial<WatchSettings>;
|
||||
const base = DEFAULT_WATCH_SETTINGS;
|
||||
const fullscreen = (input.fullscreen ?? {}) as Partial<FullscreenSettings>;
|
||||
const streamQuality = (input.streamQuality ?? {}) as Partial<StreamQualitySettings>;
|
||||
|
||||
const clamp = (value: unknown, fallback: number, min: number, max: number): number => {
|
||||
const parsed = Number(value);
|
||||
@@ -955,6 +982,10 @@ export function normalizeWatchSettings(raw: unknown): WatchSettings {
|
||||
: base.fullscreen.windowMatch,
|
||||
delayMs: clamp(fullscreen.delayMs, base.fullscreen.delayMs, 0, 120_000),
|
||||
},
|
||||
streamQuality: {
|
||||
enabled: streamQuality.enabled !== false,
|
||||
delayMs: clamp(streamQuality.delayMs, base.streamQuality.delayMs, 0, 120_000),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@ const EVENT_STYLES: Record<AgentEvent, EventStyle> = {
|
||||
'watch.failed': { icon: '!', tone: 'warn' },
|
||||
'fullscreen.restored': { icon: '⛶', tone: 'ok' },
|
||||
'fullscreen.failed': { icon: '⛶', tone: 'warn' },
|
||||
'quality.selected': { icon: '◆', tone: 'ok' },
|
||||
'quality.failed': { icon: '◆', tone: 'warn' },
|
||||
'browser.opened': { icon: '⊞', tone: 'info' },
|
||||
'browser.closed': { icon: '⊟', tone: 'offline' },
|
||||
'browser.imported': { icon: '⇄', tone: 'ok' },
|
||||
@@ -68,7 +70,7 @@ type FilterId = (typeof FILTERS)[number]['id'];
|
||||
const FILTER_PREFIXES: Record<Exclude<FilterId, 'all' | 'issues'>, string[]> = {
|
||||
record: ['record.', 'capture.', 'stream.'],
|
||||
obs: ['obs.', 'preset.', 'config.'],
|
||||
watch: ['watch.', 'fullscreen.', 'browser.'],
|
||||
watch: ['watch.', 'fullscreen.', 'browser.', 'quality.'],
|
||||
};
|
||||
|
||||
function matches(entry: LogEntry, filter: FilterId): boolean {
|
||||
|
||||
@@ -110,6 +110,13 @@ export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Pr
|
||||
setWatch((current) => ({ ...current, fullscreen: { ...current.fullscreen, ...patch } }));
|
||||
}
|
||||
|
||||
function patchStreamQuality(patch: Partial<WatchSettings['streamQuality']>) {
|
||||
setWatch((current) => ({
|
||||
...current,
|
||||
streamQuality: { ...current.streamQuality, ...patch },
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Applique le preset actuellement sélectionné, enregistré ou non : c'est un
|
||||
* essai, on veut voir le compte rendu avant de figer le choix.
|
||||
@@ -736,6 +743,39 @@ export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Pr
|
||||
Tester le plein écran
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{browser.mode === 'bidi' && (
|
||||
<>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={watch.streamQuality.enabled}
|
||||
onChange={(event) => patchStreamQuality({ enabled: event.target.checked })}
|
||||
/>
|
||||
<span>
|
||||
Sélectionner la meilleure qualité du lecteur à l'ouverture de chaque stream
|
||||
<span className="muted small">
|
||||
{' '}
|
||||
— Stripchat ne démarre pas toujours sur son maximum disponible.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{watch.streamQuality.enabled && (
|
||||
<label className="field small-field">
|
||||
<span>Délai avant réglage (s)</span>
|
||||
<input
|
||||
value={String(Math.round(watch.streamQuality.delayMs / 1000))}
|
||||
onChange={(event) =>
|
||||
patchStreamQuality({ delayMs: (Number(event.target.value) || 0) * 1000 })
|
||||
}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
<span className="muted small">Le temps que le lecteur s'initialise.</span>
|
||||
</label>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
<div className="danger-zone">
|
||||
|
||||
Reference in New Issue
Block a user