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

@@ -38,6 +38,8 @@ IP publique nécessaire, et obs-websocket reste sur `127.0.0.1`.
la même machine. la même machine.
- [Presets d'enregistrement](#presets-denregistrement) appliqués à OBS depuis l'interface, - [Presets d'enregistrement](#presets-denregistrement) appliqués à OBS depuis l'interface,
réglables VM par VM : de « Économe » à « Sans perte ». réglables VM par VM : de « Économe » à « Sans perte ».
- [Vignette du flux en direct](#vignette-du-flux-en-direct) sur la fiche compacte de chaque
profil en direct, agrandie au survol.
- [Frise des diffusions](#frise-des-diffusions) : l'historique des streams de chaque profil - [Frise des diffusions](#frise-des-diffusions) : l'historique des streams de chaque profil
suivi, avec en surimpression ce qui en a été capturé — par profil sur sa fiche, ou tous suivi, avec en surimpression ce qui en a été capturé — par profil sur sa fiche, ou tous
ensemble dans l'onglet « Timeline ». ensemble dans l'onglet « Timeline ».
@@ -594,6 +596,22 @@ l'ordre de sondage qui décide qui tente sa chance en premier sur une VM libre :
hasard laisserait un profil mineur s'en emparer, pour se faire couper dans la foulée par son hasard laisserait un profil mineur s'en emparer, pour se faire couper dans la foulée par son
aîné — même résultat, au prix d'un fichier de quelques secondes. aîné — même résultat, au prix d'un fichier de quelques secondes.
## Vignette du flux en direct
Chaque fiche compacte d'un profil **en direct** (onglet Streamers) affiche une image du
flux — pas une vidéo : le serveur n'a que l'API REST publique de la plateforme, ce n'est
pas l'agent qui pilote un navigateur ici. Au survol, l'image grandit et passe au premier
plan, quitte à déborder sur les cartes voisines.
Source : le même appel que le statut (`/api/front/v2/models/username/{pseudo}/cam`) inclut
déjà `previewUrlThumbBig` et `snapshotTimestamp` — aucune requête supplémentaire. L'URL de
la vignette ne change pas d'une image à l'autre (le CDN la sert avec un cache de 30 jours),
donc le client y ajoute `?t={snapshotTimestamp}` pour forcer un frame à jour à chaque sonde
plutôt que de garder indéfiniment la toute première image vue.
Se rafraîchit donc au rythme de la veille (`WATCHLIST_INTERVAL_MS`, 30 s par défaut) — un
aperçu, pas un flux temps réel.
## Frise des diffusions ## Frise des diffusions
Deux vues sur le même historique : la fiche d'un profil (clic sur sa vignette, onglet Deux vues sur le même historique : la fiche d'un profil (clic sur sa vignette, onglet

View File

@@ -161,6 +161,9 @@ addColumnIfMissing('watch_targets', 'priority', 'INTEGER NOT NULL DEFAULT 1');
// Droit de couper une capture de rang inférieur. Refusé par défaut : un // Droit de couper une capture de rang inférieur. Refusé par défaut : un
// enregistrement en cours ne s'interrompt pas sans un geste explicite. // enregistrement en cours ne s'interrompt pas sans un geste explicite.
addColumnIfMissing('watch_targets', 'preempt', 'INTEGER NOT NULL DEFAULT 0'); addColumnIfMissing('watch_targets', 'preempt', 'INTEGER NOT NULL DEFAULT 0');
// Vignette du flux, rafraîchie à chaque sonde — voir StripchatStatus.previewUrl.
addColumnIfMissing('watch_targets', 'preview_url', 'TEXT');
addColumnIfMissing('watch_targets', 'snapshot_at', 'INTEGER');
/** /**
* `idle` — le « revient bientôt » de Stripchat — relevait de la clôture et non de * `idle` — le « revient bientôt » de Stripchat — relevait de la clôture et non de
@@ -481,6 +484,8 @@ interface TargetRow {
status_changed_at: number | null; status_changed_at: number | null;
last_live_started_at: number | null; last_live_started_at: number | null;
last_live_ended_at: number | null; last_live_ended_at: number | null;
preview_url: string | null;
snapshot_at: number | null;
} }
const num = (value: number | null): number | null => (value === null ? null : Number(value)); const num = (value: number | null): number | null => (value === null ? null : Number(value));
@@ -508,6 +513,8 @@ function toTarget(row: TargetRow): WatchTarget {
statusChangedAt: num(row.status_changed_at), statusChangedAt: num(row.status_changed_at),
lastLiveStartedAt: num(row.last_live_started_at), lastLiveStartedAt: num(row.last_live_started_at),
lastLiveEndedAt: num(row.last_live_ended_at), lastLiveEndedAt: num(row.last_live_ended_at),
previewUrl: row.preview_url,
snapshotAt: num(row.snapshot_at),
}; };
} }
@@ -529,7 +536,8 @@ const targetStmts = {
UPDATE watch_targets UPDATE watch_targets
SET state = ?, raw_status = ?, state_since = ?, last_checked_at = ?, last_error = ?, SET state = ?, raw_status = ?, state_since = ?, last_checked_at = ?, last_error = ?,
avatar_url = ?, status_changed_at = ?, avatar_url = ?, status_changed_at = ?,
last_live_started_at = ?, last_live_ended_at = ? last_live_started_at = ?, last_live_ended_at = ?,
preview_url = ?, snapshot_at = ?
WHERE id = ? WHERE id = ?
`), `),
remove: db.prepare('DELETE FROM watch_targets WHERE id = ?'), remove: db.prepare('DELETE FROM watch_targets WHERE id = ?'),
@@ -609,6 +617,8 @@ export const targetsRepo = {
statusChangedAt: number | null; statusChangedAt: number | null;
lastLiveStartedAt: number | null; lastLiveStartedAt: number | null;
lastLiveEndedAt: number | null; lastLiveEndedAt: number | null;
previewUrl: string | null;
snapshotAt: number | null;
}, },
): void { ): void {
targetStmts.updateState.run( targetStmts.updateState.run(
@@ -621,6 +631,8 @@ export const targetsRepo = {
state.statusChangedAt, state.statusChangedAt,
state.lastLiveStartedAt, state.lastLiveStartedAt,
state.lastLiveEndedAt, state.lastLiveEndedAt,
state.previewUrl,
state.snapshotAt,
id, id,
); );
}, },

View File

@@ -80,6 +80,8 @@ class Watchlist {
let error: string | null = null; let error: string | null = null;
let avatarUrl = target.avatarUrl; let avatarUrl = target.avatarUrl;
let statusChangedAt = target.statusChangedAt; let statusChangedAt = target.statusChangedAt;
let previewUrl = target.previewUrl;
let snapshotAt = target.snapshotAt;
try { try {
const result = await fetchStripchatStatus( const result = await fetchStripchatStatus(
@@ -91,6 +93,8 @@ class Watchlist {
// On conserve la dernière valeur connue si la plateforme ne la renvoie pas. // On conserve la dernière valeur connue si la plateforme ne la renvoie pas.
avatarUrl = result.avatarUrl ?? target.avatarUrl; avatarUrl = result.avatarUrl ?? target.avatarUrl;
statusChangedAt = result.statusChangedAt ?? target.statusChangedAt; statusChangedAt = result.statusChangedAt ?? target.statusChangedAt;
previewUrl = result.previewUrl ?? target.previewUrl;
snapshotAt = result.snapshotAt ?? target.snapshotAt;
} catch (err) { } catch (err) {
// Une sonde en échec ne change pas l'état connu : on garde le dernier // Une sonde en échec ne change pas l'état connu : on garde le dernier
// verdict fiable plutôt que d'annoncer un faux passage hors-ligne. // verdict fiable plutôt que d'annoncer un faux passage hors-ligne.
@@ -128,6 +132,8 @@ class Watchlist {
statusChangedAt, statusChangedAt,
lastLiveStartedAt, lastLiveStartedAt,
lastLiveEndedAt, lastLiveEndedAt,
previewUrl,
snapshotAt,
}); });
const updated = targetsRepo.get(target.id); const updated = targetsRepo.get(target.id);

View File

@@ -916,6 +916,13 @@ export interface WatchTarget {
/** Photo de profil. */ /** Photo de profil. */
avatarUrl: string | null; avatarUrl: string | null;
/**
* Vignette du flux — voir {@link StripchatStatus.previewUrl}. Absente hors
* diffusion connue ; la carte ne montre l'aperçu que sur un profil en direct.
*/
previewUrl: string | null;
/** Horodatage de la vignette, à ajouter en paramètre d'URL pour éviter le cache. */
snapshotAt: number | null;
/** /**
* Début du statut courant d'après la plateforme. Quand `state` vaut `public`, * Début du statut courant d'après la plateforme. Quand `state` vaut `public`,
* c'est l'heure de début du stream en cours. * c'est l'heure de début du stream en cours.
@@ -1198,6 +1205,20 @@ export interface StripchatStatus {
* du stream en cours — bien plus précis que notre propre première observation. * du stream en cours — bien plus précis que notre propre première observation.
*/ */
statusChangedAt: number | null; statusChangedAt: number | null;
/**
* Vignette du flux (`previewUrlThumbBig`) — une image, pas un flux vidéo :
* l'agent seul pilote un navigateur, le serveur n'a que cette API REST.
* Absente si le modèle n'a jamais diffusé.
*/
previewUrl: string | null;
/**
* Horodatage de cette vignette côté plateforme, pour invalider le cache du
* navigateur : l'URL, elle, ne change pas d'une image à l'autre — le CDN la
* sert avec un `max-age` de 30 jours. Sans un paramètre qui varie à chaque
* rafraîchissement, le navigateur garderait indéfiniment la toute première
* image vue.
*/
snapshotAt: number | null;
} }
export async function fetchStripchatStatus( export async function fetchStripchatStatus(
@@ -1219,12 +1240,27 @@ export async function fetchStripchatStatus(
}); });
if (response.status === 404) { if (response.status === 404) {
return { raw: 'notFound', state: 'offline', avatarUrl: null, statusChangedAt: null }; return {
raw: 'notFound',
state: 'offline',
avatarUrl: null,
statusChangedAt: null,
previewUrl: null,
snapshotAt: null,
};
} }
if (!response.ok) throw new Error(`API Stripchat : HTTP ${response.status}`); if (!response.ok) throw new Error(`API Stripchat : HTTP ${response.status}`);
const payload = (await response.json()) as { const payload = (await response.json()) as {
user?: { user?: { status?: string; avatarUrl?: string; statusChangedAt?: string } }; user?: {
user?: {
status?: string;
avatarUrl?: string;
statusChangedAt?: string;
previewUrlThumbBig?: string;
snapshotTimestamp?: number;
};
};
}; };
const profile = payload?.user?.user; const profile = payload?.user?.user;
const raw = profile?.status; const raw = profile?.status;
@@ -1235,12 +1271,19 @@ export async function fetchStripchatStatus(
const changedAt = profile?.statusChangedAt const changedAt = profile?.statusChangedAt
? Date.parse(profile.statusChangedAt) ? Date.parse(profile.statusChangedAt)
: Number.NaN; : Number.NaN;
// Secondes côté API, comme tous les timestamps Unix bruts de cette plateforme.
const snapshotAt = Number(profile?.snapshotTimestamp) * 1000;
return { return {
raw, raw,
state: mapStreamStatus(raw, privateStatuses), state: mapStreamStatus(raw, privateStatuses),
avatarUrl: typeof profile?.avatarUrl === 'string' && profile.avatarUrl ? profile.avatarUrl : null, avatarUrl: typeof profile?.avatarUrl === 'string' && profile.avatarUrl ? profile.avatarUrl : null,
statusChangedAt: Number.isFinite(changedAt) ? changedAt : null, statusChangedAt: Number.isFinite(changedAt) ? changedAt : null,
previewUrl:
typeof profile?.previewUrlThumbBig === 'string' && profile.previewUrlThumbBig
? profile.previewUrlThumbBig
: null,
snapshotAt: Number.isFinite(snapshotAt) && snapshotAt > 0 ? snapshotAt : null,
}; };
} }

View File

@@ -3,7 +3,13 @@ import type { AgentView, PreemptionVerdict, WatchTarget } from '@stream-control/
import { DEFAULT_PRIORITY, priorityLabel } from '@stream-control/shared'; import { DEFAULT_PRIORITY, priorityLabel } from '@stream-control/shared';
import { api } from '../api'; import { api } from '../api';
import { formatDateTime, formatDuration, formatRelative } from '../format'; 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'; import { StreamerDetail } from './StreamerDetail';
interface Props { interface Props {
@@ -172,12 +178,18 @@ function StreamerCard({
}) { }) {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [avatarBroken, setAvatarBroken] = 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 state = stateLabel(target);
const agent = agents.find((candidate) => candidate.id === target.agentId) ?? null; const agent = agents.find((candidate) => candidate.id === target.agentId) ?? null;
const context = recordingContext(target, agent, targets); const context = recordingContext(target, agent, targets);
const verdict = preemption(target, context); const verdict = preemption(target, context);
const isLive = target.state === 'public'; 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 liveFor = isLive && target.statusChangedAt ? Date.now() - target.statusChangedAt : null;
const lastDuration = const lastDuration =
@@ -218,6 +230,22 @@ function StreamerCard({
tabIndex={0} tabIndex={0}
title="Ouvrir la fiche" 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"> <header className="streamer-head">
{target.avatarUrl && !avatarBroken ? ( {target.avatarUrl && !avatarBroken ? (
<img <img

View File

@@ -121,6 +121,19 @@ export function autoRecordHint(
return ' — armé, en attente du prochain direct'; 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. */ /** Fenêtres proposées sous les frises. */
export const TIMELINE_RANGES = [ export const TIMELINE_RANGES = [
{ days: 1, label: '24 h' }, { days: 1, label: '24 h' },

View File

@@ -642,6 +642,41 @@ fieldset.group > legend {
flex-wrap: wrap; 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 { .streamer-head {
display: flex; display: flex;
align-items: center; align-items: center;