Feat : Preview on streamers
This commit is contained in:
@@ -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,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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