228 lines
7.7 KiB
TypeScript
228 lines
7.7 KiB
TypeScript
import { useState } from 'react';
|
|
import type { AgentAction, AgentView, WatchState } from '@stream-control/shared';
|
|
import { formatBytes, formatPercent, formatRelative, formatTimecode } from '../format';
|
|
|
|
interface Props {
|
|
agent: AgentView;
|
|
selected: boolean;
|
|
onToggleSelect: (id: string) => void;
|
|
onCommand: (id: string, action: AgentAction, params?: Record<string, unknown>) => Promise<void>;
|
|
onOpenSettings: (agent: AgentView) => void;
|
|
}
|
|
|
|
export function AgentCard({ agent, selected, onToggleSelect, onCommand, onOpenSettings }: Props) {
|
|
const [pending, setPending] = useState<AgentAction | null>(null);
|
|
const { status } = agent;
|
|
|
|
async function run(action: AgentAction, params?: Record<string, unknown>) {
|
|
setPending(action);
|
|
try {
|
|
await onCommand(agent.id, action, params);
|
|
} finally {
|
|
setPending(null);
|
|
}
|
|
}
|
|
|
|
const busy = pending !== null;
|
|
const obsReady = agent.online && status.obsConnected;
|
|
|
|
const watch = status.watch;
|
|
const pausedLabel = watch?.autoPaused ? 'Pause · show privé' : 'En pause';
|
|
|
|
const state = !agent.online
|
|
? { label: 'Hors-ligne', tone: 'offline' as const }
|
|
: !status.obsConnected
|
|
? { label: 'OBS déconnecté', tone: 'warn' as const }
|
|
: status.recording
|
|
? {
|
|
label: status.recordPaused ? pausedLabel : 'Enregistre',
|
|
tone: status.recordPaused ? ('warn' as const) : ('rec' as const),
|
|
}
|
|
: { label: 'Prêt', tone: 'ok' as const };
|
|
|
|
return (
|
|
<article className={`card tone-${state.tone}${selected ? ' selected' : ''}`}>
|
|
<header className="card-head">
|
|
<label className="select">
|
|
<input
|
|
type="checkbox"
|
|
checked={selected}
|
|
onChange={() => onToggleSelect(agent.id)}
|
|
aria-label={`Sélectionner ${agent.name}`}
|
|
/>
|
|
</label>
|
|
<div className="identity">
|
|
<h2>{agent.name}</h2>
|
|
<span className="muted small">
|
|
{agent.hostname ?? '—'} · {agent.platform}
|
|
{agent.agentVersion ? ` · v${agent.agentVersion}` : ''}
|
|
</span>
|
|
</div>
|
|
<span className={`badge ${state.tone}`}>{state.label}</span>
|
|
<button className="icon" onClick={() => onOpenSettings(agent)} title="Configuration">
|
|
⚙
|
|
</button>
|
|
</header>
|
|
|
|
{status.obsError && !status.obsConnected && (
|
|
<p className="error small">{status.obsError}</p>
|
|
)}
|
|
|
|
{watch?.enabled && (
|
|
<WatchStrip
|
|
watch={watch}
|
|
busy={busy}
|
|
onCheck={() => void run('watch.check')}
|
|
onFullscreen={() => void run('hotkey.fullscreen')}
|
|
/>
|
|
)}
|
|
|
|
<div className="metrics">
|
|
<Metric label="Durée" value={formatTimecode(status.recordTimecode)} mono />
|
|
<Metric label="Fichier" value={formatBytes(status.recordBytes)} />
|
|
<Metric label="Disque libre" value={formatBytes(status.diskFreeBytes)} />
|
|
<Metric label="CPU OBS" value={formatPercent(status.cpuUsage)} />
|
|
<Metric label="FPS" value={status.fps ? status.fps.toFixed(0) : '—'} />
|
|
<Metric label="Frames perdues" value={String(status.droppedFrames ?? '—')} />
|
|
</div>
|
|
|
|
<div className="row">
|
|
<label className="field grow">
|
|
<span>Scène</span>
|
|
<select
|
|
value={status.currentScene ?? ''}
|
|
disabled={!obsReady || busy || status.scenes.length === 0}
|
|
onChange={(event) => void run('scene.set', { scene: event.target.value })}
|
|
>
|
|
{status.scenes.length === 0 && <option value="">— aucune scène —</option>}
|
|
{status.scenes.map((scene) => (
|
|
<option key={scene} value={scene}>
|
|
{scene}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="actions">
|
|
{status.recording ? (
|
|
<>
|
|
<button className="danger" disabled={busy} onClick={() => void run('record.stop')}>
|
|
■ Arrêter
|
|
</button>
|
|
{status.recordPaused ? (
|
|
<button disabled={busy} onClick={() => void run('record.resume')}>
|
|
▶ Reprendre
|
|
</button>
|
|
) : (
|
|
<button disabled={busy} onClick={() => void run('record.pause')}>
|
|
⏸ Pause
|
|
</button>
|
|
)}
|
|
<button disabled={busy} onClick={() => void run('record.split')}>
|
|
✂ Découper
|
|
</button>
|
|
</>
|
|
) : (
|
|
<button className="primary" disabled={!obsReady || busy} onClick={() => void run('record.start')}>
|
|
● Enregistrer
|
|
</button>
|
|
)}
|
|
|
|
{status.streaming ? (
|
|
<button className="danger ghost" disabled={busy} onClick={() => void run('stream.stop')}>
|
|
Arrêter le stream
|
|
</button>
|
|
) : (
|
|
<button className="ghost" disabled={!obsReady || busy} onClick={() => void run('stream.start')}>
|
|
Lancer le stream
|
|
</button>
|
|
)}
|
|
|
|
{status.obsConnected ? (
|
|
<button className="ghost" disabled={!agent.online || busy} onClick={() => void run('obs.disconnect')}>
|
|
Détacher OBS
|
|
</button>
|
|
) : (
|
|
<button className="ghost" disabled={!agent.online || busy} onClick={() => void run('obs.connect')}>
|
|
Connecter OBS
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<footer className="card-foot muted small">
|
|
<span title={status.recordDirectory ?? ''}>
|
|
{status.lastRecordingPath
|
|
? `Dernier fichier : ${basename(status.lastRecordingPath)}`
|
|
: (status.recordDirectory ?? 'Dossier inconnu')}
|
|
</span>
|
|
<span>vu {formatRelative(agent.lastSeenAt)}</span>
|
|
</footer>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
const WATCH_LABELS: Record<WatchState['state'], { text: string; tone: string }> = {
|
|
public: { text: 'public', tone: 'ok' },
|
|
private: { text: 'show privé', tone: 'rec' },
|
|
offline: { text: 'hors-ligne', tone: 'offline' },
|
|
unknown: { text: 'inconnu', tone: 'offline' },
|
|
};
|
|
|
|
function WatchStrip({
|
|
watch,
|
|
busy,
|
|
onCheck,
|
|
onFullscreen,
|
|
}: {
|
|
watch: WatchState;
|
|
busy: boolean;
|
|
onCheck: () => void;
|
|
onFullscreen: () => void;
|
|
}) {
|
|
const info = WATCH_LABELS[watch.state];
|
|
const stale = watch.lastCheckedAt > 0 && Date.now() - watch.lastCheckedAt > 60_000;
|
|
|
|
return (
|
|
<div className="watch">
|
|
<div className="watch-head">
|
|
<span className={`badge ${info.tone}`}>{info.text}</span>
|
|
<span className="watch-name" title={`${watch.provider} · ${watch.username}`}>
|
|
{watch.username || '(aucun pseudo)'}
|
|
</span>
|
|
<div className="spacer" />
|
|
<button className="icon" disabled={busy} onClick={onCheck} title="Sonder maintenant">
|
|
⟳
|
|
</button>
|
|
<button className="icon" disabled={busy} onClick={onFullscreen} title="Remettre en plein écran">
|
|
⛶
|
|
</button>
|
|
</div>
|
|
|
|
{watch.lastError ? (
|
|
<span className="small error">Sonde en échec : {watch.lastError}</span>
|
|
) : (
|
|
<span className="small muted">
|
|
{watch.rawStatus ? `statut brut « ${watch.rawStatus} »` : 'pas encore sondé'}
|
|
{watch.autoPaused && ' · pause automatique active'}
|
|
{stale && ' · dernière sonde ancienne'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Metric({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
|
return (
|
|
<div className="metric">
|
|
<span className="metric-label">{label}</span>
|
|
<span className={`metric-value${mono ? ' mono' : ''}`}>{value}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function basename(filePath: string): string {
|
|
const parts = filePath.split(/[\\/]/);
|
|
return parts[parts.length - 1] ?? filePath;
|
|
}
|