From 58aadee97d8668637bb726ee1f7a3396554ffe5d Mon Sep 17 00:00:00 2001 From: jeanotx32 Date: Wed, 12 Aug 2026 16:23:43 +0200 Subject: [PATCH] Feat : Preview on streamers --- README.md | 18 +++++++ packages/server/src/db.ts | 14 +++++- packages/server/src/watchlist.ts | 6 +++ packages/shared/src/index.ts | 47 ++++++++++++++++++- packages/web/src/components/StreamersPage.tsx | 30 +++++++++++- packages/web/src/streamers.ts | 13 +++++ packages/web/src/styles.css | 35 ++++++++++++++ 7 files changed, 159 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index bc528e9..a918995 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/packages/server/src/db.ts b/packages/server/src/db.ts index 94277ab..483bfef 100644 --- a/packages/server/src/db.ts +++ b/packages/server/src/db.ts @@ -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, ); }, diff --git a/packages/server/src/watchlist.ts b/packages/server/src/watchlist.ts index 9b10233..2c574fd 100644 --- a/packages/server/src/watchlist.ts +++ b/packages/server/src/watchlist.ts @@ -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); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b560ba4..5fd70bf 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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, }; } diff --git a/packages/web/src/components/StreamersPage.tsx b/packages/web/src/components/StreamersPage.tsx index 1f6cddd..a26a926 100644 --- a/packages/web/src/components/StreamersPage.tsx +++ b/packages/web/src/components/StreamersPage.tsx @@ -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(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 ``, 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. +
+ setBrokenPreview(preview)} + /> +
+ )} +
{target.avatarUrl && !avatarBroken ? ( ): 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' }, diff --git a/packages/web/src/styles.css b/packages/web/src/styles.css index 9493c11..4b93228 100644 --- a/packages/web/src/styles.css +++ b/packages/web/src/styles.css @@ -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 + * ``, 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;