import { useMemo, useState, type FormEvent } from 'react'; import type { AgentView, PreemptionVerdict, WatchTarget } from '@stream-control/shared'; import { DEFAULT_PRIORITY, priorityLabel } from '@stream-control/shared'; import { api } from '../api'; import { formatDateTime, formatDuration, formatRelative } from '../format'; import { PREVIEW_SIZES, loadPreviewSize, preemption, previewSrc, recordingContext, savePreviewSize, stateLabel, tagStyle, usedTags, type PreviewSize, type RecordingContext, } from '../streamers'; import { StreamerDetail } from './StreamerDetail'; import { TagChip, TagManager } from './Tags'; interface Props { targets: WatchTarget[]; agents: AgentView[]; notify: (message: string, tone?: 'info' | 'error') => void; notificationsEnabled: boolean; onEnableNotifications: () => void; } const FILTERS = [ { id: 'all', label: 'Tous' }, { id: 'favorites', label: '★ Favoris' }, { id: 'live', label: 'En direct' }, ] as const; type FilterId = (typeof FILTERS)[number]['id']; export function StreamersPage({ targets, agents, notify, notificationsEnabled, onEnableNotifications, }: Props) { const [url, setUrl] = useState(''); const [busy, setBusy] = useState(false); const [filter, setFilter] = useState('all'); const [openId, setOpenId] = useState(null); const [selectedTags, setSelectedTags] = useState>(new Set()); const [managingTags, setManagingTags] = useState(false); const [previewSize, setPreviewSize] = useState(loadPreviewSize); function changePreviewSize(size: PreviewSize) { setPreviewSize(size); // Mémorisé côté navigateur : c'est un confort d'affichage propre à l'écran // qu'on a sous les yeux, pas un réglage à imposer aux autres postes. savePreviewSize(size); } // Tirées des profils plutôt que du serveur : la liste arrive déjà entière par // le flux temps réel, donc toujours à jour, et une étiquette que personne ne // porte n'aurait de toute façon rien à filtrer. const tags = useMemo(() => usedTags(targets), [targets]); function toggleTag(id: string) { setSelectedTags((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } async function add(event: FormEvent) { event.preventDefault(); if (!url.trim()) return; setBusy(true); try { const { target } = await api.addTarget(url.trim(), null); notify(`« ${target.username} » ajouté au suivi`); setUrl(''); } catch (err) { notify(err instanceof Error ? err.message : 'Ajout impossible', 'error'); } finally { setBusy(false); } } /** * En direct d'abord, puis show privé, puis le reste ; à statut égal les * favoris devant, puis la priorité. L'ordre du statut prime : un direct en * cours appelle une décision, un favori hors-ligne non. */ const ordered = useMemo( () => [...targets].sort((a, b) => { const rank = (target: WatchTarget) => target.state === 'public' ? 0 : target.state === 'private' ? 1 : 2; return ( rank(a) - rank(b) || Number(b.favorite) - Number(a.favorite) || b.priority - a.priority || a.username.localeCompare(b.username) ); }), [targets], ); const visible = ordered.filter((target) => { // Les deux filtres se cumulent — « en direct » ET portant l'une des // étiquettes retenues. Entre étiquettes en revanche, c'est une union : en // cocher une seconde élargit la sélection, ce qui est le geste attendu // quand on empile des critères de même nature. if (selectedTags.size > 0 && !target.tags.some((tag) => selectedTags.has(tag.id))) { return false; } if (filter === 'favorites') return target.favorite; if (filter === 'live') return target.state === 'public'; return true; }); // La fiche ouverte doit refléter les mises à jour temps réel, et se refermer // d'elle-même si le profil disparaît de la liste. const openTarget = openId ? (targets.find((target) => target.id === openId) ?? null) : null; return (
setUrl(event.target.value)} placeholder="https://fr.stripchat.com/pseudo — ou simplement le pseudo" aria-label="Lien du profil à suivre" /> {!notificationsEnabled && ( )}
{targets.length > 0 && (
{FILTERS.map((entry) => ( ))}
Aperçus {PREVIEW_SIZES.map((size) => ( ))} {visible.length} profil(s)
)} {targets.length > 0 && (
{tags.map((tag) => ( ))} {tags.length === 0 && ( Aucune étiquette — pose-en une depuis la fiche d'un streamer pour trier la liste d'ici. )} {selectedTags.size > 0 && ( )}
)} {targets.length === 0 ? (

Aucun streamer suivi

Colle le lien d'un profil ci-dessus : tu seras prévenu dès qu'il passe en direct, et tu pourras lancer l'enregistrement d'un clic.

) : visible.length === 0 ? (

Aucun profil pour ce filtre.

) : (
{visible.map((target) => ( setOpenId(target.id)} /> ))}
)} {openTarget && ( setOpenId(null)} /> )} {managingTags && ( setManagingTags(false)} // Une étiquette renommée ou supprimée touche tous les profils : le // serveur les republie, et le filtre en cours peut désigner une // étiquette qui n'existe plus. onChanged={() => setSelectedTags(new Set())} /> )}
); } /** * Vignette : identité, statut, et la seule action qui presse — lancer ou * arrêter la capture. Tout le reste (agent assigné, automatisme, historique) * vit dans la fiche détaillée, qu'un clic sur la vignette ouvre. */ function StreamerCard({ target, targets, agents, notify, previewSize, onOpen, }: { target: WatchTarget; targets: WatchTarget[]; agents: AgentView[]; notify: (message: string, tone?: 'info' | 'error') => void; previewSize: PreviewSize; onOpen: () => void; }) { const [busy, setBusy] = useState(false); const [avatarBroken, setAvatarBroken] = useState(false); // La valeur en échec, pas un simple booléen : `preview` change de contenu à // chaque sonde (nouveau `snapshotAt`) sans changer d'identité de composant, // un booléen figé resterait donc bloqué après le premier accroc réseau. const [brokenPreview, setBrokenPreview] = useState(null); const state = stateLabel(target); const agent = agents.find((candidate) => candidate.id === target.agentId) ?? null; const context = recordingContext(target, agent, targets); const verdict = preemption(target, context); const isLive = target.state === 'public'; // Rien n'est calculé — ni chargé — quand les aperçus sont coupés : ce sont des // images distantes, autant ne pas les demander si personne ne les regarde. const preview = isLive && previewSize !== 'off' ? previewSrc(target) : null; const showPreview = preview !== null && preview !== brokenPreview; const liveFor = isLive && target.statusChangedAt ? Date.now() - target.statusChangedAt : null; const lastDuration = target.lastLiveStartedAt && target.lastLiveEndedAt ? target.lastLiveEndedAt - target.lastLiveStartedAt : null; async function run(action: () => Promise, success?: string) { setBusy(true); try { await action(); if (success) notify(success); } catch (err) { notify(err instanceof Error ? err.message : 'Action impossible', 'error'); } finally { setBusy(false); } } /** Les commandes ne doivent pas ouvrir la fiche en même temps qu'elles s'exécutent. */ const isolate = (handler: () => void) => (event: React.MouseEvent) => { event.stopPropagation(); handler(); }; return (
{ if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onOpen(); } }} role="button" tabIndex={0} title="Ouvrir la fiche" > {showPreview && ( // Un ``, pas un flux : le serveur n'a que l'API REST de la // plateforme, ce n'est pas l'agent qui pilote un navigateur ici. Le // clic n'est pas intercepté : même agrandie, l'image reste celle de // cette fiche, et l'ouvrir en cliquant dessus reste cohérent.
setBrokenPreview(preview)} />
)}
{target.avatarUrl && !avatarBroken ? ( setAvatarBroken(true)} /> ) : ( )}
{target.label ?? target.username} @{target.username}
{state.text}
{/* Sans retrait possible ici : la vignette sert à voir, la fiche à régler. */} {target.tags.length > 0 && (
{target.tags.map((tag) => ( ))}
)}
{isLive ? 'En direct depuis' : 'Statut depuis'}
{isLive ? formatDuration(liveFor) : target.statusChangedAt ? formatRelative(target.statusChangedAt) : '—'}
Dernier stream
{isLive ? ( en cours ) : target.lastLiveEndedAt ? ( <> {formatDateTime(target.lastLiveEndedAt)} {lastDuration ? · {formatDuration(lastDuration)} : null} ) : ( jamais observé )}
event.stopPropagation()}> {context.self ? ( ) : ( )}
{agent ? agent.name : 'aucun agent'} {/* Le rang par défaut resterait du bruit : il ne dit rien de plus que l'absence de réglage. */} {target.priority !== DEFAULT_PRIORITY && ` · ${priorityLabel(target.priority)}`} {target.preempt && }
); } function recordTitle( target: WatchTarget, agent: AgentView | null, context: RecordingContext, verdict: PreemptionVerdict | null, ): string { if (!target.agentId) return 'Assigne un agent à ce streamer (clique la fiche)'; if (!agent?.online) return 'Agent hors-ligne'; if (verdict?.allowed) { return `Interrompt la capture de ${context.busyWith} sur ${agent.name} — priorité supérieure`; } if (verdict) return `${agent.name} déjà pris : ${verdict.reason}`; if (target.state !== 'public') return "Le streamer n'est pas en direct"; return agent.browser.enabled ? "Ouvrir la page, passer en plein écran et lancer l'enregistrement" : 'Lancer OBS seul — le pilotage du navigateur est désactivé sur cet agent, ' + 'la page ne sera pas ouverte'; }