From cbbf31cabfff16e6567866c139c58dbf619baf5f Mon Sep 17 00:00:00 2001 From: jeanotx32 Date: Wed, 12 Aug 2026 22:07:39 +0200 Subject: [PATCH] Feat : preview timeline vignette --- .env.example | 7 ++ README.md | 32 +++++ packages/server/src/api.ts | 21 ++++ packages/server/src/config.ts | 17 +++ packages/server/src/db.ts | 47 +++++++ packages/server/src/index.ts | 9 ++ packages/server/src/previews.ts | 148 +++++++++++++++++++++++ packages/server/src/watchlist.ts | 10 ++ packages/shared/src/index.ts | 10 ++ packages/web/src/api.ts | 11 ++ packages/web/src/components/Timeline.tsx | 89 ++++++++++++-- packages/web/src/styles.css | 26 ++++ 12 files changed, 418 insertions(+), 9 deletions(-) create mode 100644 packages/server/src/previews.ts diff --git a/.env.example b/.env.example index 5d3d14c..776f091 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index ec9914d..e5d117f 100644 --- a/README.md +++ b/README.md @@ -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 `` 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 { }); }); +/** + * 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. */ diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index c0236c3..b3c4aa2 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -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). */ diff --git a/packages/server/src/db.ts b/packages/server/src/db.ts index 483bfef..c206233 100644 --- a/packages/server/src/db.ts +++ b/packages/server/src/db.ts @@ -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 { + const rows = sessionStmts.previewFiles.all() as unknown as Array<{ preview_file: string }>; + return new Set(rows.map((row) => row.preview_file)); + }, }; interface SpanRow { diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 2ae915a..4c4ac5a 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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(); } diff --git a/packages/server/src/previews.ts b/packages/server/src/previews.ts new file mode 100644 index 0000000..b0aaa82 --- /dev/null +++ b/packages/server/src/previews.ts @@ -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 = { + '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(); + +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 { + 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 { + 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)`); +} diff --git a/packages/server/src/watchlist.ts b/packages/server/src/watchlist.ts index 2c574fd..865dbdf 100644 --- a/packages/server/src/watchlist.ts +++ b/packages/server/src/watchlist.ts @@ -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)} »`); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 19c79af..0758d2c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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; } /** diff --git a/packages/web/src/api.ts b/packages/web/src/api.ts index fa102d1..4d2bbf4 100644 --- a/packages/web/src/api.ts +++ b/packages/web/src/api.ts @@ -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 `` 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'; diff --git a/packages/web/src/components/Timeline.tsx b/packages/web/src/components/Timeline.tsx index b23c7ca..c1aaaa3 100644 --- a/packages/web/src/components/Timeline.tsx +++ b/packages/web/src/components/Timeline.tsx @@ -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>(new Set()); + return (
@@ -113,14 +128,32 @@ export function Timeline({ from, to, lanes, hideLabels, onSelectLane }: Props) { /> ))} - {sessions.map((bar) => ( - - ))} + {sessions.map((bar) => { + const showPreview = + bar.previewId !== undefined && !broken.has(bar.previewId); + return ( + 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) => ( Aucune diffusion observée sur cette période.

)} + + {/* 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 && ( +
+ setBroken((current) => new Set(current).add(preview.id))} + /> +
+ )}
); } +/** + * 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é. diff --git a/packages/web/src/styles.css b/packages/web/src/styles.css index 81b21a0..33f4b59 100644 --- a/packages/web/src/styles.css +++ b/packages/web/src/styles.css @@ -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