Feat : Preview on streamers
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user