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.
- [Presets d'enregistrement](#presets-denregistrement) appliqués à OBS depuis l'interface,
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
suivi, avec en surimpression ce qui en a été capturé — par profil sur sa fiche, ou tous
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
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
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
// enregistrement en cours ne s'interrompt pas sans un geste explicite.
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
@@ -481,6 +484,8 @@ interface TargetRow {
status_changed_at: number | null;
last_live_started_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));
@@ -508,6 +513,8 @@ function toTarget(row: TargetRow): WatchTarget {
statusChangedAt: num(row.status_changed_at),
lastLiveStartedAt: num(row.last_live_started_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
SET state = ?, raw_status = ?, state_since = ?, last_checked_at = ?, last_error = ?,
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 = ?
`),
remove: db.prepare('DELETE FROM watch_targets WHERE id = ?'),
@@ -609,6 +617,8 @@ export const targetsRepo = {
statusChangedAt: number | null;
lastLiveStartedAt: number | null;
lastLiveEndedAt: number | null;
previewUrl: string | null;
snapshotAt: number | null;
},
): void {
targetStmts.updateState.run(
@@ -621,6 +631,8 @@ export const targetsRepo = {
state.statusChangedAt,
state.lastLiveStartedAt,
state.lastLiveEndedAt,
state.previewUrl,
state.snapshotAt,
id,
);
},

View File

@@ -80,6 +80,8 @@ class Watchlist {
let error: string | null = null;
let avatarUrl = target.avatarUrl;
let statusChangedAt = target.statusChangedAt;
let previewUrl = target.previewUrl;
let snapshotAt = target.snapshotAt;
try {
const result = await fetchStripchatStatus(
@@ -91,6 +93,8 @@ class Watchlist {
// On conserve la dernière valeur connue si la plateforme ne la renvoie pas.
avatarUrl = result.avatarUrl ?? target.avatarUrl;
statusChangedAt = result.statusChangedAt ?? target.statusChangedAt;
previewUrl = result.previewUrl ?? target.previewUrl;
snapshotAt = result.snapshotAt ?? target.snapshotAt;
} catch (err) {
// 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.
@@ -128,6 +132,8 @@ class Watchlist {
statusChangedAt,
lastLiveStartedAt,
lastLiveEndedAt,
previewUrl,
snapshotAt,
});
const updated = targetsRepo.get(target.id);

View File

@@ -916,6 +916,13 @@ export interface WatchTarget {
/** Photo de profil. */
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`,
* 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.
*/
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(
@@ -1219,12 +1240,27 @@ export async function fetchStripchatStatus(
});
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}`);
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 raw = profile?.status;
@@ -1235,12 +1271,19 @@ export async function fetchStripchatStatus(
const changedAt = profile?.statusChangedAt
? Date.parse(profile.statusChangedAt)
: Number.NaN;
// Secondes côté API, comme tous les timestamps Unix bruts de cette plateforme.
const snapshotAt = Number(profile?.snapshotTimestamp) * 1000;
return {
raw,
state: mapStreamStatus(raw, privateStatuses),
avatarUrl: typeof profile?.avatarUrl === 'string' && profile.avatarUrl ? profile.avatarUrl : 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 { 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;