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

@@ -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