771 lines
30 KiB
TypeScript
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, où 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-là 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>
|
|
);
|
|
}
|