Feat : added and tweaked state machine
This commit is contained in:
@@ -59,6 +59,11 @@ const watcher = new StreamWatcher(DEFAULT_WATCH_SETTINGS, {
|
||||
resumeRecording: async () => {
|
||||
await obs.execute('record.resume');
|
||||
},
|
||||
// Clôture, et non simple `record.stop` : la fenêtre du navigateur doit se
|
||||
// fermer aussi, sans quoi la VM resterait sur une page morte.
|
||||
stopRecording: async () => {
|
||||
await stopCapture();
|
||||
},
|
||||
});
|
||||
|
||||
let browserSettings: BrowserSettings = DEFAULT_BROWSER_SETTINGS;
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface WatchActions {
|
||||
recordState(): Promise<{ active: boolean; paused: boolean }>;
|
||||
pauseRecording(): Promise<void>;
|
||||
resumeRecording(): Promise<void>;
|
||||
/** Clôture définitive : ferme aussi la fenêtre du navigateur si elle est pilotée. */
|
||||
stopRecording(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,6 +46,8 @@ export class StreamWatcher extends EventEmitter {
|
||||
private state: WatchState;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private fullscreenTimer: NodeJS.Timeout | null = null;
|
||||
/** Compte à rebours de clôture, armé tant que le flux reste hors-ligne. */
|
||||
private offlineTimer: NodeJS.Timeout | null = null;
|
||||
private ticking = false;
|
||||
/** Le flux public a été interrompu : il faudra rappeler le plein écran. */
|
||||
private fullscreenPending = false;
|
||||
@@ -88,6 +92,7 @@ export class StreamWatcher extends EventEmitter {
|
||||
this.state.pendingConfirmations = 0;
|
||||
this.state.autoPaused = false;
|
||||
this.fullscreenPending = false;
|
||||
this.cancelOfflineStop();
|
||||
}
|
||||
if (restart) this.start();
|
||||
}
|
||||
@@ -114,6 +119,7 @@ export class StreamWatcher extends EventEmitter {
|
||||
this.timer = null;
|
||||
if (this.fullscreenTimer) clearTimeout(this.fullscreenTimer);
|
||||
this.fullscreenTimer = null;
|
||||
this.cancelOfflineStop();
|
||||
}
|
||||
|
||||
/** Sonde immédiate, utilisée par l'action `watch.check`. */
|
||||
@@ -167,6 +173,9 @@ export class StreamWatcher extends EventEmitter {
|
||||
|
||||
this.state.pendingConfirmations = next === 'private' ? this.state.pendingConfirmations + 1 : 0;
|
||||
|
||||
// Le retour du flux, public ou privé, annule toute clôture programmée.
|
||||
if (next !== 'offline') this.cancelOfflineStop();
|
||||
|
||||
if (next === 'private') {
|
||||
// Le lecteur quitte le plein écran dès que l'overlay de show privé apparaît.
|
||||
this.fullscreenPending = true;
|
||||
@@ -174,8 +183,110 @@ export class StreamWatcher extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
if (next === 'public') await this.handlePublic();
|
||||
// 'offline' / 'unknown' : on ne touche à rien, l'opérateur reste maître.
|
||||
if (next === 'public') {
|
||||
await this.handlePublic();
|
||||
return;
|
||||
}
|
||||
|
||||
if (next === 'offline') {
|
||||
await this.handleOffline();
|
||||
return;
|
||||
}
|
||||
// 'unknown' : sonde sans verdict exploitable, on ne touche à rien.
|
||||
}
|
||||
|
||||
/**
|
||||
* Flux hors-ligne : pause immédiate, clôture différée.
|
||||
*
|
||||
* Les deux temps répondent à deux risques distincts. Enregistrer l'écran
|
||||
* d'attente ne sert à rien, d'où la pause sans délai. Mais une coupure de
|
||||
* quelques minutes est fréquente, et clore tout de suite découperait le
|
||||
* fichier en deux — d'où le compte à rebours avant l'arrêt réel.
|
||||
*/
|
||||
private async handleOffline(): Promise<void> {
|
||||
if (!this.settings.stopOnOffline) return;
|
||||
|
||||
this.state.offlineSince ??= Date.now();
|
||||
|
||||
try {
|
||||
const record = await this.actions.recordState();
|
||||
if (!record.active) {
|
||||
// Rien à clore : inutile d'armer quoi que ce soit.
|
||||
this.cancelOfflineStop();
|
||||
return;
|
||||
}
|
||||
// Le garde `autoPaused` évite de rejouer la pause à chaque sonde, et donc
|
||||
// de lutter contre un opérateur qui aurait repris la main.
|
||||
if (!this.state.autoPaused && !record.paused) {
|
||||
await this.actions.pauseRecording();
|
||||
this.state.autoPaused = true;
|
||||
this.emit('log', 'info', 'Flux hors-ligne : enregistrement mis en pause', 'record.paused');
|
||||
}
|
||||
} catch (err) {
|
||||
this.emit(
|
||||
'log',
|
||||
'error',
|
||||
`Pause hors-ligne impossible : ${err instanceof Error ? err.message : String(err)}`,
|
||||
'command.failed',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.scheduleOfflineStop();
|
||||
}
|
||||
|
||||
private scheduleOfflineStop(): void {
|
||||
if (this.offlineTimer) return; // déjà armé pour cette coupure
|
||||
|
||||
const delayMs = this.settings.offlineStopDelayMs;
|
||||
this.state.stopScheduledAt = Date.now() + delayMs;
|
||||
this.emit(
|
||||
'log',
|
||||
'info',
|
||||
`Clôture de l'enregistrement dans ${formatDelay(delayMs)} si le flux ne revient pas`,
|
||||
'watch.offline',
|
||||
);
|
||||
|
||||
this.offlineTimer = setTimeout(() => {
|
||||
this.offlineTimer = null;
|
||||
void this.stopForOffline();
|
||||
}, delayMs);
|
||||
this.offlineTimer.unref?.();
|
||||
}
|
||||
|
||||
private cancelOfflineStop(): void {
|
||||
if (this.offlineTimer) clearTimeout(this.offlineTimer);
|
||||
this.offlineTimer = null;
|
||||
this.state.offlineSince = undefined;
|
||||
this.state.stopScheduledAt = undefined;
|
||||
}
|
||||
|
||||
private async stopForOffline(): Promise<void> {
|
||||
const offlineFor = this.state.offlineSince ? Date.now() - this.state.offlineSince : 0;
|
||||
this.state.stopScheduledAt = undefined;
|
||||
|
||||
// Le flux a pu revenir entre l'armement et l'échéance.
|
||||
if (this.state.state !== 'offline') return;
|
||||
|
||||
try {
|
||||
const record = await this.actions.recordState();
|
||||
if (!record.active) return;
|
||||
await this.actions.stopRecording();
|
||||
this.state.autoPaused = false;
|
||||
this.emit(
|
||||
'log',
|
||||
'info',
|
||||
`Flux hors-ligne depuis ${formatDelay(offlineFor)} : enregistrement clos`,
|
||||
'record.stopped',
|
||||
);
|
||||
} catch (err) {
|
||||
this.emit(
|
||||
'log',
|
||||
'error',
|
||||
`Clôture automatique impossible : ${err instanceof Error ? err.message : String(err)}`,
|
||||
'command.failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePrivate(): Promise<void> {
|
||||
@@ -268,6 +379,16 @@ const TRANSITIONS: Record<StreamState, AgentEvent | undefined> = {
|
||||
unknown: undefined,
|
||||
};
|
||||
|
||||
/** Durée lisible pour les messages de journal : « 1 h », « 12 min », « 45 s ». */
|
||||
function formatDelay(ms: number): string {
|
||||
if (ms >= 3_600_000) {
|
||||
const hours = ms / 3_600_000;
|
||||
return `${Number.isInteger(hours) ? hours : hours.toFixed(1)} h`;
|
||||
}
|
||||
if (ms >= 60_000) return `${Math.round(ms / 60_000)} min`;
|
||||
return `${Math.round(ms / 1000)} s`;
|
||||
}
|
||||
|
||||
function label(state: StreamState): string {
|
||||
switch (state) {
|
||||
case 'public':
|
||||
|
||||
Reference in New Issue
Block a user