Feat : preview timeline vignette
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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é.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user