Feat : STRCHA Stream status
This commit is contained in:
188
packages/agent/src/hotkey.ts
Normal file
188
packages/agent/src/hotkey.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
export interface HotkeyRequest {
|
||||
/** Touche à envoyer : « f », « F11 », « space »… */
|
||||
key: string;
|
||||
/** Fragment du titre de la fenêtre cible (insensible à la casse). */
|
||||
windowMatch: string;
|
||||
}
|
||||
|
||||
export interface HotkeyResult {
|
||||
method: string;
|
||||
window?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Jeu de touches autorisé. Ces valeurs viennent de la configuration serveur et
|
||||
* finissent dans une ligne de commande : on refuse tout ce qui sort du lot
|
||||
* plutôt que d'échapper au cas par cas.
|
||||
*/
|
||||
const KEY_PATTERN = /^(?:[A-Za-z0-9]|F[1-9]|F1[0-2]|space|Return|Escape|Tab)$/;
|
||||
|
||||
function assertKey(key: string): string {
|
||||
if (!KEY_PATTERN.test(key)) {
|
||||
throw new Error(
|
||||
`Touche « ${key} » non autorisée (lettres, chiffres, F1-F12, space, Return, Escape, Tab)`,
|
||||
);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie une touche au lecteur vidéo, par exemple pour rebasculer en plein
|
||||
* écran après un show privé.
|
||||
*
|
||||
* Limite assumée : sous X11 comme sous Windows, la fenêtre cible doit passer au
|
||||
* premier plan. Les navigateurs ignorent les évènements clavier synthétiques
|
||||
* envoyés par XSendEvent (`xdotool key --window`), donc on active la fenêtre et
|
||||
* on injecte la touche via XTEST. Sur une VM d'enregistrement dédiée c'est sans
|
||||
* conséquence, mais ça vole le focus si quelqu'un est en train de s'en servir.
|
||||
*/
|
||||
export async function sendHotkey(request: HotkeyRequest): Promise<HotkeyResult> {
|
||||
const key = assertKey(request.key);
|
||||
const match = request.windowMatch.trim();
|
||||
if (!match) throw new Error('Aucun titre de fenêtre à cibler (windowMatch vide)');
|
||||
|
||||
switch (process.platform) {
|
||||
case 'linux':
|
||||
return sendLinux(key, match);
|
||||
case 'win32':
|
||||
return sendWindows(key, match);
|
||||
case 'darwin':
|
||||
return sendDarwin(key, match);
|
||||
default:
|
||||
throw new Error(`Envoi de touche non supporté sur ${process.platform}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- X11 --------------------------------------------------------------------
|
||||
|
||||
async function sendLinux(key: string, match: string): Promise<HotkeyResult> {
|
||||
const env = { ...process.env, DISPLAY: process.env.DISPLAY ?? ':0' };
|
||||
|
||||
let ids: string[];
|
||||
try {
|
||||
const { stdout } = await run(
|
||||
'xdotool',
|
||||
['search', '--onlyvisible', '--name', match],
|
||||
{ env, timeout: 5000 },
|
||||
);
|
||||
ids = stdout.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT') {
|
||||
throw new Error('xdotool est absent — installe-le : apt install xdotool');
|
||||
}
|
||||
// xdotool sort en code 1 quand rien ne correspond.
|
||||
throw new Error(`Aucune fenêtre visible ne correspond à « ${match} »`);
|
||||
}
|
||||
if (ids.length === 0) throw new Error(`Aucune fenêtre visible ne correspond à « ${match} »`);
|
||||
|
||||
// La dernière fenêtre listée est en général la plus récemment mappée.
|
||||
const windowId = ids[ids.length - 1]!;
|
||||
|
||||
let title = windowId;
|
||||
try {
|
||||
const { stdout } = await run('xdotool', ['getwindowname', windowId], { env, timeout: 5000 });
|
||||
title = stdout.trim() || windowId;
|
||||
} catch {
|
||||
/* le titre n'est qu'informatif */
|
||||
}
|
||||
|
||||
await run('xdotool', ['windowactivate', '--sync', windowId], { env, timeout: 5000 });
|
||||
await run('xdotool', ['key', '--clearmodifiers', key], { env, timeout: 5000 });
|
||||
|
||||
return { method: 'xdotool', window: title };
|
||||
}
|
||||
|
||||
// --- Windows ----------------------------------------------------------------
|
||||
|
||||
/** Échappe une valeur pour une chaîne littérale PowerShell entre apostrophes. */
|
||||
function psLiteral(value: string): string {
|
||||
return `'${value.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
async function sendWindows(key: string, match: string): Promise<HotkeyResult> {
|
||||
// SendKeys interprète certains caractères ; les touches nommées se notent {F11}.
|
||||
const sendKeysArg = key.length === 1 ? key.toLowerCase() : `{${key.toUpperCase()}}`;
|
||||
|
||||
const script = `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type @"
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
public class Win32 {
|
||||
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
|
||||
[DllImport("user32.dll")] public static extern int GetWindowTextLength(IntPtr hWnd);
|
||||
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);
|
||||
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);
|
||||
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
|
||||
public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
|
||||
}
|
||||
"@
|
||||
$needle = ${psLiteral(match)}
|
||||
$found = [IntPtr]::Zero
|
||||
$foundTitle = ''
|
||||
$callback = [Win32+EnumWindowsProc]{
|
||||
param($hWnd, $lParam)
|
||||
if (-not [Win32]::IsWindowVisible($hWnd)) { return $true }
|
||||
$len = [Win32]::GetWindowTextLength($hWnd)
|
||||
if ($len -eq 0) { return $true }
|
||||
$sb = New-Object System.Text.StringBuilder($len + 1)
|
||||
[void][Win32]::GetWindowText($hWnd, $sb, $sb.Capacity)
|
||||
$title = $sb.ToString()
|
||||
if ($title -like ('*' + $needle + '*')) { $script:found = $hWnd; $script:foundTitle = $title; return $false }
|
||||
return $true
|
||||
}
|
||||
[void][Win32]::EnumWindows($callback, [IntPtr]::Zero)
|
||||
if ($script:found -eq [IntPtr]::Zero) { Write-Error ('Aucune fenetre ne correspond a ' + $needle); exit 1 }
|
||||
[void][Win32]::ShowWindow($script:found, 9)
|
||||
[void][Win32]::SetForegroundWindow($script:found)
|
||||
Start-Sleep -Milliseconds 300
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
[System.Windows.Forms.SendKeys]::SendWait(${psLiteral(sendKeysArg)})
|
||||
Write-Output $script:foundTitle
|
||||
`;
|
||||
|
||||
// -EncodedCommand évite tout problème de guillemets entre cmd.exe et PowerShell.
|
||||
const encoded = Buffer.from(script, 'utf16le').toString('base64');
|
||||
|
||||
try {
|
||||
const { stdout } = await run(
|
||||
'powershell.exe',
|
||||
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded],
|
||||
{ timeout: 20_000, windowsHide: true },
|
||||
);
|
||||
return { method: 'SendKeys', window: stdout.trim() || undefined };
|
||||
} catch (err) {
|
||||
const stderr = (err as { stderr?: string }).stderr?.trim();
|
||||
throw new Error(stderr || `Envoi de « ${key} » impossible vers « ${match} »`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- macOS (confort de développement) ---------------------------------------
|
||||
|
||||
async function sendDarwin(key: string, match: string): Promise<HotkeyResult> {
|
||||
const script = `
|
||||
tell application "System Events"
|
||||
set matches to (every process whose name contains "${match.replace(/["\\]/g, '')}")
|
||||
if (count of matches) = 0 then error "Aucun processus ne correspond"
|
||||
set target to item 1 of matches
|
||||
set frontmost of target to true
|
||||
delay 0.3
|
||||
keystroke "${key.toLowerCase()}"
|
||||
end tell`;
|
||||
|
||||
try {
|
||||
await run('osascript', ['-e', script], { timeout: 15_000 });
|
||||
return { method: 'osascript', window: match };
|
||||
} catch (err) {
|
||||
const stderr = (err as { stderr?: string }).stderr?.trim();
|
||||
throw new Error(stderr || `Envoi de « ${key} » impossible vers « ${match} »`);
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,24 @@
|
||||
import os from 'node:os';
|
||||
import { WebSocket } from 'ws';
|
||||
import type {
|
||||
AgentAction,
|
||||
AgentStatus,
|
||||
AgentToServer,
|
||||
LogLevel,
|
||||
ServerToAgent,
|
||||
WatchSettings,
|
||||
} from '@stream-control/shared';
|
||||
import {
|
||||
DEFAULT_WATCH_SETTINGS,
|
||||
PROTOCOL_VERSION,
|
||||
detectPlatform,
|
||||
emptyStatus,
|
||||
normalizeWatchSettings,
|
||||
safeJsonParse,
|
||||
} from '@stream-control/shared';
|
||||
import { PROTOCOL_VERSION, detectPlatform, emptyStatus, safeJsonParse } from '@stream-control/shared';
|
||||
import { loadConfig, persistIdentity, type AgentConfig } from './config.ts';
|
||||
import { ObsController } from './obs.ts';
|
||||
import { StreamWatcher } from './watcher.ts';
|
||||
import { cpuUsagePercent, diskUsage, memoryUsage } from './system.ts';
|
||||
|
||||
const AGENT_VERSION = '0.1.0';
|
||||
@@ -26,6 +36,16 @@ try {
|
||||
|
||||
const obs = new ObsController(config.obs);
|
||||
|
||||
const watcher = new StreamWatcher(DEFAULT_WATCH_SETTINGS, {
|
||||
recordState: () => obs.recordState(),
|
||||
pauseRecording: async () => {
|
||||
await obs.execute('record.pause');
|
||||
},
|
||||
resumeRecording: async () => {
|
||||
await obs.execute('record.resume');
|
||||
},
|
||||
});
|
||||
|
||||
let socket: WebSocket | null = null;
|
||||
let statusTimer: NodeJS.Timeout | null = null;
|
||||
let reconnectDelay = RECONNECT_MIN_MS;
|
||||
@@ -47,6 +67,7 @@ function report(level: LogLevel, message: string): void {
|
||||
}
|
||||
|
||||
obs.on('log', (level: LogLevel, message: string) => report(level, message));
|
||||
watcher.on('log', (level: LogLevel, message: string) => report(level, message));
|
||||
|
||||
function connect(): void {
|
||||
if (shuttingDown) return;
|
||||
@@ -114,6 +135,7 @@ async function handleServerMessage(message: ServerToAgent): Promise<void> {
|
||||
}
|
||||
|
||||
obs.applySettings(message.obs);
|
||||
applyWatchSettings(message.watch);
|
||||
report('info', `Agent « ${config.name} » rattaché au serveur (id ${message.agentId})`);
|
||||
|
||||
if (message.autoConnectObs && !obs.isConnected) {
|
||||
@@ -125,6 +147,7 @@ async function handleServerMessage(message: ServerToAgent): Promise<void> {
|
||||
|
||||
case 'config': {
|
||||
obs.applySettings(message.obs);
|
||||
applyWatchSettings(message.watch);
|
||||
if (message.autoConnectObs && !obs.isConnected) {
|
||||
obs.connect().catch((err: Error) => report('warn', err.message));
|
||||
}
|
||||
@@ -133,7 +156,7 @@ async function handleServerMessage(message: ServerToAgent): Promise<void> {
|
||||
|
||||
case 'command': {
|
||||
try {
|
||||
const data = await obs.execute(message.action, message.params ?? {});
|
||||
const data = await runAction(message.action, message.params ?? {});
|
||||
send({ type: 'result', requestId: message.requestId, ok: true, data });
|
||||
void pushStatus(); // état rafraîchi immédiatement après l'action
|
||||
} catch (err) {
|
||||
@@ -151,6 +174,25 @@ async function handleServerMessage(message: ServerToAgent): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aiguille une action : la surveillance et le clavier sont gérés par l'agent,
|
||||
* tout le reste part vers obs-websocket.
|
||||
*/
|
||||
async function runAction(action: AgentAction, params: Record<string, unknown>): Promise<unknown> {
|
||||
switch (action) {
|
||||
case 'watch.check':
|
||||
return watcher.checkNow();
|
||||
case 'hotkey.fullscreen':
|
||||
return watcher.restoreFullscreen();
|
||||
default:
|
||||
return obs.execute(action, params);
|
||||
}
|
||||
}
|
||||
|
||||
function applyWatchSettings(raw: WatchSettings | undefined): void {
|
||||
watcher.applySettings(normalizeWatchSettings(raw ?? DEFAULT_WATCH_SETTINGS));
|
||||
}
|
||||
|
||||
// --- Boucle de statut --------------------------------------------------------
|
||||
|
||||
async function buildStatus(): Promise<AgentStatus> {
|
||||
@@ -161,6 +203,7 @@ async function buildStatus(): Promise<AgentStatus> {
|
||||
return {
|
||||
...emptyStatus(),
|
||||
...snapshot,
|
||||
watch: watcher.snapshot,
|
||||
lastRecordingPath: obs.recordingPath,
|
||||
systemCpu: cpuUsagePercent(),
|
||||
systemMemoryUsed: memory.used,
|
||||
@@ -199,6 +242,7 @@ async function shutdown(signal: string): Promise<void> {
|
||||
shuttingDown = true;
|
||||
console.log(`\n${signal} reçu, arrêt de l'agent…`);
|
||||
stopStatusLoop();
|
||||
watcher.stop();
|
||||
// L'enregistrement OBS en cours n'est volontairement pas interrompu.
|
||||
await obs.disconnect().catch(() => undefined);
|
||||
socket?.close(1000, 'Arrêt de l\'agent');
|
||||
|
||||
@@ -2,6 +2,12 @@ import { EventEmitter } from 'node:events';
|
||||
import OBSWebSocket from 'obs-websocket-js';
|
||||
import type { AgentAction, AgentStatus, ObsSettings } from '@stream-control/shared';
|
||||
|
||||
/**
|
||||
* Actions relevant d'OBS. `watch.*` et `hotkey.*` sont traitées en amont par
|
||||
* l'agent : elles ne concernent pas la session obs-websocket.
|
||||
*/
|
||||
export type ObsAction = Exclude<AgentAction, 'watch.check' | 'hotkey.fullscreen'>;
|
||||
|
||||
type ObsSnapshot = Pick<
|
||||
AgentStatus,
|
||||
| 'obsConnected'
|
||||
@@ -149,9 +155,16 @@ export class ObsController extends EventEmitter {
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
|
||||
/** État d'enregistrement à la demande, sans passer par l'instantané complet. */
|
||||
async recordState(): Promise<{ active: boolean; paused: boolean }> {
|
||||
if (!this.connected) return { active: false, paused: false };
|
||||
const status = await this.obs.call('GetRecordStatus');
|
||||
return { active: status.outputActive, paused: status.outputPaused };
|
||||
}
|
||||
|
||||
// --- Exécution des actions du protocole ---------------------------------
|
||||
|
||||
async execute(action: AgentAction, params: Record<string, unknown> = {}): Promise<unknown> {
|
||||
async execute(action: ObsAction, params: Record<string, unknown> = {}): Promise<unknown> {
|
||||
switch (action) {
|
||||
case 'obs.connect':
|
||||
await this.connect();
|
||||
|
||||
284
packages/agent/src/watcher.ts
Normal file
284
packages/agent/src/watcher.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { LogLevel, StreamState, WatchSettings, WatchState } from '@stream-control/shared';
|
||||
import { emptyWatchState, mapStreamStatus } from '@stream-control/shared';
|
||||
import { sendHotkey } from './hotkey.ts';
|
||||
|
||||
const PROBE_TIMEOUT_MS = 8000;
|
||||
const BROWSER_UA =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
|
||||
|
||||
export interface ProbeResult {
|
||||
/** Statut brut renvoyé par la plateforme. */
|
||||
raw: string;
|
||||
state: StreamState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sonde Stripchat.
|
||||
*
|
||||
* Le champ autoritatif est `user.user.status` sur
|
||||
* `/api/front/v2/models/username/{pseudo}/cam`. Valeurs relevées en production :
|
||||
* `public`, `private`, `p2p`, `groupShow`, `idle`.
|
||||
* (`cam.privateMode` existe mais reste vide y compris pendant un privé : ne pas
|
||||
* s'en servir.)
|
||||
*/
|
||||
export async function probeStripchat(
|
||||
username: string,
|
||||
privateStatuses: string[],
|
||||
): Promise<ProbeResult> {
|
||||
const url = `https://fr.stripchat.com/api/front/v2/models/username/${encodeURIComponent(
|
||||
username,
|
||||
)}/cam`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: { 'user-agent': BROWSER_UA, accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (response.status === 404) return { raw: 'notFound', state: 'offline' };
|
||||
if (!response.ok) throw new Error(`API Stripchat : HTTP ${response.status}`);
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
user?: { user?: { status?: string; isLive?: boolean } };
|
||||
};
|
||||
const raw = payload?.user?.user?.status;
|
||||
if (typeof raw !== 'string') {
|
||||
throw new Error('Réponse Stripchat inattendue : user.user.status absent');
|
||||
}
|
||||
|
||||
return { raw, state: mapStreamStatus(raw, privateStatuses) };
|
||||
}
|
||||
|
||||
/** Ce que le surveillant doit pouvoir demander à OBS. */
|
||||
export interface WatchActions {
|
||||
recordState(): Promise<{ active: boolean; paused: boolean }>;
|
||||
pauseRecording(): Promise<void>;
|
||||
resumeRecording(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Surveille le statut du stream source et met l'enregistrement en pause pendant
|
||||
* les shows privés, puis le reprend et rappelle le plein écran à la reprise du
|
||||
* flux public.
|
||||
*
|
||||
* Deux garde-fous délibérés :
|
||||
* - la mise en pause exige N lectures « privé » consécutives, la reprise agit
|
||||
* immédiatement (une fausse pause perd du contenu, une fausse reprise ne
|
||||
* coûte que quelques secondes d'écran d'attente) ;
|
||||
* - une erreur de sonde ne déclenche jamais d'action : on conserve l'état connu.
|
||||
*/
|
||||
export class StreamWatcher extends EventEmitter {
|
||||
private settings: WatchSettings;
|
||||
private state: WatchState;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private fullscreenTimer: NodeJS.Timeout | null = null;
|
||||
private ticking = false;
|
||||
/** Le flux public a été interrompu : il faudra rappeler le plein écran. */
|
||||
private fullscreenPending = false;
|
||||
|
||||
constructor(
|
||||
settings: WatchSettings,
|
||||
private readonly actions: WatchActions,
|
||||
) {
|
||||
super();
|
||||
this.settings = settings;
|
||||
this.state = emptyWatchState(settings);
|
||||
}
|
||||
|
||||
get snapshot(): WatchState {
|
||||
return { ...this.state };
|
||||
}
|
||||
|
||||
applySettings(settings: WatchSettings): void {
|
||||
const restart =
|
||||
settings.enabled !== this.settings.enabled ||
|
||||
settings.username !== this.settings.username ||
|
||||
settings.provider !== this.settings.provider ||
|
||||
settings.pollIntervalMs !== this.settings.pollIntervalMs;
|
||||
|
||||
const identityChanged =
|
||||
settings.username !== this.settings.username || settings.provider !== this.settings.provider;
|
||||
|
||||
this.settings = settings;
|
||||
this.state.enabled = settings.enabled;
|
||||
this.state.provider = settings.provider;
|
||||
this.state.username = settings.username;
|
||||
|
||||
if (identityChanged) {
|
||||
this.state.state = 'unknown';
|
||||
this.state.rawStatus = undefined;
|
||||
this.state.since = Date.now();
|
||||
this.state.pendingConfirmations = 0;
|
||||
this.state.autoPaused = false;
|
||||
this.fullscreenPending = false;
|
||||
}
|
||||
if (restart) this.start();
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.stop();
|
||||
if (!this.settings.enabled || !this.settings.username) return;
|
||||
|
||||
this.emit(
|
||||
'log',
|
||||
'info',
|
||||
`Surveillance de « ${this.settings.username} » (${this.settings.provider}) toutes les ${
|
||||
this.settings.pollIntervalMs / 1000
|
||||
} s`,
|
||||
);
|
||||
void this.tick();
|
||||
this.timer = setInterval(() => void this.tick(), this.settings.pollIntervalMs);
|
||||
this.timer.unref?.();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
if (this.fullscreenTimer) clearTimeout(this.fullscreenTimer);
|
||||
this.fullscreenTimer = null;
|
||||
}
|
||||
|
||||
/** Sonde immédiate, utilisée par l'action `watch.check`. */
|
||||
async checkNow(): Promise<WatchState> {
|
||||
await this.tick();
|
||||
return this.snapshot;
|
||||
}
|
||||
|
||||
private async tick(): Promise<void> {
|
||||
if (this.ticking) return; // une sonde lente ne doit pas s'empiler
|
||||
if (!this.settings.enabled || !this.settings.username) return;
|
||||
this.ticking = true;
|
||||
|
||||
try {
|
||||
const result = await probeStripchat(this.settings.username, this.settings.privateStatuses);
|
||||
this.state.lastCheckedAt = Date.now();
|
||||
this.state.lastError = undefined;
|
||||
this.state.rawStatus = result.raw;
|
||||
await this.transition(result.state);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.state.lastCheckedAt = Date.now();
|
||||
// Sonde en échec : on ne met surtout pas l'enregistrement en pause.
|
||||
if (this.state.lastError !== message) {
|
||||
this.emit('log', 'warn', `Sonde « ${this.settings.username} » en échec : ${message}`);
|
||||
}
|
||||
this.state.lastError = message;
|
||||
} finally {
|
||||
this.ticking = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async transition(next: StreamState): Promise<void> {
|
||||
const previous = this.state.state;
|
||||
|
||||
if (next !== previous) {
|
||||
this.state.state = next;
|
||||
this.state.since = Date.now();
|
||||
this.emit(
|
||||
'log',
|
||||
'info',
|
||||
`Stream « ${this.settings.username} » : ${label(previous)} → ${label(next)} (${this.state.rawStatus})`,
|
||||
);
|
||||
}
|
||||
|
||||
this.state.pendingConfirmations = next === 'private' ? this.state.pendingConfirmations + 1 : 0;
|
||||
|
||||
if (next === 'private') {
|
||||
// Le lecteur quitte le plein écran dès que l'overlay de show privé apparaît.
|
||||
this.fullscreenPending = true;
|
||||
await this.handlePrivate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (next === 'public') await this.handlePublic();
|
||||
// 'offline' / 'unknown' : on ne touche à rien, l'opérateur reste maître.
|
||||
}
|
||||
|
||||
private async handlePrivate(): Promise<void> {
|
||||
if (!this.settings.pauseOnPrivate || this.state.autoPaused) return;
|
||||
if (this.state.pendingConfirmations < this.settings.confirmations) return;
|
||||
|
||||
try {
|
||||
const record = await this.actions.recordState();
|
||||
if (!record.active || record.paused) return;
|
||||
await this.actions.pauseRecording();
|
||||
this.state.autoPaused = true;
|
||||
this.emit('log', 'info', 'Show privé détecté : enregistrement mis en pause');
|
||||
} catch (err) {
|
||||
this.emit(
|
||||
'log',
|
||||
'error',
|
||||
`Mise en pause automatique impossible : ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePublic(): Promise<void> {
|
||||
if (this.state.autoPaused && this.settings.resumeOnPublic) {
|
||||
try {
|
||||
const record = await this.actions.recordState();
|
||||
if (record.active && record.paused) {
|
||||
await this.actions.resumeRecording();
|
||||
this.emit('log', 'info', 'Flux public rétabli : enregistrement repris');
|
||||
}
|
||||
} catch (err) {
|
||||
this.emit(
|
||||
'log',
|
||||
'error',
|
||||
`Reprise automatique impossible : ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
} finally {
|
||||
this.state.autoPaused = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.fullscreenPending && this.settings.fullscreen.enabled) {
|
||||
this.fullscreenPending = false;
|
||||
this.scheduleFullscreen();
|
||||
} else {
|
||||
this.fullscreenPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Laisse au lecteur le temps de recharger le flux public avant d'envoyer la touche. */
|
||||
private scheduleFullscreen(): void {
|
||||
if (this.fullscreenTimer) clearTimeout(this.fullscreenTimer);
|
||||
this.fullscreenTimer = setTimeout(() => {
|
||||
this.fullscreenTimer = null;
|
||||
void this.restoreFullscreen();
|
||||
}, this.settings.fullscreen.delayMs);
|
||||
this.fullscreenTimer.unref?.();
|
||||
}
|
||||
|
||||
async restoreFullscreen(): Promise<{ method: string; window?: string }> {
|
||||
const { key, windowMatch } = this.settings.fullscreen;
|
||||
try {
|
||||
const result = await sendHotkey({ key, windowMatch });
|
||||
this.emit(
|
||||
'log',
|
||||
'info',
|
||||
`Plein écran rappelé : touche « ${key} » envoyée à « ${result.window ?? windowMatch} »`,
|
||||
);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.emit('log', 'warn', `Rappel du plein écran impossible : ${message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function label(state: StreamState): string {
|
||||
switch (state) {
|
||||
case 'public':
|
||||
return 'public';
|
||||
case 'private':
|
||||
return 'privé';
|
||||
case 'offline':
|
||||
return 'hors-ligne';
|
||||
default:
|
||||
return 'inconnu';
|
||||
}
|
||||
}
|
||||
|
||||
export type { LogLevel };
|
||||
Reference in New Issue
Block a user