488 lines
17 KiB
TypeScript
488 lines
17 KiB
TypeScript
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<FilterId>('all');
|
|
const [openId, setOpenId] = useState<string | null>(null);
|
|
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
|
|
const [managingTags, setManagingTags] = useState(false);
|
|
const [previewSize, setPreviewSize] = useState<PreviewSize>(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 (
|
|
<div className="page">
|
|
<form className="row streamer-add" onSubmit={add}>
|
|
<input
|
|
value={url}
|
|
onChange={(event) => setUrl(event.target.value)}
|
|
placeholder="https://fr.stripchat.com/pseudo — ou simplement le pseudo"
|
|
aria-label="Lien du profil à suivre"
|
|
/>
|
|
<button className="primary" type="submit" disabled={busy || !url.trim()}>
|
|
Suivre
|
|
</button>
|
|
{!notificationsEnabled && (
|
|
<button type="button" className="ghost" onClick={onEnableNotifications}>
|
|
🔔 Activer les notifications
|
|
</button>
|
|
)}
|
|
</form>
|
|
|
|
{targets.length > 0 && (
|
|
<div className="row filter-row">
|
|
{FILTERS.map((entry) => (
|
|
<button
|
|
key={entry.id}
|
|
className={filter === entry.id ? 'chip active' : 'chip'}
|
|
onClick={() => setFilter(entry.id)}
|
|
>
|
|
{entry.label}
|
|
</button>
|
|
))}
|
|
<div className="spacer" />
|
|
<span className="muted small">Aperçus</span>
|
|
{PREVIEW_SIZES.map((size) => (
|
|
<button
|
|
key={size.id}
|
|
className={previewSize === size.id ? 'chip active' : 'chip'}
|
|
onClick={() => changePreviewSize(size.id)}
|
|
title={
|
|
size.id === 'off'
|
|
? "N'afficher aucune image du flux — et ne pas la charger"
|
|
: `Aperçu ${size.label.toLowerCase()}`
|
|
}
|
|
>
|
|
{size.label}
|
|
</button>
|
|
))}
|
|
<span className="muted small">{visible.length} profil(s)</span>
|
|
</div>
|
|
)}
|
|
|
|
{targets.length > 0 && (
|
|
<div className="row filter-row tag-filters">
|
|
{tags.map((tag) => (
|
|
<button
|
|
key={tag.id}
|
|
className={selectedTags.has(tag.id) ? 'tag tag-filter active' : 'tag tag-filter'}
|
|
style={tagStyle(tag.name)}
|
|
onClick={() => toggleTag(tag.id)}
|
|
title={`Ne garder que les profils portant « ${tag.name} »`}
|
|
>
|
|
{tag.name}
|
|
</button>
|
|
))}
|
|
{tags.length === 0 && (
|
|
<span className="muted small">
|
|
Aucune étiquette — pose-en une depuis la fiche d'un streamer pour trier la liste
|
|
d'ici.
|
|
</span>
|
|
)}
|
|
{selectedTags.size > 0 && (
|
|
<button className="link-button" onClick={() => setSelectedTags(new Set())}>
|
|
tout afficher
|
|
</button>
|
|
)}
|
|
<div className="spacer" />
|
|
<button className="chip" onClick={() => setManagingTags(true)} title="Renommer, supprimer">
|
|
⚙ Étiquettes
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{targets.length === 0 ? (
|
|
<div className="empty">
|
|
<h2>Aucun streamer suivi</h2>
|
|
<p className="muted">
|
|
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.
|
|
</p>
|
|
</div>
|
|
) : visible.length === 0 ? (
|
|
<p className="muted small">Aucun profil pour ce filtre.</p>
|
|
) : (
|
|
<div className={`streamer-grid preview-${previewSize}`}>
|
|
{visible.map((target) => (
|
|
<StreamerCard
|
|
key={target.id}
|
|
target={target}
|
|
targets={targets}
|
|
agents={agents}
|
|
notify={notify}
|
|
previewSize={previewSize}
|
|
onOpen={() => setOpenId(target.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{openTarget && (
|
|
<StreamerDetail
|
|
target={openTarget}
|
|
targets={targets}
|
|
agents={agents}
|
|
notify={notify}
|
|
onClose={() => setOpenId(null)}
|
|
/>
|
|
)}
|
|
|
|
{managingTags && (
|
|
<TagManager
|
|
onClose={() => 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())}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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<string | null>(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<unknown>, 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 (
|
|
<article
|
|
// La vignette entière est cliquable ; chaque commande arrête la propagation.
|
|
className={`streamer tone-${context.self ? 'rec' : state.tone} clickable`}
|
|
onClick={onOpen}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault();
|
|
onOpen();
|
|
}
|
|
}}
|
|
role="button"
|
|
tabIndex={0}
|
|
title="Ouvrir la fiche"
|
|
>
|
|
{showPreview && (
|
|
// Un `<img>`, 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.
|
|
<div className="streamer-preview">
|
|
<img
|
|
src={preview}
|
|
alt=""
|
|
loading="lazy"
|
|
referrerPolicy="no-referrer"
|
|
onError={() => setBrokenPreview(preview)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<header className="streamer-head">
|
|
{target.avatarUrl && !avatarBroken ? (
|
|
<img
|
|
className="avatar"
|
|
src={target.avatarUrl}
|
|
alt=""
|
|
loading="lazy"
|
|
// Pas de fuite de l'URL du dashboard vers le CDN de la plateforme.
|
|
referrerPolicy="no-referrer"
|
|
onError={() => setAvatarBroken(true)}
|
|
/>
|
|
) : (
|
|
<div className="avatar avatar-fallback" aria-hidden="true">
|
|
{target.username.slice(0, 1).toUpperCase()}
|
|
</div>
|
|
)}
|
|
|
|
<div className="streamer-identity">
|
|
<span className="streamer-name">{target.label ?? target.username}</span>
|
|
<span className="muted small">@{target.username}</span>
|
|
</div>
|
|
|
|
<button
|
|
className={target.favorite ? 'icon star on' : 'icon star'}
|
|
disabled={busy}
|
|
title={target.favorite ? 'Retirer des favoris' : 'Mettre en favori'}
|
|
onClick={isolate(() =>
|
|
void run(() => api.updateTarget(target.id, { favorite: !target.favorite })),
|
|
)}
|
|
>
|
|
{target.favorite ? '★' : '☆'}
|
|
</button>
|
|
|
|
<span className={`badge ${state.tone}`} title={`statut brut « ${target.rawStatus ?? '?'} »`}>
|
|
{state.text}
|
|
</span>
|
|
</header>
|
|
|
|
{/* Sans retrait possible ici : la vignette sert à voir, la fiche à régler. */}
|
|
{target.tags.length > 0 && (
|
|
<div className="tag-list">
|
|
{target.tags.map((tag) => (
|
|
<TagChip key={tag.id} name={tag.name} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<dl className="streamer-facts">
|
|
<div>
|
|
<dt>{isLive ? 'En direct depuis' : 'Statut depuis'}</dt>
|
|
<dd>
|
|
{isLive
|
|
? formatDuration(liveFor)
|
|
: target.statusChangedAt
|
|
? formatRelative(target.statusChangedAt)
|
|
: '—'}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Dernier stream</dt>
|
|
<dd>
|
|
{isLive ? (
|
|
<span className="muted">en cours</span>
|
|
) : target.lastLiveEndedAt ? (
|
|
<>
|
|
{formatDateTime(target.lastLiveEndedAt)}
|
|
{lastDuration ? <span className="muted"> · {formatDuration(lastDuration)}</span> : null}
|
|
</>
|
|
) : (
|
|
<span className="muted">jamais observé</span>
|
|
)}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
|
|
<div className="actions" onClick={(event) => event.stopPropagation()}>
|
|
{context.self ? (
|
|
<button
|
|
className="danger"
|
|
disabled={busy || !agent?.online}
|
|
onClick={() => void run(() => api.stopTarget(target.id), 'Enregistrement arrêté')}
|
|
>
|
|
■ Arrêter
|
|
</button>
|
|
) : (
|
|
<button
|
|
className="primary"
|
|
disabled={busy || !agent?.online || !isLive || verdict?.allowed === false}
|
|
title={recordTitle(target, agent, context, verdict)}
|
|
onClick={() => void run(() => api.recordTarget(target.id), 'Enregistrement lancé')}
|
|
>
|
|
{verdict?.allowed ? '● Enregistrer ⚡' : '● Enregistrer'}
|
|
</button>
|
|
)}
|
|
|
|
<div className="spacer" />
|
|
|
|
<span className="muted small">
|
|
{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 && <span title="Peut interrompre une capture de rang inférieur"> ⚡</span>}
|
|
</span>
|
|
</div>
|
|
|
|
<footer className="card-foot muted small">
|
|
<span className={context.self ? 'session-rec' : undefined}>
|
|
{context.self
|
|
? '⏺ enregistrement en cours'
|
|
: context.busyWith
|
|
? `VM occupée : ${context.busyWith}`
|
|
: target.lastCheckedAt
|
|
? `sondé ${formatRelative(target.lastCheckedAt)}`
|
|
: 'pas encore sondé'}
|
|
</span>
|
|
{target.notify ? <span title="Notifications activées">🔔</span> : <span title="Notifications désactivées">🔕</span>}
|
|
</footer>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
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';
|
|
}
|