Feat : Preview on streamers
All checks were successful
release / build (push) Successful in 27s
release / verify-windows (push) Successful in 1m27s

This commit is contained in:
jeanotx32
2026-08-12 16:23:43 +02:00
parent eae694d49a
commit 58aadee97d
7 changed files with 159 additions and 4 deletions

View File

@@ -3,7 +3,13 @@ import type { AgentView, PreemptionVerdict, WatchTarget } from '@stream-control/
import { DEFAULT_PRIORITY, priorityLabel } from '@stream-control/shared';
import { api } from '../api';
import { formatDateTime, formatDuration, formatRelative } from '../format';
import { preemption, recordingContext, stateLabel, type RecordingContext } from '../streamers';
import {
preemption,
previewSrc,
recordingContext,
stateLabel,
type RecordingContext,
} from '../streamers';
import { StreamerDetail } from './StreamerDetail';
interface Props {
@@ -172,12 +178,18 @@ function StreamerCard({
}) {
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';
const preview = isLive ? previewSrc(target) : null;
const showPreview = preview !== null && preview !== brokenPreview;
const liveFor = isLive && target.statusChangedAt ? Date.now() - target.statusChangedAt : null;
const lastDuration =
@@ -218,6 +230,22 @@ function StreamerCard({
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

View File

@@ -121,6 +121,19 @@ export function autoRecordHint(
return ' — armé, en attente du prochain direct';
}
/**
* URL de la vignette du flux, invalidée par un paramètre de cache.
*
* `previewUrl` ne change pas d'une image à l'autre — c'est le CDN qui la sert
* avec un `max-age` de 30 jours — donc sans `snapshotAt` en paramètre, le
* navigateur garderait indéfiniment la toute première image vue au lieu de la
* rafraîchir à chaque sonde.
*/
export function previewSrc(target: Pick<WatchTarget, 'previewUrl' | 'snapshotAt'>): string | null {
if (!target.previewUrl) return null;
return target.snapshotAt ? `${target.previewUrl}?t=${target.snapshotAt}` : target.previewUrl;
}
/** Fenêtres proposées sous les frises. */
export const TIMELINE_RANGES = [
{ days: 1, label: '24 h' },

View File

@@ -642,6 +642,41 @@ fieldset.group > legend {
flex-wrap: wrap;
}
/*
* Vignette du flux en direct. `overflow: hidden` recadre l'image au repos ;
* au survol il passe à `visible` pour que l'agrandissement (sur le seul
* `<img>`, via `transform`) puisse déborder par-dessus les cartes voisines
* plutôt que d'être rogné par sa propre boîte. `position: relative` rend le
* `z-index` du survol effectif face aux autres cartes, qui n'en posent pas.
*/
.streamer-preview {
position: relative;
overflow: hidden;
border-radius: 8px;
background: var(--panel-2);
aspect-ratio: 16 / 9;
cursor: zoom-in;
}
.streamer-preview img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
border-radius: inherit;
transition: transform 0.15s ease, box-shadow 0.15s ease;
/* Origine en haut : l'agrandissement s'étend vers le bas et les côtés,
jamais par-dessus l'en-tête de sa propre fiche. */
transform-origin: 50% 0%;
}
.streamer-preview:hover {
overflow: visible;
z-index: 40;
}
.streamer-preview:hover img {
transform: scale(2.6);
box-shadow: 0 16px 44px rgba(0, 0, 0, 0.65);
}
.streamer-head {
display: flex;
align-items: center;