Files
stream-control/packages/web/src/components/AgentSettings.tsx
jeanotx32 8fef1aacb0
All checks were successful
release / build (push) Successful in 27s
release / verify-windows (push) Successful in 1m15s
Feat : import session into auto firefox
2026-08-12 03:45:35 +02:00

771 lines
30 KiB
TypeScript

import { useState } from 'react';
import type {
AgentAction,
AgentView,
BrowserSettings,
PresetApplyResult,
RecordingPreset,
RecordingSettings,
SessionImportResult,
StreamState,
WatchSettings,
WatchTarget,
} from '@stream-control/shared';
import { RECORDING_ENCODERS, RECORDING_PRESETS, findRecordingPreset } from '@stream-control/shared';
import { api } from '../api';
interface Props {
agent: AgentView;
/** Streamers déjà suivis : ils alimentent la sélection ci-dessous. */
targets: WatchTarget[];
onClose: () => void;
onCommand: (id: string, action: AgentAction, params?: Record<string, unknown>) => Promise<void>;
notify: (message: string, tone?: 'info' | 'error') => void;
}
const STATE_HINTS: Record<StreamState, string> = {
public: 'en direct',
private: 'show privé',
offline: 'hors-ligne',
unknown: '',
};
const QUALITY_LABELS: Record<RecordingPreset['quality'], string> = {
Small: 'qualité haute',
HQ: 'qualité très haute',
Lossless: 'sans perte',
};
/** Résume en une ligne ce que le preset impose réellement à OBS. */
function describePreset(preset: RecordingPreset): string {
return [
preset.height ? `${preset.height}p` : 'définition de la scène',
`${preset.fps} fps`,
QUALITY_LABELS[preset.quality],
preset.format === 'mkv' ? 'MKV' : 'MP4',
`audio ${preset.audioBitrateKbps} kb/s`,
].join(' · ');
}
function cpuGauge(cost: number): string {
return '●'.repeat(cost) + '○'.repeat(4 - cost);
}
/** Compte rendu de l'agent : ce qu'OBS a accepté, refusé, et ce qui reste à faire. */
function PresetReport({ result }: { result: PresetApplyResult }) {
return (
<div className="preset-report">
<p className="small">
<strong>{result.presetLabel}</strong> appliqué · {result.video} ·{' '}
{result.applied.length} paramètre(s) écrit(s)
</p>
{result.skipped.length > 0 && (
<p className="small error">Refusé par OBS : {result.skipped.join(', ')}</p>
)}
{result.restartRequired && (
<p className="small warn-text">
L'encodeur a changé. OBS continuera d'utiliser le précédent jusqu'à son redémarrage.
</p>
)}
</div>
);
}
export function AgentSettings({ agent, targets, onClose, onCommand, notify }: Props) {
const [name, setName] = useState(agent.name);
const [host, setHost] = useState(agent.obs.host);
const [port, setPort] = useState(String(agent.obs.port));
const [password, setPassword] = useState(agent.obs.password);
const [autoConnect, setAutoConnect] = useState(agent.autoConnectObs);
const [notes, setNotes] = useState(agent.notes ?? '');
const [directory, setDirectory] = useState(agent.status.recordDirectory ?? '');
const [token, setToken] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [watch, setWatch] = useState<WatchSettings>(agent.watch);
const [browser, setBrowser] = useState<BrowserSettings>(agent.browser);
const [recording, setRecording] = useState<RecordingSettings>(agent.recording);
const [presetResult, setPresetResult] = useState<PresetApplyResult | null>(null);
const [applyingPreset, setApplyingPreset] = useState(false);
const [importResult, setImportResult] = useState<SessionImportResult | null>(null);
const [importingSession, setImportingSession] = useState(false);
const preset = findRecordingPreset(recording.presetId) ?? RECORDING_PRESETS[0]!;
const encoderInfo = RECORDING_ENCODERS[recording.encoder];
// Saisie libre : soit l'opérateur la demande, soit le pseudo déjà configuré
// ne fait pas partie des streamers suivis.
const [manualUsername, setManualUsername] = useState(
() => Boolean(agent.watch.username) && !targets.some((t) => t.username === agent.watch.username),
);
function patchBrowser(patch: Partial<BrowserSettings>) {
setBrowser((current) => ({ ...current, ...patch }));
}
function patchWatch(patch: Partial<WatchSettings>) {
setWatch((current) => ({ ...current, ...patch }));
}
function patchFullscreen(patch: Partial<WatchSettings['fullscreen']>) {
setWatch((current) => ({ ...current, fullscreen: { ...current.fullscreen, ...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.
*/
async function applyPreset() {
setApplyingPreset(true);
setPresetResult(null);
try {
const { data } = await api.command(agent.id, 'preset.apply', {
presetId: recording.presetId,
encoder: recording.encoder,
});
setPresetResult(data as PresetApplyResult);
} catch (err) {
notify(err instanceof Error ? err.message : 'Application du preset impossible', 'error');
} finally {
setApplyingPreset(false);
}
}
/**
* Copie les cookies du Firefox personnel dans le profil piloté, pour que la
* capture démarre déjà connectée. Ferme au passage l'instance pilotée si
* elle tournait — la prochaine capture la relance avec les cookies neufs.
*/
async function importSession() {
setImportingSession(true);
setImportResult(null);
try {
const { data } = await api.command(agent.id, 'browser.importSession');
setImportResult(data as SessionImportResult);
notify('Session importée');
} catch (err) {
notify(err instanceof Error ? err.message : "Import de session impossible", 'error');
} finally {
setImportingSession(false);
}
}
async function save() {
setBusy(true);
try {
await api.updateAgent(agent.id, {
name,
notes,
autoConnectObs: autoConnect,
obs: { host, port: Number(port), password },
watch,
browser,
recording,
} as Partial<AgentView>);
notify('Configuration enregistrée');
onClose();
} catch (err) {
notify(err instanceof Error ? err.message : 'Enregistrement impossible', 'error');
} finally {
setBusy(false);
}
}
async function applyDirectory() {
if (!directory.trim()) return;
await onCommand(agent.id, 'recordDirectory.set', { directory: directory.trim() });
}
async function rotate() {
if (!confirm("Régénérer le jeton ? L'agent sera déconnecté jusqu'à sa reconfiguration.")) return;
try {
const result = await api.rotateToken(agent.id);
setToken(result.token);
} catch (err) {
notify(err instanceof Error ? err.message : 'Rotation impossible', 'error');
}
}
async function remove() {
if (!confirm(`Supprimer définitivement l'agent « ${agent.name} » ?`)) return;
try {
await api.deleteAgent(agent.id);
notify('Agent supprimé');
onClose();
} catch (err) {
notify(err instanceof Error ? err.message : 'Suppression impossible', 'error');
}
}
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" onClick={(event) => event.stopPropagation()}>
<header className="modal-head">
<h2>Configuration · {agent.name}</h2>
<button className="icon" onClick={onClose}>
</button>
</header>
<div className="modal-body">
<label className="field">
<span>Nom affiché</span>
<input value={name} onChange={(event) => setName(event.target.value)} />
</label>
<div className="row">
<label className="field grow">
<span>Hôte obs-websocket</span>
<input value={host} onChange={(event) => setHost(event.target.value)} />
</label>
<label className="field small-field">
<span>Port</span>
<input value={port} onChange={(event) => setPort(event.target.value)} inputMode="numeric" />
</label>
</div>
<label className="field">
<span>Mot de passe obs-websocket</span>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="inchangé"
/>
</label>
<label className="checkbox">
<input
type="checkbox"
checked={autoConnect}
onChange={(event) => setAutoConnect(event.target.checked)}
/>
<span>Connecter OBS automatiquement au démarrage de l'agent</span>
</label>
<label className="field">
<span>Notes</span>
<textarea rows={2} value={notes} onChange={(event) => setNotes(event.target.value)} />
</label>
<div className="row">
<label className="field grow">
<span>Dossier d'enregistrement OBS</span>
<input
value={directory}
onChange={(event) => setDirectory(event.target.value)}
placeholder="D:\\records ou /srv/records"
/>
</label>
<button className="ghost align-end" onClick={() => void applyDirectory()}>
Appliquer
</button>
</div>
<fieldset className="group">
<legend>Preset d'enregistrement</legend>
<label className="checkbox">
<input
type="checkbox"
checked={recording.enabled}
onChange={(event) =>
setRecording((current) => ({ ...current, enabled: event.target.checked }))
}
/>
<span>
Piloter les réglages d'OBS depuis cette interface
<span className="muted small">
{' '}
décoché, l'agent ne touche à rien et OBS garde sa configuration manuelle.
</span>
</span>
</label>
<div className="row">
<label className="field grow">
<span>Preset</span>
<select
value={recording.presetId}
onChange={(event) =>
setRecording((current) => ({ ...current, presetId: event.target.value }))
}
>
{RECORDING_PRESETS.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.label} — CPU {cpuGauge(entry.cpuCost)}
</option>
))}
</select>
<span className="muted small">{preset.summary}</span>
</label>
<label className="field grow">
<span>Encodeur</span>
<select
value={recording.encoder}
onChange={(event) =>
setRecording((current) => ({
...current,
encoder: event.target.value as RecordingSettings['encoder'],
}))
}
>
{Object.entries(RECORDING_ENCODERS).map(([id, info]) => (
<option key={id} value={id}>
{info.label}
</option>
))}
</select>
<span className="muted small">{encoderInfo?.hint}</span>
</label>
</div>
<p className="muted small">
Réglages obtenus : <strong>{describePreset(preset)}</strong>
</p>
<div className="row">
<button
className="ghost"
disabled={applyingPreset || !agent.online || agent.status.recording}
title={
agent.status.recording
? 'Enregistrement en cours un changement de preset le corromprait'
: "Écrire ces réglages dans le profil OBS de cette VM"
}
onClick={() => void applyPreset()}
>
{applyingPreset ? 'Application' : 'Appliquer maintenant'}
</button>
<span className="muted small align-end">
Sinon appliqué à l'enregistrement de cette fiche.
</span>
</div>
{presetResult && <PresetReport result={presetResult} />}
<p className="muted small">
La qualité prend effet au prochain démarrage d'enregistrement. Un changement
d'encodeur, lui, exige un redémarrage d'OBS : celui-ci ne charge le sien qu'au
lancement.
</p>
</fieldset>
<fieldset className="group">
<legend>Pilotage du navigateur</legend>
<label className="checkbox">
<input
type="checkbox"
checked={browser.enabled}
onChange={(event) => patchBrowser({ enabled: event.target.checked })}
/>
<span>
Ouvrir la page du streamer et passer en plein écran automatiquement
</span>
</label>
<label className="field">
<span>Méthode de pilotage</span>
<select
value={browser.mode}
onChange={(event) =>
patchBrowser({ mode: event.target.value as BrowserSettings['mode'] })
}
>
<option value="bidi">WebDriver BiDi l'agent pilote Firefox (recommandé)</option>
<option value="launch">Lancement simple — une commande par page</option>
</select>
<span className="muted small">
{browser.mode === 'bidi' ? (
<>
L'agent ouvre Firefox une fois et garde un canal de commande : il navigue,
déclenche le plein écran et <strong>vérifie</strong> qu'il a pris. Seule
méthode qui fonctionne en session Wayland — elle ne passe pas par le serveur
d'affichage.
</>
) : (
<>
Une commande est lancée par page, et le plein écran passe par xdotool : il
faut alors une session Xorg, et l'effet de la touche reste invérifiable.
</>
)}
</span>
</label>
{browser.mode === 'bidi' && (
<p className="muted small">
Au premier passage en BiDi, l'agent ouvre une <em>nouvelle</em> fenêtre Firefox,
sur un profil qui lui est propre. Pointe la source « capture de fenêtre » d'OBS
dessus une fois : ensuite elle reste ouverte, y compris entre deux
enregistrements et lors d'un redémarrage de l'agent.
</p>
)}
<div className="row">
<label className="field grow">
<span>Commande du navigateur</span>
<input
value={browser.command}
onChange={(event) => patchBrowser({ command: event.target.value })}
placeholder="firefox"
/>
</label>
{browser.mode === 'launch' ? (
<label className="field grow">
<span>Arguments (séparés par des espaces)</span>
<input
value={browser.args.join(' ')}
onChange={(event) =>
patchBrowser({ args: event.target.value.split(/\s+/).filter(Boolean) })
}
placeholder="aucun"
/>
<span className="muted small">
Laisse vide : le lien part vers la fenêtre déjà ouverte.{' '}
<code>--new-window</code> en créerait une nouvelle à chaque capture, et OBS
perdrait sa cible.
</span>
</label>
) : (
<label className="field grow">
<span>Port de pilotage</span>
<input
value={String(browser.remotePort)}
onChange={(event) =>
patchBrowser({ remotePort: Number(event.target.value) || 9222 })
}
inputMode="numeric"
/>
<span className="muted small">
Écoute uniquement sur 127.0.0.1, à ne changer que si le port est déjà pris.
</span>
</label>
)}
</div>
{browser.mode === 'bidi' && (
<label className="field">
<span>Profil Firefox dédié</span>
<input
value={browser.profileDir}
onChange={(event) => patchBrowser({ profileDir: event.target.value })}
placeholder="laisse vide — l'agent choisit selon l'installation de Firefox"
/>
<span className="muted small">
Un profil séparé est obligatoire : deux instances ne peuvent pas partager le
même, et pointer celui de l'opérateur redonnerait « Firefox est déjà ouvert ».
L'agent y écrit les réglages qui comptent pour un enregistrement — lecture
automatique autorisée, bandeau de plein écran supprimé, aucun onglet d'accueil.
Sur Ubuntu, Firefox est un snap, le chemin ne doit contenir aucun segment
commençant par un point : un snap n'accède pas aux répertoires cachés.
</span>
</label>
)}
{browser.mode === 'bidi' && (
<div className="row" style={{ alignItems: 'flex-start' }}>
<div className="field grow">
<button
className="ghost"
disabled={importingSession}
onClick={() => void importSession()}
>
{importingSession ? 'Import' : 'Importer les cookies du Firefox personnel'}
</button>
<span className="muted small">
Le profil piloté part vierge, donc déconnecté des sites où tu es connecté
ailleurs. Cette action copie les cookies de session (et rien d'autre) depuis
ton Firefox habituel, en lecture seule rien n'y est jamais écrit. Connecte-toi
d'abord dans ce Firefox- si ce n'est pas déjà fait. L'instance pilotée en
cours, s'il y en a une, est fermée : la prochaine capture repart connectée.
</span>
{importResult && (
<p className="small">
Copié depuis <code>{importResult.sourceProfile}</code> :{' '}
{importResult.copied.join(', ')}
</p>
)}
</div>
</div>
)}
<div className="row">
<label className="field grow">
<span>Délai avant le plein écran (s)</span>
<input
value={String(Math.round(browser.readyDelayMs / 1000))}
onChange={(event) =>
patchBrowser({ readyDelayMs: (Number(event.target.value) || 0) * 1000 })
}
inputMode="numeric"
/>
<span className="muted small">
Le temps que la page charge et que le lecteur démarre.
</span>
</label>
</div>
<label className="field">
<span>À l'arrêt de l'enregistrement</span>
<select
value={browser.onStop}
onChange={(event) =>
patchBrowser({ onStop: event.target.value as BrowserSettings['onStop'] })
}
>
<option value="blank">Charger une page vide (recommandé)</option>
<option value="keep">Ne rien faire</option>
<option value="close">Fermer la fenêtre</option>
</select>
<span className="muted small">
La page vide libère le décodeur vidéo tout en conservant la fenêtre — donc la
source OBS. Fermer la fenêtre oblige à la repointer dans OBS au prochain
enregistrement.
</span>
</label>
<p className="muted small">
{browser.mode === 'bidi' ? (
<>
La touche du plein écran est celle configurée ci-dessous, dans « Surveillance du
stream ». Le titre de fenêtre, lui, ne sert plus : l'agent s'adresse à l'onglet,
pas à une fenêtre.
</>
) : (
<>
La touche et le titre de fenêtre utilisés pour le plein écran sont ceux
configurés ci-dessous, dans « Surveillance du stream ».
</>
)}
</p>
</fieldset>
<fieldset className="group">
<legend>Surveillance du stream</legend>
<label className="checkbox">
<input
type="checkbox"
checked={watch.enabled}
onChange={(event) => patchWatch({ enabled: event.target.checked })}
/>
<span>Mettre l'enregistrement en pause pendant les shows privés</span>
</label>
<div className="row">
<label className="field small-field">
<span>Plateforme</span>
<select
value={watch.provider}
onChange={(event) =>
patchWatch({ provider: event.target.value as WatchSettings['provider'] })
}
>
<option value="stripchat">Stripchat</option>
</select>
</label>
<label className="field grow">
<span>Streamer surveillé</span>
{targets.length > 0 && !manualUsername ? (
<select
value={watch.username}
onChange={(event) => {
if (event.target.value === '__manual__') {
setManualUsername(true);
patchWatch({ username: '' });
} else {
patchWatch({ username: event.target.value });
}
}}
>
<option value="">— aucun —</option>
{targets.map((target) => {
const hint = STATE_HINTS[target.state];
return (
<option key={target.id} value={target.username}>
{target.label ?? target.username}
{hint ? ` — ${hint}` : ''}
</option>
);
})}
<option value="__manual__">Autre pseudo…</option>
</select>
) : (
<input
value={watch.username}
onChange={(event) => patchWatch({ username: event.target.value })}
placeholder="tel qu'il apparaît dans l'URL"
autoFocus={manualUsername}
/>
)}
{targets.length === 0 ? (
<span className="muted small">
Aucun streamer suivi — ajoute-le dans l'onglet Streamers pour le
retrouver ici.
</span>
) : manualUsername ? (
<button
type="button"
className="link-button"
onClick={() => {
setManualUsername(false);
patchWatch({ username: '' });
}}
>
revenir à la liste
</button>
) : null}
</label>
</div>
<div className="row">
<label className="field grow">
<span>Intervalle de sonde (s)</span>
<input
value={String(Math.round(watch.pollIntervalMs / 1000))}
onChange={(event) =>
patchWatch({ pollIntervalMs: (Number(event.target.value) || 10) * 1000 })
}
inputMode="numeric"
/>
</label>
<label className="field grow">
<span>Lectures avant pause</span>
<input
value={String(watch.confirmations)}
onChange={(event) => patchWatch({ confirmations: Number(event.target.value) || 1 })}
inputMode="numeric"
/>
</label>
</div>
<label className="field">
<span>Statuts considérés comme « privé »</span>
<input
value={watch.privateStatuses.join(', ')}
onChange={(event) =>
patchWatch({
privateStatuses: event.target.value
.split(',')
.map((entry) => entry.trim())
.filter(Boolean),
})
}
/>
<span className="muted small">
Statuts renvoyés par Stripchat : public, private, p2p, groupShow,
virtualPrivate, ticketShow, idle, off. Ceux listés ici mettent en pause sans
clore. <strong>idle</strong> est le « revient bientôt » : le streamer est
connecté mais ne diffuse pas. Retire groupShow pour continuer à enregistrer
les shows de groupe.
</span>
</label>
<label className="checkbox">
<input
type="checkbox"
checked={watch.stopOnOffline}
onChange={(event) => patchWatch({ stopOnOffline: event.target.checked })}
/>
<span>Clore l'enregistrement quand le flux passe hors-ligne</span>
</label>
<label className="field">
<span>Attente avant clôture (minutes)</span>
<input
value={String(Math.round(watch.offlineStopDelayMs / 60_000))}
disabled={!watch.stopOnOffline}
onChange={(event) =>
patchWatch({ offlineStopDelayMs: (Number(event.target.value) || 0) * 60_000 })
}
inputMode="numeric"
/>
<span className="muted small">
Le passage hors-ligne met l'enregistrement en pause immédiatement l'écran
d'attente n'entre jamais dans le fichier. Ce délai ne décide que de la clôture :
en deçà, un retour du flux reprend le même fichier plutôt que d'en ouvrir un
second.
</span>
</label>
<label className="checkbox">
<input
type="checkbox"
checked={watch.fullscreen.enabled}
onChange={(event) => patchFullscreen({ enabled: event.target.checked })}
/>
<span>Rappeler le plein écran à la fin du show privé</span>
</label>
<div className="row">
<label className="field small-field">
<span>Touche</span>
<input
value={watch.fullscreen.key}
onChange={(event) => patchFullscreen({ key: event.target.value })}
/>
</label>
{browser.mode === 'launch' && (
<label className="field grow">
<span>Titre de la fenêtre du lecteur</span>
<input
value={watch.fullscreen.windowMatch}
onChange={(event) => patchFullscreen({ windowMatch: event.target.value })}
placeholder="fragment du titre, ex. Stripchat"
/>
</label>
)}
<label className="field small-field">
<span>Délai (s)</span>
<input
value={String(Math.round(watch.fullscreen.delayMs / 1000))}
onChange={(event) =>
patchFullscreen({ delayMs: (Number(event.target.value) || 0) * 1000 })
}
inputMode="numeric"
/>
</label>
</div>
<div className="row">
<button className="ghost" onClick={() => void onCommand(agent.id, 'watch.check')}>
Sonder maintenant
</button>
<button className="ghost" onClick={() => void onCommand(agent.id, 'hotkey.fullscreen')}>
Tester le plein écran
</button>
</div>
</fieldset>
<div className="danger-zone">
<div>
<strong>Jeton d'agent</strong>
<p className="muted small">
Affiché une seule fois. À reporter dans le fichier <code>agent.config.json</code> de la VM.
</p>
{token && <code className="token">{token}</code>}
</div>
<button className="ghost" onClick={() => void rotate()}>
Régénérer
</button>
</div>
</div>
<footer className="modal-foot">
<button className="danger ghost" onClick={() => void remove()}>
Supprimer l'agent
</button>
<div className="spacer" />
<button className="ghost" onClick={onClose}>
Annuler
</button>
<button className="primary" disabled={busy} onClick={() => void save()}>
Enregistrer
</button>
</footer>
</div>
</div>
);
}