|
|
|
|
@@ -33,7 +33,12 @@ 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, describeMetrics, type StreamQualityOutcome } from './firefox.ts';
|
|
|
|
|
import {
|
|
|
|
|
FirefoxController,
|
|
|
|
|
describeMetrics,
|
|
|
|
|
type PlaybackSample,
|
|
|
|
|
type StreamQualityOutcome,
|
|
|
|
|
} from './firefox.ts';
|
|
|
|
|
import { describeFullscreen, type FullscreenOutcome } from './fullscreen.ts';
|
|
|
|
|
import { sendHotkey } from './hotkey.ts';
|
|
|
|
|
import { cpuUsagePercent, diskUsage, memoryUsage } from './system.ts';
|
|
|
|
|
@@ -255,11 +260,36 @@ async function startCapture(params: Record<string, unknown>): Promise<unknown> {
|
|
|
|
|
// Une nouvelle capture repart avec ses propres chances : un abandon décidé sur
|
|
|
|
|
// la précédente ne doit pas laisser celle-ci sans surveillance.
|
|
|
|
|
fullscreenMisses = 0;
|
|
|
|
|
stallRecoveries = 0;
|
|
|
|
|
resetPlaybackTracking();
|
|
|
|
|
const opened = await openPage(url);
|
|
|
|
|
|
|
|
|
|
const wait = Number(params.readyDelayMs ?? browserSettings.readyDelayMs);
|
|
|
|
|
report('info', `Attente de ${Math.round(wait / 1000)} s avant le plein écran`);
|
|
|
|
|
await sleep(wait);
|
|
|
|
|
const fullscreenResult = await preparePlayer(wait);
|
|
|
|
|
|
|
|
|
|
// Dernière chance d'écrire un preset en attente : une fois l'enregistrement
|
|
|
|
|
// commencé, OBS refuse toute modification jusqu'à l'arrêt. Sans cet appel
|
|
|
|
|
// explicite, l'écriture dépendait du hasard d'un cycle de statut (toutes les
|
|
|
|
|
// 2 s) tombant avant le démarrage — perdu, le preset restait en attente
|
|
|
|
|
// jusqu'au prochain arrêt, et cet enregistrement tournait sur l'ancien réglage.
|
|
|
|
|
await drainPresetApply();
|
|
|
|
|
await obs.execute('record.start');
|
|
|
|
|
report('info', `Capture démarrée pour ${url}`, 'capture.started');
|
|
|
|
|
|
|
|
|
|
return { opened, fullscreen: fullscreenResult, recording: true };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Laisse le lecteur démarrer, puis le met en état : plein écran, puis qualité.
|
|
|
|
|
*
|
|
|
|
|
* Extrait de {@link startCapture} parce que la reprise après blocage
|
|
|
|
|
* ({@link recoverStalledPlayer}) doit rejouer exactement la même séquence : un
|
|
|
|
|
* rechargement de page repart d'un lecteur nu, tout comme une ouverture.
|
|
|
|
|
* Dupliquer l'enchaînement aurait laissé les deux copies diverger.
|
|
|
|
|
*/
|
|
|
|
|
async function preparePlayer(waitMs: number): Promise<unknown> {
|
|
|
|
|
report('info', `Attente de ${Math.round(waitMs / 1000)} s avant le plein écran`);
|
|
|
|
|
await sleep(waitMs);
|
|
|
|
|
|
|
|
|
|
const fullscreen = watcher.snapshotSettings.fullscreen;
|
|
|
|
|
let fullscreenResult: unknown = 'ignoré';
|
|
|
|
|
@@ -294,17 +324,7 @@ async function startCapture(params: Record<string, unknown>): Promise<unknown> {
|
|
|
|
|
// l'éloignement du curseur reste la dernière chose à toucher la page avant
|
|
|
|
|
// l'enregistrement.
|
|
|
|
|
await selectMaxStreamQuality();
|
|
|
|
|
|
|
|
|
|
// Dernière chance d'écrire un preset en attente : une fois l'enregistrement
|
|
|
|
|
// commencé, OBS refuse toute modification jusqu'à l'arrêt. Sans cet appel
|
|
|
|
|
// explicite, l'écriture dépendait du hasard d'un cycle de statut (toutes les
|
|
|
|
|
// 2 s) tombant avant le démarrage — perdu, le preset restait en attente
|
|
|
|
|
// jusqu'au prochain arrêt, et cet enregistrement tournait sur l'ancien réglage.
|
|
|
|
|
await drainPresetApply();
|
|
|
|
|
await obs.execute('record.start');
|
|
|
|
|
report('info', `Capture démarrée pour ${url}`, 'capture.started');
|
|
|
|
|
|
|
|
|
|
return { opened, fullscreen: fullscreenResult, recording: true };
|
|
|
|
|
return fullscreenResult;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Ouvre une page par la voie correspondant au mode de pilotage. */
|
|
|
|
|
@@ -497,6 +517,161 @@ let fullscreenMisses = 0;
|
|
|
|
|
const MAX_FULLSCREEN_MISSES = 3;
|
|
|
|
|
let fullscreenTimer: NodeJS.Timeout | null = null;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Fréquence d'échantillonnage de la lecture.
|
|
|
|
|
*
|
|
|
|
|
* Plus serrée que la relecture du plein écran : c'est l'écart entre deux
|
|
|
|
|
* relevés qui borne la précision du seuil de blocage. À 10 s, un blocage
|
|
|
|
|
* configuré à 30 s est constaté entre 30 et 40 s après coup.
|
|
|
|
|
*/
|
|
|
|
|
const PLAYBACK_SAMPLE_MS = 10_000;
|
|
|
|
|
let playbackTimer: NodeJS.Timeout | null = null;
|
|
|
|
|
|
|
|
|
|
/** Dernière position de lecture observée, et l'instant où elle a bougé. */
|
|
|
|
|
let lastPlaybackTime: number | null = null;
|
|
|
|
|
let lastProgressAt = 0;
|
|
|
|
|
/**
|
|
|
|
|
* Rechargements enchaînés sans que la lecture reparte. Au-delà, on cesse : si
|
|
|
|
|
* recharger n'y fait rien, le problème est ailleurs, et boucler ne ferait que
|
|
|
|
|
* hacher l'enregistrement toutes les 30 s. Le compteur repart dès que la
|
|
|
|
|
* lecture avance à nouveau, et à chaque nouvelle capture.
|
|
|
|
|
*/
|
|
|
|
|
let stallRecoveries = 0;
|
|
|
|
|
const MAX_STALL_RECOVERIES = 3;
|
|
|
|
|
/**
|
|
|
|
|
* Une reprise est en cours : le lecteur est délibérément en train d'être
|
|
|
|
|
* rechargé. Les deux surveillances s'abstiennent le temps que ça se fasse —
|
|
|
|
|
* sans quoi le rechargement, qui fait forcément perdre plein écran et lecture,
|
|
|
|
|
* déclencherait la reprise qu'il est justement en train d'exécuter.
|
|
|
|
|
*/
|
|
|
|
|
let recovering = false;
|
|
|
|
|
|
|
|
|
|
/** Repart de zéro : plus rien à comparer sur ce lecteur. */
|
|
|
|
|
function resetPlaybackTracking(): void {
|
|
|
|
|
lastPlaybackTime = null;
|
|
|
|
|
lastProgressAt = 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Détecte un lecteur figé et le remet en route.
|
|
|
|
|
*
|
|
|
|
|
* Le lecteur reste parfois coincé sur son indicateur de chargement sans jamais
|
|
|
|
|
* repartir. Rien ne le signale : la plateforme donne toujours le stream public,
|
|
|
|
|
* OBS continue d'enregistrer, et seul un rechargement de la page débloque la
|
|
|
|
|
* situation. C'était jusqu'ici une intervention manuelle.
|
|
|
|
|
*
|
|
|
|
|
* Le blocage se mesure à l'absence d'avancée de la position de lecture, et non
|
|
|
|
|
* à l'apparition d'un indicateur : c'est le symptôme réel, il ne dépend
|
|
|
|
|
* d'aucune classe CSS, et il survivra à n'importe quelle refonte du lecteur.
|
|
|
|
|
*/
|
|
|
|
|
async function watchPlayback(): Promise<void> {
|
|
|
|
|
const bidi = driver();
|
|
|
|
|
const timeout = browserSettings.stallTimeoutMs;
|
|
|
|
|
if (!bidi || timeout <= 0 || recovering) return;
|
|
|
|
|
if (stallRecoveries >= MAX_STALL_RECOVERIES) return;
|
|
|
|
|
|
|
|
|
|
// Même garde que la surveillance du plein écran : hors capture il n'y a rien
|
|
|
|
|
// à sauver, et une pause de show privé arrête légitimement la lecture — la
|
|
|
|
|
// prendre pour un blocage rechargerait la page à chaque passage en privé.
|
|
|
|
|
const record = await obs.recordState().catch(() => null);
|
|
|
|
|
if (!record?.active || record.paused) {
|
|
|
|
|
resetPlaybackTracking();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Second garde-fou, indispensable quand la mise en pause automatique est
|
|
|
|
|
// désactivée : le flux s'arrête alors légitimement (show privé, fin de
|
|
|
|
|
// diffusion) sans qu'OBS se mette en pause, et un lecteur à l'arrêt n'a rien
|
|
|
|
|
// d'anormal. Recharger n'y changerait rien, et la boucle hacherait le fichier.
|
|
|
|
|
if (watcher.snapshotSettings.enabled && watcher.snapshot.state !== 'public') {
|
|
|
|
|
resetPlaybackTracking();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sample = await bidi.samplePlayback();
|
|
|
|
|
if (!sample) {
|
|
|
|
|
resetPlaybackTracking();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
// Une position qui recule ou qui saute désigne un lecteur remplacé ou un
|
|
|
|
|
// repositionnement du direct : c'est du mouvement, donc le contraire d'un
|
|
|
|
|
// blocage.
|
|
|
|
|
if (lastPlaybackTime === null || sample.currentTime !== lastPlaybackTime) {
|
|
|
|
|
lastPlaybackTime = sample.currentTime;
|
|
|
|
|
lastProgressAt = now;
|
|
|
|
|
// La lecture repart : les rechargements précédents ont porté leurs fruits,
|
|
|
|
|
// ou le blocage s'est résorbé seul.
|
|
|
|
|
stallRecoveries = 0;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// `paused` ne court-circuite rien, et c'est délibéré. Vérifié en conditions
|
|
|
|
|
// réelles : selon la façon dont le flux se rompt, le lecteur reste tantôt
|
|
|
|
|
// « en lecture » avec un indicateur de chargement, tantôt suspendu de
|
|
|
|
|
// lui-même. Les deux se soldent par une position qui n'avance plus, et les
|
|
|
|
|
// deux appellent le même remède — le distinguer ne ferait que laisser passer
|
|
|
|
|
// la moitié des cas.
|
|
|
|
|
const stalledFor = now - lastProgressAt;
|
|
|
|
|
if (stalledFor < timeout) return;
|
|
|
|
|
|
|
|
|
|
await recoverStalledPlayer(stalledFor, sample);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Recharge la page, puis remet le lecteur en état — plein écran et qualité. */
|
|
|
|
|
async function recoverStalledPlayer(stalledFor: number, sample: PlaybackSample): Promise<void> {
|
|
|
|
|
const bidi = driver();
|
|
|
|
|
const url = bidi?.state.url;
|
|
|
|
|
if (!bidi || !url || url === 'about:blank') {
|
|
|
|
|
resetPlaybackTracking();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
recovering = true;
|
|
|
|
|
stallRecoveries += 1;
|
|
|
|
|
// Les relevés partent avec le constat : c'est ce qui permet de distinguer
|
|
|
|
|
// après coup un flux qui manque de données d'un lecteur qui s'est suspendu.
|
|
|
|
|
report(
|
|
|
|
|
'warn',
|
|
|
|
|
`Lecteur figé depuis ${Math.round(stalledFor / 1000)} s ` +
|
|
|
|
|
`(position ${sample.currentTime.toFixed(1)} s, readyState ${sample.readyState}` +
|
|
|
|
|
`${sample.paused ? ', suspendu' : ''}) — rechargement de la page ` +
|
|
|
|
|
`(${stallRecoveries}/${MAX_STALL_RECOVERIES})`,
|
|
|
|
|
'browser.opened',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await bidi.navigate(url);
|
|
|
|
|
// Même séquence qu'à l'ouverture d'une capture : la page revient nue, il
|
|
|
|
|
// faut lui rendre le plein écran et la qualité. Le compteur d'échecs du
|
|
|
|
|
// plein écran repart aussi — c'est une page neuve, pas la précédente.
|
|
|
|
|
fullscreenMisses = 0;
|
|
|
|
|
await preparePlayer(browserSettings.readyDelayMs);
|
|
|
|
|
report('info', 'Lecteur rechargé et remis en état', 'browser.opened');
|
|
|
|
|
if (stallRecoveries >= MAX_STALL_RECOVERIES) {
|
|
|
|
|
report(
|
|
|
|
|
'warn',
|
|
|
|
|
`Le lecteur s'est figé ${MAX_STALL_RECOVERIES} fois de suite : surveillance suspendue ` +
|
|
|
|
|
"jusqu'au prochain enregistrement. Recharger n'y change rien, la cause est ailleurs.",
|
|
|
|
|
'command.failed',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
report(
|
|
|
|
|
'error',
|
|
|
|
|
`Reprise du lecteur figé impossible : ${err instanceof Error ? err.message : String(err)}`,
|
|
|
|
|
'command.failed',
|
|
|
|
|
);
|
|
|
|
|
} finally {
|
|
|
|
|
// Après coup, pas avant : le temps passé à recharger ne doit pas compter
|
|
|
|
|
// comme une absence d'avancée, sans quoi la reprise suivante partirait
|
|
|
|
|
// immédiatement.
|
|
|
|
|
resetPlaybackTracking();
|
|
|
|
|
recovering = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Vérifie que le lecteur occupe toujours la fenêtre, et le rétablit sinon.
|
|
|
|
|
*
|
|
|
|
|
@@ -511,6 +686,9 @@ async function watchFullscreen(): Promise<void> {
|
|
|
|
|
const bidi = driver();
|
|
|
|
|
const settings = watcher.snapshotSettings.fullscreen;
|
|
|
|
|
if (!bidi || !settings.enabled || fullscreenMisses >= MAX_FULLSCREEN_MISSES) return;
|
|
|
|
|
// Un rechargement en cours fait forcément perdre le plein écran : le constater
|
|
|
|
|
// ici enverrait une touche au milieu de la reprise, qui le rétablit déjà.
|
|
|
|
|
if (recovering) return;
|
|
|
|
|
|
|
|
|
|
// Uniquement pendant une capture réellement en cours. Hors capture il n'y a
|
|
|
|
|
// rien à cadrer ; en pause (show privé), l'overlay du site occupe le lecteur
|
|
|
|
|
@@ -719,6 +897,13 @@ function startStatusLoop(): void {
|
|
|
|
|
});
|
|
|
|
|
}, FULLSCREEN_WATCH_MS);
|
|
|
|
|
fullscreenTimer.unref?.();
|
|
|
|
|
|
|
|
|
|
playbackTimer = setInterval(() => {
|
|
|
|
|
void watchPlayback().catch((err: unknown) => {
|
|
|
|
|
console.error('Surveillance de la lecture en échec :', err);
|
|
|
|
|
});
|
|
|
|
|
}, PLAYBACK_SAMPLE_MS);
|
|
|
|
|
playbackTimer.unref?.();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stopStatusLoop(): void {
|
|
|
|
|
@@ -726,6 +911,8 @@ function stopStatusLoop(): void {
|
|
|
|
|
statusTimer = null;
|
|
|
|
|
if (fullscreenTimer) clearInterval(fullscreenTimer);
|
|
|
|
|
fullscreenTimer = null;
|
|
|
|
|
if (playbackTimer) clearInterval(playbackTimer);
|
|
|
|
|
playbackTimer = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Cycle de vie ------------------------------------------------------------
|
|
|
|
|
|