Feat : preview timeline vignette
All checks were successful
release / build (push) Successful in 29s
release / verify-windows (push) Successful in 1m24s

This commit is contained in:
jeanotx32
2026-08-12 22:07:39 +02:00
parent 8d45342727
commit cbbf31cabf
12 changed files with 418 additions and 9 deletions

View File

@@ -24,3 +24,10 @@ AGENT_TIMEOUT_MS=15000
# Duree de direct exigee avant un enregistrement automatique (ms).
AUTO_RECORD_DELAY_MS=60000
# Vignettes de diffusion conservees (une par stream, montrees au survol de la frise)
PREVIEWS_DIR=./data/previews
# Attente avant de copier la vignette d'une diffusion qui demarre (ms).
# En deca, un stream trop court n'aura pas de vignette : il n'a rien a montrer.
PREVIEW_DELAY_MS=60000

View File

@@ -112,6 +112,8 @@ node /opt/stream-control-agent/agent.cjs --check
| `SESSION_SECRET` | Clé HMAC des sessions (32+ octets aléatoires) |
| `ENROLLMENT_TOKEN` | Jeton d'auto-enregistrement ; vide = désactivé |
| `DB_PATH` | Fichier SQLite |
| `PREVIEWS_DIR` | Vignettes de diffusion conservées, une par stream |
| `PREVIEW_DELAY_MS` | Attente avant de copier la vignette d'un stream qui démarre |
| `STATUS_INTERVAL_MS` | Fréquence de remontée d'état des agents |
| `AGENT_TIMEOUT_MS` | Délai avant de déclarer un agent hors-ligne |
@@ -669,6 +671,35 @@ gardée — deux pistes séparées obligeraient l'œil à faire l'alignement lui
globale rend visibles les chevauchements : deux streamers en direct en même temps sur une
seule VM, et ce qui, faute de machine libre, est passé à côté.
### Une vignette par diffusion, conservée
**Survoler une barre bleue montre une image de ce stream-là**, même des mois après. C'est ce
qui permet de reconnaître une diffusion d'un coup d'œil dans une frise qui, sans ça, n'aligne
que des rectangles interchangeables.
L'image est **copiée sur le serveur**, et non pointée vers la plateforme : la vignette servie
en direct sur l'onglet **Streamers** vient du CDN de Stripchat, qui la remplace toutes les
demi-heures environ. Passé la diffusion, son URL ne renvoie plus rien d'exploitable — rien de
ce qui s'affiche en direct ne survit au stream. D'où la copie, prise pendant qu'elle existe
encore.
Une seule par diffusion, jamais remplacée ensuite : c'est une illustration destinée à
reconnaître un stream, pas un suivi de son déroulé. Elle est prise **une minute après le début
du stream** (`PREVIEW_DELAY_MS`), pour la même raison que le délai d'amorçage de
l'enregistrement automatique : les premières secondes d'un flux montrent souvent un écran de
démarrage ou une pièce vide, et un passage éclair en public au sortir d'un show privé ne
mérite pas d'illustration. Une diffusion plus courte que ce délai n'en aura donc pas.
Les fichiers vivent dans `data/previews/` (`PREVIEWS_DIR`), à côté de la base et non dedans —
ce sont des dizaines de kilo-octets chacun, que SQLite n'a aucune raison de porter. Ne plus
suivre un profil efface ses diffusions en cascade côté SQLite, mais SQLite ne connaît rien du
disque : un **ménage quotidien** (et au démarrage) supprime les images que plus aucune
diffusion ne réclame.
La route qui les sert accepte le jeton de session en paramètre d'URL — une balise `<img>` ne
permet pas d'ajouter un en-tête, comme pour le flux temps réel. Le fichier ne changeant jamais
une fois écrit, il est servi avec un cache immuable.
### Ce qui est enregistré, et comment
| Table | Écrite par | Une ligne = |
@@ -783,6 +814,7 @@ Toutes les routes hors `/api/login` exigent `Authorization: Bearer <jeton de ses
| `PATCH` | `/api/agents/:id` | Nom, notes, paramètres OBS, auto-connexion, surveillance, navigateur, preset |
| `PATCH` | `/api/watchlist/:id` | Libellé, agent assigné, notifications, automatisme, favori, priorité, interruption |
| `GET` | `/api/timeline?days=&targetId=` | Diffusions et captures ; sans `targetId`, tous les profils |
| `GET` | `/api/timeline/sessions/:id/preview` | Vignette conservée d'une diffusion (jeton accepté en query) |
| `POST` | `/api/agents/:id/token` | Régénère le jeton (coupe la session en cours) |
| `DELETE` | `/api/agents/:id` | Supprime l'agent |
| `POST` | `/api/agents/:id/command` | `{ action, params }`, attend le résultat de l'agent |

View File

@@ -15,6 +15,7 @@ import { config } from './config.ts';
import { generateToken, hashToken, issueSession, requireSession, safeEqual } from './auth.ts';
import { agentsRepo, logsRepo, sessionsRepo, spansRepo, targetsRepo } from './db.ts';
import { hub } from './hub.ts';
import { previewFileFor } from './previews.ts';
import { getPushoverSettings, savePushoverSettings, sendPushover } from './pushover.ts';
import { startTargetRecording } from './recorder.ts';
import { watchlist } from './watchlist.ts';
@@ -390,6 +391,26 @@ api.get('/timeline', (req, res) => {
});
});
/**
* Vignette copiée d'une diffusion — l'illustration montrée au survol de la frise.
*
* Servie par le serveur plutôt que pointée vers le CDN de la plateforme : celui-ci
* remplace l'image toutes les demi-heures environ, et l'URL d'une diffusion passée
* ne renvoie plus rien. Le fichier, lui, ne change jamais une fois écrit — d'où le
* cache immuable : le navigateur ne redemandera pas la même image en survolant
* deux fois la même barre.
*/
api.get('/timeline/sessions/:id/preview', (req, res) => {
const id = Number.parseInt(req.params.id, 10);
const file = Number.isFinite(id) ? previewFileFor(id) : null;
if (!file) {
res.status(404).json({ error: 'Aucune vignette pour cette diffusion' });
return;
}
res.setHeader('cache-control', 'private, max-age=604800, immutable');
res.sendFile(file);
});
// --- Divers -----------------------------------------------------------------
/** Historique d'une VM : les mêmes entrées que le journal, filtrées et bornées. */

View File

@@ -44,6 +44,23 @@ export const config = {
dbPath: path.resolve(repoRoot, process.env.DB_PATH ?? './data/stream-control.sqlite'),
/**
* Vignettes de diffusion copiées depuis le CDN de la plateforme, une par
* diffusion. À côté de la base et non dedans : ce sont des dizaines de
* kilo-octets chacune, que SQLite n'a aucune raison de porter.
*/
previewsDir: path.resolve(repoRoot, process.env.PREVIEWS_DIR ?? './data/previews'),
/**
* Attente avant de copier la vignette d'une diffusion qui démarre.
*
* Même raison que `autoRecordDelayMs` : les premières secondes d'un flux
* montrent souvent un écran de démarrage ou une pièce vide, et un faux départ
* (passage éclair en public au sortir d'un show privé) ne mérite pas
* d'illustration. Une diffusion plus courte que ce délai n'aura pas de
* vignette — assumé, elle n'a rien à montrer.
*/
previewDelayMs: int('PREVIEW_DELAY_MS', 60_000),
statusIntervalMs: int('STATUS_INTERVAL_MS', 2000),
/** Fréquence de sondage des profils surveillés (veille). */

View File

@@ -165,6 +165,11 @@ addColumnIfMissing('watch_targets', 'preempt', 'INTEGER NOT NULL DEFAULT 0');
addColumnIfMissing('watch_targets', 'preview_url', 'TEXT');
addColumnIfMissing('watch_targets', 'snapshot_at', 'INTEGER');
// Nom du fichier de vignette copié pour cette diffusion, dans config.previewsDir.
// Le nom plutôt qu'un chemin complet : le répertoire est un réglage, et une base
// déplacée d'une machine à l'autre ne doit pas traîner les chemins de l'ancienne.
addColumnIfMissing('stream_sessions', 'preview_file', 'TEXT');
/**
* `idle` — le « revient bientôt » de Stripchat — relevait de la clôture et non de
* la pause dans les versions antérieures. Les agents créés avant ce changement
@@ -662,6 +667,11 @@ const sessionStmts = {
sinceForTarget: db.prepare(
'SELECT * FROM stream_sessions WHERE target_id = ? AND started_at >= ? ORDER BY started_at',
),
byId: db.prepare('SELECT * FROM stream_sessions WHERE id = ?'),
setPreview: db.prepare('UPDATE stream_sessions SET preview_file = ? WHERE id = ?'),
previewFiles: db.prepare(
'SELECT preview_file FROM stream_sessions WHERE preview_file IS NOT NULL',
),
};
interface SessionRow {
@@ -669,6 +679,7 @@ interface SessionRow {
target_id: string;
started_at: number;
ended_at: number | null;
preview_file: string | null;
}
const toSession = (row: SessionRow): StreamSession => ({
@@ -676,6 +687,7 @@ const toSession = (row: SessionRow): StreamSession => ({
targetId: row.target_id,
startedAt: Number(row.started_at),
endedAt: num(row.ended_at),
hasPreview: Boolean(row.preview_file),
});
export const sessionsRepo = {
@@ -696,6 +708,17 @@ export const sessionsRepo = {
sessionStmts.close.run(endedAt, targetId);
},
/** La diffusion en cours de ce profil, ou `null` s'il n'y en a pas. */
openFor(targetId: string): StreamSession | null {
const row = sessionStmts.openFor.get(targetId) as unknown as SessionRow | undefined;
return row ? toSession(row) : null;
},
get(id: number): StreamSession | null {
const row = sessionStmts.byId.get(id) as unknown as SessionRow | undefined;
return row ? toSession(row) : null;
},
since(from: number, targetId?: string | null): StreamSession[] {
const rows = (
targetId
@@ -704,6 +727,30 @@ export const sessionsRepo = {
) as unknown as SessionRow[];
return rows.map(toSession);
},
/** Rattache le fichier de vignette copié pour cette diffusion. */
setPreviewFile(id: number, file: string): void {
sessionStmts.setPreview.run(file, id);
},
/** Nom du fichier de vignette, ou `null` si cette diffusion n'en a pas. */
previewFile(id: number): string | null {
const row = sessionStmts.byId.get(id) as unknown as SessionRow | undefined;
return row?.preview_file ?? null;
},
/**
* Tous les fichiers de vignette encore référencés.
*
* Sert au ménage sur disque : la suppression d'un profil emporte ses
* diffusions en cascade côté SQLite, mais laisse leurs fichiers orphelins —
* rien dans la base ne les désigne plus, et sans cette liste il n'existerait
* aucun moyen de savoir lesquels sont devenus inutiles.
*/
allPreviewFiles(): Set<string> {
const rows = sessionStmts.previewFiles.all() as unknown as Array<{ preview_file: string }>;
return new Set(rows.map((row) => row.preview_file));
},
};
interface SpanRow {

View File

@@ -8,6 +8,7 @@ import { verifySession } from './auth.ts';
import { api } from './api.ts';
import { hub } from './hub.ts';
import { authenticateAgent, handleAgentConnection } from './agentGateway.ts';
import { sweepPreviews } from './previews.ts';
import { watchlist } from './watchlist.ts';
const app = express();
@@ -92,6 +93,13 @@ const heartbeat = setInterval(() => {
}, 5000);
heartbeat.unref();
// Ménage des vignettes devenues orphelines (profil qu'on ne suit plus). Une
// fois par jour suffit : rien ne presse à quelques heures près, et le passage
// au démarrage rattrape les suppressions faites serveur éteint.
sweepPreviews();
const previewSweep = setInterval(sweepPreviews, 24 * 3_600_000);
previewSweep.unref();
watchlist.start();
server.listen(config.port, config.host, () => {
@@ -106,6 +114,7 @@ server.listen(config.port, config.host, () => {
function shutdown(signal: string): void {
console.log(`\n${signal} reçu, arrêt…`);
clearInterval(heartbeat);
clearInterval(previewSweep);
server.close(() => process.exit(0));
setTimeout(() => process.exit(1), 5000).unref();
}

View File

@@ -0,0 +1,148 @@
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import type { WatchTarget } from '@stream-control/shared';
import { config } from './config.ts';
import { sessionsRepo } from './db.ts';
/**
* Copie locale d'une vignette par diffusion, pour que la frise reste illustrée
* des mois après coup.
*
* La vignette servie en direct (`WatchTarget.previewUrl`) pointe vers le CDN de
* la plateforme, qui la remplace toutes les demi-heures environ : passé la
* diffusion, l'URL ne renvoie plus rien d'exploitable. Survoler une barre de
* frise vieille de trois jours ne pouvait donc rien montrer sans en garder une
* copie au moment où elle existait encore.
*
* Une seule par diffusion, jamais remplacée ensuite : c'est une illustration
* destinée à reconnaître un stream d'un coup d'œil, pas un suivi de son
* déroulé.
*/
/** Au-delà, ce n'est plus une vignette — on refuse plutôt que de remplir le disque. */
const MAX_BYTES = 4 * 1024 * 1024;
const FETCH_TIMEOUT_MS = 8000;
const EXTENSIONS: Record<string, string> = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
'image/avif': '.avif',
};
/**
* Diffusions déjà tentées depuis le démarrage du serveur, réussites comprises.
*
* Sans ce garde-fou, une diffusion dont la copie échoue verrait une nouvelle
* tentative à chaque sonde — toutes les 30 s pendant des heures. En mémoire et
* non en base, à dessein : un redémarrage rend sa chance à une diffusion
* toujours en cours, sans jamais graver un échec passager dans le schéma.
*/
const attempted = new Set<number>();
function previewPath(file: string): string {
// `basename` par principe : le nom vient de la base, mais rien ne justifie de
// laisser un chemin relatif remonter hors du répertoire prévu.
return path.join(config.previewsDir, path.basename(file));
}
/**
* Copie la vignette de la diffusion en cours de ce profil, si l'heure est venue.
*
* Sans effet — et sans erreur — dans tous les cas où il n'y a rien à faire :
* profil hors diffusion, vignette déjà copiée, diffusion trop jeune, plateforme
* qui ne fournit pas d'image. Appelée à chaque sonde, elle doit rester
* silencieuse la quasi-totalité du temps.
*/
export async function capturePreview(target: WatchTarget): Promise<void> {
if (target.state !== 'public' || !target.previewUrl) return;
const session = sessionsRepo.openFor(target.id);
if (!session || session.hasPreview) return;
if (attempted.has(session.id)) return;
// Le délai se mesure au début de la diffusion, pas à notre première
// observation : un serveur qui redémarre au milieu d'un stream doit pouvoir
// en tirer une vignette immédiatement plutôt que d'attendre une minute de plus.
if (Date.now() - session.startedAt < config.previewDelayMs) return;
attempted.add(session.id);
await download(session.id, target.previewUrl);
}
async function download(sessionId: number, url: string): Promise<void> {
const response = await fetch(url, {
headers: { accept: 'image/*' },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const type = (response.headers.get('content-type') ?? '').split(';')[0]!.trim().toLowerCase();
if (!type.startsWith('image/')) {
throw new Error(`type inattendu : ${type || 'inconnu'}`);
}
const bytes = Buffer.from(await response.arrayBuffer());
if (bytes.byteLength === 0) throw new Error('image vide');
if (bytes.byteLength > MAX_BYTES) {
throw new Error(`image trop lourde (${Math.round(bytes.byteLength / 1024)} ko)`);
}
// Suffixe aléatoire plutôt que le seul identifiant de diffusion : une base
// repartie de zéro réattribuerait les mêmes identifiants, et un fichier
// resté d'une base précédente illustrerait alors la mauvaise diffusion.
const file = `${sessionId}-${randomUUID().slice(0, 8)}${EXTENSIONS[type] ?? '.jpg'}`;
const target = previewPath(file);
fs.mkdirSync(config.previewsDir, { recursive: true });
// Écriture puis renommage : le fichier n'apparaît sous son nom définitif
// qu'entier. Servi à mi-écriture, il donnerait une image tronquée.
const temp = `${target}.part`;
fs.writeFileSync(temp, bytes);
fs.renameSync(temp, target);
sessionsRepo.setPreviewFile(sessionId, file);
}
/** Chemin du fichier à servir pour cette diffusion, ou `null`. */
export function previewFileFor(sessionId: number): string | null {
const file = sessionsRepo.previewFile(sessionId);
if (!file) return null;
const full = previewPath(file);
return fs.existsSync(full) ? full : null;
}
/**
* Supprime les vignettes que plus aucune diffusion ne réclame.
*
* Ne plus suivre un profil efface ses diffusions en cascade côté SQLite, mais
* SQLite ne connaît rien du disque : sans ce ménage, leurs images resteraient
* indéfiniment, sans plus rien pour les désigner ni les afficher.
*/
export function sweepPreviews(): void {
if (!fs.existsSync(config.previewsDir)) return;
const referenced = sessionsRepo.allPreviewFiles();
// Une écriture en cours porte encore son nom provisoire et n'est référencée
// nulle part : la fenêtre est d'une fraction de seconde, mais le ménage
// tourne en parallèle des sondes, et rien ne garantit qu'ils ne se croisent
// jamais. L'âge du fichier tranche sans avoir à les synchroniser.
const cutoff = Date.now() - 3_600_000;
let removed = 0;
for (const entry of fs.readdirSync(config.previewsDir)) {
if (referenced.has(entry)) continue;
const full = path.join(config.previewsDir, entry);
try {
if (fs.statSync(full).mtimeMs > cutoff) continue;
fs.unlinkSync(full);
removed += 1;
} catch {
// Fichier disparu entre-temps, ou droits refusés : rien à signaler, le
// prochain passage retombera dessus si le problème persiste.
}
}
if (removed > 0) console.log(`previews · ${removed} vignette(s) orpheline(s) supprimée(s)`);
}

View File

@@ -7,6 +7,7 @@ import {
import { config } from './config.ts';
import { sessionsRepo, targetsRepo } from './db.ts';
import { hub } from './hub.ts';
import { capturePreview } from './previews.ts';
import { notifyTargetLive } from './pushover.ts';
import { startTargetRecording } from './recorder.ts';
@@ -142,6 +143,15 @@ class Watchlist {
hub.publishTarget(updated);
this.maybeAutoRecord(updated);
// Détaché du cycle de sonde : un CDN lent ne doit pas retarder les profils
// suivants, et une image manquée n'est jamais une raison d'interrompre la
// veille. Le compte rendu part au journal, sans évènement typé — c'est une
// illustration, pas un incident d'exploitation.
void capturePreview(updated).catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
hub.log(null, 'warn', `Vignette de ${updated.username} non copiée : ${message}`);
});
if (changed) {
const name = updated.label ?? updated.username;
hub.log(null, 'info', `Veille : ${name} est passé « ${labelOf(next)} »`);

View File

@@ -949,6 +949,16 @@ export interface StreamSession {
startedAt: number;
/** Nul tant que la diffusion est en cours. */
endedAt: number | null;
/**
* Une vignette de cette diffusion a été copiée sur le serveur, et reste donc
* consultable des mois plus tard.
*
* Nécessaire parce que `WatchTarget.previewUrl` ne vaut que sur l'instant :
* elle pointe vers le CDN de la plateforme, dont l'image expire en une
* trentaine de minutes. Rien de ce qui est affiché en direct ne survit à la
* diffusion elle-même — d'où cette copie, prise une seule fois par diffusion.
*/
hasPreview: boolean;
}
/**

View File

@@ -166,6 +166,17 @@ export const api = {
}),
};
/**
* Vignette conservée d'une diffusion, à poser dans un `src` d'image.
*
* Jeton en query et non en en-tête : une balise `<img>` ne permet pas d'en
* ajouter un, et cette route l'accepte pour cette raison — comme le flux
* temps réel plus bas.
*/
export function sessionPreviewSrc(sessionId: number): string {
return `/api/timeline/sessions/${sessionId}/preview?token=${encodeURIComponent(getToken() ?? '')}`;
}
/** URL du flux temps réel, jeton en query (les WS ne portent pas d'en-tête). */
export function dashboardSocketUrl(): string {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import type { RecordingSpan, StreamSession } from '@stream-control/shared';
import { sessionPreviewSrc } from '../api';
import { formatDuration } from '../format';
export interface TimelineLane {
@@ -28,8 +29,13 @@ interface Bar {
ms: number;
/** Toujours en cours au moment du rendu. */
open: boolean;
/** Diffusion dont une vignette a été conservée : à montrer au survol. */
previewId?: number;
}
/** Largeur de la vignette de survol, en pixels ; la hauteur suit le 16/9. */
const PREVIEW_WIDTH = 260;
/**
* Frise des diffusions, avec les portions capturées en surimpression.
*
@@ -46,6 +52,15 @@ export function Timeline({ from, to, lanes, hideLabels, onSelectLane }: Props) {
const ticks = useMemo(() => buildTicks(from, to), [from, to]);
const nowLeft = now >= from && now <= to ? percent(now, from, to) : null;
const [preview, setPreview] = useState<{ id: number; x: number; y: number } | null>(null);
/**
* Vignettes annoncées par le serveur mais introuvables à l'affichage — un
* fichier disparu du disque, par exemple. Mémorisées pour ne plus les
* redemander : sans ça, chaque mouvement de souris sur la barre relancerait
* la requête, échouerait, et bouclerait.
*/
const [broken, setBroken] = useState<Set<number>>(new Set());
return (
<div className={hideLabels ? 'timeline timeline-bare' : 'timeline'}>
<div className="timeline-axis">
@@ -113,14 +128,32 @@ export function Timeline({ from, to, lanes, hideLabels, onSelectLane }: Props) {
/>
))}
{sessions.map((bar) => (
<span
key={bar.key}
className={bar.open ? 'timeline-bar live' : 'timeline-bar'}
style={{ left: `${bar.left}%`, width: `${bar.width}%` }}
title={bar.title}
/>
))}
{sessions.map((bar) => {
const showPreview =
bar.previewId !== undefined && !broken.has(bar.previewId);
return (
<span
key={bar.key}
className={bar.open ? 'timeline-bar live' : 'timeline-bar'}
style={{ left: `${bar.left}%`, width: `${bar.width}%` }}
title={bar.title}
// `mousemove` autant que `mouseenter` : la vignette suit le
// curseur le long de la barre, qui peut traverser tout
// l'écran sur une longue diffusion.
onMouseEnter={
showPreview
? (event) => setPreview({ id: bar.previewId!, ...place(event) })
: undefined
}
onMouseMove={
showPreview
? (event) => setPreview({ id: bar.previewId!, ...place(event) })
: undefined
}
onMouseLeave={showPreview ? () => setPreview(null) : undefined}
/>
);
})}
{spans.map((bar) => (
<span
@@ -142,10 +175,45 @@ export function Timeline({ from, to, lanes, hideLabels, onSelectLane }: Props) {
{lanes.length === 0 && (
<p className="muted small">Aucune diffusion observée sur cette période.</p>
)}
{/* Hors de la piste, et en `fixed` : `.timeline-track` recadre son
contenu (`overflow: hidden`) pour que les barres ne débordent pas de
la fenêtre affichée — une vignette posée dedans s'en trouverait
rognée, et son ancrage suivrait le défilement horizontal de la frise. */}
{preview && (
<div
className="timeline-preview"
style={{ left: preview.x, top: preview.y, width: PREVIEW_WIDTH }}
>
<img
src={sessionPreviewSrc(preview.id)}
alt=""
onError={() => setBroken((current) => new Set(current).add(preview.id))}
/>
</div>
)}
</div>
);
}
/**
* Position de la vignette près du curseur, sans jamais sortir de la fenêtre.
*
* Au-dessus du curseur par défaut — la frise se lit de haut en bas, et une
* vignette posée dessous masquerait les voies suivantes. En dessous seulement
* quand le haut manque de place, sur la première ligne de la frise.
*/
function place(event: { clientX: number; clientY: number }): { x: number; y: number } {
const height = (PREVIEW_WIDTH * 9) / 16;
const margin = 8;
const x = Math.min(
Math.max(event.clientX - PREVIEW_WIDTH / 2, margin),
window.innerWidth - PREVIEW_WIDTH - margin,
);
const above = event.clientY - height - 16;
return { x, y: above >= margin ? above : event.clientY + 16 };
}
const percent = (at: number, from: number, to: number) => ((at - from) / (to - from)) * 100;
/**
@@ -172,6 +240,9 @@ function toBars(
key: `${kind}-${interval.id}`,
left: percent(start, from, to),
width: percent(end, from, to) - percent(start, from, to),
// Seules les diffusions en ont une ; les intervalles de capture partagent
// cette fonction mais ne portent pas le champ.
previewId: 'hasPreview' in interval && interval.hasPreview ? interval.id : undefined,
// Conservé à part des pourcentages : repasser de la largeur affichée aux
// millisecondes ferait perdre une minute sur deux heures, et le total
// annoncé ne collerait plus aux durées listées juste à côté.

View File

@@ -976,6 +976,32 @@ fieldset.group > legend {
background: var(--rec);
}
/*
* Vignette conservée d'une diffusion, montrée au survol de sa barre. En `fixed`
* et positionnée depuis le curseur : la piste qui l'accueillerait sinon recadre
* son contenu et défile horizontalement.
*/
.timeline-preview {
position: fixed;
z-index: 60;
padding: 3px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.6);
/* Le curseur la traverserait sinon en la survolant, ce qui déclencherait le
`mouseleave` de la barre : la vignette clignoterait sous la souris. */
pointer-events: none;
}
.timeline-preview img {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
border-radius: 4px;
background: var(--panel-2);
}
/* --- Journal --- */
/* Vue pleine page depuis l'onglet Journal. Elle défile avec la page, comme la