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